1. 项目概述:从零拆解一个回合制战斗原型
最近在社区里看到不少朋友对《宝可梦》这类经典回合制游戏的实现原理很感兴趣,但面对动辄几十万行代码的商业项目,往往不知从何下手。正好,我手头有一个几年前为了教学目的而开发的Unity初级案例,它完整地实现了一个简化版的“类宝可梦”回合制战斗系统。这个项目麻雀虽小,五脏俱全,涵盖了状态机、技能系统、UI交互、数据驱动等核心模块。今天,我就以这个案例为蓝本,带大家进行一次彻底的源码级“解剖”。无论你是刚学完Unity基础语法的新手,还是想了解回合制游戏架构的进阶开发者,相信都能从中获得一些可以直接“抄作业”的灵感和避坑指南。
这个案例的核心目标非常明确:在Unity中构建一个可运行、可扩展的回合制战斗Demo。它模拟了经典回合制RPG的核心循环:敌我双方轮流行动,每个单位拥有生命值(HP)、技能,战斗中存在属性克制关系。我们将不会涉及庞大的世界观、复杂的剧情或者联网功能,而是聚焦于战斗逻辑这个单一核心,把它的实现原理、代码组织和设计思路掰开揉碎了讲清楚。接下来,我们就进入正题,看看这个案例的整体设计思路。
2. 核心架构与设计思路拆解
在动手写第一行代码之前,理清架构是避免后期陷入“屎山”的关键。对于回合制游戏,尤其是参考《宝可梦》这类,其核心特点在于顺序性和策略性。顺序性要求我们严格管理游戏流程,策略性则要求系统有良好的数据与逻辑分离,便于配置和调整。
2.1 为什么选择状态机(Finite State Machine)?
这是本案例第一个关键设计决策。战斗流程本质上是离散的状态切换:等待玩家输入 -> 播放攻击动画 -> 计算伤害 -> 切换到下一个单位。如果使用一堆if-else或者标志位来控制,代码会迅速变得难以维护。
状态机将每个阶段抽象为一个独立的“状态”(State),并明确定义状态之间转换的规则(Transition)。在这个案例中,我定义了一个BattleStateMachine作为总控制器,它管理着以下几个核心状态:
- StartState: 战斗初始化,加载双方单位数据,决定先手顺序。
- PlayerActionState: 等待玩家为当前可控单位选择技能或道具。
- PerformActionState: 执行已选定的行动(包括玩家和AI的行动),处理技能动画、伤害计算等。
- EnemyActionState: 如果是敌方回合,则由AI逻辑决定行动。
- Win/LoseState: 判断战斗结果并结束战斗。
使用状态机的好处显而易见:
- 逻辑清晰:每个状态的职责单一,调试时只需关注当前状态的逻辑。
- 易于扩展:要增加新的战斗阶段(例如“逃跑”或“捕获”),只需新增一个状态类,修改转换条件即可,不会影响其他状态。
- 便于管理:状态机天然地防止了非法状态的出现(比如不可能在计算伤害时又让玩家选择技能)。
实操心得:在Unity中实现状态机,我推荐使用
enum配合switch的简单模式入门,或者使用抽象类/接口定义状态基类。本案例为了清晰,采用了后者。每个状态都是一个继承自BattleState的类,拥有Enter(),Tick(),Exit()三个基本方法,分别处理进入、更新和离开时的逻辑。
2.2 数据与表现分离:ScriptableObject的妙用
第二个关键设计是充分利用Unity的ScriptableObject(SO)来管理游戏数据。在《宝可梦》游戏中,一个技能有威力、命中率、属性、特效等数十个参数。如果把这些硬编码在Monster或Skill脚本里,每次调整都是噩梦。
在本案例中,我创建了以下SO资产:
- MonsterBase: 定义怪物的基础属性,如名称、基础HP、攻击力、防御力、速度、属性类型(火、水、草等)。
- SkillBase: 定义技能的基础属性,如名称、威力、命中率、技能属性、特殊效果标识(如是否造成中毒)。
- LevelGrowthData: 定义怪物每级成长带来的属性提升。
这样做的好处是:
- 非程序员友好:策划或设计师可以在Unity编辑器内无代码地创建和调整成千上万的怪物和技能,像填表格一样方便。
- 热重载:在编辑器运行模式下,修改SO资产的值可以立即生效,便于快速迭代和平衡性测试。
- 内存友好:SO作为资产文件加载,相同的基数据可以被多个游戏对象实例共享引用,而不是复制多份。
例如,一个“小火龙”的MonsterBase资产被创建后,在战斗中生成的每一个“小火龙”实例(BattleMonster组件)都引用这个SO来获取它的基础属性,然后根据等级计算最终属性。
2.3 战斗单位的实体组件:BattleMonster
有了数据定义,就需要一个在战斗场景中代表每个单位的实体。我创建了BattleMonster这个MonoBehaviour组件,它挂载在每个战斗单位的GameObject上。它的核心职责包括:
- 持有数据引用:关联一个
MonsterBase和当前等级,动态计算最终战斗属性(当前HP、最大HP、攻击力等)。 - 管理技能列表:存储该单位在当前战斗中可使用的技能(
SkillBase引用列表)。 - 挂载视觉组件:关联UI血条(
HealthBar)、动画控制器(Animator)等。 - 提供行动接口:对外提供
TakeDamage(),UseSkill()等方法,供PerformActionState调用。
BattleMonster是连接数据(SO)和逻辑(状态机)的桥梁,也是连接逻辑和表现(动画、UI)的枢纽。这种设计确保了战斗逻辑的纯粹性,状态机只关心调用BattleMonster的接口,而不需要知道血条如何更新、动画如何播放。
3. 核心模块源码深度解析
理解了宏观架构,我们深入到几个最核心的模块,看看代码具体是如何实现的。这里我会贴出关键代码片段并加以解释。
3.1 状态机基类与战斗状态机实现
首先,我们看状态机的基类设计。这是一个非常经典且轻量的实现。
// BattleState.cs public abstract class BattleState { protected BattleStateMachine stateMachine; public BattleState(BattleStateMachine bsm) { stateMachine = bsm; } public virtual void Enter() { } public virtual void Tick(float deltaTime) { } public virtual void Exit() { } } // BattleStateMachine.cs public class BattleStateMachine : MonoBehaviour { private BattleState _currentState; public void ChangeState(BattleState newState) { _currentState?.Exit(); _currentState = newState; _currentState?.Enter(); } void Update() { _currentState?.Tick(Time.deltaTime); } // 提供便捷方法,用于状态间传递数据,例如当前选中的技能和目标 public SkillBase SelectedSkill { get; set; } public BattleMonster SelectedTarget { get; set; } }BattleStateMachine挂载在战斗管理器的GameObject上。每个具体的状态,比如PlayerActionState,是这样工作的:
// PlayerActionState.cs public class PlayerActionState : BattleState { public PlayerActionState(BattleStateMachine bsm) : base(bsm) { } public override void Enter() { // 1. 显示玩家可操作的UI(技能按钮、目标选择面板) UIManager.Instance.ShowActionPanel(true); // 2. 高亮当前可操作的单位 BattleManager.Instance.HighlightCurrentTurnMonster(); Debug.Log("进入玩家行动状态"); } public override void Tick(float deltaTime) { // 此状态主要靠UI按钮事件驱动,所以Tick里可能只处理一些超时逻辑 // 例如,如果长时间不操作,可以给出提示(本案例未实现) } public override void Exit() { // 隐藏UI,取消高亮 UIManager.Instance.ShowActionPanel(false); BattleManager.Instance.ClearHighlight(); } // 这个方法由UI技能按钮的点击事件调用 public void OnSkillSelected(SkillBase skill, BattleMonster target) { stateMachine.SelectedSkill = skill; stateMachine.SelectedTarget = target; // 状态转换:玩家选择完毕,进入执行阶段 stateMachine.ChangeState(new PerformActionState(stateMachine)); } }注意事项:状态转换时,
Exit()和Enter()的调用顺序至关重要。确保在Exit()中清理干净旧状态的所有副作用(如关闭UI、停止协程),再在Enter()中初始化新状态。否则很容易出现UI重叠或逻辑冲突的Bug。
3.2 伤害计算系统:属性克制与随机波动
伤害计算是回合制游戏的灵魂,也是数值策划的战场。本案例实现了一个简化但完整的公式:
// DamageCalculator.cs (静态工具类) public static class DamageCalculator { public static CalculatedDamage Calculate(BattleMonster attacker, BattleMonster defender, SkillBase usedSkill) { // 1. 基础伤害 = (攻击方攻击力 * 技能威力 / 防御方防御力) / 50 + 2 float baseDamage = (attacker.Attack * usedSkill.Power / defender.Defense) / 50f + 2; // 2. 属性克制系数 float typeMultiplier = GetTypeMultiplier(usedSkill.Type, defender.Base.Type); baseDamage *= typeMultiplier; // 3. 随机波动 (0.85 ~ 1.0) float randomFactor = UnityEngine.Random.Range(0.85f, 1.0f); baseDamage *= randomFactor; // 4. 最终取整 int finalDamage = Mathf.FloorToInt(baseDamage); // 5. 确保至少造成1点伤害(除非免疫) if (typeMultiplier == 0) finalDamage = 0; // 属性免疫 else if (finalDamage < 1) finalDamage = 1; return new CalculatedDamage(finalDamage, typeMultiplier); } private static float GetTypeMultiplier(SkillType attackType, MonsterType defenderType) { // 这里用一个二维字典或矩阵来定义克制关系,例如:火->草=2.0, 草->水=2.0, 水->火=2.0, 相互克制=0.5, 无效=0 // 简化示例: if (attackType == SkillType.Fire && defenderType == MonsterType.Grass) return 2.0f; if (attackType == SkillType.Water && defenderType == MonsterType.Fire) return 2.0f; if (attackType == SkillType.Grass && defenderType == MonsterType.Water) return 2.0f; // ... 其他克制关系 if (attackType == SkillType.Fire && defenderType == MonsterType.Water) return 0.5f; // ... 默认情况 return 1.0f; } } public struct CalculatedDamage { public int Damage; public float Multiplier; // 用于UI显示“效果绝佳!”或“效果不好” public CalculatedDamage(int dmg, float mul) { Damage = dmg; Multiplier = mul; } }这个公式包含了几个经典要素:
- 攻防比公式:核心是攻击力、技能威力和防御力的比值。除以50和加2是为了将伤害值控制在一个合理的范围内。
- 属性克制:通过一个独立的系数倍增,这是策略性的主要来源。系数2.0(效果绝佳)、0.5(效果不好)、0(无效)能极大影响战局。
- 随机波动:引入少量随机性,避免战斗变成完全确定的数学题,增加趣味性和重玩价值。
- 保底伤害:除非免疫,否则至少造成1点伤害,防止“刮痧”局出现。
实操心得:伤害计算公式务必单独放在一个像
DamageCalculator这样的静态类中。千万不要把计算逻辑散落在BattleMonster或状态里。这样做的好处是:第一,集中管理,易于修改和平衡;第二,便于单元测试,你可以单独测试各种攻击防御组合下的伤害值是否正确。
3.3 UI与逻辑的通信:事件与委托
回合制游戏有大量的UI交互:点击技能按钮、选择目标、使用道具。如何让UI事件驱动核心战斗状态机?我采用了C#的Action委托和Unity的UnityEvent相结合的方式,实现松耦合通信。
首先,在UIManager或专门的BattleUIController中,定义事件:
// BattleUIController.cs public class BattleUIController : MonoBehaviour { public static System.Action<SkillBase, BattleMonster> OnSkillSelected; // 当UI技能按钮被点击时调用 public void UI_SelectSkill(int skillIndex) { var currentMonster = BattleManager.Instance.CurrentTurnMonster; if (currentMonster != null && skillIndex < currentMonster.Skills.Count) { var skill = currentMonster.Skills[skillIndex]; // 先进入选择目标模式,高亮敌方单位 EnterTargetSelectionMode(skill); } } // 当UI上某个敌方单位被点击作为目标时调用 public void UI_SelectTarget(BattleMonster target) { if (_skillPendingSelection != null) { // 触发事件,通知状态机 OnSkillSelected?.Invoke(_skillPendingSelection, target); _skillPendingSelection = null; ExitTargetSelectionMode(); } } }然后,在状态机(如PlayerActionState)中订阅这个事件:
public override void Enter() { // ... 其他初始化 BattleUIController.OnSkillSelected += HandleSkillSelected; } public override void Exit() { // 务必在离开状态时取消订阅,防止内存泄漏和错误调用 BattleUIController.OnSkillSelected -= HandleSkillSelected; // ... 其他清理 } private void HandleSkillSelected(SkillBase skill, BattleMonster target) { // 这里直接调用状态机转换 stateMachine.ChangeState(new PerformActionState(stateMachine, skill, target)); }这种基于事件的通信模式,完美地将UI层与核心逻辑层解耦。UI不需要知道当前是什么状态,它只负责触发事件;状态机也不需要直接操作UI组件,它只负责响应事件并改变状态。
避坑指南:使用事件和委托时,最大的坑就是忘记取消订阅。如果一个对象订阅了事件,但它在被销毁(如场景切换、对象禁用)时没有取消订阅,那么事件发布者还会持有对这个对象的引用,导致内存无法被垃圾回收(内存泄漏)。更严重的是,下次事件触发时,会尝试调用一个已销毁对象的方法,导致
MissingReferenceException。所以,记住黄金法则:在OnEnable/Start中订阅,在OnDisable/OnDestroy中取消订阅。
4. 关键流程实现与实操步骤
现在,我们把各个模块串联起来,看看一个完整的“玩家回合”是如何进行的。我会以步骤的形式,结合代码和场景操作来说明。
4.1 步骤一:战斗初始化与状态启动
场景准备:在Unity编辑器中,创建一个战斗场景。场景中包含:
- 一个空的
GameObject命名为BattleManager,挂载BattleStateMachine和BattleManager(另一个管理战斗单位列表、回合顺序的单例类)脚本。 - 玩家单位和敌方单位的预设体(Prefab),每个预设体上挂载
BattleMonster组件,并关联对应的MonsterBaseScriptableObject资产。 - Canvas下布置好战斗UI:双方血条、技能按钮面板、战斗日志文本等。
- 一个空的
代码启动:在
BattleManager的Start()方法中,进行初始化。void Start() { // 1. 实例化敌我双方单位,并注册到管理列表 SpawnPlayerTeam(); SpawnEnemyTeam(); // 2. 根据速度属性决定行动顺序列表 DetermineTurnOrder(); // 3. 获取状态机引用,并启动初始状态 stateMachine = GetComponent<BattleStateMachine>(); stateMachine.ChangeState(new StartState(stateMachine)); }StartState的Enter()方法会播放一个简单的开场动画(如镜头推进),然后立即切换到PlayerActionState(如果先手是我方)或EnemyActionState。
4.2 步骤二:玩家行动状态与UI交互
进入PlayerActionState后:
- UI控制器收到状态进入的消息(可以通过状态机触发事件,或者UI直接监听状态机当前状态),显示技能按钮面板。
- 技能按钮的数据动态生成:遍历当前行动单位(
BattleManager.Instance.CurrentTurnMonster)的Skills列表,为每个技能创建一个按钮,并设置其文本为技能名,绑定点击事件UI_SelectSkill。 - 玩家点击一个技能按钮后,UI控制器调用
UI_SelectSkill,传入技能索引。此时并不立即执行,而是先隐藏技能面板,并高亮所有可选的敌方目标(通过改变其材质或添加外发光效果)。 - 玩家点击一个高亮的敌方目标,UI控制器调用
UI_SelectTarget,并触发OnSkillSelected事件。 PlayerActionState中的事件处理器HandleSkillSelected被调用,它携带了选择的技能和目标信息,触发状态向PerformActionState转换。
4.3 步骤三:执行行动状态与伤害结算
PerformActionState是战斗的核心,它通过一个协程(Coroutine)来顺序执行一系列动作,保证视觉效果和逻辑同步。
public override void Enter() { // 立即开始执行行动的协程 StartCoroutine(PerformActionRoutine()); } private IEnumerator PerformActionRoutine() { // 1. 播放攻击者“使用技能”的动画 BattleMonster attacker = BattleManager.Instance.CurrentTurnMonster; attacker.PlayAnimation("Attack"); yield return new WaitForSeconds(0.5f); // 等待动画前半段 // 2. 播放技能特效(如发射火球),特效可以是一个预制体,从攻击者位置飞向目标 GameObject vfx = Instantiate(skillVFXPrefab, attacker.transform.position, Quaternion.identity); // ... 移动特效到目标的代码(可以使用Dotween或Lerp) yield return new WaitForSeconds(0.3f); // 等待特效命中 // 3. 播放受击者“被击中”的动画 BattleMonster target = stateMachine.SelectedTarget; target.PlayAnimation("Hurt"); // 4. 核心:计算伤害 CalculatedDamage damage = DamageCalculator.Calculate(attacker, target, stateMachine.SelectedSkill); // 5. 应用伤害 target.TakeDamage(damage.Damage); // 6. 更新UI:刷新目标血条,在战斗日志中显示伤害数字和效果文本 UIManager.Instance.UpdateHealthBar(target); UIManager.Instance.LogBattleMessage($"{attacker.Name} 使用了 {stateMachine.SelectedSkill.Name}!"); if (damage.Multiplier > 1.5f) UIManager.Instance.LogBattleMessage("效果绝佳!"); else if (damage.Multiplier < 0.8f) UIManager.Instance.LogBattleMessage("效果不好..."); yield return new WaitForSeconds(1.0f); // 给玩家一点时间阅读信息 // 7. 检查目标是否被击败 if (target.CurrentHP <= 0) { target.PlayAnimation("Faint"); yield return new WaitForSeconds(0.8f); // 从战场移除目标 BattleManager.Instance.RemoveFromBattle(target); // 检查战斗是否结束(一方全灭) if (BattleManager.Instance.CheckBattleEnd()) { stateMachine.ChangeState(new WinState(stateMachine)); // 或 LoseState yield break; } } // 8. 切换到下一个单位的回合 BattleManager.Instance.AdvanceToNextTurn(); if (BattleManager.Instance.CurrentTurnMonster.IsPlayerControlled) stateMachine.ChangeState(new PlayerActionState(stateMachine)); else stateMachine.ChangeState(new EnemyActionState(stateMachine)); }这个协程就像一部电影的导演,严格规定了每个“镜头”(动画、特效、计算、UI更新)的顺序和时长。使用yield return new WaitForSeconds来控制节奏至关重要,它让战斗过程有张有弛,玩家能看清发生了什么。
4.4 步骤四:敌方AI行动逻辑实现
EnemyActionState的Enter()方法会启动一个简单的AI决策流程。对于初级案例,AI可以非常直接:
public override void Enter() { // 在真实项目中,可能会用协程来模拟AI“思考”时间 // 这里简单起见,立即决策 ChooseActionAndExecute(); } private void ChooseActionAndExecute() { BattleMonster enemy = BattleManager.Instance.CurrentTurnMonster; List<BattleMonster> playerTargets = BattleManager.Instance.PlayerTeam.Where(m => m.CurrentHP > 0).ToList(); if (playerTargets.Count == 0) return; // 1. 随机选择一个技能(更复杂的AI可以基于属性克制、HP等因素选择) SkillBase chosenSkill = enemy.Skills[Random.Range(0, enemy.Skills.Count)]; // 2. 随机选择一个玩家目标(更复杂的AI可以优先攻击血量最低或属性被克制的目标) BattleMonster chosenTarget = playerTargets[Random.Range(0, playerTargets.Count)]; // 3. 直接设置状态机数据,并切换到执行状态 stateMachine.SelectedSkill = chosenSkill; stateMachine.SelectedTarget = chosenTarget; stateMachine.ChangeState(new PerformActionState(stateMachine)); }这个AI虽然简单,但已经具备了基本的“思考-行动”流程。你可以很容易地扩展它,比如增加一个AIDecisionMaker类,使用权重系统来评估每个技能对每个目标的价值,然后选择最优解。
5. 常见问题、调试技巧与扩展方向
即使按照上述步骤实现,在实际开发中你仍会遇到各种问题。下面是我在开发这个案例以及教学过程中,学员们最常踩的坑和对应的解决方案。
5.1 问题排查速查表
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 点击技能按钮无反应 | 1. UI按钮事件未绑定。 2. 当前状态不是 PlayerActionState,事件被忽略。3. BattleMonster.Skills列表为空或索引越界。 | 1. 检查Unity Inspector中按钮的OnClick()事件列表是否指向了BattleUIController.UI_SelectSkill方法,参数是否正确。2. 在 PlayerActionState.Enter()中打Log,确认状态已进入。检查事件订阅是否成功。3. 在 UI_SelectSkill方法开头打Log,输出currentMonster和skillIndex,检查数据是否正常。 |
| 伤害计算永远是0或1 | 1. 攻防属性值获取错误(为0)。 2. 属性克制系数计算错误,返回了0。 3. 伤害公式整数除法问题。 | 1. 在DamageCalculator.Calculate第一行打Log,输出attacker.Attack,defender.Defense,usedSkill.Power。2. 调试 GetTypeMultiplier函数,检查传入的技能属性和怪物属性是否匹配你的克制关系表。3. 确保公式中使用了 float进行除法(如/ 50f),而不是整数除法(/ 50)。 |
| 战斗结束后,UI还显示旧数据 | 1. 没有在战斗结束时或单位死亡时更新UI。 2. UI元素引用了已被销毁的 BattleMonster对象。 | 1. 在RemoveFromBattle和Win/LoseState中,调用UI刷新方法(如UIManager.Instance.ClearAllTargetUI())。2. 在UI更新代码中,增加空值判断: if (target == null) return;。使用事件通知UI更新,而非UI主动轮询。 |
| 状态切换时发生异常(如空引用) | 1. 在新状态的Enter()中访问了尚未初始化的数据。2. 在旧状态的 Exit()中错误地清理了共享资源。 | 1. 确保状态转换时,所需数据(如SelectedTarget)已由前一个状态正确设置。在访问前进行空值检查。2. 区分“状态独占资源”和“全局共享资源”。只清理前者(如本状态打开的特定UI面板)。 |
| 动画播放不同步或卡顿 | 1. 协程中的WaitForSeconds时间设置不合理。2. 动画事件(Animation Event)未正确触发逻辑。 | 1. 调整等待时间,使其与动画剪辑长度匹配。可以使用Animator的GetCurrentAnimatorStateInfo来获取动画长度。2. 如果使用动画事件,确保函数是 public的,并且在正确的GameObject上。更稳妥的做法是用协程等待一个固定时间或通过回调通知。 |
5.2 性能与内存优化提示
虽然这是个初级案例,但养成好习惯很重要:
- 对象池管理特效:技能特效(VFX)的实例化(
Instantiate)和销毁(Destroy)是性能开销。如果战斗频繁,应使用对象池来复用特效GameObject。 - 避免每帧查找:
BattleManager.Instance、UIManager.Instance这种单例模式方便,但不要在Update()或频繁调用的方法里通过GameObject.Find或GetComponent去查找。在Start或Awake中缓存引用。 - ScriptableObject的引用:
MonsterBase和SkillBase这类SO是资产引用,不会产生GC(垃圾回收)压力。但要避免在运行时有代码动态创建大量的新SO实例。
5.3 项目扩展方向
这个基础框架就像一副骨架,你可以为其增添血肉,打造更丰富的游戏体验:
- 技能系统深化:
- 状态异常:在
SkillBase中添加CauseStatus字段(如中毒、麻痹)。在BattleMonster中添加状态列表和每回合的状态结算逻辑。 - 技能特效:在
SkillBase中关联更多的视觉特效预制体和音效。 - 目标类型:扩展技能目标为单体、群体、自身、全场等。
- 状态异常:在
- AI智能化:
- 实现一个基于行为树(Behavior Tree)或效用理论(Utility Theory)的AI系统,让敌人能根据血量、属性、玩家状态做出更智能的决策。
- 数据持久化:
- 使用
JsonUtility或第三方库(如Newtonsoft.Json)将玩家的队伍、怪物等级、技能学习情况保存到本地文件。
- 使用
- 战斗外系统:
- 怪物养成:添加升级、进化、技能学习界面。
- 背包与道具:创建
ItemBase的SO,实现道具使用逻辑。 - 地图与遭遇:设计一个简单的世界地图,使用触发器或随机步数触发战斗。
这个案例的源码价值不在于它有多复杂,而在于它清晰地展示了一个完整游戏功能模块应该如何从设计到实现。我建议你在理解的基础上,亲手敲一遍代码,并尝试去修改它,比如调整伤害公式、添加一个新的属性(“电”系)、或者实现一个简单的“防御”指令。在动手和调试的过程中,你会对Unity的游戏架构有更深的理解。