本章介绍了卡牌游戏设计的部分基础,国内关于卡牌游戏的设计教程都很少,而且都涉及的很浅显,基本只教你大概的框架,我学了一段时间的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<Card> cardList = new List<Card>();
public List<CharacterCard> characterCardList = new List<CharacterCard>();
// 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<string> datas = new List<string>();
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<CardDisplay>().card = CardStore.RandomCard();,,即上文CardStore里的抽卡逻辑在cardPool的位置生成卡牌,并加入销毁卡牌和存储数据的操作,这样可以把以出现的卡牌进行存储并更新显示。
using System.Collections.Generic;
using UnityEngine;
public class OpenPackage : MonoBehaviour
{
public GameObject cardPrefab;
public GameObject cardPool;
CardStore CardStore;
List<GameObject> Cards = new List<GameObject>();
public PlayerData PlayerData;
private int maxCardLimit = 4;
// Start is called before the first frame update
void Start()
{
CardStore = GetComponent<CardStore>();
}
// 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<CardDisplay>().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<CardDisplay>().card.id;
PlayerData.playerCards[id] += 1;
}
}
}
四、挂件的挂载和项目实现
然后将四个组件依次挂载上去就可以完成简单的卡牌显示啦
把playerdata绑定到一个空物体
还有CardStore绑定到另一个空物体,并加入Open Package的代码挂载上去
并新建一个CardPool,挂载上Grid Layout Group挂件(同于填充卡牌组,并设计其间隔)
最后添加一个按钮open在鼠标点击中选择OpenPackage里的OnClickOpen方法,即鼠标点击时会调用此代码
这个时候一个简易的卡牌显示功能就完成了。
本人只是一位初学者,还差错的话,请多多理解。
本文参考了部分大佬的设计,适合初学者看看,大佬可绕道。
以上只是卡牌游戏中比较小的一部分,后面战斗编辑器最简单的都写到了一千行左右,之后再结合介绍。