> 技术文档 > Unity卡牌游戏设计:从基础到实战_unity卡牌游戏制作教程

Unity卡牌游戏设计:从基础到实战_unity卡牌游戏制作教程

    本章介绍了卡牌游戏设计的部分基础,国内关于卡牌游戏的设计教程都很少,而且都涉及的很浅显,基本只教你大概的框架,我学了一段时间的unity2d卡牌游戏设计,也看过很多大佬的讲解,今天讲讲我遇到的部分问题和学到的一些基础。我的unity版本是2022版的,可能一些内容会因版本问题有些差错。

一、基本流程:

卡牌的显示流程如下图:

先读取卡牌数据再存储到CardStore并生成对应的卡组,然后playerdata读取CardStore里的卡组信息加载对应的卡组,再讲加载的卡牌通过存有CardDisplay组件的卡牌显示出来。

二、设计内容:

1.card设计

    首先是界面和卡牌这两部分的设计,代码的调用首先得创造一个具体的游戏项目作为调用对象,那卡牌的设计怎么设计呢,卡牌的设计一般通过创建ui里的image图像和text文本,一张卡牌就好像一个盒子(即空物体),里面分别存着image和text的方块,image方块代表着显示出来的卡牌图像,关系到后面的美术设计,而其中的text方块存储着卡牌的数值,这是卡牌游戏的重点。我们可以通过ui创建我们自己的卡牌模版,往里面加入image和text,譬如下图:

    这张卡CarplayerCharacter由七个方块组成,两个image和五个text,Backgroundimage是青色的背景,image是白色的边框,剩下五个name(角色名)、text(技能描述)、attack(攻击力)、mana(法力值)、armor(护甲)则是text。

2.脚本介绍

    那么说了方块,接下来我们来聊聊scripts也就是脚本或者说组件,通常在检查器里的添加组件里,我们可以直接往往卡牌套上组件,也就是盒子,也可以通过在项目assets文件里开一个新文件储存我们的脚本,方法是右键鼠标创建脚本,更加建议用这种方法,方便储存我们我们写过的脚本。

    写代码的部分我个人习惯用Visual Studio,我们可以再左上角的编辑-->首选项-->外部工具,外部脚本编辑器选择Visual Studio 2022(我的版本是2022),然后我们创建脚本就会默认使用Visual Studio进行编写,没有Visual Studio可以自行下载。

    接下来进入到脚本的编写,我们可以在脚本文件夹里右键创建C#脚本,首先需要编写card脚本存储我们已经设计好了的属性。

public class Card{ public int id; public string cardName; public int mana; // 将 mana 添加到基类 // 构造函数 public Card(int id, string cardName, int mana) { this.id = id; this.cardName = cardName; this.mana = mana; }}public class AttackCard : Card{ public int attack; public int attackTime; public AttackCard(int _id, string _cardName, int _attack, int _mana) : base(_id, _cardName, _mana) // 调用基类构造函数 { this.attack = _attack; attackTime = 2; }}public class SpellCard : Card{ public int effectvalue; public string effect; public SpellCard(int _id, string _cardName, string _effect, int _effectvalue, int _mana) : base(_id, _cardName, _mana) { this.effect = _effect; this.effectvalue = _effectvalue; }}public class ShieldCard : Card{ public string effect; public int Shield; public ShieldCard(int _id, string _cardName, int _Shield, int _mana) : base(_id, _cardName, _mana) { this.Shield = _Shield; }}public class CharacterCard : Card{ public int healthPoint; // 生命值 public string skill; // 技能 public int shield; // 构造函数 public CharacterCard(int _id, string _cardName, int _healthPoint, int _mana, string _skill, int _shield) : base(_id, _cardName, _mana) { this.healthPoint = _healthPoint; this.skill = _skill; this.shield = _shield; }}

    这里涉及到建立基类这一操作,就好比所以得卡牌都会涉及到卡牌的id(便于后面的读取和运用),卡牌的name(无论是本身的角色卡,还是需要打出的战斗卡、护盾卡、魔法卡),以及魔力的大小(持有魔力和消耗魔力的大小)而基类的使用就是让后续设计的角色卡,战斗卡以及其他卡牌可以基于card基类进行编写,默认存在基类中涉及的数据,在我的代码就是id、cardName和mana。后续的战斗卡只需要加上 “:card”在建立的类名后面就可以使用基类,然后定义设置数值。

    “this.变量 = _变量”的写法确保了每当一个新的卡牌对象被创建时,它的属性都会被赋予一个从外部传递进来的具体值,而这个外部传递的数值就涉及到另一部分存储的问题,我们通常使用一个csv文件或者excel、json文件来储存我们的各类数值,我代码中使用的是csv文件,csv文件的创建也很简单,只需要建立一个文本编辑器,加入数值,然后保存为csv文件即可,或者建立excel文件填入数值在转化为csv文件。

    譬如下面我的csv文件,值得一提的是设置了Visual Studio作为外部脚本编辑器,可以直接将我们设置的csv文件拉入Visual Studio中进行编写。

    接下来就是如何将csv文件中填写的数据通过代码组件填入到我们的卡牌的text中(填入text后会换掉原本卡牌text写过的内容,这也是为什么在上文CarplayerCharacter的text只写了简单的内容),这种方法更有利于后期的数据更新,总不能没有一张卡都重新设置一张卡牌在慢慢填入相应的内容吧。这个时候就得介绍一下prefab(预制件)了,在我们设计好一张卡牌的ui界面(指text和image的位置设置以及美术设计)时,我们可以将它整体拉到我们的assets项目文件(建议新建一个文件夹存储预制体)中并点击选择原始预制件即可储存为预制体,这样随时想要使用的时候就可以调用它。之后的很多项目文件都可以存储成预制体。

三、代码编写:

1.CardDisplay编写

    而不同的卡牌需要显示的text不同,有些可能需要显示,有些不需要,下面CardDisplay代码通过定义text属性和image属性,以及最重要Card型变量card,创建一个新类来判断卡牌类型并显示我们想要的属性,用if函数进行卡牌类型判断,然后将变量 card 转换为 AttackCard 类型,并将结果赋值给 attackcard,以战斗卡为例攻击卡只需要显示卡牌名字,攻击造成的伤害值,以及消耗的魔力值,这个时候将需要的数值填入text1、text2、text3并且在检查器中将对应的text拉入即可显示,而其中的“Text3.gameObject.SetActive(false);”是让对应的文本不显示。之后的卡牌也是同样的意思。

using UnityEngine;using UnityEngine.UI;public class CardDisplay : MonoBehaviour{ public Text nameText; public Text Text1; public Text Text2; public Text effectText; public Text Text3; public Image backgroundImage; public Card card; // Start is called before the first frame update void Start() { ShowCard(); } // Update is called once per frame void Update() { } public void ShowCard() { if (card is AttackCard) { var attackcard = card as AttackCard; //将变量 card 转换为 AttackCard 类型,并将结果赋值给 attackcard nameText.text = card.cardName; //将文本nameText设置为card的cardName Text1.text = attackcard.attack.ToString(); //将文本Text1设置为attackcard的attack Text2.text = attackcard.mana.ToString(); effectText.gameObject.SetActive(false); Text3.gameObject.SetActive(false); //隐藏文字描述 } else if (card is SpellCard) { var spell = card as SpellCard; effectText.text = spell.effect; nameText.text = card.cardName; Text1.text = spell.effectvalue.ToString(); Text2.text = spell.mana.ToString(); Text3.gameObject.SetActive(false); } else if (card is CharacterCard) { var character = card as CharacterCard; nameText.text = card.cardName; effectText.text = character.skill; Text1.text = character.mana.ToString(); Text2.text = character.healthPoint.ToString(); Text3.text = character.shield.ToString(); } else if (card is ShieldCard) { var shieldCard = card as ShieldCard; nameText.text = card.cardName; effectText.gameObject.SetActive(false); Text1.gameObject.SetActive(false); Text.text2 = shieldCard.mana.ToString(); Text.text3 = shieldCard.Shield.ToString(); } }}

2.CardStore编写

在完成了卡牌显示的脚本后,接下来需要来编写另一个脚本也就是CardStore,其中包含了读取卡牌数据的功能,通过读取的第一列内容进行分类,譬如第一列是“#”的就跳过,是“attack”生成攻击卡,以此类推。以战斗卡为例,读取第二列的卡牌id(相同的卡牌可以有不同的id,以此来进行相同卡牌的不同调用),第三列则是卡牌的名字,第四列攻击力,第五列消耗的蓝量,然后新建一个AttackCard变量(参考card脚本)来存储这张卡并将其加入卡组中(这个卡组也是之后playerdata存储需要用到的卡组),在完成魔法卡,护甲卡,角色卡(游戏卡需要单独一个卡组,你也不想抽卡抽出角色卡吧)后,便是随机抽卡也是之后游戏抽卡的逻辑,还有复制卡牌的逻辑(现在可以先不管)。

using System.Collections.Generic;using UnityEngine;public class CardStore : MonoBehaviour{ public TextAsset cardData; public List cardList = new List(); public List characterCardList = new List(); // Start is called before the first frame update void Start() { LoadCardData(); //TestLoad(); } // Update is called once per frame void Update() { } public void LoadCardData() { string[] dataRow = cardData.text.Split(\'\\n\'); foreach (var row in dataRow) { string[] rowArray = row.Split(\',\'); if (rowArray[0] == \"#\") { continue; } else if (rowArray[0] == \"attack\") { //新建攻击卡 int id = int.Parse(rowArray[1]); string name = rowArray[2]; int atk = int.Parse(rowArray[3]); int mana = int.Parse(rowArray[4]); AttackCard attackCard = new AttackCard(id, name, atk, mana); cardList.Add(attackCard); //Debug.Log(\"读取到攻击卡:\" + monsterCard.cardName); } else if (rowArray[0] == \"spell\") { //新建魔法卡 int id = int.Parse(rowArray[1]); string name = rowArray[2]; string effect = rowArray[3]; int effectvalue = int.Parse(rowArray[4]); int mana = int.Parse(rowArray[5]); SpellCard spellCard = new SpellCard(id, name, effect, effectvalue, mana); cardList.Add(spellCard); } else if (rowArray[0] == \"character\") { // 加载角色卡 int id = int.Parse(rowArray[1]); string name = rowArray[2]; int health = int.Parse(rowArray[3]); int mana = int.Parse(rowArray[4]); string skill = rowArray[5]; int shield = int.Parse(rowArray[6]); CharacterCard characterCard = new CharacterCard(id, name, health, mana, skill, shield); characterCardList.Add(characterCard); } else if (rowArray[0] == \"shield\") { // 加载护盾卡 int id = int.Parse(rowArray[1]); string name = rowArray[2]; int shield = int.Parse(rowArray[3]); int mana = int.Parse(rowArray[4]); ShieldCard shieldCard = new ShieldCard(id, name, shield, mana); cardList.Add(shieldCard); } } } //测试卡牌是否进去卡包 public void TestLoad() { foreach (var card in cardList) { Debug.Log(\"卡牌:\" + card.id.ToString() + card.cardName); } foreach (var CharacterCard in characterCardList) { Debug.Log(\"卡牌:\" + CharacterCard.id.ToString() + CharacterCard.cardName); } } public Card RandomCard() { //随机从cardList取一张卡 Card card = cardList[Random.Range(0, cardList.Count)]; return card; } public CharacterCard RandomCharacterCard() { if (characterCardList.Count > 0) { CharacterCard randomCard = characterCardList[Random.Range(0, characterCardList.Count)]; return randomCard; } Debug.LogWarning(\"没有可用的角色卡!\"); return null; } public Card CopyCard(int _id) { if (_id < cardList.Count) { // 普通卡牌 Card originalCard = cardList[_id]; if (originalCard is AttackCard) { var attackCard = originalCard as AttackCard; return new AttackCard(attackCard.id, attackCard.cardName, attackCard.attack, attackCard.mana); } else if (originalCard is SpellCard) { var spellCard = originalCard as SpellCard; return new SpellCard(spellCard.id, spellCard.cardName, spellCard.effect, spellCard.effectvalue, spellCard.mana); } else if (originalCard is ShieldCard) { var shieldCard = originalCard as ShieldCard; return new ShieldCard(shieldCard.id, shieldCard.cardName, shieldCard.Shield, shieldCard.mana); } } // 角色卡 if (_id < characterCardList.Count) { var characterCard = characterCardList[_id]; return new CharacterCard(characterCard.id, characterCard.cardName, characterCard.healthPoint, characterCard.mana, characterCard.skill, characterCard.shield); } Debug.LogError(\"无效的卡牌 ID: \" + _id); return null; }}

3.playerdata编写

接下来就是playerdata脚本的编写,首先编写加载数据的方法,加载CardStore里的卡组和角色卡卡组,还有玩家的金币(后续做商店会用到),然后将卡牌按id数量存储到playerdata.csv文件

using System.Collections.Generic;using UnityEngine;using System.IO;#if UNITY_EDITORusing UnityEditor;#endifpublic class PlayerData : MonoBehaviour{ public CardStore CardStore; public int playerCoins; public int[] playerCards; public int[] playerDeck; public CharacterCard CharacterCard; public TextAsset playerData; // Start is called before the first frame update void Start() { //先加载卡牌在加载数据 CardStore.LoadCardData(); LoadPlayerData(); InitializeManaFromCharacterCard(); } // Update is called once per frame void Update() { } public void LoadPlayerData() { playerCards = new int[CardStore.cardList.Count]; playerDeck = new int[CardStore.cardList.Count]; string[] dataRow = playerData.text.Split(\'\\n\'); foreach (var row in dataRow) { string[] rowArray = row.Split(\',\'); if (rowArray[0] == \"#\") { continue; } else if (rowArray[0] == \"coins\") { playerCoins = int.Parse(rowArray[1]); } else if (rowArray[0] == \"card\") { int id = int.Parse(rowArray[1]); int num = int.Parse(rowArray[2]); //载入玩家数据 //确保id在有效范围内,避免数组越界或无效赋值 playerCards[id] = num; } else if (rowArray[0] == \"deck\") { int id = int.Parse(rowArray[1]); int num = int.Parse(rowArray[2]); //载入玩家数据 //确保id在有效范围内,避免数组越界或无效赋值 playerDeck[id] = num; } else if (rowArray[0] == \"character\") { int characterId = int.Parse(rowArray[1]); // 根据 ID 加载角色卡 if (characterId >= 0 && characterId < CardStore.characterCardList.Count) {  CharacterCard = CardStore.characterCardList[characterId]; } } } } public void SavePlayerData() { //待完善 //csv文件无法实时更新,已解决在编辑器实时更新,但最后构筑的时候是无法更新的 //需之后修改为json文件进行读取 string path = Application.dataPath + \"/datas/playerdata.csv\"; List datas = new List(); datas.Add(\"coins,\" + playerCoins.ToString()); for (int i = 0; i < playerCards.Length; i++) { if (playerCards[i] != 0) { datas.Add(\"card,\" + i.ToString() + \",\" + playerCards[i].ToString()); } } //保存卡组 for (int i = 0; i < playerDeck.Length; i++) { if (playerDeck[i] != 0) { datas.Add(\"deck,\" + i.ToString() + \",\" + playerDeck[i].ToString()); } } if (CharacterCard != null) { int characterId = CardStore.characterCardList.IndexOf(CharacterCard); if (characterId != -1) { datas.Add(\"character,\" + characterId.ToString()); } } //保存数据 File.WriteAllLines(path, datas);#if UNITY_EDITOR AssetDatabase.Refresh();#endif }}

4.OpenPackage编写

最后是开卡包的功能,我们需要编写一个代码,将想要显示的卡牌显示到特定的位置,下面是它的代码:

首先是生成卡牌的逻辑,调用到newCard.GetComponent().card = CardStore.RandomCard();,,即上文CardStore里的抽卡逻辑在cardPool的位置生成卡牌,并加入销毁卡牌和存储数据的操作,这样可以把以出现的卡牌进行存储并更新显示。

using System.Collections.Generic;using UnityEngine;public class OpenPackage : MonoBehaviour{ public GameObject cardPrefab; public GameObject cardPool; CardStore CardStore; List Cards = new List(); public PlayerData PlayerData; private int maxCardLimit = 4; // Start is called before the first frame update void Start() { CardStore = GetComponent(); } // Update is called once per frame void Update() { } public void OnClinkOpen() { // 计算还能生成多少张卡牌 int remainingSlots = maxCardLimit - Cards.Count; // 如果没有剩余槽位,则直接返回 if (remainingSlots <= 0) { Debug.Log(\"卡牌数量已达到上限,无法继续生成!\"); return; } //每次点击扣除2金币 if (PlayerData.playerCoins < 2) { return; } else { PlayerData.playerCoins -= 2; } // 只生成不超过剩余槽位数量的卡牌 int cardsToGenerate = Mathf.Min(3, remainingSlots); for (int i = 0; i < cardsToGenerate; i++) { GameObject newCard = GameObject.Instantiate(cardPrefab, cardPool.transform); newCard.GetComponent().card = CardStore.RandomCard(); Cards.Add(newCard); } SaveCardData(); PlayerData.SavePlayerData(); } //销毁全部卡牌 public void ClearPool() { foreach (var card in Cards) { Destroy(card); } if (Cards.Count >= maxCardLimit) { Cards.Clear(); } } public void SaveCardData() { foreach (var card in Cards) { int id = card.GetComponent().card.id; PlayerData.playerCards[id] += 1; } }}

四、挂件的挂载和项目实现

然后将四个组件依次挂载上去就可以完成简单的卡牌显示啦

把playerdata绑定到一个空物体

还有CardStore绑定到另一个空物体,并加入Open Package的代码挂载上去

并新建一个CardPool,挂载上Grid Layout Group挂件(同于填充卡牌组,并设计其间隔)

最后添加一个按钮open在鼠标点击中选择OpenPackage里的OnClickOpen方法,即鼠标点击时会调用此代码

这个时候一个简易的卡牌显示功能就完成了。

本人只是一位初学者,还差错的话,请多多理解。

本文参考了部分大佬的设计,适合初学者看看,大佬可绕道。

以上只是卡牌游戏中比较小的一部分,后面战斗编辑器最简单的都写到了一千行左右,之后再结合介绍。