简介:一份基于Unity引擎的《炉石传说》完整复刻项目,面向Unity开发者、卡牌游戏爱好者和想研究暴雪级别项目结构的程序员。整套资源共1575个文件,压缩包106.87MB,包含229个C#脚本、38个Prefab预制体、26个Unity场景、23个Shader着色器、11个材质、5个FBX模型、5个动画和4个音频等典型Unity工程文件,可对照学习游戏逻辑、界面布局、资源管理与渲染效果。项目采用NGUI(Next-Gen UI)插件实现卡牌、玩家信息面板和战斗界面,Scenes文件夹清晰展示了游戏流程与关卡组织,HearthStone目录内集中了模型、纹理、音频及脚本,便于逐模块拆解。通过研究代码、场景与资源,可掌握Unity场景管理、C#交互逻辑、3D模型渲染及复杂卡牌机制的落地方式。已有2568人学习下载,适合从初级到中高级的开发者按需取用。
1. 炉石传说Unity完整项目:先拆清它到底在解决什么问题
如果你在 Unity 里导入过一个标着“炉石传说完整项目”的资源包,大概率会遇到两种情况:要么打开工程后一堆模型和 UI 预制体躺在 Assets 里,Battle 场景根本跑不起来;要么能跑,但一抽牌就报空引用,翻代码发现数据层和战斗逻辑全耦合在几个巨无霸类里。这个标题真正值钱的部分,从来不是美术资源,而是那套把卡牌、回合、区域和目标选择串起来的工程结构。
我说的是以炉石传说这类卡牌对战玩法为蓝本的 Unity 完整工程——战斗逻辑、数据配置、UI 交互和资源管理都在一个项目里能跑通。它解决的是一类动手时最容易反复的问题:你不想从零设计回合状态机,也不想自己造轮子处理拖拽冲突和区域管理,更不想在加了十几个英雄技能之后把 BattleController 类写成五千行的天文数字。
适合谁读?打算做卡牌对战、回合制策略,甚至自走棋的 Unity 开发者;也适合买了某份别人跑不通的工程、想自己重构的人。下面按我自己的落地顺序写:先把工程骨架立住,再做数据层,然后攻战斗核心,最后给一份避坑清单,收尾时聊几个从单机往联机和 AI 方向延伸的常用做法。
2. 项目骨架从场景和目录开始:先让工程能跑再谈卡牌
2.1 目录结构:按职责划分 Assets,别让美术资源插进逻辑层
我接手过一份标着“完整项目”的资源包,里面几乎全部脚本堆在一个叫 Scripts 的根目录里,UI 预制体和模型混战,找了半小时才定位到回合管理类。后来给某跨平台系统项目梳理工程时,我习惯这样划目录,直接照搬也行:
| 目录路径 | 放什么 | 为什么这么放 |
|---|---|---|
| Assets/Scenes | Boot、MainMenu、Battle 三个场景 | 启动流程清晰,避免一个场景干完所有事 |
| Assets/Scripts/Core | 单例、事件中心、公共工具 | 全局基础稳定,不依赖业务 |
| Assets/Scripts/Data | CardData、CardEffect 等只存数据的类 | 数据层不引用 MonoBehaviour |
| Assets/Scripts/Battle | 回合状态机、区域管理、目标选择 | 战斗逻辑集中,后续换玩法好下手 |
| Assets/Scripts/UI | 手牌容器、卡牌视图、血量显示 | 表现和逻辑分离 |
| Assets/Resources/Cards | 卡牌配置资产、卡图、卡牌音效 | 运行时用 Resources 加载方便原型验证 |
| Assets/Resources/Effects | 特效预制体 | 和卡牌数据分开,换皮时不动配置 |
这个划分的核心思路是:能让数据层不依赖任何脚本的,就别让它依赖;能让战斗逻辑不直接操作 UI 的,就别让它操作。别小看这一步,后面你给一张卡加“战吼抽两张牌”时,直接往 Effects 列表加一项就完事,而不是在卡牌视图脚本里到处打补丁。
2.2 事件中心与全局管理器:解耦的最小可用写法
光有目录还不够,项目里的各个系统之间得有一个不互相引用的通信方式。最稳妥、不用装任何第三方库的办法是做一个类型安全的静态事件中心。下面是我常用的实现,已把字典的一对多安全移除处理进去了:
using System; using System.Collections.Generic; /// <summary> /// 全局事件中心:模块间通信统一走这里,避免 A 类直接 new B 类 /// </summary> public static class EventCenter { // 事件名 -> 委托链。用 object 装箱参数,简单场景够用 private static readonly Dictionary<string, Action<object>> listeners = new Dictionary<string, Action<object>>(); public static void AddListener(string eventName, Action<object> callback) { if (!listeners.ContainsKey(eventName)) listeners[eventName] = delegate { }; // 占位空委托,防止后面空调用 listeners[eventName] += callback; } public static void RemoveListener(string eventName, Action<object> callback) { if (listeners.ContainsKey(eventName)) listeners[eventName] -= callback; } public static void Dispatch(string eventName, object param = null) { if (listeners.ContainsKey(eventName)) listeners[eventName]?.Invoke(param); } }这里有两个参数值得注意。Dispatch的第二个参数设计成object,意味着事件可以带任意数据对象,比如一张卡、一组目标。RemoveListener要求移除时传入和添加时“同一个委托实例”,所以千万别写AddListener("OnDraw", (param) => {...})这种匿名函数,匿名函数每次都是新对象,移除时会失效,内存泄漏就是这么来的。
全局管理器我一般只留一个GameManager做单例,负责持有当前回合数、当前是否玩家回合这些极少数全局状态:
public class GameManager : MonoBehaviour { public static GameManager Instance { get; private set; } public int CurrentTurn { get; private set; } = 1; public bool IsPlayerTurn { get; private set; } = true; private void Awake() { // 场景切换时保持唯一,重复创建直接销毁新来的 if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; DontDestroyOnLoad(gameObject); } }DontDestroyOnLoad这一步很多人会忘,后果是每次加载战斗场景都多出一个 GameManager 实例,回合计数被反复重置。逻辑很简单但属于那种“找不到原因”的经典玄学问题。要注意,单例虽好用但别滥用,每做一个子系统都塞一个单例,后期调试会非常痛苦,我更倾向于只有跨场景的全局状态才放单例。
2.3 启动流程怎么编排:从 Boot 场景到战斗场景的切换
启动流程我一般固定拆三段。Boot场景只放一个脚本,负责做前置初始化:加载存档、设置屏幕分辨率、初始化事件中心。初始化完成后,用SceneManager.LoadScene进MainMenu。菜单场景里玩家点“开始对局”后,再进Battle。
这段能跑的最小启动代码,压成这样:
using UnityEngine; using UnityEngine.SceneManagement; public class Bootstrapper : MonoBehaviour { private void Awake() { // 锁帧率在 60,省电也稳定表现层的输入判断 Application.targetFrameRate = 60; // 此处可以读存档、初始化日志系统 SceneManager.LoadScene("MainMenu"); } }别忘了在Build Settings里把Boot放在场景列表第一位,并且把MainMenu、Battle都加进去。很多新手在编辑器里直接打开Battle场景调试,发现 GameManager 不在场景里就报空引用。编辑器调试阶段你可以用一个简单的[RuntimeInitializeOnLoadMethod]自动补一个 GameManager 出来,但正式包还是走 Boot 流程更干净。
3. 卡牌数据层独立设计:把卡牌属性、效果和实例分开
3.1 用 ScriptableObject 存卡牌静态数据
卡牌最直观的落地方式是做成ScriptableObject资产。每张卡一个 .asset 文件,在 Inspector 就能填数值、拖美术资源,不需要为每张卡写一行代码。下面是最基础的字段集合:
using UnityEngine; // 在 Project 窗口右键 -> Create -> CardGame/CardData 即可新建一张卡 [CreateAssetMenu(fileName = "CardData", menuName = "CardGame/CardData")] public class CardData : ScriptableObject { public string cardName; // 卡牌名,显示用 public int cost; // 费用,出牌时扣水晶 public int attack; // 攻击力,出牌后随从的攻击 public int health; // 血量,随从站场后的生命 public CardType cardType; // 法术 / 随从 / 武器 public Sprite cardSprite; // 卡图 public AudioClip playSound; // 出牌音效 [TextArea] public string description; // 卡牌描述,装效果文案 }用ScriptableObject而不是 Json 或MonoBehaviour的原因很直接:第一,字段在 Inspector 里可视化编辑,美术和策划不用打开代码;第二,运行时改数值不会污染原始资产;第三,可以方便地在资产上挂效果对象引用(下一节的效果系统就是这么接上的)。这是做完整项目时最省心的配置路径。如果哪天卡牌数量量级超过 300 张,再考虑转成外包配置表批量导入,但仍保留 ScriptableObject 作为运行时内存形态。
3.2 卡牌效果拆成可组合对象,别用一坨 switch
最开始我图省事,把“战吼造成 2 点伤害”“抽一张牌”“恢复 3 点生命”全部写进一个卡牌效果枚举,再用switch分发。加效果一时爽,等往里面加“随机打一个敌方随从”“对所有其他角色造成 1 点伤害”这类变体时,switch里新增一堆 case,参数也乱成一团。翻车之后我换成了组合式的效果对象:
using System.Collections.Generic; using UnityEngine; /// <summary> /// 效果基类:所有卡牌效果都继承它, /// 在 Inspector 里配置成一个可复用的资产挂在 CardData.effects 数组上 /// </summary> public abstract class CardEffect : ScriptableObject { public abstract void Execute( CardInstance source, List<CardInstance> targets, ZoneManager zones); }一个具体效果的实现,比如经典的“造成 N 点伤害”:
[CreateAssetMenu(fileName = "DamageEffect", menuName = "CardGame/Effects/Damage")] public class DamageEffect : CardEffect { [Min(0)] public int amount = 2; // 伤害值,每个配置资产单独填 public override void Execute(CardInstance source, List<CardInstance> targets, ZoneManager zones) { // 对每个合法目标扣血,血量归零的由 ZoneManager 统一移入坟场 foreach (CardInstance target in targets) target.ModifyHealth(-amount); } }这样设计以后,一张新卡效果 = 创建一个效果资产 + 在CardData.effects数组里拖进去。组合不同效果连创建新类都不需要了。比如一张“造成 2 点伤害并抽 1 张牌”,就是挂一个DamageEffect和一个DrawEffect,各自填好参数。后面如果要做“造成 2 点伤害但只打随从”,只写新类、不动已有资产。
这里有个容易忽略的坑:CardEffect作为资产,千万别在里面存“运行中状态”字段。比如“下次使用时的额外伤害 +1”,这个递增值放效果资产上,会被所有实例共享,第二张同名牌会带着第一张的加成,数据完全错乱。运行时可变状态永远放在CardInstance上。
3.3 运行时实例与静态数据分离:为什么必须单独搞一个 CardInstance
这一节是数据层最容易想不明白的地方。卡牌数据是“模板”,打到场上的是“实例”。CardData里的health是满血值,而场上这张随从被打过一拳之后血量变成 3,这个 3 必须存在某个不被模板共享的地方。这就是CardInstance存在的理由:
using System; /// <summary> /// 运行时卡牌实例:战斗过程只改这里的数据,CardData 永远保持原始配置 /// </summary> public class CardInstance { public CardData Data { get; private set; } public int CurrentAttack { get; private set; } public int CurrentHealth { get; private set; } public string InstanceId { get; private set; } // 每张实例唯一,用于网络同步和日志追踪 public CardInstance(CardData data) { Data = data; CurrentAttack = data.attack; CurrentHealth = data.health; InstanceId = Guid.NewGuid().ToString("N"); } public void ModifyHealth(int delta) { CurrentHealth += delta; } public bool IsDead => CurrentHealth <= 0; }InstanceId平时不起眼,但当你做联机同步、定位“这张卡为什么被错误移入坟场”时,它是最能直接定位目标的东西。给每张实例一个Guid字符串成本极低,却能让日志和调试信息一下子清晰。实战里我见过因为缺失这个字段,出牌时从手牌列表Remove掉错误引用的惨状,加上唯一编号之后这类问题彻底消失了。
4. 战斗核心实现:回合状态机、区域管理与目标选择
4.1 回合流程用状态机管住,别用 Update 里一堆布尔判断
回合制对战最怕“逻辑散落”。我见过有人用四个bool表示“抽牌阶段、出牌阶段、敌方动、回合结束”,Update 里判断这四个布尔然后决定执行什么。加一个“战斗阶段”或者“任务结算阶段”时,布尔组合爆炸到 8 种以上,调试直接放弃。完整项目里我固定用有限状态机,枚举 + 协程把流程串起来:
using System.Collections; using UnityEngine; public enum TurnPhase { Idle, // 初始 PlayerDraw, // 玩家抽牌 PlayerPlay, // 玩家出牌 EnemyDraw, // 敌方抽牌 EnemyPlay, // 敌方出牌 TurnEnd // 回合过渡 } public class TurnStateMachine : MonoBehaviour { public TurnPhase CurrentPhase { get; private set; } = TurnPhase.Idle; private bool _endTurnRequested = false; public void StartMatch() { StartCoroutine(RunTurnLoop()); } // 供 UI 的“结束回合”按钮调用 public void RequestEndTurn() { _endTurnRequested = true; } private IEnumerator RunTurnLoop() { while (true) { // 玩家抽牌阶段,派发事件后其它系统各自响应 CurrentPhase = TurnPhase.PlayerDraw; EventCenter.Dispatch("OnPlayerDrawStart"); yield return new WaitForSeconds(0.3f); // 玩家出牌阶段:等结束回合按钮 CurrentPhase = TurnPhase.PlayerPlay; EventCenter.Dispatch("OnPlayerPlayStart"); _endTurnRequested = false; while (!_endTurnRequested) yield return null; // 敌方抽牌 CurrentPhase = TurnPhase.EnemyDraw; EventCenter.Dispatch("OnEnemyDrawStart"); yield return new WaitForSeconds(0.3f); // 敌方出牌:这里由 AI 系统决定打什么牌 CurrentPhase = TurnPhase.EnemyPlay; EventCenter.Dispatch("OnEnemyPlayStart"); yield return new WaitForSeconds(1.0f); CurrentPhase = TurnPhase.TurnEnd; EventCenter.Dispatch("OnTurnEnd"); yield return null; } } }核心技巧是把“等待玩家操作”用一个while (!_endTurnRequested) yield return null挂起协程,其它任何系统都可以在玩家做出操作时直接修改战斗状态,不需要状态机理解具体卡牌。EventCenter.Dispatch让手牌系统、费用系统、敌方 AI 各自监听对应事件,互不写死。
用协程而不是在Update里写状态切换的另一个原因是:硬生生地在 Update 里写if (phase == PlayerPlay && Input.GetKeyDown(...))会让状态切换逻辑高度耦合输入检测,协程写法天然让流程从上到下可阅读,加新阶段就是插入一行yield。这段逻辑建议单独挂在一个空物体上,比如挂名为TurnSystem的节点,和卡牌显示彻底分离。
4.2 区域管理:牌库、手牌、坟场、战场各自维护一个 List
炉石类对局的本质是卡牌在四个区域间移动:牌库、手牌、战场、坟场。最省事也是最可靠的区域实现方式是——每个区域就是一个List<CardInstance>,外加一个统一的移动方法:
using System.Collections.Generic; using UnityEngine; public enum ZoneType { Deck, Hand, Board, Graveyard } /// <summary> /// 区域管理器:所有卡牌的移入移出都走这里,保证数据始终同步 /// </summary> public class ZoneManager : MonoBehaviour { public List<CardInstance> Deck = new List<CardInstance>(); public List<CardInstance> Hand = new List<CardInstance>(); public List<CardInstance> Board = new List<CardInstance>(); public List<CardInstance> Graveyard = new List<CardInstance>(); public void MoveCard(CardInstance card, ZoneType from, ZoneType to) { GetZone(from)?.Remove(card); GetZone(to)?.Add(card); // 广播移动事件,UI 层监听后刷新对应区域的显示 EventCenter.Dispatch("OnCardMoved", new CardMovedData(card, from, to)); } public List<CardInstance> GetZone(ZoneType zone) { switch (zone) { case ZoneType.Deck: return Deck; case ZoneType.Hand: return Hand; case ZoneType.Board: return Board; case ZoneType.Graveyard: return Graveyard; default: return null; } } } /// <summary>区域移动事件携带的数据,避免参数过多</summary> public class CardMovedData { public CardInstance Card; public ZoneType From; public ZoneType To; public CardMovedData(CardInstance card, ZoneType from, ZoneType to) { Card = card; From = from; To = to; } }为什么不用 Transform 父子关系管区域?第一,一张卡在“手牌展开扇形”和“战场站位”之间的坐标变换由 UI 层负责,不该和数据层混在一起;第二,数据层用 List 以后,查找“战场上有哪些嘲讽随从”就是一个Board.FindAll()的事,不需要遍历场景里所有物体。区域和数据分离还有一个好处:你随时可以做“从手牌回到牌库”这种倒流操作,不会受场景层级限制。
有个细节:Graveyard这种区域通常没有 3D 实体,在 UI 上只是显示数量。数据层先维护好准确的列表,表现层想显示成什么形态都行。我曾经把坟场先做成列表,后面需求变成“洗回牌库”时,直接调用MoveCard(card, Graveyard, Deck)再Deck.Shuffle(),UI 层监听到事件自动刷新,结构没改一行就完成了新规则。
4.3 目标选择与拖拽交互:射线检测和事件拦路问题一起解决
随从攻击、法术选目标,都依赖同一个核心交互:点一张卡,再把目标选到另一个单位身上。最低成本的方式是屏幕射线检测。但这里有个肉眼看不见的坑:Physics.Raycast只返回第一个命中物体,而 UI 上的卡牌和战场单位叠在一起时,经常点到被遮挡的那张。处理方式是RaycastAll后按距离排序,并逐层检查:
using System.Collections.Generic; using UnityEngine; /// <summary> /// 目标选择器:从点击位置向上拾取所有可交互对象,按需过滤出合法目标 /// </summary> public class TargetSelector : MonoBehaviour { public LayerMask interactableLayers; // 在 Inspector 里只勾选 CardLayer、BoardLayer public CardInstance SelectTarget(CardInstance source) { if (!Input.GetMouseButtonDown(0)) return null; Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit[] hits = Physics.RaycastAll(ray, 50f); // 按距离从近到远排序,避免 UI 遮挡导致选错 System.Array.Sort(hits, (a, b) => a.distance.CompareTo(b.distance)); foreach (RaycastHit hit in hits) { // 没在指定层里的忽略 if ((interactableLayers.value & (1 << hit.collider.gameObject.layer)) == 0) continue; CardView view = hit.collider.GetComponentInParent<CardView>(); if (view != null && IsValidTarget(source, view.Card)) return view.Card; } return null; } private bool IsValidTarget(CardInstance source, CardInstance target) { // 示例:法术只能打对面单位;随从只能攻击对面单位且不能打英雄 // 实际规则按项目需求展开 return target != null && target != source; } }interactableLayers这个参数是关键。把场景里的卡牌、单位放到CardLayer,地面、障碍物放到别的层,拾取时用interactableLayers.value & (1 << layer)过滤,能避免“点卡牌点出地面坐标”这类古怪问题。排序那行也别省,否则多个物体叠在一起时选了错误的命中对象,这在卡牌拖拽中非常常见。
还有一类常见毛病是手牌列表用了ScrollRect,拖拽卡牌时列表跟着滚。处理思路分两步:首先在拖拽开始时GetComponent<ScrollRect>().StopMovement()并暂时设置horizontal = false;其次拖拽结束时恢复。别去和 EventSystem 内部指针抢占逻辑较劲,直接禁掉滚动是最简单可靠的后悔药。
5. 卡牌Unity项目避坑清单:5条翻车记录与修复方案
5.1 手牌 ScrollRect 抢走拖拽事件,卡牌原地抽搐
现象:从手牌往外拖随从时,卡牌一边跟着鼠标走,下方的整排手牌同时左右滚动起来,松手后卡牌停在半空中。
原因:ScrollRect的IBeginDragHandler和卡牌自身的拖拽脚本同时响应,ScrollRect 认为你在拖列表,卡牌脚本认为你在拖卡,二者互抢事件。
解决:在卡牌拖拽脚本里控制滚动容器。OnBeginDrag时调用scrollRect.StopMovement()并将horizontal置为 false;OnEndDrag时恢复horizontal = true。如果还想更省事,可以在OnBeginDrag里直接scrollRect.enabled = false,但结束时要记得恢复,否则打完一局列表再也滚不动。
5.2 对象池复用卡牌,新卡带着上一张的 buff 出场
现象:从对象池取出一张“冰霜新星”,放在场上时身上还留着上一张“火球术”附加的法术伤害 +2 数值,打出去伤害异常。
原因:对象池只做了Instantiate和SetActive,没有把运行时数据重置。CardInstance上的CurrentAttack、CurrentHealth和附魔标记是上次对局残留的,卡牌视图直接显示没做清零。
解决:池化对象的取用方法里做完整重置,而不是在Awake里初始化。把卡牌视图暴露一个Bind(CardInstance instance)方法,OnGet时统一调它,把CurrentAttack重置为Data.attack、CurrentHealth重置为Data.health。养成“凡是从池里拿,必先重置”的习惯,能避免整一类相似问题。
5.3 敌人回合“时间停顿”后动画全乱,协程被缩放拖垮
现象:做“敌方回合思考”时把Time.timeScale调成 0.2 播放慢动作,结果敌方抽牌、出牌的yield return new WaitForSeconds(0.3f)全部变成 1.5 秒,表现节奏完全失控。
原因:WaitForSeconds受Time.timeScale影响。0.3 秒实际等待了 1.5 秒。也就是说,时间缩放同时影响了逻辑等待和动画速度,两条时间线纠缠在一起。
解决:逻辑等待用WaitForSecondsRealtime,或者自己写一个不受缩放影响的等待:
// 用不受 Time.timeScale 影响的等待,保证回合节奏稳定 private IEnumerator WaitRealtime(float seconds) { float endTime = Time.realtimeSinceStartup + seconds; while (Time.realtimeSinceStartup < endTime) yield return null; }特效动画、卡牌翻转这类表现层片段再用Time.timeScale做慢动作,逻辑推进与动画表现各走各的时间线,问题就消失了。
5.4 联机测试时两边抽牌对不上:每个客户端各自随机
现象:两台设备进入同一局对战,抽了十张牌后手牌内容完全不同。场上随从攻击相同目标,一边造成 3 点伤害,另一边造成 2 点伤害。
原因:洗牌和随机抽卡的Random没有统一种子。两个客户端独立执行Random.Range,得到不同的牌序。
解决:在对局创建时,由一个权威端生成随机种子并广播给所有客户端,所有抽牌、洗牌操作都基于同一个种子初始化System.Random。注意不要在战斗逻辑里穿插调用UnityEngine.Random,统一封装一个GameRandom静态类,内部持有System.Random,并把种子打上日志,排查两边是否一致时直接对比种子即可。
5.5 卡牌一多 DrawCall 就爆炸,卡图没进图集
现象:一套 30 张卡的对局,全部上场后性能明显掉帧,Profiler 里 DrawCall 飙到 200 以上。
原因:卡图是散落的独立 Texture,每张卡牌是一个独立的 Sprite 渲染批次。卡牌一多,批次就线性上涨。
解决:把卡图打进 Sprite Atlas(或用 Unity 的图集工具),并确认卡牌视图的 Image 引用的是图集里的 Sprite。开局后先看每个卡牌节点是否共享同一张图集材质。注意图集尺寸上限,卡图超过上限的要么压分辨率,要么分多张图集按卡牌类型切换,别让一张图集撑爆内存。
6. 从单机玩法到可上线:AI出牌、指令流同步与性能收尾
6.1 先给敌方 AI 写一个贪心出牌思路
敌方回合最省事也最稳的 AI 逻辑是贪心评分。遍历手牌,给每张牌算一个“当前价值分”,打出分数最高的牌:
public class SimpleAiController : MonoBehaviour { private ZoneManager _zones; // 简单的价值评分:随从看攻击加血,法术看直接伤害,费用越高效越好 private int EvaluateCard(CardInstance card) { int score = 0; if (card.Data.attack > 0) score += card.Data.attack * 2 + card.Data.health; score -= card.Data.cost; // 费用作为惩罚,让 AI 优先打低费牌 foreach (CardEffect effect in card.Data.effects) { if (effect is DamageEffect damage) score += damage.amount * 3; } return score; } }这版 AI 的要点是“可调参”。attack * 2和damage.amount * 3里的系数,决定了 AI 是激进抢脸还是保守解场。把评分函数写成独立方法后,你可以在编辑器里快速调系数,不需要改逻辑结构就能试出不同风格。真要上难度,再考虑搜索树或者基于规则的出牌选择,但贪心评分足够撑起一个能玩的单人关卡。
6.2 把本地操作改造成可同步的指令流
联机对战不要直接把卡牌状态互相发,正确做法是只传递“操作指令”,由每个客户端各自执行同一套逻辑,最终状态一致。比如玩家召唤一张牌,不发送“我的战场现在有 3 个单位”,而是发送“PlayerSummon(cardInstanceId, slotIndex)”。接受端收到后在自己的牌库里找对应InstanceId执行召唤。
这套指令流设计的关键是:所有影响对局的操作都要带InstanceId,而且必须经过一个统一的接口比如BattleCommandExecutor.Execute(command),而不是直接在卡牌视图脚本里改数据。这样以后加观战、加回放就很容易,记录指令流重放一遍即可。这也是为什么我在第 3 章坚持给CardInstance加InstanceId,没有它,指令流方案会寸步难行。
6.3 性能收尾和我的回归习惯
完整项目到这个阶段,需要过一次性能底线。卡牌生成用对象池,别在每回合用 Instantiate 大量创建新实例;卡图先进图集再谈渲染;Update 里不要每帧遍历手牌列表——用手牌变化事件驱动刷新。你可以先压一个极端用例:玩家手牌 20 张、战场 14 个单位,帧率稳住 60 帧再放行。
我个人的收尾习惯了:每次改完区域逻辑、事件监听或者目标选择代码,都手动跑一遍“抽牌—出牌—随从攻击—死亡入坟—回手”的最小回归用例,确认数据层的四个区域列表都正确。这个测试路径短但覆盖了最核心的链路,全手动也只要两三分钟。改完卡牌效果后,再补测一张带战吼和亡语的卡,确认效果执行顺序没有倒挂。希望这整套拆解能让你在自己的炉石类 Unity 项目里少走几趟弯路。
本文还有配套的精品资源,点击获取