Unity SRPG战棋战斗系统原型开发:从网格地图到回合状态机
把“超级机器人大战 ZOP”当作一个工程代号而不是游戏产品来理解会比较顺畅。这个代号代表一个从零开始搭建的 SRPG 回合制战斗原型目标是在 Unity 中跑通这样的闭环玩家在网格地图上选择机体、查看移动范围、移动机体、在射程内选择敌人、计算命中与伤害、结束回合后由敌方行动最后根据双方存活状态判断胜负。对刚接触战棋游戏开发的开发者来说这个项目正好把编程里最容易忽略的几块内容压缩在一起二维网格建模、路径搜索、状态机流转、数据驱动、点击事件与坐标换算。需要先声明一点这个项目不包含超级机器人大战官方的剧情、角色立绘、战斗动画或音效只是借用同类 SRPG 的核心玩法逻辑来练习系统开发。名字里的“ZOP”可以理解为 Zero Original Prototype也就是“从零开始的原型”。因此在阅读下面的代码时不需要考虑任何版权素材只需要关注战斗逻辑本身。1. 先定位 ZOP 项目一套可以运行的 SRPG 战斗系统而不是完整游戏1.1 为什么战棋战斗系统值得单独做一遍很多人第一次接触“超级机器人大战”类的游戏会觉得重点是华丽的机体、热血的BGM和大量原作剧情。但从开发角度看真正决定游戏能不能玩起来的是回合制战棋战斗系统本身。它最核心的难点不是动画播放而是状态之间的切换。当玩家点击一台机体时系统需要判断当前是否处于玩家回合、该机体是否已经行动、点击的是地面还是敌人、地面是否在移动范围内、敌人是否在攻击范围内。这些判断如果全部堆在事件回调里代码会很快失控。工程化的做法是把流程拆成清晰的阶段选择单位、移动、攻击、结束回合、敌方回合、胜负判断。这套流程恰好是一个有限状态机。梳理好状态机再实现移动范围和攻击范围计算整个战斗系统就有了骨架。后面再添加动画、剧情、存档都是在骨架之上增加内容。1.2 选择 Unity 作为实现载体的原因ZOP 原型选择 Unity而不是纯 Python 或控制台程序主要原因是交互反馈更直观。移动范围、攻击范围、高亮格子、点击选中单位这些操作如果用控制台打印坐标也能完成逻辑验证但无法体验“移动机体后攻击范围跟着变化”的手感。Unity 的网格组件、物理射线、Gizmos 调试工具都能降低实现成本。本文示例基于 Unity 2022.3 LTSC# 脚本使用 Unity 默认的 Mono 开发环境。如果使用其他版本代码主体不需要大改只需要关注Input、Physics.Raycast、Vector3这些 API 在不同版本里是稳定存在的。1.3 项目边界和验收标准学习项目不能一开始就追求完整游戏功能。ZOP 原型应该限定在一个短周期内可以跑通的范围功能模块原型阶段要求正式游戏阶段要求地图固定 8 x 8 网格支持障碍物多张地图、动态加载、地形属性单位2 个阵营每侧 2 到 3 台机体大量机体、技能、驾驶员系统回合玩家行动后切敌方回合多名单位行动排序、支援回合移动BFS 计算移动范围A* 寻路、ZOC、地形消耗攻击命中率、伤害、射程武器类型、精神指令、动画演出胜负一方全部击坠剧情分支、撤退单位、回合限制验收标准也很简单能完整打一局双方都能移动、攻击、扣血一方的单位全部被击坠后弹出胜负结果。在这个标准之上再考虑扩展动画和 UI。2. Unity 工程初始化和地图网格设计2.1 创建工程和建议目录结构打开 Unity Hub新建一个 3D Core 工程。不需要启用任何额外的渲染管线因为原型阶段只需要 Cube 作为地面格子和单位占位。工程创建完成后建议在Assets下按模块建立目录避免所有脚本堆在根目录Assets/ Scripts/ BattleMap/ Unit/ Turn/ Battle/ Utility/ Prefabs/ Unit/ Data/ Json/按模块分目录的好处是当脚本数量增加时可以准确找到某个功能对应的文件。比如后面要排查移动范围问题就直接进BattleMap目录不相关代码不会混进来。2.2 用二维数组表达地图战棋地图有两个维度行和列。最直接的数据结构是二维数组。虽然 Unity 中显示世界坐标常用Vector3但格子坐标建议用一个Vector2Int或者自定义的GridPos来表示。下面定义地形枚举和地图数据类public enum TerrainType { Empty, Obstacle } public class BattleMapData { public int Width; public int Height; public TerrainType[,] Cells; public BattleMapData(int width, int height) { Width width; Height height; Cells new TerrainType[width, height]; } public bool IsBlocked(int x, int z) { return x 0 || z 0 || x Width || z Height || Cells[x, z] TerrainType.Obstacle; } }这里把坐标顺序固定为(x, z)其中x代表列z代表行。很多新手会把二维数组的索引顺序和 Unity 世界坐标搞混。Unity 的 3D 场景通常把高度放在y轴所以地图网格应当放在xz平面上。这个约定从第一版地图开始就要统一否则后面坐标换算会出现非常隐蔽的错位。2.3 网格坐标与世界坐标之间的换算为了让格子显示在场景中需要把(x, z)格子坐标转换成Vector3世界坐标。这里引入两个参数cellSize表示每个格子的真实尺寸originPosition表示格子(0,0)对应的世界坐标。public class BattleMapView : MonoBehaviour { public int width 8; public int height 8; public float cellSize 1f; public Vector3 originPosition Vector3.zero; public Vector3 CellToWorld(int x, int z) { float worldX originPosition.x x * cellSize; float worldZ originPosition.z z * cellSize; return new Vector3(worldX, 0f, worldZ); } public Vector2Int WorldToCell(Vector3 worldPos) { int x Mathf.FloorToInt((worldPos.x - originPosition.x) / cellSize); int z Mathf.FloorToInt((worldPos.z - originPosition.z) / cellSize); return new Vector2Int(x, z); } public bool IsInsideGrid(Vector2Int cell) { return cell.x 0 cell.z 0 cell.x width cell.z height; } }这段代码的关键点是Mathf.FloorToInt。如果格子尺寸是 1地面中心点取整后可能落在边界上容易产生偏移。建议在放置地面格子时把每个格子的中心点作为CellToWorld的返回值这样点击地面时使用WorldToCell会更稳定。2.4 在编辑器里绘制网格辅助线为了不依赖美术资源可以用OnDrawGizmos在 Scene 窗口直接画出网格边界。这样每次修改地图宽度、高度或格子尺寸都能立刻看到变化。private void OnDrawGizmos() { Gizmos.color Color.white; for (int x 0; x width; x) { Vector3 start CellToWorld(x, 0); Vector3 end CellToWorld(x, height); Gizmos.DrawLine(start, end); } for (int z 0; z height; z) { Vector3 start CellToWorld(0, z); Vector3 end CellToWorld(width, z); Gizmos.DrawLine(start, end); } }在 Scene 窗口看到完整网格后就可以继续开发单位数据模型。不要在没有网格可视化的情况下直接写UnitController因为调试点击事件时你会分不清格子坐标和世界坐标是否匹配。3. 单位数据模型与阵营管理3.1 数据与表现分离SRPG 的单位包含两类信息一类是固定数值比如攻击力、防御力、移动力另一类是运行时状态比如当前血量、是否行动过。如果把这些字段全部塞进UnitController会导致单位生成、加载存档、敌人 AI 都依赖同一个类后续扩展非常困难。一个更清晰的思路是定义纯数据类UnitData让UnitController负责表现和交互。UnitData不依赖任何 Unity API可以被 JSON、ScriptableObject 或数据库反序列化。[System.Serializable] public class UnitData { public string unitId; public string displayName; public Faction faction; public int maxHp; public int currentHp; public int moveRange 4; public int minAttackRange 1; public int maxAttackRange 2; public int attack; public int defense; public int accuracy; public int evasion; public int mentalValue; public bool hasActed; }把hasActed放在UnitData里虽然方便但在生产项目中更推荐把它放到单独的回合状态对象中避免存档数据被临时状态污染。原型阶段放在一起可以减少代码量但你要知道这是一个需要改进的点。3.2 阵营枚举与单位管理器战斗中至少要区分两个阵营玩家和敌方。用枚举表示比字符串更安全也能避免拼写错误。public enum Faction { Player, Enemy }单位管理器负责登记所有单位并在单位死亡时从列表中移除。同时提供按阵营查询单位的方法供回合管理和胜负判断使用。public class UnitManager : MonoBehaviour { public ListUnitController allUnits new ListUnitController(); private ListUnitController pendingRemoval new ListUnitController(); public void Register(UnitController unit) { if (!allUnits.Contains(unit)) { allUnits.Add(unit); } } public void Unregister(UnitController unit) { pendingRemoval.Add(unit); } public ListUnitController GetUnitsByFaction(Faction faction) { ListUnitController result new ListUnitController(); foreach (var unit in allUnits) { if (unit.Data.faction faction) { result.Add(unit); } } return result; } public bool HasUnitAt(Vector2Int cell) { foreach (var unit in allUnits) { if (unit.GridPos cell) { return true; } } return false; } public void ApplyPendingRemoval() { foreach (var unit in pendingRemoval) { allUnits.Remove(unit); } pendingRemoval.Clear(); } }这里引入pendingRemoval是因为不能在遍历列表时直接Remove单位否则会破坏迭代顺序。ApplyPendingRemoval通常在回合切换或战斗结算结束后调用。3.3 从数据生成单位单位生成建议用 Prefab 实例化再把UnitData注入给UnitController。下面是一个最小实现public class UnitSpawner : MonoBehaviour { public GameObject unitPrefab; public UnitController Spawn(UnitData data, Vector3 worldPos) { GameObject go Instantiate(unitPrefab, worldPos, Quaternion.identity); UnitController controller go.GetComponentUnitController(); controller.Init(data); return controller; } }UnitController.Init要做两件事把数据保存到自身并设置显示名和血量占位。public class UnitController : MonoBehaviour { public UnitData Data { get; private set; } public Vector2Int GridPos { get; set; } public TextMesh label; public void Init(UnitData data) { Data data; GridPos Vector2Int.zero; if (label ! null) { label.text ${data.displayName}\n{data.currentHp}/{data.maxHp}; } } }在场景中创建单位时建议只创建一个空物体然后通过按钮或代码调用Spawn。不要把单位数据写死在 Prefab 里。数据写死会让后续测试不同属性组合变得非常麻烦。4. 回合流程状态机4.1 回合状态枚举回合是 SRPG 最重要的状态机。如果回合状态不清晰玩家可能会在敌方回合继续操作或者敌方单位在一回合内多次行动。定义状态枚举public enum TurnSide { PlayerTurn, EnemyTurn, BattleEnd }在玩家回合内部还需要更细的状态可能处于“待选择单位”、“待移动”、“待攻击”、“已行动”。这里可以再拆分一个阶段枚举public enum PlayerActionPhase { SelectUnit, SelectMoveCell, SelectAttackTarget, WaitEndTurn }阶段越细交互逻辑越清晰。比如点击地面时只有在SelectMoveCell阶段才需要响应移动点击敌人时只有在SelectAttackTarget阶段才允许攻击。4.2 玩家回合的流程控制回合管理器代码如下public class TurnManager : MonoBehaviour { public UnitManager unitManager; public BattleMapView mapView; public TurnSide currentSide; public PlayerActionPhase playerPhase PlayerActionPhase.SelectUnit; public void StartBattle() { currentSide TurnSide.PlayerTurn; ResetAllUnitActedFlag(); Debug.Log(玩家回合开始); } public void EndPlayerTurn() { if (!AreAllPlayerUnitsActed()) { Debug.LogWarning(还有玩家单位尚未行动); return; } currentSide TurnSide.EnemyTurn; RunEnemyTurn(); } private bool AreAllPlayerUnitsActed() { var playerUnits unitManager.GetUnitsByFaction(Faction.Player); foreach (var unit in playerUnits) { if (!unit.Data.hasActed) { return false; } } return true; } private void ResetAllUnitActedFlag() { foreach (var unit in unitManager.allUnits) { unit.Data.hasActed false; } } }EndPlayerTurn里做了保护如果有单位没有行动直接结束回合会丢失操作权。很多原型出现“敌人突然都动不了”或“我方还能再动一次”的问题都是因为这里缺少行动标记检查。4.3 敌方回合的最小 AI 逻辑敌方回合不需要复杂 AI。可以让每个敌人寻找距离最近的玩家单位移动到攻击范围内然后发动攻击。这里把它简化成逐个执行private void RunEnemyTurn() { var enemies unitManager.GetUnitsByFaction(Faction.Enemy); foreach (var enemy in enemies) { if (enemy.Data.currentHp 0) continue; var target FindNearestPlayerUnit(enemy.GridPos); if (target null) continue; int distance Mathf.Abs(enemy.GridPos.x - target.GridPos.x) Mathf.Abs(enemy.GridPos.z - target.GridPos.z); if (distance enemy.Data.maxAttackRange distance enemy.Data.minAttackRange) { BattleCalculator.Attack(enemy, target); } else { MoveToward(enemy, target.GridPos); } enemy.Data.hasActed true; } CheckBattleEnd(); if (currentSide ! TurnSide.BattleEnd) { currentSide TurnSide.PlayerTurn; ResetAllUnitActedFlag(); Debug.Log(玩家回合开始); } }FindNearestPlayerUnit和MoveToward在原型中可以采用最简单的“向目标方向移动一格”逻辑。真正的游戏会使用 A* 寻路但在格子地图上两步或三步的敌方行动不需要一开始就做得很聪明。4.4 胜负判断胜负判断可以放在单位死亡之后也可以放在回合切换之前。建议独立成一个方法避免在伤害逻辑里引用回合管理。public bool CheckBattleEnd() { var playerUnits unitManager.GetUnitsByFaction(Faction.Player); var enemyUnits unitManager.GetUnitsByFaction(Faction.Enemy); int alivePlayer 0; foreach (var unit in playerUnits) { if (unit.Data.currentHp 0) alivePlayer; } int aliveEnemy 0; foreach (var unit in enemyUnits) { if (unit.Data.currentHp 0) aliveEnemy; } if (alivePlayer 0) { currentSide TurnSide.BattleEnd; Debug.Log(玩家失败); return true; } if (aliveEnemy 0) { currentSide TurnSide.BattleEnd; Debug.Log(玩家胜利); return true; } return false; }此时胜负判断只根据存活数量不处理“撤退”和“回合数限制”。原型阶段已经够用。注意回合状态机里最容易被忽略的是“单位死亡后从列表移除”的时机。不要把Destroy(gameObject)直接放在伤害计算内部。建议战斗结算结束后调用ApplyPendingRemoval()再判断胜负。5. 移动范围和攻击范围计算5.1 移动范围为什么用 BFS战棋地图上移动范围本质上是“从起点出发经过消耗 1 的相邻格子最多走moveRange步能到达的所有格子”。这个场景非常适合用广度优先搜索 BFS。BFS 可以保证每个格子第一次访问时记录的是最短步数避免绕路导致结果错误。如果使用深度优先递归容易在一个环形路径里反复递归最终导致栈溢出或重复计算。所以原型阶段固定用 BFS后续需要地形消耗或 ZOC 时再把“步数消耗”从固定 1 改成动态值。5.2 BFS 移动范围实现public HashSetVector2Int GetMoveRange(Vector2Int start, int movePoints) { HashSetVector2Int result new HashSetVector2Int(); QueueCellCost queue new QueueCellCost(); DictionaryVector2Int, int costMap new DictionaryVector2Int, int(); costMap[start] 0; queue.Enqueue(new CellCost(start, 0)); int[] dx { 1, -1, 0, 0 }; int[] dz { 0, 0, 1, -1 }; while (queue.Count 0) { CellCost current queue.Dequeue(); Vector2Int pos current.pos; int cost current.cost; if (cost movePoints) continue; if (cost 0 !IsCellWalkable(pos)) continue; result.Add(pos); for (int i 0; i 4; i) { Vector2Int next new Vector2Int(pos.x dx[i], pos.z dz[i]); if (costMap.ContainsKey(next)) continue; int nextCost cost 1; if (nextCost movePoints) { costMap[next] nextCost; queue.Enqueue(new CellCost(next, nextCost)); } } } result.Remove(start); return result; }这里面有两个细节值得注意。第一IsCellWalkable不仅要判断地图障碍物还要判断其他单位是否占用该格子。如果移动可以穿过敌人可以直接把敌人格子也加入costMap但能否停留在敌人身上需要额外判断。原型阶段建议同时禁止穿越和停留。第二result.Remove(start)会移除起点。因为起点上已经有当前单位不需要再一次移动。IsCellWalkable方法通常放在BattleMapView或一个工具类中public bool IsCellWalkable(Vector2Int cell) { if (!IsInsideGrid(cell)) return false; if (mapData.IsBlocked(cell.x, cell.z)) return false; if (unitManager.HasUnitAt(cell)) return false; return true; }5.3 攻击范围要从移动后的格子开始扩展攻击范围的常见错误是玩家站在格子 A移动力是 5攻击射程是 2结果系统只计算了 A 点周围 2 格范围没有计算移动到 A1 点后周围 2 格范围。这样会造成“能移动过去但打不到敌人”的奇怪体验。正确做法是先拿到移动范围moveCells然后对每个可达格子扩展射程public HashSetVector2Int GetAttackRange( Vector2Int start, int movePoints, int minRange, int maxRange) { HashSetVector2Int moveCells GetMoveRange(start, movePoints); HashSetVector2Int attackCells new HashSetVector2Int(); moveCells.Add(start); foreach (Vector2Int moveCell in moveCells) { for (int x moveCell.x - maxRange; x moveCell.x maxRange; x) { for (int z moveCell.z - maxRange; z moveCell.z maxRange; z) { Vector2Int cell new Vector2Int(x, z); if (!IsInsideGrid(cell)) continue; int distance Mathf.Abs(x - moveCell.x) Mathf.Abs(z - moveCell.z); if (distance minRange distance maxRange) { attackCells.Add(cell); } } } } attackCells.Remove(start); return attackCells; }这里使用了曼哈顿距离也就是四方向移动下“走横格和竖格”的最短距离。如果项目要支持斜向移动需要改用切比雪夫距离并同步修改 BFS 的方向数组。5.4 高亮格子与点击选中的衔接有了范围和坐标接下来要把范围画在地图上。最简单的方式是生成一批半透明 Cube 到目标格子但要注意对象生命周期避免每次点击都创建新物体public class RangeHighlight : MonoBehaviour { public GameObject cellHighlightPrefab; private ListGameObject activeHighlights new ListGameObject(); public void ShowCells(HashSetVector2Int cells, BattleMapView mapView) { ClearHighlights(); foreach (Vector2Int cell in cells) { GameObject go Instantiate(cellHighlightPrefab, mapView.CellToWorld(cell.x, cell.z), Quaternion.identity); activeHighlights.Add(go); } } public void ClearHighlights() { foreach (GameObject go in activeHighlights) { Destroy(go); } activeHighlights.Clear(); } }在玩家选中单位后调用两次范围计算moveRange mapView.GetMoveRange(selectedUnit.GridPos, selectedUnit.Data.moveRange); attackRange mapView.GetAttackRange( selectedUnit.GridPos, selectedUnit.Data.moveRange, selectedUnit.Data.minAttackRange, selectedUnit.Data.maxAttackRange);然后先显示移动范围当玩家点击一个移动范围内的格子时再根据新的单位位置重新显示攻击范围。很多原型只计算一次攻击范围导致移动后高亮区域没有更新这个问题在调试时要特别注意。6. 伤害公式、命中率和战斗结算6.1 伤害公式的设计理由SRPG 的伤害公式不需要一开始就完全还原官方作品。原型阶段建议使用一个“减法公式”它足够直观也方便调参基础伤害 攻击力 - 防御力 最终伤害 基础伤害 * 随机浮动系数为了防止高防御单位完全无伤可以把最终伤害最小值设为 1。这样即使攻击力低于防御力也不会出现无法击坠对方的情况。参数含义原型建议范围Attack机体攻击力800 - 2000Defense机体防御力600 - 1500Accuracy命中补正70 - 100Evasion闪避补正10 - 60MaxHp机体血量3000 - 6000这个数值范围只是示例实际项目要根据战斗节奏不断调整。不要一上来就追求数值平衡先把流程跑通再用批量战斗模拟去测平衡。6.2 命中率计算命中率公式采用加法模型public static int CalcHitRate(UnitData attacker, UnitData defender) { int hitRate 100 attacker.accuracy - defender.evasion; return Mathf.Clamp(hitRate, 30, 100); }Mathf.Clamp(hitRate, 30, 100)保证了最低 30% 命中最高 100% 命中。这样不会出现命中率低于 0 的极端情况也不会出现超过 100% 后命中率失效的问题。需要注意的是这个公式没有考虑地形回避、距离惩罚、驾驶员技能。如果需要加入这些因素可以在CalcHitRate中增加参数public static int CalcHitRate(UnitData attacker, UnitData defender, int terrainBonus) { int hitRate 100 attacker.accuracy - defender.evasion - terrainBonus; return Mathf.Clamp(hitRate, 30, 100); }6.3 执行攻击的完整逻辑战斗结算建议写成一个静态工具类方便被玩家点击和敌方 AI 复用public static class BattleCalculator { public static BattleRecord Attack(UnitController attacker, UnitController defender) { if (defender.Data.currentHp 0) { return null; } int hitRate CalcHitRate(attacker.Data, defender.Data); bool isHit Random.Range(0, 100) hitRate; BattleRecord record new BattleRecord { attackerName attacker.Data.displayName, defenderName defender.Data.displayName, hitRate hitRate, isHit isHit, damage 0 }; if (isHit) { int damage CalcDamage(attacker.Data, defender.Data); defender.Data.currentHp - damage; record.damage damage; if (defender.Data.currentHp 0) { defender.Data.currentHp 0; record.isKilled true; } } else { record.isKilled false; } attacker.Data.hasActed true; return record; } private static int CalcDamage(UnitData attacker, UnitData defender) { int baseDamage Mathf.Max(1, attacker.attack - defender.defense); float randomFactor Random.Range(0.9f, 1.1f); return Mathf.RoundToInt(baseDamage * randomFactor); } }在这个实现里attacker.Data.hasActed true放在攻击成功或失败之后。这样无论是否命中该单位都算行动完毕避免玩家无限攻击同一个敌人。如果日后要支持“攻击后还能移动”可以把行动标记拆成“移动标记”和“攻击标记”。6.4 战斗记录与日志输出为了让调试可见定义一个简单的战斗记录类public class BattleRecord { public string attackerName; public string defenderName; public int hitRate; public bool isHit; public int damage; public bool isKilled; }在玩家点击敌人后调用BattleCalculator.Attack然后把BattleRecord输出到 Console 或者 UI 面板BattleRecord record BattleCalculator.Attack(selectedUnit, clickedEnemy); if (record ! null) { if (record.isHit) { Debug.Log(${record.attackerName} 攻击 {record.defenderName}命中 {record.hitRate}%造成 {record.damage} 点伤害); } else { Debug.Log(${record.attackerName} 攻击 {record.defenderName}命中 {record.hitRate}%攻击未命中); } if (record.isKilled) { Debug.Log(${record.defenderName} 被击坠); unitManager.Unregister(clickedEnemy); Destroy(clickedEnemy.gameObject); } }这段逻辑可以放到一个BattleController中负责串联TurnManager、UnitManager和BattleCalculator。不要在UnitController内部直接调用伤害计算否则单位对象既要负责显示又要负责战斗规则职责会越来越重。7. 常见问题排查7.1 高频问题现象和处理方式ZOP 原型虽然代码量不大但每个问题都容易反复出现。下面整理了一份排查表问题现象常见原因检查方式解决建议单位可以重复移动或攻击hasActed没有在攻击后置为 true打断点查看hasActed赋值时机在移动完成和攻击完成后都设置hasActed移动范围穿过了敌人IsCellWalkable没有查询单位占用在移动范围方法里打印阻挡格坐标在IsCellWalkable中调用unitManager.HasUnitAt攻击范围只围绕初始格不跟随移动使用移动前的起点计算攻击范围检查调用GetAttackRange时的参数先计算移动范围再基于移动后的位置计算攻击范围点击地面没有反应没有判断当前回合阶段在点击回调里打印playerPhase将响应分成SelectUnit、SelectMoveCell、SelectAttackTarget射线打不到地面或打到了单位地面没有碰撞体或点击用了Physics.Raycast没有过滤层在 Raycast 前检查层掩码给地面加BoxCollider使用LayerMask过滤坐标错位单位出现在两格之间CellToWorld和WorldToCell的offset不一致分别打印CellToWorld(1,1)和WorldToCell结果统一使用格子的中心点进行换算7.2 从现象到根因的排查路径遇到问题时不要直接改代码。推荐按照固定顺序排查检查输入是否正确。点击的格子是否真的能被WorldToCell转换到网格内。检查状态机。当前TurnSide和PlayerActionPhase是否满足操作条件。检查范围计算结果。在移动和攻击前用Debug.Log打印HashSet容量。检查单位标记。hasActed是否已经复位或置位。检查列表更新。死亡单位是否从allUnits中移除是否在遍历列表时发生了修改。检查坐标约定。所有代码是否统一使用Vector2Int的x和z不要把y当成行号。例如玩家点击敌人时无法攻击应该先看playerPhase。如果当前处于SelectUnit那么攻击逻辑根本不应该触发。这是最常见的问题不是攻击逻辑写错了而是状态没有切到SelectAttackTarget。排查 SRPG 逻辑问题时最有效的做法是“每一步都打印状态”。打印playerPhase、当前选中单位、移动范围数量、攻击范围数量。确认这些中间量都符合预期后再检查最终伤害和胜负判断。8. 项目落地建议与后续扩展方向8.1 学习环境与生产环境的差异ZOP 原型可以快速用固定数组和硬编码数值跑通但如果要进入长期维护或团队协作必须把数据外置、日志规范化、代码分层更严格地做好。关注点原型阶段做法生产环境建议地图代码里new BattleMapData(8, 8)Excel 或 ScriptableObject 配置多张地图单位UnitSpawner里手动传入UnitDataJSON 或 CSV 批量读取日志Debug.Log简单输出使用日志库按模块输出到文件存档不处理序列化单位状态、回合状态、地图状态对象管理Destroy立即销毁使用对象池回收单位和高亮格子性能每帧遍历单位列表对单位坐标建立字典缓存避免频繁全量查询8.2 数据配置外置化当单位数量增多时不建议在代码里手动new UnitData。可以放到 JSON 文件中。Unity 的JsonUtility不能直接反序列化顶层数组所以需要包一层[System.Serializable] public class UnitDataWrapper { public ListUnitData units; }读取示例TextAsset json Resources.LoadTextAsset(UnitConfigs/player_units); UnitDataWrapper wrapper JsonUtility.FromJsonUnitDataWrapper(json.text); foreach (var unitData in wrapper.units) { spawner.Spawn(unitData, startPos); }对应 JSON 示例{ units: [ { unitId: prototype_01, displayName: 试作机, faction: Player, maxHp: 5000, currentHp: 5000, moveRange: 5, minAttackRange: 1, maxAttackRange: 3, attack: 1200, defense: 800, accuracy: 85, evasion: 30, mentalValue: 100, hasActed: false } ] }这里的currentHp虽然是运行时状态但为了读取初始配置方便也可以保留在 JSON 中。生产环境建议把初始血量和当前血量分离避免存档数据覆盖初始配置。8.3 性能优化方向当网格从 8 x 8 扩大到 100 x 100单位数量从 6 个扩大到 100 个时原来的全量遍历和即时Instantiate就会成为性能瓶颈。优先做三件事单位坐标索引用DictionaryVector2Int, UnitController缓存每个格子的单位避免每帧线性遍历。高亮格子对象池移动范围和攻击范围每次切换都会生成大量 Cube使用对象池可以避免频繁Instantiate和Destroy。A* 寻路当移动力较大时BFS 仍可算出可达范围但单位移动过程需要一条实际路径建议用 A* 替代“向目标方向移动一格”的敌方 AI 逻辑。BFS 负责范围A* 负责路径两者互补。8.4 可以继续添加的功能ZOP 原型跑通后下一步可以从最容易提升手感的模块开始加精神指令系统必中、热血、铁壁等效果本质上是给单位临时 buff。地形效果森林、基地、建筑提供防御或回避补正。反击系统攻击结束后如果目标存活且射程内有攻击者可以触发一次反击注意反击不应对回合行动标记造成影响。战斗动画用引擎的Animation或简单粒子模拟攻击表现此时BattleRecord中的信息已经足够支持动画播报。存档将UnitData的当前血量、坐标、已行动状态和当前回合数序列化到 JSON 或 PlayerPrefs。这个项目的价值不在于“做出一个完整游戏”而在于把 SRPG 最底层的三个能力练扎实状态机控制流程、网格算法处理范围和位移、数据驱动支撑后续扩展。建议在继续加功能之前先自己动手实现一遍 8 x 8 地图上的完整对局把每一处 Debug 日志都打印出来亲眼确认 BFS 范围、攻击范围、伤害结果和回合切换都没有遗漏。这样后面无论做机战类还是火焰纹章类玩法都能复用同一套骨架。
