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 List<Skill> 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. 计算移动路径,尝试靠近玩家 List<Vector2Int> 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.GetComponent<GridCell>(); if (cell != null && cell.IsHighlighted) { // 点击了高亮网格(可能是移动终点或攻击目标) if (selectedUnit != null) { selectedUnit.MoveTo(cell.GridPosition); } } else { Unit unit = hit.collider.GetComponent<Unit>(); 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(List<Vector2Int> 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)。
- 射线检测(Raycast): 确保被点击的GameObject有Collider组件(Box Collider或Mesh Collider)。检查Camera的Tag是否为
- 解决方案: 使用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版本: 项目可能是在较新或较旧的Unity版本中创建的。用文本编辑器打开项目根目录的
- 解决方案: 优先根据错误信息去安装对应的Unity Package或Asset。如果问题依旧,可以尝试新建一个空的Unity项目,再将源码中的Assets、Packages、ProjectSettings文件夹覆盖过去。
这个项目源码的价值在于它展示了一个完整、可运行的游戏循环。我建议你不要只满足于让它跑起来,而是尝试去修改它:增加一个新的“冲锋”技能,让单位可以移动后攻击更远的敌人;或者修改AI,让敌人会优先集火血量最低的我方单位。在这个过程中,你会对Unity游戏开发有更深刻、更实际的理解。游戏开发是门实践的手艺,动手改,比光看十遍代码都管用。