Unity战棋游戏核心系统拆解:地图生成、寻路与战斗管理 📅 发布时间:2026/9/15 17:05:45 👁 浏览次数: 简介这是一份基于Unity引擎开发的战棋游戏完整源码工程面向希望深入理解战棋玩法实现的中级Unity开发者、独立游戏制作人及游戏设计课程学员。项目完整实现了随机地图生成、地图编辑、战斗逻辑单例管理、路径搜索与战斗渲染等核心功能其中随机地图部分融合随机游走算法与细胞洞穴算法可生成多样化战斗场景战斗管理器采用单例模式保证状态唯一性渲染层利用Unity Tilemap绘制地形、单位及行动范围。整个工程共140个文件核心包括21个C#脚本、26个asset配置、5个prefab预制体、3个unity场景、14个png贴图及动画控制器、anim动画、json数据等文件压缩包仅399KB结构紧凑便于逐个模块研读。目前已有97人学习使用通过阅读源码与预制体配置可以快速掌握从地图生成、寻路、战斗结算到场景渲染的完整链路也可作为个人战棋原型或毕业设计的起点。1. 战棋游戏源码拆解Unity里最容易被低估的三个系统拿到这份基于Unity引擎的战棋游戏源码压缩包解开后第一眼看到的不是场景文件而是select.anim、streak.anim这类动画资源和一整套*.asset配置文件。很多人会误以为战棋游戏的核心是角色动画或战斗演出但真正决定一个SRPG好不好玩的是随机地图生成、战斗逻辑管理和路径搜索这三个系统。它们不像渲染特效那么抢眼却直接决定了游戏的耐玩度和策略深度。这篇博文会沿着这三个核心逐个拆开重点讲清随机游走和细胞洞穴算法怎么生成地图、单例模式在战斗管理器里的真实用法、以及Tilemap系统下寻路节点如何构建。源码里的ProjectSettings.asset、InputManager.asset这些配置我最后会挑几个实际影响开发的讲。适合想用Unity复刻经典战棋玩法、或者正被地图生成和寻路性能卡住的人。2. 随机地图生成随机游走与细胞洞穴的组合策略2.1 两种算法各自解决什么问题战棋游戏的地图不像动作游戏那样可以线性铺场景需要每次开局生成不同的障碍分布与可行走区域。源码里同时实现了随机游走算法和细胞洞穴算法这是两种思路相反的生成方案。随机游走算法的核心是从某个起点开始每次向随机方向走一步经过大量步数后形成一条连续的“路径”。在地图生成里通常用它来生成走廊或河流。这个算法生成的地图地形连通性极好因为每一步都落在已有路径的相邻位置不会出现断裂。但缺点是结构单一很难产生大面积的开放区域而且如果步数太少地图会显得像一条细线。细胞洞穴算法是另一种思路基于元胞自动机进行多次迭代。它先初始化一张全是随机噪点的地图每个格子按概率设为墙或空地然后反复执行规则如果周围墙的数量大于某个阈值就把当前格子变成墙否则变成空地。经过四五轮迭代会形成成片的洞穴区域和自然分隔的墙体。这种地图适合表达山洞、废墟等场景但问题是可能生成孤岛也就是某块可行走区域被墙完全包围导致战斗单位无法到达。源码里把两种算法结合使用先用细胞洞穴算法生成主体地形再用随机游走在关键位置之间挖出连接通道保证整张地图的连通性。这个顺序很有讲究。如果反过来先游走后洞穴游走路径会被洞穴的墙体覆盖等于白做。2.1.1 代码级别的算法落地看源码里的实际调用逻辑大致结构是public class MapGenerator : MonoBehaviour { public int width 60; public int height 40; public int seed 12345; public bool useCellularFirst true; private int[,] map; // 0空地1墙体 public void Generate() { if (useCellularFirst) { map CellularAutomata.Generate(width, height, seed); ListVector2Int startEndPoints FindFarPoints(map); map RandomWalk.ConnectPoints(map, startEndPoints[0], startEndPoints[1], width, height); } else { map RandomWalk.Generate(width, height, seed); map CellularAutomata.Smooth(map, width, height); } ApplyToTilemap(); } }这里CellularAutomata.Generate内部会做随机初始化加多轮迭代RandomWalk.ConnectPoints则用A星或BFS找到两个点的路径并强制挖开。FindFarPoints用来挑选两个最远的可通行点作为连接目标这样能避免无效挖掘。2.2 在Unity中落地Tilemap与算法输出对接算法生成的是int[,]二维数组但Unity场景里要用Tilemap显示中间必须做一次映射。常见做法是在场景里建一个Grid对象下面挂Tilemap子物体然后用SetTile根据二维数组的每个格子写入对应的Tile。void ApplyToTilemap() { tilemap.ClearAllTiles(); for (int x 0; x width; x) { for (int y 0; y height; y) { Vector3Int cellPos new Vector3Int(x, y, 0); if (map[x, y] 1) { tilemap.SetTile(cellPos, wallTile); } else { tilemap.SetTile(cellPos, groundTile); } } } }这个映射很简单但要注意Vector3Int的坐标与int[,]的索引一一对应。如果以后要支持负数坐标就要引入原点偏移否则地图会显示在错误位置。源码里这块没有做偏移处理意味着地图生成范围从(0,0)开始对于战棋游戏来说足够用但如果你要做无缝大地图就得改成中心点偏移。2.2.1 为什么不用Grid组件直接放预制体有开发者习惯用Instantiate生成一个个方块预制体来铺地图这在几十格的小地图上没问题但战棋地图动辄60x40也就是2400个格子每个都生成预制体意味着2400个GameObjectDrawCall和内存都会飙升。Tilemap把整张地图合成一个Mesh性能好很多。源码里选择Tilemap而不是预制体说明作者对渲染合批有基本认知。如果你在优化帧率还可以把Tilemap的Compression改成None减少重建Mesh时的CPU开销。2.3 参数怎么调地图规模、连通性与性能两个算法都有核心参数调参是整个地图生成最有含金量的部分。细胞洞穴算法主要控制几项参数典型范围作用调大后的效果初始空地概率0.4 - 0.6随机初始化时空地占比地图更开阔洞穴更大迭代轮数4 - 6平滑次数墙壁更连续但过多会合并洞穴墙体判定阈值4 - 5周围墙数大于该值则变成墙阈值越高墙越少地图越空连通性修正次数1 - 3随机游走补连接的次数修正越多地图越单调我一般建议空地概率从0.45起步迭代5轮。低于0.4容易生成大片死区高于0.6则洞穴失去边界战斗时找不到掩体。随机游走部分最主要的参数是步数和步长。源码里没有暴露步数选项但你可以尝试把单步长度从1改到2或3这样路径会更宽适合作为战棋地图的主干道。调参时要时刻盯着一个指标可行走区域率。可以用BFS从某个空地格子出发统计能访问到的格子数占总空地数的比例。低于90%就说明地图存在孤岛需要补连接。这也是为什么源码要在洞穴算法后调用随机游走修正——它保证的不只是好看而是可玩。3. 战斗逻辑管理单例模式下的状态机与单位行为3.1 为什么战斗管理器要选单例源码中的战斗逻辑管理器用了单例模式。很多人对单例有争议但在战棋游戏这个场景下战斗管理器保存的是全局唯一的战斗状态包括当前回合、当前行动单位、已执行过的行动列表。这些数据如果每个场景都独立创建一份切场景后就需要复杂的状态传递。单例让所有系统都通过BattleManager.Instance访问同一份数据。public class BattleManager : MonoBehaviour { public static BattleManager Instance { get; private set; } public BattlePhase currentPhase { get; private set; } public UnitData currentUnit { get; private set; } public int currentPlayerIndex 0; private void Awake() { if (Instance ! null Instance ! this) { Destroy(gameObject); return; } Instance this; DontDestroyOnLoad(gameObject); } }DontDestroyOnLoad在这里很关键。战棋游戏通常有独立的战斗场景和主菜单场景如果战斗管理器会因场景加载被销毁那么所有战斗数据都会丢。常规做法是把管理器放在启动场景里标记为不销毁。但这也带来一个新的风险如果开发中重新load启动场景会生成第二个单例。所以Awake里的重复检测必须写上这是源码里做得对的地方。3.2 战斗状态流转与单位行动序列战棋战斗是一套严格的状态机。常见的状态至少包括PlayerTurn、EnemyTurn、ActionMenu、Moving、Attacking、Animation。源码里的currentPhase就是用来标记当前处于哪个状态。比如玩家点击己方单位时如果当前不是PlayerTurn就应该直接忽略输入防止在敌人回合抢操作。一个最小可用的状态流转逻辑是这样的public enum BattlePhase { PlayerTurn, EnemyTurn, Idle } public void EndPlayerTurn() { if (battlePhase ! BattlePhase.PlayerTurn) return; battlePhase BattlePhase.EnemyTurn; StartCoroutine(ExecuteEnemyTurn()); } IEnumerator ExecuteEnemyTurn() { foreach (UnitData enemy in enemyUnits) { BattleAction action UnitAI.DecideAction(enemy); yield return StartCoroutine(ExecuteAction(action)); } BattleManager.Instance.StartPlayerTurn(); }这里最重要的是每个阶段结束后的显式切换。很多新手会把输入处理和战斗逻辑混在一起导致点击敌人单位时还没结束移动状态出现角色瞬移的Bug。把BattlePhase作为唯一事实来源所有交互逻辑先检查状态再执行可以避免绝大多数状态错乱。3.2.1 单位行动顺序的数据驱动设计源码中单位行为没有硬编码在控制器里而是通过UnitData这个数据类保存。每个单位有移动力、攻击力、技能列表等属性。行动时客户端只负责调用UnitAI.DecideAction获取一个BattleAction再交给ExecuteAction执行。这样做的好处是以后添加新单位不需要改战斗管理器只要扩展数据类跟AI决策逻辑。public class BattleAction { public UnitActionType actionType; public UnitData target; public Vector2Int moveTo; } public enum UnitActionType { Move, Attack, Skill, Wait }这种设计把“做什么决定”和“怎么执行”分开。即使后面接入网络同步也只需要把BattleAction序列化后广播出去接收方用同样的ExecuteAction回放就能保持一致。3.3 数据驱动的技能与行动模块源码里的技能模块很简洁不采用每个技能一个Monobehaviour的做法而是用SkillData配置表。技能的作用范围、伤害倍率、附加效果都以字段形式存在ScriptableObject里。[CreateAssetMenu(fileName NewSkill, menuName Battle/SkillData)] public class SkillData : ScriptableObject { public string skillName; public int damageMultiplier 100; public int range 1; public TargetType targetType; public int cooldown 0; }使用ScriptableObject而不是JSON或Excel最大的优势是Unity编辑器里直接拖引用不需要额外的解析管线。缺点是不方便策划用表格批量维护。如果你的项目需要大量角色技能建议保留一个CSV转ScriptableObject的编辑器脚本。源码里没有这个工具但数据结构本身已经足够支撑批量导入。4. 路径搜索与导航管理器从Tilemap到寻路节点4.1 节点图构建把Tilemap转成寻路网格战棋寻路不能直接用Unity的NavMesh因为NavMesh面向连续空间而战棋是离散格子。所以源码里实现了一个导航管理器负责把Tilemap的可行走格子转换成寻路节点图。构建节点图不需要预先创建上千个GameObject只要维护一个二维数组或者字典。常见做法是用DictionaryVector2Int, Node存所有空地节点。每个Node记录坐标、可通行标记和邻居列表。邻居关系可以在初始化时计算四个方向或者八个方向。public class NavManager { private DictionaryVector2Int, Node nodeDict; public void BuildGraph(int[,] map) { nodeDict new DictionaryVector2Int, Node(); for (int x 0; x map.GetLength(0); x) { for (int y 0; y map.GetLength(1); y) { if (map[x, y] 0) { var node new Node(new Vector2Int(x, y)); nodeDict.Add(node.pos, node); } } } foreach (var node in nodeDict.Values) { node.neighbors FindNeighbors(node.pos); } } }这里节点本身是轻量级对象不挂任何组件。寻路时只操作这些数据渲染时再映射到Tilemap坐标。如果地图里有动态障碍物比如门或者陷阱需要在寻路前更新对应节点的可通行状态而不是重新构建整张图。4.1.1 四方向还是八方向战棋游戏通常用四方向移动因为斜向移动会破坏格子之间的公平性。八方向虽然路径更短但会引出斜线穿越墙角的Bug。源码里用的是四方向也就是一个节点最多四个邻居。这个选择和多数SRPG一致。如果你要做斜向移动寻路时得额外检查两格间的墙角是否可通行否则角色会穿墙。4.2 核心寻路算法与优先级队列导航管理器里的核心函数是寻路算法。战棋地图通常不大A星算法足够胜任。但要注意实现细节open list必须用优先级队列而不是每次遍历所有节点找最小F值。当下移动棋盘容易过不了性能关。public ListVector2Int FindPath(Vector2Int start, Vector2Int goal) { var open new PriorityQueueNode, float(); var cameFrom new DictionaryVector2Int, Vector2Int(); var gScore new DictionaryVector2Int, float(); var startNode nodeDict[start]; var goalNode nodeDict[goal]; gScore[start] 0; open.Enqueue(startNode, 0); while (open.Count 0) { var current open.Dequeue(); if (current.pos goal) break; foreach (var neighbor in current.neighbors) { float tentative gScore[current.pos] 1; if (!gScore.ContainsKey(neighbor.pos) || tentative gScore[neighbor.pos]) { cameFrom[neighbor.pos] current.pos; gScore[neighbor.pos] tentative; float fScore tentative Heuristic(neighbor.pos, goal); open.Enqueue(neighbor, fScore); } } } return ReconstructPath(start, goal, cameFrom); } private float Heuristic(Vector2Int a, Vector2Int b) { return Mathf.Abs(a.x - b.x) Mathf.Abs(a.y - b.y); }PriorityQueue建议使用C#内置的PriorityQueueTElement, TPriority在Unity 2021.3 及以上版本可以直接用不需要额外插件。注意这里的H值用的是曼哈顿距离因为四方向地图只能走直线欧几里得距离会导致F值不完全一致最终找出来的路径虽然可行但搜索效率不高。4.3 移动范围与攻击范围的计算战棋游戏里一个单位的移动范围不是简单画一个圆而是所有在移动力限制内可到达的格子。用BFS从单位当前位置扩散每扩散一层距离加1超过移动力就停止。public HashSetVector2Int GetReachableCells(Vector2Int start, int moveRange) { var visited new HashSetVector2Int(); var queue new Queue(Vector2Int pos, int cost)(); queue.Enqueue((start, 0)); visited.Add(start); while (queue.Count 0) { var (currentPos, cost) queue.Dequeue(); if (cost moveRange) continue; foreach (var dir in Directions4) { var next currentPos dir; if (!nodeDict.ContainsKey(next)) continue; if (visited.Contains(next)) continue; int nextCost cost GetMoveCost(next); if (nextCost moveRange) { visited.Add(next); queue.Enqueue((next, nextCost)); } } } return visited; }GetMoveCost可以按地形类型返回不同移动消耗比如草地消耗1森林消耗2沼泽消耗3。这个实现比A星简单得多因为不需要找最短路径只关心在消耗上限内能不能到达。攻击范围的计算也类似但不再考虑地形消耗直接以单位位置为中心向外扩散攻击距离。远程单位例如弓手攻击距离为2就直接从单位周围取曼哈顿距离等于2且小于等于2的格子去掉墙体即可。源码里把移动范围和攻击范围分开计算这样高亮颜色可以不同也方便做“移动后可攻击”的叠加区域。5. 战斗渲染与数据管理Tilemap上的表现层与存档设计5.1 行动范围高亮与选择效果渲染战棋游戏里点击单位后显示可移动范围这是最直观的反馈。源码里有select.anim和streak.anim这两个动画资源就是用来表现单位选中和攻击轨迹的。视觉表现本身不复杂复杂的是如何把第4章计算出的网格集合渲染到Tilemap上。常见做法是准备两个Tilemap图层一个MoveRangeTilemap专门画高亮格子一个UnitTilemap放单位。计算完目标格子后遍历集合设置半透明Tilepublic void ShowMoveRange(HashSetVector2Int cells, Tile highlightTile) { moveRangeTilemap.ClearAllTiles(); foreach (var cell in cells) { moveRangeTilemap.SetTile(new Vector3Int(cell.x, cell.y, 0), highlightTile); moveRangeTilemap.SetColor(new Vector3Int(cell.x, cell.y, 0), new Color(0, 1, 0, 0.4f)); } }这里用SetColor设置透明度时要注意SetColor修改的是TilemapRenderer的vertex color必须确保Tilemap的材质支持顶点透明。否则颜色设置了但不显示。另一个坑是ClearAllTiles不会清除颜色如果复用同一个Tilemap高亮格子清除后再次SetTile时旧的颜色信息可能残留。我一般会同时调用ClearAllTiles和重置所有已设置格子的颜色或者干脆为高亮单独建一个Tilemap专层专用。5.2 单位数据的序列化与读写数据管理这块源码没有过多深入但作为完整项目它是必备的。战棋游戏的存档需要保存单位位置、血量、等级、物品、当前回合等数据。最简单的方案是用JsonUtility直接序列化UnitData的列表。不过JsonUtility不能直接序列化Dictionary所以单位身上的Key-Value型属性需要绕道。我常用的做法是把单位数据拆成可序列化的存档类字段全部用基础类型或数组[Serializable] public class UnitSaveData { public string unitId; public int posX; public int posY; public int hp; public int mp; public int[] skillIds; } public static class SaveManager { public static void SaveBattle(ListUnitSaveData units, int turn) { BattleSaveData save new BattleSaveData { units units.ToArray(), currentTurn turn }; string json JsonUtility.ToJson(save); File.WriteAllText(Application.persistentDataPath /battle.json, json); } }Application.persistentDataPath是唯一可写的应用目录在PC上是Users目录下的AppData里在手机上也是沙盒目录。如果你直接写Application.dataPath在打包后读不到因为那是只读目录。这是很多新手存档失败最常见的原因。5.2.1 关于Unity配置文件的那几个Passet解压源码后看到的ProjectSettings.asset、InputManager.asset等文件属于Unity工程设置不是游戏逻辑的一部分。但有几个会影响你的实际开发。InputManager.asset定义了旧版Input的轴映射战棋游戏主要用鼠标点击如果你的代码里用了Input.GetMouseButtonDown默认设置就能满足。GraphicsSettings.asset里设置的渲染管线会影响Tilemap的合成方式如果你的项目升级到URP需要重新生成这组资产否则某些shader会显示成洋红色。EditorSettings.asset里有一个重要字段是serializationMode设置为ForceText后场景和预制体都会用文本形式保存方便用Git对比和合并。这对团队协作排查冲突很有帮助。如果源码里这项没有设置我建议你改成ForceText再入库。5.3 一个排错技巧地图编辑后Tilemap坐标漂移最后一个实用技巧针对地图编辑功能。源码提供了地图编辑允许用户手动改Tile。但编辑中很容易遇到一种问题直接在运行时修改Tilemap的格子之后重新生成导航图发现单位走到错误位置。原因是运行时Tilemap的单元格坐标是绝对的但你的导航管理器读取的是内存里的int[,]。如果你在编辑器中删除了一列TileTilemap的锚点或者Grid的Cell Size没同步视觉位置和逻辑坐标就分叉了。排查步骤很简单打印tilemap.WorldToCell(worldPos)与导航管理器计算得到的节点坐标先确认它们是否一致。如果不一致多半是Grid的Cell Layout不是Rectangle或者Tilemap的TileAnchor被改动了。战棋游戏统一使用Rectangle布局和默认TileAnchor就基本不会出现这个偏差。另一个常见现象是编辑时用鼠标点击Tile通过Camera.ScreenToWorldPoint拿到世界坐标但Z轴不为0导致WorldToCell偏移。把接收到的世界坐标强制设为new Vector3(screenWorld.x, screenWorld.y, 0)再转换问题立刻消失。这两个坑我在自己项目里各踩过一次源码里没写显式处理但只要你意识到坐标轴上的Z值来源就能快速定位。本文还有配套的精品资源点击获取