资讯动态

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

发布时间:2026/10/22 9:45:56 来源:尧图企业网站定制
简介这份资源是面向计算机、通信、自动化等相关专业学生与开发者的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 ListGridCell GetReachableCells(GridCell start, int moveRange) { var result new ListGridCell(); var visited new DictionaryGridCell, int(); // 格子 - 已消耗步数 var queue new QueueGridCell(); 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(ListGridCell cells) { foreach (var renderer in activeHighlights) // 上一轮高亮 renderer.color Color.white; activeHighlights.Clear(); foreach (var cell in cells) { var r tileViewMap[cell].GetComponentSpriteRenderer(); 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 ActionTurnPhase 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 GetComponentSpriteRenderer(); 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 跑得飞快打包后卡通常是DictionaryGridCell, 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防御 201BFS 范围移动力 2无障碍12 格不含起点第三把数值抽成 ScriptableObject 或 CSV。单位属性、地形加成、伤害系数都别写死在代码里论文里可以专门写一节「数据驱动设计」说明策划改数值不用重新编译。这一步做完你的项目就从「能跑的 Demo」变成「有工程思维的毕业设计」。最后说个我自己的教训早期我总想先把画面做好看结果逻辑一塌糊涂返工时发现美术资源和逻辑耦合太深改一处崩三处。后来我强迫自己先跑通「纯逻辑 白块占位」确认回合、寻路、结算都对再换美术效率反而高得多。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑