简介:本资源是面向Unity开发者的《杀戮尖塔》风格地图算法实现Demo,适合已掌握C#基础、希望学习Roguelike地图生成与路线连接逻辑的中级开发者参考。项目以Unity为引擎,核心包含地图房间生成与路线生成两大部分:房间数量按起点层动态配置、中间层在最小值2与起点数量×2-1之间取值、Boss层固定为1;房间位置在平铺基础上叠加横向与纵向随机偏移。路线生成则通过检索最近上层房间、断路检索补全上层对象列表,再完成层间链接,形成完整可通行的地图结构。压缩包共144个文件,约10.3MB,以png贴图、asset配置、meta与json数据文件为主,另含少量cs脚本、prefab预制体及Unity工程配置,便于直接导入编辑器运行调试。目前已有1081人学习下载,可作为地图生成算法学习与二次开发的实践参考。
1. 从一张随机地图说起:这个 Demo 到底解决了什么问题
如果你做过卡牌构筑类游戏,一定绕不开一个核心问题:地图怎么生成才既随机又有策略感。很多新手一上来就写个纯随机节点,结果玩家走两步就发现路线要么全挤在一起,要么永远只有一条主干道,玩起来像在走迷宫而不是做选择。这个 Unity 杀戮尖塔地图算法实现 Demo,解决的就是这件事——它把那种“分叉、汇聚、带精英和商店节点”的路线结构,用一套可复现的算法跑了出来。
它适合两类人:一类是正在做 Roguelike 卡牌项目、卡在关卡结构上的 Unity 开发者;另一类是想搞懂“分层随机图”怎么落地、不想只抄个结果的人。Demo 本身不依赖特定版本的 Unity,核心逻辑集中在 C# 脚本里,你把它挂到空场景就能看到生成结果。接下来我会按“算法原理 → 参数怎么调 → 怎么验证 → 坑在哪”的顺序拆一遍,中间给到能直接抄的代码和参数表。
2. 分层随机图的生成逻辑:从节点分配到边连接
2.1 为什么是“分层 + 随机游走”而不是纯随机
杀戮尖塔那种地图,本质是一张有向无环图。它有几个硬约束:起点唯一、终点唯一、每一层的节点数在一个区间内波动、相邻层之间必须有边、不能出现跨层连线、不能有孤立节点。纯随机连边几乎必然违反其中几条,所以常见做法是先把地图切成固定层数,再在每层里放节点,最后用“随机游走 + 补边”的方式把层与层连起来。
这个 Demo 里我看到的实现思路是:先生成一条从起点到终点的主路径,保证连通性;然后在主路径之外,按概率在每层补充额外节点,并让它们向前后层找邻居。这样做的好处是,主路径一定存在,不会出现“生成完发现走不通”的翻车情况。代价是地图的“野生感”会弱一点,但可以通过调参把额外节点的比例拉高来弥补。
提示:如果你想要更野的拓扑,可以把主路径生成改成“多条主路径并行”,但那样连通性校验要写得更严,新手不建议一上来就改。
2.2 节点分配与层数控制的核心代码
下面这段是层节点分配的逻辑,我把它从 Demo 里抽出来做了简化,方便你直接放进自己的项目。它负责决定每一层放几个节点,以及哪些层允许出现精英或商店。
// MapGenerator.cs // 负责按层分配节点数量与类型 public class MapGenerator : MonoBehaviour { [Header("层数与节点范围")] public int totalLayers = 15; // 总层数,不含起点终点 public int minNodesPerLayer = 2; // 每层最少节点 public int maxNodesPerLayer = 4; // 每层最多节点 [Header("特殊节点概率")] [Range(0f, 1f)] public float eliteChance = 0.15f; // 精英节点出现概率 [Range(0f, 1f)] public float shopChance = 0.10f; // 商店节点出现概率 [Range(0f, 1f)] public float restChance = 0.12f; // 休息节点出现概率 // 生成每一层的节点列表 public List<List<MapNode>> GenerateLayers() { var layers = new List<List<MapNode>>(); for (int i = 0; i < totalLayers; i++) { int count = Random.Range(minNodesPerLayer, maxNodesPerLayer + 1); var layer = new List<MapNode>(); for (int j = 0; j < count; j++) { var node = new MapNode(); node.layerIndex = i; node.nodeIndex = j; node.nodeType = RollNodeType(i); layer.Add(node); } layers.Add(layer); } return layers; } // 根据层数和概率决定节点类型 private NodeType RollNodeType(int layer) { // 第一层不出精英和商店,避免开局压力过大 if (layer == 0) return NodeType.Battle; float roll = Random.value; if (roll < eliteChance) return NodeType.Elite; if (roll < eliteChance + shopChance) return NodeType.Shop; if (roll < eliteChance + shopChance + restChance) return NodeType.Rest; return NodeType.Battle; } }这段代码里,totalLayers控制地图纵向长度,minNodesPerLayer和maxNodesPerLayer决定每层宽度。eliteChance这类概率参数是直接暴露在 Inspector 里的,方便你在运行时拖拽观察变化。注意RollNodeType里对第一层做了保护,这是血泪经验——早期版本没加这个判断,结果第一层就刷出精英,玩家开局直接劝退。
2.3 边连接:随机游走与补边策略
节点分配完之后,下一步是把相邻层连起来。Demo 里的做法是:对每一层的每个节点,随机选择下一层的一个或多个节点作为后继。为了保证不出现孤立节点,还会反向检查下一层每个节点是否至少有一个前驱,没有的话就从上一层随机挑一个补一条边。
// 连接相邻层 public void ConnectLayers(List<List<MapNode>> layers) { for (int i = 0; i < layers.Count - 1; i++) { var currentLayer = layers[i]; var nextLayer = layers[i + 1]; // 正向:每个节点至少连一条边到下一层 foreach (var node in currentLayer) { int connectCount = Random.Range(1, 3); // 每个节点连出 1~2 条边 for (int k = 0; k < connectCount; k++) { var target = nextLayer[Random.Range(0, nextLayer.Count)]; if (!node.nextNodes.Contains(target)) node.nextNodes.Add(target); } } // 反向:确保下一层每个节点至少有一个前驱 foreach (var nextNode in nextLayer) { bool hasPredecessor = currentLayer.Exists(n => n.nextNodes.Contains(nextNode)); if (!hasPredecessor) { var fallback = currentLayer[Random.Range(0, currentLayer.Count)]; fallback.nextNodes.Add(nextNode); } } } }connectCount的范围决定了地图的“分叉密度”。设成 1 到 2 时,路线比较收敛;设成 2 到 3 时,交叉会明显变多。反向补边那一段是必须的,否则你会看到某些节点永远走不到,玩家点上去没反应。这个坑我在早期版本里踩过,排查了半天才发现是孤立节点导致的。
3. 参数调优与可视化:让地图“看起来对”
3.1 关键参数对照表
调参这件事没有标准答案,但有几个经验区间可以参考。下面这张表是我在 Demo 里反复试出来的,你可以直接拿去当起点。
| 参数名 | 作用 | 保守值 | 激进值 | 影响 |
|---|---|---|---|---|
| totalLayers | 地图总层数 | 12 | 18 | 越大流程越长,后期容易疲劳 |
| minNodesPerLayer | 每层最少节点 | 2 | 3 | 越大路线越多,选择困难 |
| maxNodesPerLayer | 每层最多节点 | 3 | 5 | 同上,但影响上限 |
| eliteChance | 精英节点概率 | 0.10 | 0.20 | 越高压力越大,奖励也越高 |
| shopChance | 商店概率 | 0.08 | 0.15 | 影响经济循环节奏 |
| restChance | 休息点概率 | 0.10 | 0.18 | 影响续航,太高会降低紧张感 |
我一般会先把totalLayers定在 15,然后把maxNodesPerLayer压到 3,跑几遍看路线是否清晰。如果发现地图太“胖”,就降maxNodesPerLayer;如果发现太“瘦”,就升minNodesPerLayer。精英和商店的概率建议分开调,不要一起拉高,否则中期会同时遇到高压力和强经济,节奏容易崩。
3.2 用 Gizmos 把生成结果画出来
光看数据很难判断地图长什么样,Demo 里用OnDrawGizmos把节点和边画在 Scene 视图里。这样你调参的时候能立刻看到变化,不用反复运行游戏。
// 在 MapGenerator 里加这个函数,方便在 Scene 视图调试 private void OnDrawGizmos() { if (layers == null || layers.Count == 0) return; float layerSpacing = 2f; // 层与层之间的纵向间距 float nodeSpacing = 1.5f; // 同层节点之间的横向间距 for (int i = 0; i < layers.Count; i++) { var layer = layers[i]; for (int j = 0; j < layer.Count; j++) { var node = layer[j]; // 计算节点在 Scene 视图中的位置 Vector3 pos = new Vector3( (j - (layer.Count - 1) * 0.5f) * nodeSpacing, -i * layerSpacing, 0 ); // 按类型给不同颜色 Gizmos.color = node.nodeType switch { NodeType.Battle => Color.white, NodeType.Elite => Color.red, NodeType.Shop => Color.yellow, NodeType.Rest => Color.green, _ => Color.gray }; Gizmos.DrawSphere(pos, 0.3f); // 画边 Gizmos.color = Color.cyan; foreach (var next in node.nextNodes) { int nextIndex = layers[next.layerIndex].IndexOf(next); Vector3 nextPos = new Vector3( (nextIndex - (layers[next.layerIndex].Count - 1) * 0.5f) * nodeSpacing, -next.layerIndex * layerSpacing, 0 ); Gizmos.DrawLine(pos, nextPos); } } } }这段代码不参与打包,只在编辑器里生效。layerSpacing和nodeSpacing只影响观察效果,不影响实际逻辑。颜色区分能让你一眼看出精英和商店的分布是否合理。如果发现某一层全是红色,说明eliteChance在该层被随机放大了,可以考虑加一个“每层精英数量上限”的约束。
3.3 生成结果的连通性校验
调参调多了,难免会遇到“看起来没问题、跑起来走不通”的情况。Demo 里加了一个简单的 BFS 校验,从起点出发看能不能到达终点,以及有没有节点是孤立的。
// 校验地图连通性 public bool ValidateMap(List<List<MapNode>> layers) { if (layers.Count == 0) return false; var visited = new HashSet<MapNode>(); var queue = new Queue<MapNode>(); // 从第一层所有节点出发 foreach (var node in layers[0]) { queue.Enqueue(node); visited.Add(node); } while (queue.Count > 0) { var current = queue.Dequeue(); foreach (var next in current.nextNodes) { if (visited.Add(next)) queue.Enqueue(next); } } // 检查最后一层是否全部可达 foreach (var node in layers[layers.Count - 1]) { if (!visited.Contains(node)) { Debug.LogWarning($"终点层节点 {node.nodeIndex} 不可达"); return false; } } return true; }这个校验建议在生成之后立刻调用一次,失败就重新生成。虽然会多花一点时间,但比玩家走到一半发现路断了要好得多。visited用HashSet是为了避免重复入队,queue做标准 BFS。如果你把地图改成多起点或多终点,这段逻辑要相应调整。
4. 避坑与常见问题排查
4.1 现象:地图生成后某些节点点不进去
原因:反向补边没做,或者补边时只补了部分层。常见于只写了正向连接、忘了检查下一层前驱的情况。
解决:在ConnectLayers里加上反向遍历,确保每个非首层节点至少有一个前驱。如果已经生成了旧地图,可以手动跑一遍ValidateMap定位是哪个节点孤立。
4.2 现象:精英节点扎堆出现在同一层
原因:eliteChance是逐节点独立掷骰,没有层内数量约束。层节点数越多,扎堆概率越高。
解决:在RollNodeType里加一个层内计数器,或者生成完一层后做一次“去重”,把多余的精英改成普通战斗。我一般会限制每层最多一个精英,超过就重掷。
4.3 现象:地图看起来太“直”,没有分叉感
原因:connectCount范围太小,或者maxNodesPerLayer设得太低。节点少、边也少,自然就成了一条线。
解决:先把maxNodesPerLayer提到 4 或 5,再把connectCount的上限提到 3。注意不要同时拉太高,否则地图会变成一团乱麻,玩家反而不知道该走哪条。
4.4 现象:重新生成时偶尔卡死或报空引用
原因:layers没有清空就重新生成,旧引用残留;或者Random.Range的整数重载用错,导致范围异常。
解决:每次生成前先layers.Clear(),并确保所有Random.Range的整数版本用的是min, max + 1。浮点版本不需要加一,但要注意上下限顺序。
4.5 现象:Scene 视图里 Gizmos 画出来的边和实际逻辑不一致
原因:OnDrawGizmos里用了IndexOf去反查节点位置,如果节点列表在运行时被修改过,索引会对不上。
解决:把节点的位置信息直接存在MapNode里,生成时就算好,Gizmos 只负责读取。这样即使列表顺序变了,画出来的图也不会错位。
5. 进阶:把地图数据导出成可配置资产
5.1 用 ScriptableObject 存地图模板
如果你想让策划直接调地图,而不是每次改代码,可以把生成好的地图存成ScriptableObject。这样运行时直接读资产,不用每次重新生成,也方便做固定种子复现。
// MapTemplate.cs using UnityEngine; [CreateAssetMenu(fileName = "MapTemplate", menuName = "Map/MapTemplate")] public class MapTemplate : ScriptableObject { public int seed; // 随机种子,用于复现 public List<MapLayerData> layers; // 每层的节点数据 } [System.Serializable] public class MapLayerData { public List<MapNodeData> nodes; } [System.Serializable] public class MapNodeData { public int nodeType; // 对应 NodeType 枚举 public List<int> nextNodeIndices; // 下一层节点的索引 }生成时把layers转成MapLayerData存进去,运行时再转回来。seed字段是关键——有了它,同一个种子每次生成的结果完全一致,方便排查问题和做关卡设计。我一般会在生成器里加一个useFixedSeed开关,调试时打开,发布时关掉。
5.2 用固定种子验证生成稳定性
固定种子不只是为了复现,还能帮你验证算法是否稳定。比如你调了一个参数,想确认它只影响某类节点,就可以用同一个种子跑两次,对比差异。
// 在生成入口处设置种子 public void GenerateWithSeed(int seed) { Random.InitState(seed); layers = GenerateLayers(); ConnectLayers(layers); if (!ValidateMap(layers)) { Debug.LogWarning("地图校验失败,重新生成"); GenerateWithSeed(seed + 1); // 换种子重试 } }Random.InitState会重置全局随机状态,所以调用之前要确保没有其他逻辑依赖随机数。如果项目里有多处用随机,建议把地图生成的随机逻辑单独封装一个System.Random实例,避免互相干扰。递归重试那里加个上限,比如最多重试 5 次,防止极端参数下无限递归。
5.3 一个我常用的调试习惯
从那以后我每次改完地图参数,都会强制走一遍“固定种子 + 连通性校验 + Gizmos 截图”这三步。固定种子保证我能对比前后差异,连通性校验保证不会出现走不通的路,Gizmos 截图则是留档,方便回头查是哪次改动导致地图变丑了。这套习惯帮我省了不少后悔药,尤其是多人协作时,策划说“地图感觉不对”,我能直接翻出截图对比,而不是靠嘴描述。
希望帮到你。
本文还有配套的精品资源,点击获取