Unity回合制战斗系统开发:状态机、ScriptableObject与伤害计算实战

发布时间:2026/8/4 18:58:16
Unity回合制战斗系统开发:状态机、ScriptableObject与伤害计算实战
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的ScriptableObjectSO来管理游戏数据。在《宝可梦》游戏中一个技能有威力、命中率、属性、特效等数十个参数。如果把这些硬编码在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.ActionSkillBase, 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 GetComponentBattleStateMachine(); 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; ListBattleMonster 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或11. 攻防属性值获取错误为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的游戏架构有更深的理解。