ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Unity杀戮尖塔地图算法:分层随机生成与连通性校验实战

Unity杀戮尖塔地图算法:分层随机生成与连通性校验实战

简介:这份Unity杀戮尖塔地图算法实现Demo面向有一定Unity与C#基础的开发者,尤其是想学习Roguelike地图生成思路的进阶学习者。资源以工程源码形式呈现,核心包含地图房间生成与路线生成两大部分:房间数量按起点层动态配置、中间层取最小值2至起点数量乘2减1、Boss层固定为1;房间位置在平铺基础上叠加横纵随机偏移,形成自然错落感。路线生成则通过检索最近上层房间、断路检索补全上层对象列表,再完成层间链接,逻辑清晰可复用。压缩包共144个文件,约10.3MB,以png贴图、asset配置、meta与json数据为主,另含少量cs脚本、prefab预制体及Unity工程配置,便于直接导入运行与二次修改。目前已有1081人学习下载,适合作为地图生成算法的参考模板,帮助读者理解房间布局、随机偏移与连通性处理的完整实现路径。

1. 从一张随机地图说起:这套方案到底解决什么问题

做过卡牌构筑类项目的开发者大多经历过一个尴尬时刻:战斗逻辑、卡牌效果、遗物系统都跑通了,唯独地图生成像一坨被揉过的纸——节点忽多忽少,路径时断时续,Boss 前一个休息点都刷不出来。玩家走两步就撞进死胡同,或者一条直线直通终点,整局节奏直接崩掉。这类问题在 Unity 里尤其常见,因为很多人第一反应是用Random.Range硬铺格子,结果就是玄学地图,改一次种子换一个世界。

这套 Unity 杀戮尖塔地图算法实现 Demo 要解决的,正是「分层随机地图如何既随机又可控」这件事。它面向的是正在做卡牌 Roguelike 毕设、课设,或者要给自己的独立项目补一套地图系统的开发者。核心思路不复杂:把地图拆成若干层,每层放固定数量的节点,再用带约束的连边规则把层与层接起来,最后用一套校验逻辑保证从起点到 Boss 一定走得通。听起来像图论作业,但真正落地时,节点类型分配、路径交叉、休息点保底这些细节才是血泪经验集中的地方。下面按需求、设计、实现、避坑、验收的顺序,把整套方案摊开讲。

2. 需求拆解与技术选型:为什么不用纯随机而要分层

2.1 地图要满足的五个硬性约束

在动手写代码之前,先把「一张合格的地图」翻译成可验证的条件。通常的做法是列出下面五条,后面所有设计都围绕它们展开:

  • 连通性:从起点层任意节点出发,必须存在至少一条路径抵达 Boss 层,不能出现孤岛节点。
  • 层数固定:整张地图的层数在配置里写死,比如 15 层,Boss 固定在第 15 层,避免随机层数导致节奏失控。
  • 节点数量可控:每层节点数在一个区间内浮动,比如 2 到 4 个,既不会太稀疏也不会挤成一团。
  • 路径不交叉:相邻两层之间的连线尽量不出现交叉,否则视觉上像一团乱麻,玩家读图成本极高。
  • 类型分布合理:战斗、精英、商店、休息、宝箱、Boss 这些节点类型要按权重分配,且休息点不能连续出现,精英不能扎堆在前三层。

这五条里,连通性和路径不交叉是最容易翻车的。很多人写完生成逻辑发现地图能跑,但偶尔刷出一个只有一条入口却有三条出口的节点,或者两条路径在中间交叉,玩家一看就出戏。

2.2 为什么选「分层 + 约束连边」而不是纯随机或预设模板

纯随机铺点的做法前面已经否掉了,问题在于不可控。预设模板则是另一个极端:手摆几十张地图,玩家玩几局就记住套路,Roguelike 的重复可玩性直接归零。分层加约束连边的折中方案,本质上是把「随机」限制在一个受控的图结构里——层数、每层节点数、连边规则都是确定性的框架,只有节点类型和具体连边在框架内随机。这样既保证每局地图不一样,又保证每局都「能玩」。

在 Unity 里实现这套结构,技术选型上不需要引入额外图论库。C# 自带的List、Dictionary和简单的递归就够用。数据层用一个MapNode类存节点信息,生成层用一个MapGenerator类跑算法,表现层再用LineRenderer或 UI 图片把节点和连线画出来。三层分离的好处是算法可以单独跑单元测试,不用启动场景就能验证连通性。

提示:如果你的项目用的是 Unity 2021 以后的版本,建议把生成逻辑放在纯 C# 类里,不要继承MonoBehaviour,这样在编辑器里写测试用例会方便很多。

2.3 节点类型与权重的配置化设计

节点类型不能写死在代码里,否则调平衡时要重新编译。通常的做法是抽一个ScriptableObject或者 JSON 配置,把每种类型的权重、出现层数限制、互斥规则都放进去。比如休息点权重设为 12,但限制「不能出现在第 1 层和第 2 层」,精英权重设为 8,限制「前 3 层不出现,且连续两层不能都是精英」。这些规则在生成时逐条检查,不满足就重新抽,抽满一定次数还不行就降级为普通战斗,避免死循环。

3. 总体架构设计:三层结构与四个核心模块的职责划分

3.1 数据层:MapNode 与 MapGraph 的字段定义

数据层只做一件事:把地图描述清楚。MapNode需要存节点 ID、所在层数、层内序号、节点类型、以及它连向下一层的节点 ID 列表。MapGraph则持有所有节点的字典和按层分组的列表,方便按层遍历。下面是一个精简后的字段定义:

// 单个地图节点,只存数据不存表现 public class MapNode { public int Id; // 全局唯一 ID public int Layer; // 所在层,从 0 开始 public int IndexInLayer; // 层内序号,用于计算坐标 public NodeType Type; // 战斗/精英/商店/休息/宝箱/Boss public List<int> NextNodes; // 连向下一层的节点 ID public List<int> PrevNodes; // 连向上一层的节点 ID,便于回溯 } // 整张地图,按层组织 public class MapGraph { public Dictionary<int, MapNode> Nodes = new Dictionary<int, MapNode>(); public List<List<int>> Layers = new List<List<int>>(); // 每层的节点 ID 列表 public int BossNodeId; }

字段设计上有两个点值得注意。一是PrevNodes不是必须的,但加上之后做「从 Boss 反向校验连通性」会方便很多,不用每次遍历全图找前驱。二是IndexInLayer单独存而不是用列表下标代替,是因为后续做坐标计算时,层内序号直接决定节点的水平位置,和列表顺序解耦更安全。

3.2 生成层:MapGenerator 的主流程与随机种子管理

生成层是整个方案的核心。主流程按顺序做四件事:初始化每层节点、建立层间连边、分配节点类型、跑校验。随机种子用一个int存下来,方便复现问题地图。下面是一个主流程的骨架:

public class MapGenerator { private System.Random rng; // 用 System.Random 而不是 UnityEngine.Random,便于脱离引擎测试 public MapGraph Generate(int seed, MapConfig config) { rng = new System.Random(seed); var graph = new MapGraph(); CreateNodes(graph, config); // 第一步:每层放节点 ConnectLayers(graph, config); // 第二步:层间连边 AssignTypes(graph, config); // 第三步:分配类型 Validate(graph, config); // 第四步:校验,失败则抛异常或重试 return graph; } }

用System.Random而不是UnityEngine.Random是一个小但重要的选择。前者不依赖引擎生命周期,可以在纯 C# 测试里跑,也能保证同一个种子在不同机器上生成完全一样的地图。后者受引擎内部状态影响,复现问题时会遇到「同样的种子两台机器结果不一样」的玄学情况。

3.3 表现层:节点坐标计算与连线渲染

表现层拿到MapGraph后,先算坐标再画。坐标计算通常用「层高固定、层内水平均分」的策略:第layer层的 Y 坐标等于layer * layerHeight,第index个节点的 X 坐标等于(index - (count - 1) / 2f) * nodeSpacing,这样每层节点会以中线为轴左右对称排开。连线用LineRenderer逐条画,或者用 UI 的Image拉伸成线段。如果节点数量多,建议把连线合并到一个 Mesh 里,避免几十个LineRenderer拖累性能。

注意:坐标计算里(count - 1) / 2f的f不能省,否则整数除法会让奇数个节点的层偏移半个身位,视觉上歪得很明显。

3.4 配置层:MapConfig 里该放哪些参数

配置层决定地图的「手感」。通常会把下面这些参数暴露出来:总层数、每层节点数下限和上限、各节点类型的权重、类型出现的层数限制、连边时的最大跨度和最小跨度。把这些放在一个ScriptableObject里,策划改数值不用碰代码,调平衡效率高很多。参数之间有关联,比如「每层节点数上限」调大之后,「最大连边跨度」也要跟着调,否则容易出现一个节点连出五六条线的情况。

4. 核心算法实现:连边规则、类型分配与连通性校验

4.1 层间连边:从「每个节点至少一条出边」开始

连边是整套算法里最容易写错的部分。一个稳妥的做法是分两轮:第一轮保证每个节点至少有一条出边,第二轮再随机补边。第一轮的具体操作是遍历当前层每个节点,从下一层节点里随机挑一个连上,同时记录下一层每个节点被连的次数。第二轮遍历下一层节点,如果某个节点入度为 0,就从上一层随机挑一个节点连过来。这样两轮下来,连通性基本有保障,且不会出现孤立节点。

private void ConnectLayers(MapGraph graph, MapConfig config) { for (int layer = 0; layer < graph.Layers.Count - 1; layer++) { var current = graph.Layers[layer]; var next = graph.Layers[layer + 1]; var nextInDegree = new Dictionary<int, int>(); // 第一轮:每个当前层节点至少连一条出边 foreach (int nodeId in current) { int target = next[rng.Next(next.Count)]; graph.Nodes[nodeId].NextNodes.Add(target); graph.Nodes[target].PrevNodes.Add(nodeId); nextInDegree[target] = nextInDegree.GetValueOrDefault(target) + 1; } // 第二轮:补入度为 0 的下一层节点 foreach (int nodeId in next) { if (!nextInDegree.ContainsKey(nodeId)) { int source = current[rng.Next(current.Count)]; graph.Nodes[source].NextNodes.Add(nodeId); graph.Nodes[nodeId].PrevNodes.Add(source); } } } }

这段代码里GetValueOrDefault是Dictionary的扩展方法,省去手动判断 key 是否存在。第二轮补边时没有去重,理论上可能出现同一个 source 连了两次同一个 target,实际跑的时候概率极低,但严谨起见可以在Add之前判断一下NextNodes里是否已存在。连边完成后,每个节点的出边数量可能不均匀,有的只有一条,有的有三四条,这属于正常现象,后续可以通过「最大出边数」参数限制。

4.2 节点类型分配:权重抽签与互斥规则

类型分配在连边之后做,因为有些规则依赖节点的入度和出度。比如「商店节点不能是死胡同」这条,就需要先知道节点的出边数量。分配时按层遍历,每层对每个节点做一次加权抽签,抽完检查互斥规则,不满足就重抽,重抽超过 10 次降级为普通战斗。下面是一个简化的抽签逻辑:

private NodeType RollType(MapConfig config, int layer, List<NodeType> layerTypes) { float total = 0f; foreach (var entry in config.TypeWeights) { if (!IsTypeAllowed(entry.Type, layer, layerTypes)) continue; total += entry.Weight; } float roll = (float)rng.NextDouble() * total; foreach (var entry in config.TypeWeights) { if (!IsTypeAllowed(entry.Type, layer, layerTypes)) continue; roll -= entry.Weight; if (roll <= 0f) return entry.Type; } return NodeType.Battle; // 兜底 }

IsTypeAllowed里集中放互斥规则,比如休息点不能连续两层出现、精英不能出现在前 3 层、Boss 层固定为 Boss 类型。把规则集中在一个方法里,后续加新规则不用改抽签主逻辑。权重表建议用List而不是Dictionary,因为遍历顺序稳定,同一个种子抽出来的结果才可复现。

4.3 连通性校验:从起点正向遍历与从 Boss 反向回溯

校验分两步。第一步从第 0 层所有节点出发做广度优先遍历,看能不能到达 Boss 节点;第二步从 Boss 节点反向遍历,看能不能回到第 0 层。两步都通过,才认为连通性合格。只做正向遍历是不够的,因为可能存在「能到 Boss 但 Boss 回不去」的情况,这种地图在玩家实际走的时候表现为某些节点永远走不到终点。

private bool Validate(MapGraph graph, MapConfig config) { // 正向:从第 0 层出发能否到达 Boss var visited = new HashSet<int>(); var queue = new Queue<int>(); foreach (int id in graph.Layers[0]) { queue.Enqueue(id); visited.Add(id); } while (queue.Count > 0) { int cur = queue.Dequeue(); foreach (int next in graph.Nodes[cur].NextNodes) { if (visited.Add(next)) queue.Enqueue(next); } } if (!visited.Contains(graph.BossNodeId)) return false; // 反向:从 Boss 出发能否回到第 0 层 visited.Clear(); queue.Enqueue(graph.BossNodeId); visited.Add(graph.BossNodeId); while (queue.Count > 0) { int cur = queue.Dequeue(); foreach (int prev in graph.Nodes[cur].PrevNodes) { if (visited.Add(prev)) queue.Enqueue(prev); } } foreach (int id in graph.Layers[0]) { if (!visited.Contains(id)) return false; } return true; }

校验失败时不要直接抛异常崩掉,通常的做法是记录种子和失败原因,然后换一个种子重新生成,重试上限设为 20 次。20 次还失败说明配置本身有问题,比如层数太少但节点数要求太多,这时候应该报配置错误而不是继续重试。

4.4 路径不交叉的近似处理

严格意义上的「路径不交叉」在分层图里是一个平面嵌入问题,实现成本较高。实际项目里通常用近似策略:连边时限制「当前层第 i 个节点只能连向下一层第 i-1 到 i+1 范围内的节点」。这样虽然不能百分百保证不交叉,但能大幅降低交叉概率,视觉上已经足够干净。如果对交叉零容忍,可以在连边后跑一次交叉检测,发现交叉就交换两个节点的连边目标,迭代几次通常能收敛。

5. 避坑与常见问题排查:那些让地图「看起来能跑」的陷阱

5.1 现象:地图能生成但偶尔没有通往 Boss 的路径

原因:连边时只保证了每个节点有出边,但没有保证出边最终能汇聚到 Boss。如果某一层的节点全部连向了下一层的同一个节点,而那个节点又恰好没有继续往下连,就会出现断头路。

解决:在连边完成后立刻跑一次正向遍历校验,不通过就重新生成。不要等到运行时玩家走到死胡同才发现,那时候已经晚了。校验逻辑放在生成层里,和生成逻辑一起跑,生成失败就换种子重试。

5.2 现象:休息点连续出现或者精英扎堆在前三层

原因:类型分配时只做了单节点权重抽签,没有做层间互斥检查。权重高的类型在连续几层里被反复抽中,概率上完全可能。

解决:在IsTypeAllowed里加层间规则,比如「当前层已有休息点则本层不再抽休息」「前 3 层不允许精英」。规则要写成可配置的,不要硬编码在抽签方法里,否则调平衡时改起来很痛苦。

5.3 现象:同一个种子在不同机器上生成的地图不一样

原因:用了UnityEngine.Random或者把System.Random的实例在多个生成器之间共享,导致随机序列被其他逻辑消耗。

解决:每个MapGenerator实例持有自己的System.Random,种子从外部传入,生成过程中不调用任何全局随机源。如果项目里其他地方也要用随机数,确保它们用的是独立的随机实例。

5.4 现象:节点多了之后连线渲染卡顿

原因:每个连线用一个LineRenderer,几十条线就是几十个 DrawCall,加上节点本身的 UI 图片,批次一多帧率就掉。

解决:把连线合并到一个 Mesh 里,用MeshRenderer一次性画完。或者用 UI 的MaskableGraphic自定义一个组件,把所有线段顶点塞进一个VertexHelper。节点数量在 100 以内时,合并后通常能压到 2 到 3 个 DrawCall。

5.5 现象:重试 20 次仍然生成失败

原因:配置参数本身矛盾,比如总层数设为 5 但要求每层至少 4 个节点且精英不能出现在前 3 层,可用的层数根本不够分配。

解决:在生成前先做一次配置合法性检查,把明显矛盾的参数组合拦下来,报明确的配置错误。比如「精英限制层数 + 精英最少出现次数」不能超过可用层数,这类检查写一次能省掉后面无数次调试。

6. 验收与答辩:怎么证明这套地图算法真的合格

验收这套方案,不能只看「跑起来有张图」就完事。我一般会准备三组证据:一组是自动化测试,一组是可视化对比,一组是边界用例。自动化测试用纯 C# 跑,不启动 Unity,对 1000 个不同种子各生成一次地图,统计连通性通过率、类型分布偏差、平均出边数。连通性通过率必须是 100%,类型分布偏差控制在配置权重的正负 5% 以内,平均出边数落在 1.5 到 2.5 之间。这三项达标,算法本身就算合格。

可视化对比是答辩时最直观的材料。同一个种子生成的地图,在编辑器里画出来,和另一套纯随机方案生成的地图并排放在一起。评审一眼就能看出分层方案的地图「有结构」,节点分布均匀、路径清晰,而纯随机方案的地图要么挤成一团要么散得离谱。如果时间允许,可以录一段 10 秒的 GIF,展示同一个种子反复生成结果完全一致,证明随机种子管理是可靠的。

边界用例主要测三种极端配置:每层最少节点数、每层最多节点数、总层数取最小值和最大值。这四种组合各跑 100 次,看有没有生成失败或者校验不通过的情况。下面这张表是我常用的验收清单,可以直接抄:

验收项合格标准测试方法
连通性1000 个种子全部通过纯 C# 批量生成 + 正反向遍历
类型分布偏差在权重正负 5% 内统计 1000 张地图各类型占比
路径交叉交叉数不超过总连边数的 5%几何检测相邻层连线相交
种子复现同种子两次生成结果完全一致序列化对比节点和连边
边界配置四种极端配置各 100 次无失败参数化批量生成

答辩时如果被问到「为什么不用 A* 或者更复杂的图算法」,回答的口径是:这套方案的目标不是找最短路径,而是生成一张结构可控的随机图,A* 解决的是寻路问题,和地图生成是两回事。如果被问到「怎么保证每局体验不一样」,就展示种子机制和权重抽签,说明同一套配置下不同种子会产出不同地图,而同一局内种子固定保证可复现。

从那以后我每次做随机生成相关的模块,都强制先写校验再写生成,哪怕多花半天时间,也比后期玩家反馈「卡在地图里出不去」要划算得多。希望这套结构能帮到你,把地图这块硬骨头啃下来。

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

RELATED READING

延伸阅读

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