简介: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(x+1, 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 Dictionary<Vector2Int, 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 开始,单位移动力给 4~5,远程攻击距离给 2~3,这样一局能在 15 分钟内打完。格子大小影响的是“单位模型是否互相穿模”,1.2~1.5 是 3D 小人的安全区间;如果用的是 2D Sprite,0.8~1.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 Action<TurnPhase> 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 List<Unit> PlayerUnits { get; } = new(); public List<Unit> EnemyUnits { get; } = new(); public int TurnCount { get; private set; } = 1; public Unit CurrentActiveUnit { get; set; } public IEnumerable<Unit> 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 List<Vector2Int> GetMovableCells(GridManager grid, Unit unit) { var start = unit.Cell; var frontier = new Queue<Vector2Int>(); var costSoFar = new Dictionary<Vector2Int, int>(); var result = new List<Vector2Int>(); 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 IEnumerable<Vector2Int> GetAttackCells(Unit attacker) { var cells = new List<Vector2Int>(); 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。方块和地面贴在一起,视角一转就闪。我的做法是:
- 高亮块是一个非常扁的 Quad,Y 轴偏移抬高 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 Raycast |
| RenderingLayerMask | 渲染分组、后处理、光照包含 | 仅让“高亮”被辉光后处理选中 |
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 命中演出:先把逻辑结算和视觉表现解耦
战斗结算在逻辑层完成,动画只是播放器。不要用动画事件回调触发伤害,否则动画没播完血量就变了。常见做法是:结算函数返回一个BattleResult,UI和动画订阅它。演出顺序:镜头拉近 → 攻击方播放攻击动画 → 命中时闪白 → 受击方播放受伤动画 → 镜头归位。
6.2 自动对战:我每天改完数值都会跑一遍的验证脚本
最后一步值得单独说。写一个 AutoBattle 脚本,在编辑器菜单里点一下,让两队 AI 用同一套状态机自动互打。我用它做回归测试,改伤害公式、地形代价、移动力之后,睡觉前跑 50 局,观察是否有单位卡住、回合无法推进、胜负一边倒。这个习惯比任何调试器都值钱。
我自己做新战棋原型时,会优先写自动对战,再写玩家输入。给 AI 随机选格子、随机攻击,几分钟就能暴露 70% 的状态机问题,因为人操作是有耐心的,AI 没有。希望这篇能帮你在 Unity 里把第一个小型战棋稳稳做完。
本文还有配套的精品资源,点击获取