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_EDITOR

using UnityEditor;

#endif

public 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方法,即鼠标点击时会调用此代码

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

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

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

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

相关创意