ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用Unity打造小型战棋游戏:网格、状态机与范围计算实战指南

用Unity打造小型战棋游戏:网格、状态机与范围计算实战指南 简介Unity独立开发的小型战棋游戏完整项目属于个人毕业设计代码均经过调试运行成功适合作为答辩展示与教学演示素材。资源面向计算机相关专业学生、Unity初学者以及需要快速搭建游戏原型的开发者既能用于毕设、课程设计和项目初期立项也能帮助系统学习战棋游戏的完整制作思路。整包共2000个文件以Unity工程目录为主涵盖.cs游戏逻辑脚本、.prefab预制体、.unity场景、.asset资源对象以及png/psd美术图片、json/xml数据配置、md说明文档等类型压缩包整体约135MB文件组织规范可对照目录快速定位核心模块。目前已有322人学习下载。借助这套工程读者可获得完整可运行的战棋源码、场景与预制体配置、界面素材及配套说明理解从场景搭建、角色与地图编辑到战斗逻辑实现的完整流程代码基础良好的学习者还能在此之上扩展功能或改造玩法直接服务于毕业设计、项目演示或游戏开发入门训练。1. 用Unity做小型战棋先想清楚要哪一半战棋游戏在Unity里做最大的问题不是“怎么做”而是“做不完”。网格、回合、移动范围、战斗结算、AI、演出任何一块都能吃掉一周时间。独立开发者的预算通常只有几个周末所以标题里的“小型”不是妥协是护身符。这里只讲一件事用最小但完整的框架把“玩家指挥一队单位和一个AI轮流行动直到一方团灭”这个闭环跑起来。适合两类人一类是还没有完整回合制项目经验的Unity开发者另一类是想试水SRPG、但不想上来就碰六边形网格和复杂状态机的人。核心结论先行战棋的代码资产可以浓缩成三个东西——网格数据、回合状态机、范围计算。2. 网格地图与坐标骨架让每一格都能被点名2.1 矩形网格优先小型战棋的选型理由先回答最吵的问题要不要用六边形网格。六边形在模拟经营和军事题材里确实更“地道”但它会立刻带来三件额外的事奇偶行偏移坐标、六方向邻接遍历、斜线和边界的判定。你还没有一个单位能上场就先欠了一屁股几何债。矩形网格配合四方向移动邻接就是上下左右范围就是矩形代码量少一半数值调整也直观。对一个人开发的战棋来说把策略深度放在兵种克制和地形收益上比放在网格形状上更容易出效果。维度矩形四方向矩形八方向六边形六方向邻接遍历4个方向最简单8个方向斜向需要特殊判定6个方向奇偶行偏移范围形状正方形/菱形菱形带斜角正六边形近似圆贴图与对齐Unity Tilemap 直接支持需要额外处理对角线穿插需要偏轴坐标系代码成本低中中高选矩形四方向还有一个好处用Unity Tilemap渲染地图时Tile坐标和网格坐标几乎一一对应出错时肉眼就能看出来。提示如果你预感到将来可能换六边形把网格相关的方法全部收敛到 GridManager 一个类里。别在战斗脚本里直接写new Vector2Int(x1, y)统一走GetNeighbors(cell)。2.2 GridData 与单位占用表一套干净的数据骨架网格的“地图本身”和“单位站哪一格”是两件事。我一般用二维数组存地形用 Dictionary 存单位占用。地形是稠密数据一个格子必然有地形属性单位是稀疏数据全图可能只有二十几个单位用二维数组存单位只会浪费检索时间。// GridManager.cs —— 网格数据与单位占用 public class GridManager : MonoBehaviour { public int width 12; // 地图格子数横 public int height 8; // 地图格子数纵 public float cellSize 1.2f; // 每格世界单位长度 private readonly DictionaryVector2Int, Unit _units new(); public bool IsInside(Vector2Int pos) { return pos.x 0 pos.x width pos.y 0 pos.y height; } // 没有单位、且在地图内才可走 public bool IsWalkable(Vector2Int pos) { return IsInside(pos) !_units.ContainsKey(pos); } public void PlaceUnit(Unit unit, Vector2Int pos) { if (_units.ContainsKey(pos)) Debug.LogWarning($格点 {pos} 已有单位旧单位将被覆盖); _units[pos] unit; unit.Cell pos; unit.transform.position CellToWorld(pos); } public Vector2Int WorldToCell(Vector3 worldPos) { Vector3 local worldPos - transform.position; return new Vector2Int( Mathf.FloorToInt(local.x / cellSize), Mathf.FloorToInt(local.z / cellSize) ); } public Vector3 CellToWorld(Vector2Int pos) { Vector3 origin transform.position; return origin new Vector3(pos.x * cellSize, 0, pos.y * cellSize); } }这段代码解决三件事世界坐标与网格坐标互转、单位占位登记、格子合法性判断。注意cellSize直接影响摄像机要能看到多大范围12×8 的地图配合 1.2 的格子尺寸在 16:10 的画面比例下正好让边界留在视野内。WorldToCell 里用了FloorToInt表示鼠标点在格子的右边缘和左边缘会归到不同的格。这个细节会让玩家觉得“点哪走哪”是准的。如果你直接用RoundToInt你会发现在格子临界点上点选单位有时会走到身后那格这就是一些战棋手感发飘的根源。2.3 Tilemap 地图渲染和对齐把贴图块与逻辑格对齐Unity Tilemap 负责画地形。常见做法是新建一个 Grid 物体下面挂 Tilemap格子尺寸设置为 GridManager 里的cellSize然后把地图锚点放在场景原点附近。大多数翻车都出现在 Tilemap 的锚点与 GridManager 的原点不重合。假设你的 Tilemap 用new Vector3Int(-6, -4, 0)作为原点而 GridManager 挂在原点 (0,0,0) 的空物体上那WorldToCell算出的格子会整体偏移。最简单的处理方式GridManager 直接挂在 Grid 物体上或者把 Tilemap 原点固定在 GridManager 的transform.position上让两者共用同一个原点。注意Unity Tilemap 的坐标是 XY 平面而战棋通常在 XZ 平面竖一个相机。如果你强行用 Tilemap 默认方向需要把整个 Grid 旋转 -90 度或者所有WorldToCell里的local.y替换为local.z。我个人习惯把 Grid 物体旋转到 XZ 平面然后代码里统一用local.z这样场景里摆单位模型时不用额外改旋转。2.4 网格尺寸与摄像机范围的匹配常见错误先定了 20×15 的大地图然后相机跟随做得很痛苦。小型战棋我建议从 12×8 或 14×10 开始单位移动力给 45远程攻击距离给 23这样一局能在 15 分钟内打完。格子大小影响的是“单位模型是否互相穿模”1.21.5 是 3D 小人的安全区间如果用的是 2D Sprite0.81.0 可能更合适。如果你用透视相机相机离地高度和 FOV 会影响地图边缘是否出界。定网格数之前先把相机摆到目标高度跑一次场景看边界。这个步骤不要省。这些设计将决定后续所有寻路和攻击范围的复杂度也是后面排查点选问题和阴影问题的前提。3. 回合状态机与单位行动玩家和AI共用一套规则3.1 把状态机做成纯 C# 类别把它绑死在场景里很多新手做回合都是在 MonoBehaviour 里写Update用一个bool isPlayerTurn翻转。这种写法前期很快但一旦加入 AI、Replay、悔棋发现到处都在判断isPlayerTurn最后只能推倒。正确做法是回合状态机独立成纯 C# 类场景里的 MonoBehaviour 只负责把它的事件转发给 UI 和摄像机。// TurnStateMachine.cs —— 纯 C# 回合状态机 public enum TurnPhase { PlayerSelect, // 玩家选择行动单位 PlayerAction, // 玩家在执行移动/攻击 Resolve, // 全局结算异常状态、伤害倒计时 EnemyTurn, // AI 行动 TurnEnd // 清理行动标记进入新回合 } public class TurnStateMachine { public TurnPhase Current { get; private set; } TurnPhase.PlayerSelect; public event ActionTurnPhase PhaseChanged; public void NextPhase() { Current Current switch { TurnPhase.PlayerSelect TurnPhase.PlayerAction, TurnPhase.PlayerAction TurnPhase.Resolve, TurnPhase.Resolve TurnPhase.EnemyTurn, TurnPhase.EnemyTurn TurnPhase.TurnEnd, TurnPhase.TurnEnd TurnPhase.PlayerSelect, _ Current }; PhaseChanged?.Invoke(Current); } }这段状态机只做一件事让全局流程“玩家选择单位 → 玩家行动 → 结算 → AI 行动 → 回合结束”单向流转。PhaseChanged事件是 UI、摄像机、敌人 AI 共同的入口它们各自订阅自己关心的阶段。提示不要把“选单位”和“行动”混成一个状态。选单位阶段只负责把可操作单位列表弹给玩家玩家点选了某单位后才进入 PlayerAction。分得清这两个阶段玩家的误触就会少很多。3.2 从 GameState 到 UnitActionState状态不是只有回合才有回合状态机管的是“全局顺序”单位自己还需要一个小的行动状态机Idle、Moving、Attacking、Done。这里的关键是不允许玩家在UnitState.Attacking时又下达移动指令否则会收到一连串取消动画的 Bug。// GameState.cs —— 一局游戏的核心状态 public class GameState { public ListUnit PlayerUnits { get; } new(); public ListUnit EnemyUnits { get; } new(); public int TurnCount { get; private set; } 1; public Unit CurrentActiveUnit { get; set; } public IEnumerableUnit AllUnits() { return PlayerUnits.Concat(EnemyUnits); } public void NextTurn() { TurnCount; foreach (Unit u in AllUnits()) { u.RefreshAction(); // 恢复移动力和行动状态 } } }这里把“我方单位”和“敌方单位”分开是为了后面 AI 做目标选择时不用临时过滤阵营。RefreshAction()在每个回合开始时把单位的movePoint和hasActed复位。3.3 单位基类与可配置数值别让数值散在 Prefab 里单位最基础的字段是阵营、格子位置、移动力、攻击力、防御力、最大血量、当前血量。把这些集中放到 Unit 类上并把可调参数暴露在 Inspector。不要在每个脚本里单独存一份moveRange否则后期调平衡会改到血压升高。// Unit.cs —— 挂在单位 Prefab 根节点 public class Unit : MonoBehaviour { public Faction faction; public int maxHp 30; public int currentHp 30; public int atk 10; public int def 4; public int movePoint 4; public int attackRange 1; public Vector2Int Cell { get; set; } public bool hasActed { get; private set; } public void ConsumeAction() { if (hasActed) Debug.LogWarning(单位已经行动过了指令将被忽略); hasActed true; } public void RefreshAction() { hasActed false; } }ConsumeAction 里的 LogWarning 是调试期的“后悔药”当 AI 或玩家对同一单位连续下发两次移动指令时你能立刻看到责任人而不是让单位自己瞬移两格。3.4 调试捷径用宏定义让自动操作只出现在编辑器用 Unity 宏定义可以做到“编辑器里自动跳过动画、直接结算”。在 Player 脚本里写#if UNITY_EDITOR [ContextMenu(强制回合结束)] private void ForceEndTurn() { machine.NextPhase(); } #endif这样你在运行时可以在 Inspector 右键直接切阶段不用每次点 UI 按钮。这对后面要做的 AI 联调特别重要因为你能在任意阶段停下来看状态。4. 移动与攻击范围算清楚谁能打到谁4.1 用 BFS 算移动范围一次遍历同时拿到“能到哪”和“要走几步”战棋的移动范围本质是一个单源最短路问题边权是地形移动消耗。最常见的做法是 BFS/ Dijkstra。因为格子数少12×8 全图也就96格直接用带代价的 BFS 绰绰有余。// RangeCalculator.cs —— 统一求可移动格与攻击格 public static ListVector2Int GetMovableCells(GridManager grid, Unit unit) { var start unit.Cell; var frontier new QueueVector2Int(); var costSoFar new DictionaryVector2Int, int(); var result new ListVector2Int(); frontier.Enqueue(start); costSoFar[start] 0; while (frontier.Count 0) { var cur frontier.Dequeue(); if (costSoFar[cur] unit.movePoint) continue; result.Add(cur); foreach (var dir in Dir4) { var next cur dir; var nextCost costSoFar[cur] grid.GetTerrainCost(next); if (costSoFar.TryGetValue(next, out int known) known nextCost) continue; if (!grid.IsWalkable(next)) continue; costSoFar[next] nextCost; frontier.Enqueue(next); } } return result; }GetTerrainCost是地形移动消耗通常草地为1、森林为2、河流为3。注意result包含了当前单位自己所在的格子高亮它时会显示“原地待机”这个符合多数战棋的直觉。注意if (costSoFar[cur] unit.movePoint) continue;这行放在出队时判断比放在入队时判断更安全因为地形代价可能导致入队时不知道后续是不是有更优路径。经典 Dijkstra 的“晚了也不晚”原则在这里同理适用。地形移动消耗说明平原1默认可走森林2防御加成 1河流999默认不可走山崖999阻挡4.2 攻击范围和远程单位的“环形框”攻击范围比移动范围简单它不依赖地形只检查距离。对近战单位是四个邻接格对远程单位是曼哈顿距离 ≤ 攻击距离并且排除自己。public static IEnumerableVector2Int GetAttackCells(Unit attacker) { var cells new ListVector2Int(); for (int dx -attacker.attackRange; dx attacker.attackRange; dx) for (int dy -attacker.attackRange; dy attacker.attackRange; dy) { var pos attacker.Cell new Vector2Int(dx, dy); if (Mathf.Abs(dx) Mathf.Abs(dy) 0) continue; if (Mathf.Abs(dx) Mathf.Abs(dy) attacker.attackRange) cells.Add(pos); } return cells; }这里用曼哈顿距离而不是欧氏距离是为了和四方向移动的几何一致。否则你画出来的攻击范围会和单位实际能打到的格子对不上。4.3 高亮可视化用一个“指示器层”而不是上百个 GameObject常见新手做法每个可达格子 Instantiate 一个立方体。地图一大就掉帧。更好的是用一个预制体池或者一个 Tilemap 专门刷高亮 Tile。我把高亮块放到一个单独的 Grid 或 Tilemap 下统一管理。在 3D 战棋里高亮块最容易犯的错是 z-fighting。方块和地面贴在一起视角一转就闪。我的做法是高亮块是一个非常扁的 QuadY 轴偏移抬高 0.05材质用透明队列关闭深度写入高亮块所在 Layer 设为 Ignore Raycast避免挡住点击射线。4.4 用 LayerMask 过滤点击别让高亮块吃掉所有射线点击检测用Physics.Raycast传入LayerMask。最常见的坑是高亮块也带 Collider射线打到高亮块上玩家点不到地面和单位。所以高亮块所在的层设置后要从射线检测里排除。这里要顺便提一下 Unity 中的 LayerMask 与 RenderingLayerMask 的区别LayerMask 是物理/射线分组的筛选RenderingLayerMask 是渲染层参与的是后处理、光照分组这类视觉效果比如战斗演出加到辉光效果的层。很多战棋新手把两者混用结果写着写着发现某个特效只对特定单位生效排查半天。4.5 不要把物理 OverlapSphere 当作攻击判定血泪经验有人图省事攻击范围直接用Physics.OverlapSphere(transform.position, range)。然后地图一调、碰撞体一缩放判定和显示不一致玩家明明看到格子绿色却打不到。战棋的判定一定要基于网格坐标物理系统只用来判断“点击到了哪个单位”。物理和逻辑两层分开是战棋项目存活的第一原则。判定方式优点缺点OverlapSphere写起来快受碰撞体大小/缩放影响判定与高亮易不一致网格距离与高亮完全一致多写一个坐标转换5. 战棋开发必踩的5个坑与排查方法5.1 点选后排单位被前排挡住的角色怎么点现象战场上两单位一前一后玩家点后面的单位永远选中前排。原因射线返回的是最近碰撞体如果后排单位没有更高的体型它的碰撞体被前排盖住了。解决不要在“单个射线命中”里死磕。常见做法是收集射线穿过的所有单位弹一个选择列表。火焰纹章在3D化之后也是这么处理的。代码里用RaycastAll按距离排序然后让 UI 层弹按钮列表。加上一条用相机方向对单位做前后排序则保持在视野里的顺序稳定。5.2 高亮块和阴影混战地面闪烁单位被阴影压成黑块现象高亮块叠在地面上出现条纹闪烁单位走到阴影里黑得看不清。原因Unity阴影问题在高亮块上被放大。半透明材质没有写深度阴影投射与地面互相打架单位受光不足时又显得很脏。解决高亮块不要接收阴影也关闭 Cast Shadows材质放在透明队列。单位模型如果有阴影需求用一把虚拟光的投射参数来控制范围不把阴影接收打开。这里的核心提醒是战棋是高亮信息最密集的游戏类型宁可损失一点真实感也要保证格子信息可读。5.3 摄像机跟随为什么 SmoothDamp 之后还是抖现象摄像机跟随单位移动单位每走一格画面就抖一下边缘还露出地图外。原因你直接在 LateUpdate 里transform.position target.position没有插值或者插值系数在 LateUpdate 里开太大目标单位还没有真正走到新格子镜头就先冲了。解决把镜头的目标点与单位显示位置分离镜头跟随“单位目标”而不是“单位当前transform”。再用Vector3.SmoothDamp做平滑。地图边界要 clamp如果目标是格子中心就把目标坐标限制在地图的包围盒内。// FollowCamera.cs —— 平滑且不出界的摄像机跟随 public class FollowCamera : MonoBehaviour { public Transform target; // 单位身上的空物体随单位逻辑走 public Vector3 offset new(0, 10, -6); public float smoothTime 0.15f; public Bounds bounds; // 地图世界包围盒由 GridManager 提供 private Vector3 _velocity; void LateUpdate() { Vector3 desired target.position offset; desired.x Mathf.Clamp(desired.x, bounds.min.x, bounds.max.x); desired.z Mathf.Clamp(desired.z, bounds.min.z, bounds.max.z); transform.position Vector3.SmoothDamp( transform.position, desired, ref _velocity, smoothTime); } }smoothTime 参数 0.1 会让镜头很“跟手”0.3 会很懒。多单位行动时建议保持在 0.15 左右玩家能看清移动过程但不会等太久。5.4 把 LayerMask 当成万能筛子渲染和物理互相干扰现象特定后处理特效只作用于小兵却把整块地图染了色。或者单位的高亮框没有描边效果。原因把LayerMask用在物理运算里又用RenderingLayerMask想控制后处理但两者混着设互相干扰。解决物理分组用 LayerMask视觉分组用 RenderingLayerMask。千万不要图省事同一层既做物理过滤又做渲染过滤。名称适用范围典型用法LayerMask物理射线、碰撞响应、相机剔除点击单位时过滤 Ignore RaycastRenderingLayerMask渲染分组、后处理、光照包含仅让“高亮”被辉光后处理选中5.5 悔棋做不出来一撤销单位不再冷静现象实现悔棋后单位位置和血量恢复了但行动状态还是“已行动”下一个回合不能再操作。原因撤销只恢复了可视字段位置、血量没有恢复行为状态行动标记、回合计数。解决“后悔药”要整个回合快照而不是字段级撤销。小型项目可以保存UnitSnapshot阵营、格子、血量、行动状态。每回合开始前记录一份悔棋时整体覆盖。// 简易快照只存关键字段 public class UnitSnapshot { public Vector2Int cell; public int hp; public bool acted; public static UnitSnapshot From(Unit u) new() { cell u.Cell, hp u.currentHp, acted u.hasActed }; public void Restore(Unit u) { u.Cell cell; u.transform.position GridManager.I.CellToWorld(cell); u.currentHp hp; if (u.acted) u.ConsumeAction(); else u.RefreshAction(); } }这里要写明原理快照是一种“存档式撤销”实现成本低对小型战棋足够。它不能撤销随机数序列和动画状态但玩家要的是“这步走错了我想重来”不是“算出之前所有随机结果”。把悔棋范围限定在“当前单位行动前”即可。6. 手感收尾命中演出与自动对战验证6.1 命中演出先把逻辑结算和视觉表现解耦战斗结算在逻辑层完成动画只是播放器。不要用动画事件回调触发伤害否则动画没播完血量就变了。常见做法是结算函数返回一个BattleResultUI和动画订阅它。演出顺序镜头拉近 → 攻击方播放攻击动画 → 命中时闪白 → 受击方播放受伤动画 → 镜头归位。6.2 自动对战我每天改完数值都会跑一遍的验证脚本最后一步值得单独说。写一个 AutoBattle 脚本在编辑器菜单里点一下让两队 AI 用同一套状态机自动互打。我用它做回归测试改伤害公式、地形代价、移动力之后睡觉前跑 50 局观察是否有单位卡住、回合无法推进、胜负一边倒。这个习惯比任何调试器都值钱。我自己做新战棋原型时会优先写自动对战再写玩家输入。给 AI 随机选格子、随机攻击几分钟就能暴露 70% 的状态机问题因为人操作是有耐心的AI 没有。希望这篇能帮你在 Unity 里把第一个小型战棋稳稳做完。本文还有配套的精品资源点击获取
返回列表