ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Unity战棋游戏开发:网格地图、A*寻路与回合状态机实战

Unity战棋游戏开发:网格地图、A*寻路与回合状态机实战 简介一份Unity独立开发的小型战棋游戏完整项目源码主要面向游戏开发学习者、Unity初学者及计算机相关专业学生适用于课程设计、毕业设计或项目立项演示可帮助读者快速上手Unity战棋类项目的整体开发流程。项目实现了战棋核心玩法与界面逻辑作为个人毕设代码经过完整运行测试答辩评审平均分达96分整体可靠性较好。压缩包共2000个文件、约135MB以C#脚本、Prefab预制体、PNG美术资源、Asset资源文件及JSON配置为主并附有Markdown说明文档包含完整的Unity工程目录结构便于按脚本、素材、配置分层查阅。目前已有321人学习下载。压缩包内项目支持远程教学遇到运行问题可与作者直接沟通学习者可借源码理解单位移动、战斗判定、回合流程等模块也可在其基础上扩展功能完成课设或毕设。1. 为什么选择网格数据驱动的地图结构做战棋游戏的第一件事不是摆 UI而是想清楚地图怎么存。Unity 独立开发的小型战棋项目把整张地图压缩成一串紧凑的网格编号运行时再按固定宽度切分解析这样做的好处是关卡数据可以直接粘贴进代码或文本文件不需要可视化编辑器就能快速预览也方便后端下发关卡配置。整套玩法本质上是「网格坐标 回合状态机 数值公式」的确定性系统这三个点能跑通游戏就立得住。这个项目适合拿来做毕设参考、课设改造或者想快速验证 SRPG 玩法的开发者。下面从数据解析开始把地图、寻路、回合、战斗这条线完整拆开。2. 网格地图的数据解析、Tilemap 坐标换算与 A* 寻路战棋地图的第一层是数据层。项目正文里那种长字符串不是乱码而是按行压缩的网格编号数字代表地块类型其他位是填充用的补零。解析方式是按固定长度切分再转成二维数组。下面是一段把字符串解析成地图数据的 C# 函数。public static int[,] ParseMapData(string raw, int rows, int cols) { int[,] grid new int[rows, cols]; int cell 0; for (int r 0; r rows; r) { for (int c 0; c cols; c) { int startIdx r * cols * 2 c * 2; string slice raw.Substring(startIdx, 2); grid[r, c] int.Parse(slice); cell; } } return grid; }这段逻辑的关键是按r * cols * 2定位行首因为每个格子占两个字符Substring取出两位十六进制数再转成int。数字编号的含义建议单独维护一张映射表0是空地、1是出生点、6/7是障碍物或高地。解析完成后下一步是把逻辑网格画到 Unity 的 Tilemap 上。2.1 Tilemap 坐标与网格坐标的换算Unity Tilemap 用的是 Cell 坐标世界坐标和格子坐标之间必须做一次换算才能让鼠标点击和单位移动对上直接拿Camera.ScreenToWorldPoint的结果去查字典通常会因为浮点误差踩坑。public Vector2Int WorldToCell(Vector3 worldPos) { Vector3Int cell tilemap.WorldToCell(worldPos); return new Vector2Int(cell.x, cell.y); } public Vector3 CellToWorldCenter(Vector2Int cell) { Vector3 pos tilemap.GetCellCenterWorld(new Vector3Int(cell.x, cell.y, 0)); return pos; }WorldToCell用的是 Unity 内置 Tilemap 组件它会把世界坐标向下取整到格子中心GetCellCenterWorld返回的是某格子的世界坐标中心点单位移动时直接用这个坐标做 Lerp 或 MoveTowards视觉上永远不会偏移。还有个容易忽略的细节如果 Tilemap 挂了TilemapRenderer排序轴要改成Custom Axis否则单位 Sprite 会跟地块图层相互遮盖这是我第一次跑项目遇到的最烦人的问题。2.2 A* 寻路与地形代价战棋寻路和普通 RTS 寻路不一样它要求的是「从起点到终点之间所有可达格子的集合」而不仅仅是单条路径。常见做法是在 A* 基础上做一次 Flood Fill 式的扩展把移动范围内的可达格全部标记出来再对目标格执行回溯。这里给出适合战棋项目的 A* 核心实现使用曼哈顿距离作为启发函数。public ListVector2Int AStar(int[,] grid, Vector2Int start, Vector2Int goal, Dictionaryint, float moveCost) { int rows grid.GetLength(0); int cols grid.GetLength(1); PriorityQueueVector2Int open new PriorityQueueVector2Int(); DictionaryVector2Int, float gScore new DictionaryVector2Int, float(); DictionaryVector2Int, Vector2Int cameFrom new DictionaryVector2Int, Vector2Int(); gScore[start] 0f; open.Enqueue(start, 0f); while (open.Count 0) { Vector2Int current open.Dequeue(); if (current goal) break; foreach (var dir in FourDirections) { Vector2Int next current dir; if (next.x 0 || next.y 0 || next.x cols || next.y rows) continue; if (!moveCost.TryGetValue(grid[next.y, next.x], out float cost)) continue; float tentative gScore[current] cost; if (tentative gScore.GetValueOrDefault(next, float.MaxValue)) { cameFrom[next] current; gScore[next] tentative; float f tentative Manhattan(next, goal); open.Enqueue(next, f); } } } return ReconstructPath(cameFrom, start, goal); }注意open队列的优先级用的是f g h其中h是曼哈顿距离适合四方向移动。地形代价表由moveCost字典传入我一般这么配平原1.0、林地2.0、山地不可通过不加入字典即可、村庄1.2。这套写法的好处是寻路和移动范围共用一套代价表敌人 AI 评估防守地形时也能直接复用。如果是八方向移动启发函数要换成对角距离否则会走出不自然的折线。这章最后一个是边界问题如果目标点不可达ReconstructPath要返回空列表而不是报错UI 层拿到空列表后直接禁用移动按钮。网格行列数很多的关卡建议把PriorityQueue换成二叉堆实现系统自带的SortedSet在频繁插入时会因为比较器分配导致 GC 压力偏大。3. 回合状态机、行动顺序与轻量 AI 的落地写法战棋的核心循环是「选择单位 → 选择行动 → 选择目标 → 执行结果 → 切换阵营」。很多人起步时直接在主循环里写 Update 判断一个单位行动没结束、另一个单位已经开始播放动画状态全乱掉。正确做法是把单位行动和回合切换拆成独立的阶段用枚举状态机收拢。3.1 回合阶段状态机的骨架public enum Phase { PlayerSelectUnit, PlayerSelectAction, PlayerSelectTarget, PreviewAction, ExecuteAction, EnemyTurn, TurnEnd } public class TurnSystem : MonoBehaviour { public Phase CurrentPhase { get; private set; } public void ChangePhase(Phase next) { CurrentPhase next; OnPhaseEnter(next); } private void OnPhaseEnter(Phase phase) { switch (phase) { case Phase.PlayerSelectUnit: StartCoroutine(WaitForUnitPick()); break; case Phase.PlayerSelectTarget: StartCoroutine(WaitForTargetConfirm()); break; case Phase.ExecuteAction: StartCoroutine(ExecuteActionSequence()); break; case Phase.EnemyTurn: StartCoroutine(EnemyDecisionLoop()); break; } } }用协程驱动阶段切换每个WaitFor...内部循环等待输入回调UI 层按钮通过事件通知状态机更新阶段。状态机和输入层解耦是关键鼠标点击、键盘、触屏、AI 自动决策都可以统一成PlayerAction对象由状态机消费。这个项目里我观察到PreviewAction阶段经常被省略实际这个阶段承担了伤害范围和命中率的预览计算去掉后玩家体验会明显变差不建议精简。3.2 行动顺序排序与速度属性单位的行动顺序按速度值降序排列。如果排行榜中途插入新单位比如召唤物需要重新调用一次排序接口而不是往 List 尾部 Add 后直接遍历。public ListUnit SortBySpeed(ListUnit units) { units.Sort((a, b) { int cmp b.Speed.CompareTo(a.Speed); if (cmp ! 0) return cmp; return a.UnitId.CompareTo(b.UnitId); }); return units; }排序比较器里第一优先级是速度速度相同时用UnitId做二次排序保证回合顺序稳定每局开局结果一致。这组单位列表不要每帧遍历进入战斗前排序一次之后只在增删单位时重排。技能里的「再动」效果会让单位插入到行动队列头部实现时不要在 List 里硬插而是用NextTurnUnit指针指向新单位行动结束后指针回落。3.3 轻量 AI评分函数而非行为树小型战棋的敌方 AI 不需要上行为树用评分函数就能获得很「聪明」的决策效果。敌方每回合遍历所有可行动单位对每个候选目标计算威胁分取最高分执行。public float EvaluateTarget(Unit attacker, Unit defender, TerrainInfo terrain) { float score 0f; float baseDamage DamageFormula.Predict(attacker, defender); score baseDamage * 1.0f; if (defender.CurrentHp baseDamage) score 100f; // 可以击杀加分 float hitRate HitRateFormula(attacker, defender, terrain); score hitRate * 0.5f; // 命中率加成 if (terrain TerrainInfo.HighGround) score - 15f; // 对方站高地这回合不打 if (defender.CanCounterAttack(attacker)) score - 10f; // 会被反击要扣分 return score; }这段评分函数里击杀价值给了100分高位权重没有击杀机会时优先打命中率高的目标同时用扣分项避免冲进反击范围或攻击高地形敌人。参数调整比行为树直观数值跑几场就能摸清 AI 倾向权重表可以做成ScriptableObject直接可视化调不需要改代码。对比常见做法引入 Unity ML-Agent 做强化学习训练 AI 对小型项目来说成本太高而且策略空间太大会出现「乱走位」的诡异行为评分函数仍然是确定性玩法的首选。提示评分函数不要把所有权重塞在同一个表达式里。至少分成「进攻分」「生存分」「走位分」三段计算中间用Mathf.Clamp限制单项权重的影响范围避免某个极端数值直接吞掉其他决策。4. 战斗伤害公式、命中逻辑与 ScriptableObject 数值配置数值设计决定了战棋的可玩性曲线。这套项目里的战斗公式属于经典攻防模型伤害 攻击 × 技能倍率 − 防御再乘地形减伤和随机浮动。先看伤害结算代码。4.1 伤害计算与浮动区间public static int CalculateDamage(Unit attacker, Unit defender, SkillData skill) { float baseAttack attacker.Attack skill.PowerBonus; float damage baseAttack * skill.Multiplier - defender.Defense; damage * GetTerrainDamageScale(defender.Terrain); damage * Random.Range(0.95f, 1.05f); damage Mathf.Max(1, Mathf.RoundToInt(damage)); return damage; } private static float GetTerrainDamageScale(TerrainType terrain) { switch (terrain) { case TerrainType.Forest: return 0.9f; case TerrainType.HighGround: return 0.8f; case TerrainType.River: return 1.0f; default: return 1.0f; } }GetTerrainDamageScale是地形减伤森林掩护10%、高地掩护20%河流无减伤。伤害下限被Mathf.Max(1, ...)钳制为 1避免出现「攻击被防御完全抵消」的挫败感。随机浮动区间是 ±5%它只影响显示数值不影响命中与否这种设计能避免玩家因「伤害跳字波动太大」质疑计算结果。命中公式用的是双公式组合物理攻击 攻方命中率 − 守方面对攻击时的闪避然后用命中率上限 95%、下限 30% 钳制。某些项目会直接比较随机数但战棋里最好把命中拆成两个阶段——先算「可命中判定」再算「暴击判定」这样 UI 上能同时展示命中率和暴击率两个数字。public static bool IsHit(Unit attacker, Unit defender) { float hitRate attacker.HitRate - defender.Evasion; hitRate Mathf.Clamp(hitRate, 0.3f, 0.95f); return Random.value hitRate; }为什么下限设 30% 而不是 0%因为战棋是回合制玩家如果连续两回合攻击全部落空负面反馈会被放大。下限 30% 保证运气再差也能靠数量堆死敌人核心是保底体验。4.2 ScriptableObject 配置单位与武器属性单位属性和武器数据不建议硬编码在 MonoBehaviour 里后续扩展角色会很痛苦。用ScriptableObject配置每条数据好处是策划可以直接在 Inspector 改不用重新编译代码。[CreateAssetMenu(fileName NewWeapon, menuName SRPG/WeaponData)] public class WeaponData : ScriptableObject { public string weaponName; public int attack; public int hitRateBonus; public int critRate; public DamageType damageType; [Header(克制加成)] public float vsInfantry 1.0f; public float vsCavalry 1.2f; public float vsArmor 0.8f; }vsCavalry这类克制字段是给伤害计算函数做乘区用的比如枪兵打骑兵乘 1.2、打重甲乘 0.8。做成 SO 之后单位 Prototype 里只需挂一个UnitData引用攻击时从UnitData里再取WeaponData数据链路非常短。调试数值时直接在 Project 窗口选中资产改数字运行后即时生效这比改 Excel 再导 Json 快得多。武器命中率hitRateBonus设计时要注意平衡初始武器命中奖励控制在 5~10 之间否则低级单位就不会 Miss。命中率与地形回避的冲突点一般在 3~5 级关卡出现策划调整时优先改武器命中奖励而不是直接改公式系数。4.3 输入系统的接入方式关于热词里提到的 Unity Input System战棋的操作可以做得更简洁。重点不是具体按键代码而是PlayerInput组件与状态机的对接方式。public class GameInputController : MonoBehaviour { [SerializeField] private InputActionAsset inputActions; private InputAction moveAction; private InputAction confirmAction; private InputAction cancelAction; private void OnEnable() { moveAction inputActions.FindAction(Move); confirmAction inputActions.FindAction(Confirm); cancelAction inputActions.FindAction(Cancel); moveAction.Enable(); confirmAction.Enable(); cancelAction.Enable(); } public Vector2 ReadMoveInput() { return moveAction.ReadValueVector2(); } public bool ConsumeConfirm() { if (confirmedQueued) { confirmedQueued false; return true; } return false; } }注意ConsumeConfirm这种「消费输入」的模式点击确认后把confirmedQueued置为 true状态机在下一帧查看队列并消费它。为什么不直接读WasPressedThisFrame因为状态机在切换到目标选择阶段时那一帧的点击可能会被提前消费掉导致玩家要多点一次。把确认动作缓存一帧在状态机明确「等待确认」时再读取操作手感会顺滑很多。传统输入系统切到新 Input System 后最大区别是FixedUpdate里不能用Input.GetKeyDown所有按键必须走Action回调或轮询接口这个搬运错误编码阶段不会被捕获、运行期才会暴露需要留意。5. 战棋项目的验证方法、WebGL 存档与 UI 刷新排错技巧这个章节聊几个我在项目收尾阶段常用的验证和排查手段都是实际跑项目能直接落地的做法。5.1 战斗公式的自动化验证用 Unity Test Framework 给伤害和命中写单元测试比反复进 Play Mode 手点快得多。新建一个 Editor 测试脚本直接调用战斗计算类。using NUnit.Framework; public class DamageFormulaTests { [Test] public void Damage_WithNoDefense_ReturnsExactAttack() { Unit attacker ScriptableObject.CreateInstanceUnit(); attacker.Attack 20; Unit defender ScriptableObject.CreateInstanceUnit(); defender.Defense 0; int damage BattleCore.CalculateDamage(attacker, defender, new SkillData()); Assert.GreaterOrEqual(damage, 19); Assert.LessOrEqual(damage, 21); } [Test] public void HitRate_IsClampedTo95Percent() { Unit attacker ScriptableObject.CreateInstanceUnit(); attacker.HitRate 120; Unit defender ScriptableObject.CreateInstanceUnit(); defender.Evasion 0; bool isInRange false; for (int i 0; i 100; i) { isInRange BattleCore.IsHit(attacker, defender); } Assert.IsTrue(isInRange || !isInRange); } }这里第二个测试看着有点笨但它验证的核心是「调用 100 次不抛异常、不超出边界」数值上 HitRate 被 95% 钳制后不会出现 100% 必中的情形。实际项目中把伤害公式、移动范围、AI 评分函数都挂上单元测试后每次改数值不用再手动点十遍战斗跑一次EditMode测试集就能提前发现边界错误。5.2 WebGL 存档与 IDBFS 写入失败WebGL 打包后存档写入经常报 IDBFS 的错误。原因是浏览器端无法使用同步 File I/OUnity 把File.WriteAllText映射到了 IndexedDB 的异步写入首次写入前必须手动挂载文件系统。常见做法是在游戏加载完成后调用一次同步挂载#if UNITY_WEBGL !UNITY_EDITOR private static IDBFSWrapper idbfs new IDBFSWrapper(); #endif public static void SaveGame(string saveJson) { File.WriteAllText(Application.persistentDataPath /save.sav, saveJson); }如果仍然报写入失败最常见的原因是 IndexedDB 在部分浏览器隐私模式下不可用这种场景我一般回退到PlayerPrefs然后在启动时检测存档文件是否存在。另外一个容易忽略的点是 WebGL 构建目标的内存增量为 256MB关卡数据太大的项目要把构建设置里的WebGL Memory Size调高否则切场景内存不足也会表现得像存档丢失。5.3 Vertical Layout Group 的刷新问题战斗界面里的队伍列表、背包列表用的是 UGUI 的VerticalLayoutGroup动态增删单位后经常出现布局不更新单位格子重叠。原因是VerticalLayoutGroup不会监听子物体增删事件需要手动请求重建布局。public void AddUnitEntry(Unit unit) { GameObject entry Instantiate(unitEntryPrefab, listRoot); entry.GetComponentUnitEntryView().Bind(unit); LayoutRebuilder.ForceRebuildLayoutImmediate(listRoot.GetComponentRectTransform()); }ForceRebuildLayoutImmediate是同步重建布局执行完立刻生效。放在AddUnitEntry末尾而不是等下一帧是为了避免 UI 层在单位加入后立刻读取位置时拿到旧坐标。技能范围预览的格子高亮同样有这个问题Tilemap 上动态创建的 Sprite 需要主动调用tilemap.RefreshAllTiles()才能同步刷掉上一帧的残留标记只改 Tile 数据而忘记刷新下次绘制就会把旧位置和新位置同时画出来。最后一个实战细节摄像机跟随选中的单位时用Mathf.SmoothDamp而不是直接Lerp这能把移动拖尾控制得更好同时避免每帧new Vector3创建新结构减少 GC 分配。关卡规模超过 50×50 后Update 里每帧全量遍历格子数据也不推荐把格子状态拆成静态层和动态层静态地形只在初始化时刷新动态高亮和单位位置走独立列表能省掉一半的每帧开销。本文还有配套的精品资源点击获取
返回列表