2026/10/6 19:03:51

Unity独立开发战棋游戏:地图、回合状态机与AI实战

Unity独立开发战棋游戏:地图、回合状态机与AI实战 简介这是一款基于Unity引擎独立开发的小型战棋游戏完整项目源码面向计算机相关专业的在校学生、教师及企业开发者尤其适合作为毕业设计、课程设计、作业或项目初期立项演示的参考素材。项目代码经过完整测试运行稳定答辩评审平均分达到96分对Unity游戏开发、战棋玩法逻辑与C#脚本编写感兴趣的学习者可直接下载研究。压缩包共约2000个文件整体大小135.37MB涵盖cs脚本、prefab预制体、unity场景、asset资源、png与psd美术素材、mat材质、json配置、xml数据及md说明文档等完整保留了Unity工程的目录结构与资源组织方式。目前已有322人学习下载。读者可从中获取一套可运行、可修改的战棋游戏工程理解回合制战斗、地图格子、单位控制与资源管理等模块的实现思路并在此基础上扩展新功能用于个人练习或二次开发。1. 从一张格子地图开始Unity 独立开发小型战棋游戏到底难在哪很多人第一次动独立战棋的念头都是因为玩过《火焰纹章》或《皇家骑士团》觉得“不就是格子加回合制吗”。真上手用 Unity 做第一周就会撞墙格子坐标和世界坐标怎么换算、移动范围怎么用 BFS 算出来、角色走位怎么平滑过渡、敌方 AI 怎么不显得像傻子。这四个问题任何一个没处理好游戏就变成“能跑但不好玩”的 demo。这篇笔记面向的是有 Unity 基础、想独立完成一款小型战棋10 到 20 关体量的开发者。我会按实际开发顺序把地图系统、回合状态机、寻路与移动范围、战斗数值、敌方 AI 这几块拆开讲每块给出可复现的代码和参数。不追求 3A 品质目标是让你在两周内跑通一个完整可玩的循环。战棋游戏的核心乐趣来自“决策密度”而不是画面所以前期把逻辑做扎实比堆粒子特效重要得多。2. 地图与格子系统把 Tilemap 变成可计算的战场2.1 为什么用 Tilemap 而不是自己画 MeshUnity 的 Tilemap 系统在 2D 战棋里几乎是默认选择。它自带 Grid 组件做坐标换算支持 Rule Tile 自动拼接地形边缘还能用 Tilemap Collider 做物理层。自己写 Mesh 生成器当然更灵活但独立开发的时间成本划不来。常见做法是一个 Grid 下挂三层 Tilemap——地面层Terrain、装饰层Decoration、覆盖层Overlay用来显示移动范围高亮。坐标换算的核心 API 只有两个grid.WorldToCell(worldPos)和grid.CellToWorld(cellPos)。注意CellToWorld返回的是格子左下角不是中心点。如果你想让角色站在格子正中间需要加上grid.cellSize * 0.5f。这个偏移量在等距视角Isometric下会更复杂因为 CellToWorld 返回的是菱形包围盒的角点。// GridCoordinateHelper.cs using UnityEngine; using UnityEngine.Tilemaps; public class GridCoordinateHelper : MonoBehaviour { public Grid grid; public Tilemap terrainMap; // 世界坐标转格子坐标 public Vector3Int WorldToCell(Vector3 worldPos) { return grid.WorldToCell(worldPos); } // 格子坐标转世界坐标返回格子中心 public Vector3 CellToWorldCenter(Vector3Int cellPos) { Vector3 corner grid.CellToWorld(cellPos); return corner grid.cellSize * 0.5f; } // 判断某格是否有地形可通行 public bool HasTerrain(Vector3Int cellPos) { return terrainMap.HasTile(cellPos); } }逻辑说明WorldToCell内部做了浮点到整数的取整默认是 Floor 行为所以鼠标点在格子右上方时可能落到相邻格。如果发现点击偏移检查 Grid 的cellSize和 Tilemap 的tileAnchor是否一致。参数方面cellSize建议设为 (1, 1, 0) 或 (1, 0.5, 0)等距不要用非整数否则浮点误差会让寻路出现“明明相邻却走不过去”的玄学问题。2.2 地形数据与可通行标记Tilemap 只负责渲染逻辑层需要单独维护一份地形数据。我一般用一个二维数组TerrainType[,]或者字典DictionaryVector3Int, TerrainData。TerrainData 里至少包含移动消耗int、是否可通行bool、防御加成float、特殊效果枚举。[System.Serializable] public class TerrainData { public string terrainName; public int moveCost 1; // 移动消耗-1 表示不可通行 public float defenseBonus 0f; // 防御加成百分比 public bool blocksSight false; } public class TerrainManager : MonoBehaviour { public TerrainData[] terrainPalette; // 在 Inspector 里配置 private DictionaryVector3Int, TerrainData terrainDict new(); public void RegisterTerrain(Vector3Int cell, int paletteIndex) { terrainDict[cell] terrainPalette[paletteIndex]; } public TerrainData GetTerrain(Vector3Int cell) { return terrainDict.TryGetValue(cell, out var data) ? data : null; } public bool IsWalkable(Vector3Int cell) { var t GetTerrain(cell); return t ! null t.moveCost 0; } }参数说明moveCost设为 -1 表示不可通行墙、水设为 2 表示森林或沼泽消耗两步。防御加成建议控制在 0 到 0.3 之间超过 0.5 会让战斗节奏拖沓。这套数据结构的好处是寻路算法只读 TerrainManager不依赖 Tilemap 的渲染状态后期换美术资源不影响逻辑。2.3 移动范围计算BFS 而不是 A*战棋里显示“角色能走到哪些格子”用的是 BFS广度优先搜索因为移动力有限通常 4 到 7 格BFS 能保证在消耗范围内找到所有可达格。A* 是用来找“到某个目标点的最短路径”的两者用途不同。常见错误是用 A* 去遍历所有格子性能差且代码绕。using System.Collections.Generic; using UnityEngine; public class MovementRangeCalculator { // 返回所有可达格子及其消耗 public DictionaryVector3Int, int CalculateRange( Vector3Int start, int movePoints, TerrainManager terrain) { var visited new DictionaryVector3Int, int(); var queue new QueueVector3Int(); visited[start] 0; queue.Enqueue(start); // 四方向邻接战棋通常不允许斜走 Vector3Int[] dirs { new Vector3Int(1, 0, 0), new Vector3Int(-1, 0, 0), new Vector3Int(0, 1, 0), new Vector3Int(0, -1, 0) }; while (queue.Count 0) { var current queue.Dequeue(); int currentCost visited[current]; foreach (var dir in dirs) { var next current dir; if (!terrain.IsWalkable(next)) continue; int stepCost terrain.GetTerrain(next).moveCost; int newCost currentCost stepCost; if (newCost movePoints) continue; if (visited.ContainsKey(next) visited[next] newCost) continue; visited[next] newCost; queue.Enqueue(next); } } visited.Remove(start); // 起点不算可达格 return visited; } }逻辑说明队列里存的是格子坐标visited字典同时充当“已访问”和“最小消耗”两个角色。每次从队列取出一个格子检查四个方向的邻居如果新消耗小于已记录消耗就更新并入队。注意这里没有用visited.Contains直接跳过而是比较消耗值因为同一格可能通过不同路径到达且消耗不同。参数方面movePoints就是角色的移动力属性建议初始值设为 5。如果游戏里有“再移动”技能可以在计算完范围后额外加一圈。性能上20x20 的地图 BFS 耗时在 0.1ms 级别完全不需要优化。但如果地图超过 100x100考虑用分块加载。3. 回合状态机与角色行动流程别让 Update 里塞满 if3.1 用状态模式管理回合流转战棋的回合流程是典型的有限状态机玩家回合开始 → 选中角色 → 显示移动范围 → 移动 → 选择行动攻击/待机/技能→ 结算 → 下一个角色 → 玩家回合结束 → 敌方回合。如果全用 bool 标志位在 Update 里判断代码会迅速膨胀成意大利面条。我一般用一个TurnStateMachine管理全局状态每个状态实现IState接口Enter/Execute/Exit。状态之间通过事件或直接调用切换。下面是一个精简版实现public interface ITurnState { void Enter(); void Execute(); void Exit(); } public class TurnStateMachine : MonoBehaviour { private ITurnState currentState; public PlayerTurnState playerTurn; public EnemyTurnState enemyTurn; void Start() { playerTurn new PlayerTurnState(this); enemyTurn new EnemyTurnState(this); TransitionTo(playerTurn); } public void TransitionTo(ITurnState next) { currentState?.Exit(); currentState next; currentState.Enter(); } void Update() { currentState?.Execute(); } }逻辑说明TransitionTo保证 Exit 和 Enter 成对调用避免状态残留。Execute每帧执行适合处理输入检测和动画等待。参数上状态切换的时机很关键——不要在动画播放中途切换否则会出现“角色还在走回合已经结束”的翻车现场。常见做法是在动画事件Animation Event或 DOTween 的 OnComplete 回调里触发切换。3.2 角色行动子状态选中、移动、行动全局状态之下每个角色还有自己的行动子状态。我通常用一个UnitController挂载在角色预制体上内部维护UnitState枚举Idle、Selected、Moving、Acting、Done。public enum UnitState { Idle, Selected, Moving, Acting, Done } public class UnitController : MonoBehaviour { public UnitState State { get; private set; } UnitState.Idle; public int movePoints 5; public int actionPoints 1; private MovementRangeCalculator rangeCalc new(); private ListVector3Int currentPath; public void Select() { if (State ! UnitState.Idle) return; State UnitState.Selected; // 通知 UI 显示移动范围 var range rangeCalc.CalculateRange( GetCellPosition(), movePoints, TerrainManager.Instance); RangeHighlighter.Instance.Show(range); } public void MoveAlongPath(ListVector3Int path) { if (State ! UnitState.Selected) return; currentPath path; State UnitState.Moving; StartCoroutine(MoveRoutine()); } private IEnumerator MoveRoutine() { foreach (var cell in currentPath) { Vector3 target GridCoordinateHelper.Instance.CellToWorldCenter(cell); while (Vector3.Distance(transform.position, target) 0.01f) { transform.position Vector3.MoveTowards( transform.position, target, 4f * Time.deltaTime); yield return null; } } State UnitState.Acting; // 移动结束显示行动菜单 ActionMenu.Instance.Show(this); } }参数说明移动速度4f是每秒移动的世界单位数格子大小为 1 时大约每格 0.25 秒节奏比较舒服。actionPoints默认 1表示移动后还能攻击或待机。如果游戏有“再行动”机制可以在 Acting 状态结束后重置为 Idle。注意MoveTowards的到达阈值设为 0.01f太小会导致浮点精度问题卡住太大则角色位置偏移累积。如果发现角色走完后不在格子中心检查CellToWorldCenter是否用了正确的 Grid 引用。3.3 回合结束与阵营切换一个阵营的所有角色都进入 Done 状态后切换到对方阵营。这里容易出的 bug 是角色死亡时没有从“待行动列表”里移除导致回合永远结束不了。解决方法是维护一个ListUnitController activeUnits每帧或每次行动后清理死亡单位。public class TurnManager : MonoBehaviour { public ListUnitController playerUnits; public ListUnitController enemyUnits; private bool isPlayerTurn true; public bool AllUnitsDone(ListUnitController units) { foreach (var u in units) { if (u ! null u.State ! UnitState.Done) return false; } return true; } public void EndTurn() { if (isPlayerTurn) { if (!AllUnitsDone(playerUnits)) return; isPlayerTurn false; ResetUnits(enemyUnits); TurnStateMachine.Instance.TransitionTo( TurnStateMachine.Instance.enemyTurn); } else { if (!AllUnitsDone(enemyUnits)) return; isPlayerTurn true; ResetUnits(playerUnits); TurnStateMachine.Instance.TransitionTo( TurnStateMachine.Instance.playerTurn); } } private void ResetUnits(ListUnitController units) { foreach (var u in units) { if (u ! null) u.State UnitState.Idle; } } }逻辑说明AllUnitsDone遍历检查注意判空因为单位可能被销毁。ResetUnits把下一方所有单位重置为 Idle。参数上如果游戏有“等待”指令等待的单位也应该设为 Done否则会卡住回合。4. 战斗数值与敌方 AI让每一局都有变化4.1 伤害公式与随机性控制战棋的伤害公式不需要太复杂但要有可预测性。我常用的是伤害 攻击力 × 武器倍率 - 防御力 × (1 地形防御加成)然后乘以一个 0.9 到 1.1 的随机系数。暴击率单独计算通常 5% 到 15%。public class CombatCalculator { public static int CalculateDamage( UnitStats attacker, UnitStats defender, TerrainData defenderTerrain) { float baseDamage attacker.attack * attacker.weaponMultiplier; float defense defender.defense * (1f (defenderTerrain?.defenseBonus ?? 0f)); float raw baseDamage - defense; raw Mathf.Max(raw, 1f); // 保底 1 点伤害 float randomFactor Random.Range(0.9f, 1.1f); return Mathf.RoundToInt(raw * randomFactor); } public static bool RollCrit(float critRate) { return Random.value critRate; } }参数说明weaponMultiplier通常设为 1.0普通武器、1.5克制武器、0.5被克制。防御加成上限建议 0.3否则高防御单位在森林里几乎无敌。保底伤害 1 点是为了避免“打不动”的挫败感。随机系数范围不要超过 ±15%否则玩家会觉得“同样的操作结果差太多”这在战棋里是体验杀手。4.2 敌方 AI 的决策树独立开发的战棋 AI 不需要神经网络一个优先级决策树就够了。核心逻辑如果攻击范围内有敌人 → 评估最优攻击目标 → 移动并攻击否则 → 向最近的敌人移动。评估目标时考虑能否击杀、伤害输出、自身安全。public class EnemyAI : MonoBehaviour { public UnitController unit; public int attackRange 1; public void TakeTurn() { var enemies TurnManager.Instance.playerUnits; UnitController bestTarget null; float bestScore float.MinValue; foreach (var enemy in enemies) { if (enemy null) continue; int dist ManhattanDistance(unit.GetCellPosition(), enemy.GetCellPosition()); if (dist unit.movePoints attackRange) continue; float score EvaluateTarget(enemy); if (score bestScore) { bestScore score; bestTarget enemy; } } if (bestTarget ! null) { // 移动到攻击位置并攻击 MoveAndAttack(bestTarget); } else { // 向最近敌人移动 MoveTowardNearestEnemy(enemies); } } private float EvaluateTarget(UnitController target) { float score 0f; // 能击杀加分 int damage CombatCalculator.CalculateDamage( unit.stats, target.stats, TerrainManager.Instance.GetTerrain(target.GetCellPosition())); if (damage target.stats.currentHp) score 100f; // 伤害越高越好 score damage * 2f; // 自身受到的反击伤害扣分 int counterDamage CombatCalculator.CalculateDamage( target.stats, unit.stats, TerrainManager.Instance.GetTerrain(unit.GetCellPosition())); score - counterDamage * 1.5f; return score; } }逻辑说明ManhattanDistance是曼哈顿距离横纵坐标差之和因为战棋不允许斜走。EvaluateTarget的权重是经验值击杀权重 100伤害权重 2反击扣分权重 1.5。这套权重让 AI 优先击杀残血目标同时避免自杀式攻击。参数调整建议如果 AI 太激进提高反击扣分权重到 2.0如果太保守降低到 1.0。attackRange近战为 1弓手为 2 到 3。注意 AI 移动时也要用 BFS 计算可达范围不能直接朝目标走否则会穿墙。4.3 让 AI 有“性格”的简单方法纯优先级 AI 玩久了会腻。加一点随机性就能让行为多样化在评分基础上加一个 ±10% 的随机扰动或者给每个敌人单位分配一个“性格”参数激进/保守/均衡影响权重系数。public enum AIPersonality { Aggressive, Defensive, Balanced } public float GetPersonalityModifier(AIPersonality p) { return p switch { AIPersonality.Aggressive 1.3f, // 伤害权重放大 AIPersonality.Defensive 0.7f, // 伤害权重缩小更看重生存 _ 1.0f }; }这个改动很小但玩家能感觉到“这个敌人比较莽那个比较怂”。对于小型战棋来说这种程度的 AI 已经足够支撑 10 到 20 关的体验。5. 避坑与排查独立开发战棋最容易翻车的 5 个地方5.1 角色移动后位置偏移越走越歪现象角色走完路径后不在格子中心偏差随移动次数累积。 原因用MoveTowards时目标点用了CellToWorld而不是CellToWorldCenter或者 Grid 的cellSize不是整数导致浮点误差。 解决统一用CellToWorldCenter到达阈值设为 0.01f移动结束后强制transform.position target对齐。5.2 移动范围显示包含了不可通行的格子现象高亮格子覆盖了墙壁或水面角色走上去后卡住。 原因BFS 计算时只检查了moveCost没有检查IsWalkable或者 TerrainManager 的数据和 Tilemap 不同步。 解决在 BFS 的邻居检查里同时判断IsWalkable和moveCost 0。建议写一个ValidateTerrainData方法在关卡加载时对比 Tilemap 和 TerrainManager 的格子数量。5.3 回合结束后敌方不行动现象玩家回合结束后敌方单位原地不动回合卡死。 原因敌方 AI 的TakeTurn是协程但没有启动或者AllUnitsDone检查时敌方单位状态不是 Done。 解决确保 AI 行动是协程且用StartCoroutine启动。在 AI 行动结束后显式设置State UnitState.Done。加一个超时保护如果敌方回合超过 10 秒没有切换强制结束。5.4 伤害计算出现负数或零现象攻击后敌人血量不变或者血量变成负数。 原因防御力大于攻击力时raw为负没有做保底处理或者血量扣减后没有Mathf.Max(0, hp)。 解决伤害计算加Mathf.Max(raw, 1f)血量扣减后加Mathf.Max(0, currentHp)。如果设计上允许 0 伤害把保底改成 0 但 UI 要显示“无效”。5.5 打包到手机后画面拉伸或点击偏移现象在 Editor 里正常打包到手机后 UI 错位点击格子对不上。 原因Canvas 的 Render Mode 用了 Screen Space - Overlay 但没适配分辨率或者 Camera 的 orthographicSize 在移动端被改变。 解决Canvas 加CanvasScalerReference Resolution 设为 1920x1080Match 设为 0.5。Camera 的 orthographicSize 根据屏幕宽高比动态调整保证格子始终是正方形。点击检测用Camera.ScreenToWorldPoint而不是直接读 Input.mousePosition。6. 进阶技巧用 ScriptableObject 管理关卡数据与数值平衡做到第 5 关左右你会发现数值调平衡靠 Inspector 手改越来越乱。这时候该上 ScriptableObject 了。把每个角色的基础属性、每把武器的参数、每个关卡的地形配置都做成 ScriptableObject 资产好处是可以在 Editor 里批量对比、可以用代码生成、可以热重载不用重启游戏。[CreateAssetMenu(fileName NewUnitData, menuName Tactics/UnitData)] public class UnitData : ScriptableObject { public string unitName; public int baseHp 20; public int baseAttack 8; public int baseDefense 4; public int baseMove 5; public int attackRange 1; public AIPersonality personality AIPersonality.Balanced; public Sprite portrait; public GameObject prefab; }创建几个资产后写一个UnitDataValidator在 Editor 下运行检查所有角色的数值是否在合理区间HP 在 15 到 40 之间攻击在 5 到 15 之间移动在 3 到 7 之间。超出范围的在 Console 里报警告。这个习惯能帮你避免“第 8 关 Boss 攻击力 99 秒杀全队”的翻车。另一个技巧是用[Range]属性限制 Inspector 里的输入范围[Range(1, 50)] public int baseHp 20; [Range(1, 20)] public int baseAttack 8; [Range(0f, 0.5f)] public float critRate 0.1f;这样即使手滑也输不了离谱的值。数值平衡的验证方法是写一个模拟器让 AI 对战 AI 跑 1000 局统计每局的回合数和胜率。如果平均回合数低于 5 或高于 30说明数值需要调整。这个模拟器不需要 UI纯逻辑跑批就行半小时能跑完。最后说一个我自己的习惯每做完一个系统先不急着做下一关而是花 10 分钟把当前版本打包到手机上实际玩一遍。Editor 里手感对不代表手机上对尤其是点击精度和动画节奏。我踩过最深的坑是在 Editor 里用鼠标点格子很准到手机上手指挡住了半个屏幕后来把格子点击判定区域放大了 1.5 倍才解决。希望帮到你。本文还有配套的精品资源点击获取