ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity战棋游戏开发实战:网格寻路、回合管理与战斗结算

Unity战棋游戏开发实战:网格寻路、回合管理与战斗结算

简介:这份资源是面向计算机、通信、自动化等相关专业学生与开发者的C#毕业设计项目源码,基于Unity引擎实现一款小型战棋游戏,适合作为期末课程设计、课程大作业或毕业设计参考,也可供C#与Unity初学者进阶学习。压缩包共约2000个文件,整体约135.37MB,以info、meta、png、json、cs、bin、md、xml、asset、prefab等类型为主,涵盖脚本代码、场景与预制体资源、贴图素材、配置数据及说明文档,完整保留了Unity工程目录结构。项目为个人毕设成果,答辩评审分达98分,代码经过调试测试可正常运行,已有244人学习下载。读者可从中获取战棋核心逻辑实现、回合制战斗流程、地图与角色资源组织方式及工程配置思路,基础较好的开发者还能在此基础上修改调整,扩展出不同玩法与功能。

1. 从一份 C# 战棋源码说起:Unity 里怎么把回合制逻辑跑通

很多同学拿到「C#毕业设计-基于Unity的一款小型战棋游戏项目源码.zip」这类压缩包时,第一反应是双击打开场景按 Play,然后发现角色能动但敌人不动、格子高亮错位、回合切换卡死。这不是源码写得差,而是战棋这类回合制策略游戏的核心从来不在美术资源上,而在「谁先动、动几步、打完怎么结算」这套状态机。它适合两类人:一类是正在做毕业设计、需要一套能讲清楚架构的完整 Demo;另一类是想从动作游戏转做策略玩法、需要理解网格寻路与回合调度的开发者。这篇笔记不逐行念代码,而是把这类项目里最值钱的三块——网格系统、回合管理器、战斗结算——拆成能直接抄的落地步骤,顺带把参数怎么调、哪里最容易翻车讲透。你照着走一遍,至少能判断这份源码值不值得改,以及改哪里收益最大。

2. 网格与寻路:战棋地图的坐标系怎么定才不返工

战棋游戏的地图看着是一张图,实际是三层数据叠出来的:逻辑网格、视觉格子、寻路图。很多新手一上来就用 Unity 的 Tilemap 直接当逻辑层,结果做攻击范围判定时发现拿不到「第几行第几列」,只能靠世界坐标反算,精度一塌糊涂。常见做法是逻辑与表现分离:逻辑层用纯 C# 的二维数组或字典存格子状态,表现层再用 Tilemap 或 SpriteRenderer 去画。

2.1 逻辑网格的数据结构选型

我一般会定义一个GridCell类,只存坐标和占用状态,不继承 MonoBehaviour。这样单元测试能直接跑,不用启动 Unity。

// 逻辑格子:不挂 MonoBehaviour,方便单元测试 public class GridCell { public int X; // 列索引 public int Y; // 行索引 public bool IsWalkable; // 是否可通行,墙/水设 false public Unit Occupant; // 当前站着的单位,空为 null public GridCell(int x, int y, bool walkable = true) { X = x; Y = y; IsWalkable = walkable; } }

这段代码的关键点是Occupant只存引用不存数据,单位移动时先清旧格再写新格,避免出现「两个格子都认为自己在占用」的幽灵状态。IsWalkable和Occupant要分开判断:可通行不代表没人,寻路时两者都要查。

2.2 用 BFS 算移动范围而不是 A*

战棋里「这回合能走到哪」是范围查询,不是点到点最短路径,所以用广度优先搜索(BFS)比 A* 更合适,还能天然处理移动力消耗。

// 以起点为中心,按移动力扩散,返回所有可达格子 public List<GridCell> GetReachableCells(GridCell start, int moveRange) { var result = new List<GridCell>(); var visited = new Dictionary<GridCell, int>(); // 格子 -> 已消耗步数 var queue = new Queue<GridCell>(); queue.Enqueue(start); visited[start] = 0; while (queue.Count > 0) { var current = queue.Dequeue(); int cost = visited[current]; if (cost >= moveRange) continue; // 步数用完不再扩散 foreach (var neighbor in GetNeighbors(current)) // 上下左右四方向 { if (!neighbor.IsWalkable || neighbor.Occupant != null) continue; if (visited.ContainsKey(neighbor)) continue; visited[neighbor] = cost + 1; result.Add(neighbor); queue.Enqueue(neighbor); } } return result; }

参数moveRange就是单位的移动力,通常从单位属性里读。注意这里把「有占用」的格子直接跳过,所以友军会挡路——这是战棋的常见规则,如果你想要「穿过友军但不能停」,就把Occupant != null的判断从跳过改成「可入队但不加入 result」,这个差异在验收时经常被忽略。

2.3 表现层同步与格子高亮

逻辑算完后,表现层要做的只有一件事:把result里的格子染成蓝色。别在 BFS 里直接改材质,那样逻辑和渲染耦合,回退时很难清理。

// 表现层:根据逻辑结果刷新高亮,先清后画 public void RefreshHighlight(List<GridCell> cells) { foreach (var renderer in activeHighlights) // 上一轮高亮 renderer.color = Color.white; activeHighlights.Clear(); foreach (var cell in cells) { var r = tileViewMap[cell].GetComponent<SpriteRenderer>(); r.color = new Color(0.3f, 0.6f, 1f, 0.6f); activeHighlights.Add(r); } }

tileViewMap是逻辑格子到场景物体的字典,初始化时建一次。高亮色用半透明蓝,别用纯色,否则会盖住地形细节。这一步做完,你就能看到「点角色→显示可走范围→点目标格→移动」的完整闭环,这也是判断一份战棋源码是否合格的第一道门槛。

3. 回合管理器:状态机怎么写才不会卡死

战棋的回合流程本质是一个状态机:等待输入 → 选中单位 → 显示范围 → 执行移动/攻击 → 结算 → 切换阵营。很多源码卡死,是因为把「当前是谁的回合」和「当前处于哪个阶段」混成一个变量,切着切着就乱了。正确做法是两层状态分开:阵营轮转和阶段推进各管各的。

3.1 用枚举定义阶段,用事件驱动切换

public enum TurnPhase { Idle, // 空闲,等待玩家操作 UnitSelected, // 已选中单位 Moving, // 移动动画播放中 Attacking, // 攻击结算中 TurnEnd // 本回合结束,准备切换 } public class TurnManager : MonoBehaviour { public TurnPhase CurrentPhase { get; private set; } = TurnPhase.Idle; public int CurrentTeam { get; private set; } = 0; // 0 玩家,1 敌方 public event Action<TurnPhase> OnPhaseChanged; public void SetPhase(TurnPhase next) { if (CurrentPhase == next) return; // 防止重复触发 CurrentPhase = next; OnPhaseChanged?.Invoke(next); } }

OnPhaseChanged事件是解耦的关键:UI 面板、单位控制器、音效都去订阅它,而不是互相直接调用。if (CurrentPhase == next) return;这行是后悔药,能挡掉大量「同一帧内重复切阶段」导致的动画叠加。

3.2 阵营轮转与单位重置

一个阵营的回合结束时,要做三件事:清空该阵营所有单位的「已行动」标记、恢复移动力、把当前阵营切到对面。

public void EndTurn() { SetPhase(TurnPhase.TurnEnd); foreach (var unit in GetUnitsByTeam(CurrentTeam)) { unit.HasActed = false; unit.CurrentMovePoints = unit.MaxMovePoints; } CurrentTeam = 1 - CurrentTeam; // 0 和 1 互换 SetPhase(TurnPhase.Idle); }

1 - CurrentTeam这种写法只适合两个阵营。如果你要做三方混战,就得换成取模:CurrentTeam = (CurrentTeam + 1) % teamCount。这个细节在扩展玩法时是血泪经验,早期写死两阵营,后期加第三方要改一堆判断。

3.3 单位行动标记与「已行动」灰显

每个单位要有一个HasActed布尔值,行动完置 true,表现层据此把角色染灰。注意这个标记要在回合开始时统一重置,而不是行动时自己清,否则会出现「敌方回合结束没重置、玩家单位永远灰着」的经典 bug。

// 单位控制器里订阅阶段变化 void OnPhaseChanged(TurnPhase phase) { if (phase == TurnPhase.Idle && unit.Team == turnManager.CurrentTeam) { // 轮到自己阵营空闲时,刷新可交互状态 var sr = GetComponent<SpriteRenderer>(); sr.color = unit.HasActed ? Color.gray : Color.white; } }

把「灰显」放在阶段事件里做,而不是每帧 Update 里查,能省掉大量无谓的渲染开销。这套回合管理器跑通后,你会明显感觉到逻辑清晰了:任何时刻你都能回答「现在是谁的回合、处于哪个阶段」,排查问题时直接打印这两个值即可。

4. 战斗结算:伤害公式与攻击范围判定的落地细节

战棋的战斗看着简单,实际坑最多:攻击范围算错一格、伤害取整方式不一致、死亡单位没从格子清理导致寻路穿模。这一章把结算拆成「范围判定 → 伤害计算 → 死亡处理」三步,每步都给可抄的实现。

4.1 攻击范围的两种判定方式

近战通常用曼哈顿距离,远程用切比雪夫距离或直线。别用 Unity 的Vector3.Distance,那是欧氏距离,斜向会多算。

// 曼哈顿距离:适合十字形攻击范围 public int ManhattanDistance(GridCell a, GridCell b) { return Mathf.Abs(a.X - b.X) + Mathf.Abs(a.Y - b.Y); } // 切比雪夫距离:适合方形范围,含斜向 public int ChebyshevDistance(GridCell a, GridCell b) { return Mathf.Max(Mathf.Abs(a.X - b.X), Mathf.Abs(a.Y - b.Y)); }

近战单位用ManhattanDistance <= 1判定,远程弓箭手用ChebyshevDistance <= range。这两个函数要放在工具类里,别散落在各单位脚本中,否则改规则时你会找疯。

4.2 伤害公式与取整

一个够用又不失衡的公式:伤害 = 攻击力 - 防御力 * 0.5,最低保底 1 点。

public int CalculateDamage(Unit attacker, Unit defender) { float raw = attacker.Attack - defender.Defense * 0.5f; int damage = Mathf.Max(1, Mathf.RoundToInt(raw)); // 保底 1,四舍五入 // 地形加成:高地 +20% if (defender.CurrentCell.TerrainType == TerrainType.HighGround) damage = Mathf.RoundToInt(damage * 0.8f); return damage; }

Mathf.RoundToInt用的是银行家舍入,如果你要严格的四舍五入,得自己写(int)(raw + 0.5f)。这个差异在数值敏感的游戏里会被玩家发现,比如 2.5 期望变 2 而不是 3。保底 1 点是为了避免「高防单位完全免伤」导致战斗僵持。

4.3 死亡处理与格子清理

单位死亡时必须做两件事:从格子的Occupant清空、从单位列表移除。漏掉任何一步,寻路都会把死单位当障碍。

public void KillUnit(Unit unit) { unit.CurrentCell.Occupant = null; // 先清格子占用 allUnits.Remove(unit); // 再移出列表 unit.OnDeath?.Invoke(); // 触发死亡事件,播动画 Destroy(unit.gameObject, 0.5f); // 延迟销毁,等动画播完 }

顺序不能反:先清格子再销毁物体,否则在销毁回调里访问CurrentCell会拿到已置空的对象。Destroy的第二个参数是延迟秒数,给死亡动画留时间,别用 0 直接销毁,否则动画根本播不出来。

5. 避坑与排查:这类战棋源码最容易翻车的五个地方

5.1 现象:点角色没反应,格子也不高亮

原因通常是射线检测被 UI 挡住,或者Physics2D.queriesHitTriggers设置不对。战棋格子常用 Trigger 碰撞体,如果项目设置里关了触发器射线,点谁都点不到。解决:检查Project Settings > Physics 2D > Queries Hit Triggers是否勾选,同时确认 Canvas 的Graphic Raycaster没有全屏拦截。

5.2 现象:移动动画播完角色停在半路

原因是逻辑坐标和世界坐标换算时用了错误的格子尺寸。常见做法是worldPos = new Vector3(cell.X * cellSize, cell.Y * cellSize, 0),但如果你 Tilemap 的 Cell Size 不是 1,就会偏移。解决:把cellSize抽成配置项,逻辑移动和动画目标都用同一个值,别一处写 1 一处写 0.5。

5.3 现象:回合切换后敌方单位不动

多半是 AI 逻辑挂在Update里但没判断当前阵营,或者协程在切换阵营时被意外停止。解决:AI 行动用协程写,并在EndTurn前yield return等所有敌方单位行动完,别用Invoke定时,定时器在暂停时会失准。

5.4 现象:攻击后伤害数字和血条对不上

原因是先扣血再播动画,或者动画回调里又扣了一次。解决:伤害计算和扣血放在同一处,动画只负责表现,用事件通知 UI 刷新,绝不在动画回调里改数值。

5.5 现象:打包后寻路变慢甚至卡死

编辑器里 BFS 跑得飞快,打包后卡,通常是Dictionary<GridCell, int>的哈希开销被放大,或者每帧都在重建可达范围。解决:可达范围只在选中单位时算一次并缓存,移动过程中不重算;GridCell重写GetHashCode用X * 1000 + Y,比默认引用哈希稳定。

6. 进阶技巧:把这份源码改成能写进毕业设计论文的架构

如果你不只是想跑通,而是要把这份战棋源码写进毕业设计,评审看的不是画面,是「架构是否清晰、有没有可扩展性、测试能不能过」。我的习惯是先做三件事:抽接口、加单元测试、留配置表。

第一,把单位行为抽成接口。IUnitAction定义Execute()和CanExecute(),移动、攻击、待机各实现一个。这样论文里画类图时,策略模式一目了然,答辩时也能说清楚「新增技能只需加一个实现类」。

public interface IUnitAction { bool CanExecute(Unit unit, GridCell target); IEnumerator Execute(Unit unit, GridCell target); }

第二,给纯逻辑部分加单元测试。网格 BFS、伤害公式、距离计算这三块不依赖 Unity 运行时,用 NUnit 直接测。论文里附一张测试通过截图,比十页文字都有说服力。

测试项输入期望输出
曼哈顿距离(0,0) 到 (3,4)7
伤害保底攻击 5,防御 201
BFS 范围移动力 2,无障碍12 格(不含起点)

第三,把数值抽成 ScriptableObject 或 CSV。单位属性、地形加成、伤害系数都别写死在代码里,论文里可以专门写一节「数据驱动设计」,说明策划改数值不用重新编译。这一步做完,你的项目就从「能跑的 Demo」变成「有工程思维的毕业设计」。

最后说个我自己的教训:早期我总想先把画面做好看,结果逻辑一塌糊涂,返工时发现美术资源和逻辑耦合太深,改一处崩三处。后来我强迫自己先跑通「纯逻辑 + 白块占位」,确认回合、寻路、结算都对,再换美术,效率反而高得多。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进