Unity回合制战斗系统:状态机、网格寻路与技能架构实战

Unity回合制战斗系统:状态机、网格寻路与技能架构实战
1. 项目概述与核心价值最近在整理自己的项目库翻到了一个几年前做的战旗回合制格斗游戏Demo当时是为了验证一套基于状态机的战斗逻辑框架。看到网上很多朋友在找类似的免费资源索性就把这个项目的核心源码和设计思路整理出来分享给大家。这不仅仅是一份“源码”我更想把它看作一个可拆解、可学习、可二次开发的Unity3D回合制战斗系统模板。这个项目麻雀虽小五脏俱全。它实现了一个经典的战棋SRPG玩法在一个网格地图上敌我双方单位轮流行动每个回合可以移动、攻击或释放技能。同时它又融合了传统格斗游戏的元素比如攻击命中时的受击反馈、连招Combo的潜力设计以及技能的前摇、后摇帧管理。对于想入门回合制游戏开发或者想深入研究Unity中状态管理、网格寻路、战斗数值计算的开发者来说这个项目有不错的参考价值。它适合谁呢如果你是Unity初学者想通过一个完整的项目理解游戏循环、预制体、动画状态机等基础概念这个项目提供了清晰的场景结构。如果你是有一定经验的开发者正在为你的独立游戏寻找一个稳定可靠的回合制战斗底层框架这里的基于网格的移动系统、可扩展的技能系统和基于事件驱动的UI交互能为你节省大量前期架构设计的时间。最关键的是它完全免费没有任何加密或依赖不明资产所有代码都开放你可以随意修改、学习甚至用于你的商业项目当然直接套用美术资源可能涉及版权请注意。2. 核心系统设计与架构拆解拿到一个游戏源码直接一头扎进代码里是最低效的。我们先从顶层设计看起理解作者是如何组织各个模块的这比看懂某一行代码更重要。2.1 整体架构基于MVC的变体这个项目没有严格遵循教科书式的MVC而是采用了一种在Unity中更实用的数据-逻辑-表现分离架构。我们可以将其理解为Model数据/逻辑层 核心是GameManager单例和Unit单位类。GameManager掌管整个游戏流程如回合切换、胜负判定、地图数据一个二维数组表示的网格。Unit类则封装了一个战斗单位的所有属性和核心逻辑如生命值、攻击力、移动范围、所拥有的技能列表等。所有核心计算伤害、移动路径都发生在这里。Controller控制层 主要是UnitController和InputHandler。UnitController挂载在每个单位GameObject上它接收玩家的输入指令通过InputHandler然后调用Unit模型层的方法执行移动或攻击并触发视图层的更新。InputHandler负责处理鼠标点击、悬停等交互将屏幕坐标转换为游戏网格逻辑坐标。View表现层 包括所有看得见的部分UnitView负责播放单位的移动、攻击、受击动画、GridHighlighter高亮显示可移动范围、攻击范围、UIManager显示血条、行动菜单、战斗日志。这一层严格依赖模型层的数据模型层状态变化后通过C#事件或Unity的SendMessage/UnityAction通知视图层更新。这种架构的好处是清晰。当你需要修改一个单位的攻击力时你只需改动Unit模型类的数值当你觉得血条样式不好看你只需修改UIManager中血条预制体的样式无需触碰核心逻辑代码。2.2 状态机游戏逻辑的脊柱回合制游戏的核心是“状态”。这个项目里至少有两套关键的状态机在协同工作游戏全局状态机GameState 由GameManager管理。通常包括PlayerTurn玩家回合、EnemyTurn敌人回合、UnitActing单位行动中、GameOver等状态。状态切换驱动着游戏流程。例如当最后一个敌人单位被击败状态从EnemyTurn切换到GameOver并触发胜利UI。单位动画与行为状态机Animator UnitState 每个单位都有一个Unity Animator Controller管理Idle待机、Walk移动、Attack攻击、Hit受击、Die死亡等动画状态。更重要的是在代码层面UnitController内部维护着一个简单的UnitActionState如Selecting选择目标、Moving移动中、Attacking攻击中这个逻辑状态与Animator的动画状态通过参数Parameters同步确保角色的行为与动画表现一致。实操心得在Unity中不要试图用一套状态机管理所有事情。将“游戏流程状态”和“单位行为/动画状态”分开能让代码更清爽。游戏状态机可以用简单的enum加switch语句实现而单位行为状态机则更适合与Animator深度结合。2.3 网格与寻路系统战棋游戏的舞台是网格。本项目使用一个简单的二维数组int[,] mapGrid来表示地图其中不同的整数值代表地形如0平地可通行1障碍物2高地有防御加成。移动范围计算 核心算法是广度优先搜索BFS。当玩家选中一个单位时系统会以该单位所在网格为起点根据单位的移动力MoveRange向外层层扩散标记出所有在移动力内且可通行的网格。这里会考虑地形消耗例如沼泽地可能每格消耗2点移动力。路径查找与显示 在BFS计算的过程中系统会同时记录每个可达网格的“父节点”。当玩家点击一个目标网格时系统通过从终点回溯父节点快速生成一条最短路径。GridHighlighter脚本会实例化一些半透明的色块如蓝色代表可移动红色代表可攻击来可视化这些范围。攻击范围 通常基于“曼哈顿距离”或“切比雪夫距离”计算。例如一个近战单位的攻击范围可能是自身周围1格曼哈顿距离1。计算方式与移动范围类似但只检查距离不考虑路径是否被阻挡除非设定为“直线攻击”。3. 核心模块深度解析与实现理解了架构我们深入几个最关键的模块看看代码是如何具体实现的。3.1 单位Unit数据与技能系统Unit类是战斗的基石。它的主要属性包括public class Unit : MonoBehaviour { public string unitName; public int maxHP; private int currentHP; public int attackPower; public int defense; public int moveRange; public ListSkill skills; // 技能列表 public Faction faction; // 阵营Player 或 Enemy // 关键方法 public bool TakeDamage(int damage) { int finalDamage Mathf.Max(1, damage - defense); // 简易伤害公式 currentHP - finalDamage; if (currentHP 0) { currentHP 0; Die(); } OnHPChanged?.Invoke(currentHP, maxHP); // 触发事件更新血条UI return currentHP 0; } public void UseSkill(Skill skill, Unit target) { // 消耗MP、计算冷却等 skill.Execute(this, target); // 执行技能效果 } }技能系统通过Skill基类实现多态。这是一种非常优雅的设计public abstract class Skill : ScriptableObject // 使用ScriptableObject便于在编辑器内配置 { public string skillName; public int mpCost; public int range; public Sprite icon; public abstract void Execute(Unit caster, Unit target); } // 具体技能类型 [CreateAssetMenu(fileName New AttackSkill, menuName Skills/Attack)] public class AttackSkill : Skill { public int powerMultiplier 100; // 百分比 public override void Execute(Unit caster, Unit target) { int damage caster.attackPower * powerMultiplier / 100; target.TakeDamage(damage); } } [CreateAssetMenu(fileName New HealSkill, menuName Skills/Heal)] public class HealSkill : Skill { public int healAmount; public override void Execute(Unit caster, Unit target) { target.Heal(healAmount); } }使用ScriptableObject来定义技能意味着你可以在Unity编辑器里像配置资源一样拖拽设置技能的名称、图标、消耗和效果参数无需硬编码。当需要新增一个“造成中毒效果”的技能时你只需新建一个PoisonSkill类继承Skill并实现其Execute逻辑即可原有系统完全不受影响。3.2 回合流程与AI实现GameManager中的回合流程控制是游戏的大脑void Update() { switch (currentGameState) { case GameState.PlayerTurn: // 等待玩家操作由InputHandler触发 break; case GameState.EnemyTurn: if (!isAIActing) { StartCoroutine(EnemyAITurn()); // 使用协程让AI行动有间隔便于观察 } break; case GameState.UnitActing: // 等待单位行动移动、攻击动画完成 break; } } IEnumerator EnemyAITurn() { isAIActing true; foreach (Unit enemy in enemyUnits) { if (enemy.IsDead) continue; // 1. 寻找最近玩家单位 Unit nearestPlayer FindNearestPlayer(enemy); if (nearestPlayer null) break; // 2. 计算移动路径尝试靠近玩家 ListVector2Int path CalculatePathTo(enemy.gridPos, nearestPlayer.gridPos); if (path.Count 1 path.Count enemy.moveRange 1) { yield return StartCoroutine(enemy.MoveAlongPath(path)); // 移动 } // 3. 判断是否在攻击范围内 if (IsInAttackRange(enemy, nearestPlayer)) { yield return StartCoroutine(enemy.Attack(nearestPlayer)); // 攻击 } yield return new WaitForSeconds(0.5f); // 每个AI行动间隔增加可读性 } EndEnemyTurn(); // 切换到玩家回合 isAIActing false; }这是一个非常基础的“寻敌-移动-攻击”AI。在实战中你可以扩展它比如让AI优先攻击血量最低的单位或者根据技能冷却和MP值智能选择技能。注意事项AI计算特别是寻路可能比较耗时。一定要把密集计算放在协程中分帧进行或者使用async/await避免在一帧内卡住主线程。对于大型地图可以考虑使用A*算法替代BFS并缓存寻路结果。3.3 动画与事件融合如何让单位的攻击动作播放到最高点时才触发伤害计算这里用到了动画事件Animation Event。在Unity动画窗口中编辑Attack动画片段。在攻击动作的拳头接触到敌人的那一帧添加一个动画事件。在事件函数栏填写一个方法名例如OnAttackHit。在UnitView或UnitController脚本中定义这个OnAttackHit方法public void OnAttackHit() { // 此时才真正执行伤害计算 if (currentTarget ! null) { bool isDead currentTarget.TakeDamage(CalculateDamage()); // 可以在这里触发屏幕震动、音效、受击动画等 if (isDead) { PlayDeathAnimation(); } } }这种方法实现了表现与逻辑的精准同步是格斗和动作类游戏常用的技巧。同样你可以在移动动画结束时触发“移动完成”事件来通知GameManager进入下一个状态。4. 关键实现步骤与代码剖析让我们跟随一个完整的“玩家单位攻击”流程把上述模块串联起来。4.1 步骤一玩家输入与目标选择InputHandler脚本在Update中检测鼠标点击if (Input.GetMouseButtonDown(0) GameManager.Instance.CurrentState GameState.PlayerTurn) { Ray ray Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit; if (Physics.Raycast(ray, out hit)) { GridCell cell hit.collider.GetComponentGridCell(); if (cell ! null cell.IsHighlighted) { // 点击了高亮网格可能是移动终点或攻击目标 if (selectedUnit ! null) { selectedUnit.MoveTo(cell.GridPosition); } } else { Unit unit hit.collider.GetComponentUnit(); if (unit ! null unit.faction Faction.Player) { // 点击了己方单位选中它 SelectUnit(unit); } else if (unit ! null unit.faction Faction.Enemy selectedUnit ! null) { // 点击了敌方单位且己方单位已选中尝试攻击 if (selectedUnit.IsInAttackRange(unit)) { selectedUnit.Attack(unit); } } } } }SelectUnit(Unit unit)方法会做几件事清除之前的高亮计算并高亮该单位的移动范围将UI行动菜单攻击、技能、待机显示在屏幕下方。4.2 步骤二移动与路径渲染当玩家点击一个高亮的移动网格Unit.MoveTo(Vector2Int targetPos)被调用public IEnumerator MoveAlongPath(ListVector2Int path) { controller.SetState(UnitActionState.Moving); foreach (Vector2Int step in path) { Vector3 worldPos GridManager.Instance.GridToWorld(step); // 使用Lerp或DoTween制作平滑移动 yield return StartCoroutine(MoveToPosition(worldPos, moveSpeed)); gridPos step; // 更新逻辑坐标 } controller.SetState(UnitActionState.Idle); OnMoveCompleted?.Invoke(); // 通知游戏管理器移动结束 }移动过程中UnitActionState.Moving会触发Animator播放行走动画。GridHighlighter会在移动开始前绘制出路径线通常用LineRenderer实现。4.3 步骤三攻击执行与伤害结算玩家从行动菜单选择“攻击”或直接点击敌人后Unit.Attack(Unit target)启动public IEnumerator Attack(Unit target) { if (IsInAttackRange(target)) { controller.SetState(UnitActionState.Attacking); currentTarget target; // 1. 播放攻击动画动画内包含事件触发OnAttackHit animator.SetTrigger(Attack); // 2. 等待攻击动画完成可以通过动画事件或等待时间 while (controller.CurrentState UnitActionState.Attacking) { yield return null; } // 3. 清理 currentTarget null; EndTurn(); // 单位行动结束可能切换控制权 } }真正的伤害计算发生在OnAttackHit()动画事件中。计算完成后调用target.TakeDamage()该方法会触发OnHPChanged事件。监听此事件的UIManager会立即更新目标头顶的血条UI如果血量归零则播放死亡动画并将该单位从活动单位列表中移除。4.4 步骤四回合结束与切换一个单位行动结束后移动后攻击或直接待机会调用EndTurn()。GameManager会检查当前阵营是否所有单位都行动完毕。如果是则切换游戏状态public void OnUnitTurnEnd(Unit unit) { if (unit.faction Faction.Player) { playerUnitsActedThisTurn; if (playerUnitsActedThisTurn totalPlayerUnits) { // 所有玩家单位行动完毕切换到敌人回合 StartEnemyTurn(); } } // ... 类似逻辑处理敌人 }切换到敌人回合后就进入了前面提到的EnemyAITurn()协程流程。5. 性能优化与扩展方向一个基础框架跑起来后要考虑如何让它更健壮、更高效。5.1 性能优化要点对象池Object Pooling 战斗中的伤害数字、技能特效、高亮网格块都是频繁生成和销毁的对象。使用对象池可以极大减少GC垃圾回收压力。项目中可以为DamageText、GridHighlight等创建简单的对象池。寻路优化 当前BFS对于小地图没问题但地图变大后效率低。可以引入A*寻路算法它通过启发式函数更智能地寻找最短路径。Unity的NavMesh虽然强大但对于严格的网格化战棋可能过于重量级A*是更轻量级的选择。协程Coroutine管理 大量使用yield return new WaitForSeconds的协程如果管理不善会造成混乱。可以使用更现代的UniTask库需安装来获得更好的可读性和性能或者自己封装一个简单的协程管理器来统一启动和停止。事件系统解耦 当前模块间通信可能直接调用方法或使用SendMessage。可以引入一个全局事件中心Event Center各模块通过监听和触发字符串或枚举类型的事件来通信彻底解耦。例如Unit死亡时触发UnitDied事件AchievementManager和SoundManager分别监听这个事件来解锁成就和播放死亡音效而Unit类完全不知道它们的存在。5.2 功能扩展思路这个Demo是一个完美的起点你可以在此基础上添加无数功能地形与高度差 在mapGrid中存储地形类型和高度。移动力消耗、命中率、伤害加成都可以与地形挂钩。攻击时检查攻击者与目标之间是否有高度阻挡。技能范围与效果多样化 扩展Skill系统支持扇形范围、直线范围、十字范围等。增加Buff/Debuff系统技能可以附加“中毒”、“眩晕”、“攻击提升”等状态效果这些效果持续若干回合。角色成长与装备系统 为Unit添加Experience和Level属性击败敌人获得经验升级。设计一个Inventory和Equipment系统不同的武器、防具会影响单位属性。更复杂的AI 实现行为树Behavior Tree或有限状态机FSM来管理AI决策。AI可以有不同的性格激进型、保守型、治疗型根据战场形势己方血量、敌方阵型做出更智能的选择。网络对战PvP 这是最具挑战性的扩展。需要将所有的游戏状态单位位置、血量、行动指令进行序列化并通过网络同步。可以使用Mirror、Photon PUN或Fish-Networking等网络库。核心原则是确定性逻辑确保所有客户端在相同输入下计算出完全相同的结果通常需要固定步长的逻辑帧更新。6. 常见问题排查与调试技巧在复现或修改这个项目的过程中你肯定会遇到一些问题。这里记录几个我踩过的坑和解决方法。6.1 单位移动或攻击后游戏状态卡住问题现象 单位行动完毕但游戏没有切换到下一个单位或下一个回合。排查思路检查事件触发 在Unit的EndTurn()方法里打日志或设断点看是否被正确调用。检查GameManager状态机 确认OnUnitTurnEnd事件是否被GameManager监听到。确保事件没有被重复触发或遗漏。检查协程 如果移动或攻击是协程确保协程正常执行完毕没有因为异常而中途停止。在协程开始和结束处打日志。解决方案 最常见的原因是动画事件没有正确触发或者动画状态机参数设置错误导致UnitActionState没有从Attacking切换回Idle。仔细检查Animator Controller中的状态过渡条件和动画事件帧。6.2 点击没有反应或点击位置不准问题现象 鼠标点击单位或网格没有响应或者响应的是错误的对象。排查思路射线检测Raycast 确保被点击的GameObject有Collider组件Box Collider或Mesh Collider。检查Camera的Tag是否为MainCamera。图层Layer 为可交互的物体如Unit、GridCell设置单独的Layer如“Unit”“Ground”。在Physics.Raycast方法中指定图层掩码避免检测到UI或其他无关物体。Raycast(ray, out hit, Mathf.Infinity, LayerMask.GetMask(Unit, Ground))。屏幕坐标转换 如果使用了UI和世界空间混合注意区分Screen Point To Ray用于3D物体和Graphic Raycaster用于UI。解决方案 使用Unity的Debug模式在Scene视图中显示射线可以直观看到射线是否击中了目标Collider。6.3 伤害计算或技能效果不符合预期问题现象 打出的伤害数值不对或者技能没有产生效果。排查思路公式检查 在TakeDamage或Skill.Execute方法中打印出每一步的计算中间值如基础攻击力、防御减免、最终伤害。数据引用 检查Inspector面板中单位的攻击力、防御力数值是否被正确赋值。检查Skill ScriptableObject的资源引用是否正确。目标确认 确保技能执行时target参数不是null并且是正确的目标单位。解决方案 在关键计算函数开头加入Debug.Log($计算伤害攻击力{attacker.attackPower}, 防御力{target.defense}, 最终伤害{finalDamage});这是最直接的调试手段。6.4 在Git导入或版本升级后出现编译错误问题现象 从GitCode等平台下载源码后Unity报错主要是命名空间找不到或脚本依赖丢失。排查思路检查Unity版本 项目可能是在较新或较旧的Unity版本中创建的。用文本编辑器打开项目根目录的ProjectSettings/ProjectVersion.txt查看原项目版本。尽量使用相同或相近的版本打开。重新导入Asset 在Unity Editor中尝试Assets - Reimport All。检查插件依赖 项目可能使用了第三方插件如DOTween用于动画、TextMeshPro用于UI文字。如果缺失需要从Unity Asset Store或Package Manager中自行安装。脚本编译顺序 如果错误是关于某个类未定义可能是脚本编译顺序问题。确保基础类如Unit,Skill放在标准Assets文件夹下它们会比Editor文件夹或其他特殊文件夹下的脚本先编译。解决方案 优先根据错误信息去安装对应的Unity Package或Asset。如果问题依旧可以尝试新建一个空的Unity项目再将源码中的Assets、Packages、ProjectSettings文件夹覆盖过去。这个项目源码的价值在于它展示了一个完整、可运行的游戏循环。我建议你不要只满足于让它跑起来而是尝试去修改它增加一个新的“冲锋”技能让单位可以移动后攻击更远的敌人或者修改AI让敌人会优先集火血量最低的我方单位。在这个过程中你会对Unity游戏开发有更深刻、更实际的理解。游戏开发是门实践的手艺动手改比光看十遍代码都管用。

最新新闻

日新闻

周新闻

月新闻