简介本资源是面向Unity开发者的《杀戮尖塔》风格地图算法实现Demo适合已掌握C#基础、希望学习Roguelike地图生成与路线连接逻辑的中级开发者。项目基于Unity引擎用C#完整实现了房间生成与路线生成两大核心模块房间数量按起点层动态配置、中间层在最小值与起点数量乘二减一之间取值、Boss层固定为一间房间位置在平铺基础上叠加横纵随机偏移形成自然错落感。路线部分通过检索最近上层房间、断路检索补全上层对象列表、再逐层链接的方式构建出可通行的地图网络。压缩包共144个文件约10.3MB包含5个cs脚本、21个asset配置、34张png贴图及prefab预制体等覆盖工程设置、场景资源与核心逻辑代码目录结构清晰。目前已有1081人学习下载可作为地图生成算法的参考实现帮助读者理解随机布局、断路处理与层级连接的具体落地方式。1. 从一张随机地图说起这个 Unity 算法 Demo 到底能跑出什么如果你玩过《杀戮尖塔》大概会对那种“每局路线都不一样、但每一条都看起来合理”的地图生成印象深刻。我最近拆了一个用 Unity 实现的杀戮尖塔地图算法 Demo它把这种“随机但可控”的爬塔地图生成逻辑完整跑通了。简单说它能在 Unity 里根据一组参数自动生成一张由节点和连线组成的多层地图节点类型包括战斗、精英、商店、休息、宝箱和 Boss层与层之间的路径不会交叉也不会出现死路。这个 Demo 适合两类人一是想在自己的 Roguelike 项目里复刻类似地图系统的 Unity 开发者二是想搞懂“带约束的随机图生成”到底怎么落地的算法学习者。它不是一个成品游戏而是一个可运行、可调参、可拆开看每一步在干什么的算法演示工程。2. 地图生成的核心逻辑从固定层数到路径约束2.1 为什么不能直接随机撒点很多人第一次做爬塔地图第一反应是“每层随机放几个节点然后随机连线”。我早期也这么干过结果就是地图要么出现孤岛节点要么两条路径交叉得像蜘蛛网玩家根本看不出哪条路通向哪里。杀戮尖塔的地图之所以看起来舒服是因为它在生成时加了几条硬约束每层节点数量在一个范围内浮动、相邻层之间只允许特定数量的连线、任意节点至少有一条入边和一条出边、路径不能交叉。这个 Demo 把这些约束拆成了几个可配置的参数而不是写死在代码里。常见做法是先用一个二维数组表示地图的层和列然后按层推进每一层根据上一层的节点分布决定当前层要生成几个节点、每个节点连到上一层的谁。听起来简单但真正难的是“连线不交叉”这一条。Demo 里用了一个比较取巧的办法给每个节点分配一个列索引连线时只允许从上一层的第 i 个节点连到当前层的第 j 个节点且满足 j 和 i 的差值在一个范围内同时用一个占用表记录已经被连过的目标节点避免多条线汇聚到同一个节点导致视觉混乱。2.2 节点类型分配与权重表地图骨架搭好之后下一步是给每个节点分配类型。这个 Demo 没有用完全随机而是用了一张权重表。权重表按层数分段比如前几层战斗权重高、精英不出中间层开始出现商店和休息Boss 层固定只有一个节点。下面这段代码是 Demo 里节点类型分配的核心逻辑我把它简化了一下方便看清结构// 节点类型枚举 public enum NodeType { Battle, Elite, Shop, Rest, Treasure, Boss } // 按层数返回权重表层数从 0 开始 DictionaryNodeType, int GetWeightTable(int floorIndex, int totalFloors) { var table new DictionaryNodeType, int(); float progress (float)floorIndex / totalFloors; // 前 20% 层只有战斗和宝箱 if (progress 0.2f) { table[NodeType.Battle] 70; table[NodeType.Treasure] 30; } // 中间层战斗、精英、商店、休息混合 else if (progress 0.8f) { table[NodeType.Battle] 50; table[NodeType.Elite] 15; table[NodeType.Shop] 15; table[NodeType.Rest] 20; } // 最后 20% 层休息和商店权重升高为 Boss 做准备 else { table[NodeType.Battle] 30; table[NodeType.Rest] 40; table[NodeType.Shop] 30; } return table; } // 按权重随机抽取一个节点类型 NodeType RollNodeType(DictionaryNodeType, int table) { int total 0; foreach (var kv in table) total kv.Value; int roll Random.Range(0, total); int acc 0; foreach (var kv in table) { acc kv.Value; if (roll acc) return kv.Key; } return NodeType.Battle; // 兜底 }这段代码里有两个关键参数progress和权重值。progress是当前层数占总层数的比例用它来分段而不是写死层号这样你改总层数的时候权重表不用重写。权重值本身没有绝对标准我一般会让战斗类节点在前期占比高一些因为前期玩家需要积累卡牌和金币休息和商店在后期占比高因为 Boss 前需要调整卡组和回血。如果你想让地图更“刺激”可以把精英权重整体调高但要注意精英节点不能连续出现否则玩家血量压力会太大。2.3 路径生成与交叉检测路径生成是整个 Demo 里最绕的部分。我一开始以为只要保证每个节点都有入边和出边就行了后来发现玩家实际走的时候如果两条路径在视觉上交叉会让人产生“我是不是走错了”的错觉。Demo 里用了一个简单的交叉检测对于任意两条边 (a1, b1) 和 (a2, b2)如果 a1 的列索引小于 a2但 b1 的列索引大于 b2就说明这两条边交叉了。检测到交叉后要么交换目标节点要么重新选一条边。// 检测两条边是否交叉 bool IsCross(Edge e1, Edge e2) { // 假设 Edge 有 fromCol 和 toCol 两个属性 return (e1.fromCol e2.fromCol e1.toCol e2.toCol) || (e1.fromCol e2.fromCol e1.toCol e2.toCol); } // 生成一层连线时反复尝试直到没有交叉 ListEdge GenerateEdges(ListNode upper, ListNode lower) { ListEdge edges new ListEdge(); foreach (var node in lower) { // 每个下层节点至少连一个上层节点 int fromIndex Random.Range(0, upper.Count); edges.Add(new Edge(upper[fromIndex], node)); } // 简单交叉修正如果发现交叉交换目标 for (int i 0; i edges.Count; i) { for (int j i 1; j edges.Count; j) { if (IsCross(edges[i], edges[j])) { // 交换 to 节点 var tmp edges[i].to; edges[i].to edges[j].to; edges[j].to tmp; } } } return edges; }这段代码里的交叉修正不是最优解但胜在逻辑简单、容易调试。实际项目里如果层数多、节点多这种 O(n²) 的检测会有点慢可以考虑按列索引排序后再连线。另外要注意每个下层节点至少有一条入边但上层节点不一定有出边所以生成完之后还要检查一遍有没有“死节点”——也就是没有任何连线的节点有的话要么删掉要么补一条边。3. 在 Unity 里把地图跑起来场景搭建与参数调试3.1 场景层级与预制体组织Demo 的 Unity 场景结构很直白一个空物体挂 MapGenerator 脚本下面放一个 Canvas 用来显示地图每个节点是一个 Button 预制体连线用 LineRenderer 或者 UI Image 拉伸实现。我建议把节点预制体做成一个带 Image 和 Button 的 UI 元素Image 用来显示节点图标Button 用来接收点击事件。连线如果也用 UI 做可以用一个细长的 Image 旋转到对应角度比 LineRenderer 更容易控制层级和点击穿透。场景里我一般会建这几个物体MapRoot空物体挂 MapGenerator作为所有节点的父级。NodePrefab节点预制体包含图标和按钮。LinePrefab连线预制体一个拉伸的 Image。MapConfigScriptableObject存放层数、每层节点数范围、权重表等参数。把参数抽到 ScriptableObject 里的好处是你可以在不打开代码的情况下调地图生成结果而且不同关卡可以用不同的配置资产。Demo 里如果没带 ScriptableObject你也可以直接在 Inspector 里暴露 public 字段效果一样只是改起来没那么方便。3.2 关键参数怎么调地图生成的可调参数大概有这些总层数、每层最小节点数、每层最大节点数、每层最大连线数、节点类型权重表、Boss 层节点数。我按自己的经验给一组起步值参数建议值说明总层数15太少不够玩太多玩家会累每层最小节点数2低于 2 会出现单线路径选择感弱每层最大节点数5超过 5 屏幕会挤UI 不好排每层最大连线数3连线太多会交叉视觉混乱Boss 层节点数1固定一个 Boss 节点调参的时候有个血泪经验不要一次性把所有参数都改一遍。我一般先固定层数和节点数范围只调权重表看节点类型分布是否符合预期然后再调连线数看路径是否合理最后再微调层数。每次只改一个维度出问题的时候才知道是哪个参数导致的。3.3 生成结果的可视化验证Demo 跑起来之后你会在 Canvas 上看到一张从上到下或者从左到右的地图。我习惯在生成完之后加一段调试代码把每个节点的类型、层号、列号打印到 Console方便对照。如果发现某一层节点特别少或者特别多先检查Random.Range的上下界是不是写反了——Random.Range(int min, int max)是包含 min 不包含 max 的这个坑我踩过不止一次。// 生成后打印地图结构方便调试 void DebugPrintMap(ListListNode map) { for (int floor 0; floor map.Count; floor) { string line $Floor {floor}: ; foreach (var node in map[floor]) { line $[{node.type} col{node.col}] ; } Debug.Log(line); } }这段调试代码虽然简单但能帮你快速定位“为什么这层没有商店”或者“为什么精英连续出现了三次”这类问题。如果你发现节点类型分布和权重表对不上先确认权重表的 key 有没有拼写错误再确认RollNodeType里的累加逻辑有没有漏掉某个类型。4. 避坑与排查地图生成里最容易翻车的几个点4.1 节点孤岛某层节点没有任何连线现象生成出来的地图里某个节点孤零零地悬在那里既没有入边也没有出边玩家点不到或者点进去没反应。原因连线生成时只保证了“每个下层节点至少有一条入边”但没有保证“每个上层节点至少有一条出边”。如果某一层的某个节点在随机连线时没有被任何下层节点选中它就成了孤岛。解决在连线生成之后加一轮检查遍历所有节点如果发现某个节点既没有入边也没有出边就强制给它连一条边。如果是中间层的节点可以连到下一层任意一个节点如果是最后一层之前的节点可以连到 Boss 节点。Demo 里我一般会在GenerateEdges之后加一个FixOrphanNodes方法专门处理这种情况。4.2 路径交叉连线看起来像蜘蛛网现象地图上两条线交叉在一起玩家分不清哪条线连到哪个节点视觉上很乱。原因连线时没有做交叉检测或者交叉检测的逻辑只处理了部分情况。比如只检测了相邻层之间的交叉没有检测跨层交叉或者交叉修正时只交换了一次没有循环检测直到没有交叉。解决把交叉检测做成一个循环每次发现交叉就交换目标节点直到一整轮下来没有新的交叉为止。如果节点数太多导致循环次数爆炸可以限制最大迭代次数超过之后直接重新生成这一层的连线。另外给节点分配列索引时尽量让同一层的节点按列号有序排列这样交叉检测会简单很多。4.3 权重表不生效商店和休息永远不出现现象调了权重表把商店权重调到很高但生成出来的地图里一个商店都没有。原因最常见的是权重表的 key 和枚举值对不上比如枚举里是Shop权重表里写的是StoreRollNodeType遍历的时候永远匹配不到。另一个原因是progress的计算方式有问题比如用了floorIndex / totalFloors但两个都是 int结果永远是 0 或 1。解决先检查枚举名和权重表 key 是否完全一致大小写敏感。然后检查progress的计算确保至少有一个操作数是 float。可以在RollNodeType里加一行Debug.Log打印当前层的权重表和 roll 值看看是不是真的按预期在抽。4.4 地图太大导致 UI 溢出屏幕现象层数或每层节点数调大之后地图超出 Canvas 范围玩家看不到后面的节点。原因节点位置计算时用了固定间距没有根据 Canvas 尺寸做自适应。或者 Canvas 的 Render Mode 是 Screen Space - Overlay但节点坐标是按世界坐标算的。解决把节点位置计算改成基于 Canvas 的 RectTransform 尺寸做归一化或者给地图加一个 ScrollRect让玩家可以滚动查看。如果不想加滚动就限制总层数和每层节点数的乘积保证在常见分辨率下能完整显示。我一般会在MapGenerator里加一个maxVisibleNodes参数超过这个数量就自动缩小节点间距。4.5 重复生成时旧节点没清理现象点“重新生成”按钮之后新地图和旧地图叠在一起节点越点越多。原因生成新地图之前没有销毁MapRoot下的旧节点和连线。解决在GenerateMap方法开头加一段清理逻辑遍历MapRoot的所有子物体并Destroy。注意如果用了对象池要改成回收而不是销毁。另外如果连线是单独放在另一个父物体下的也要一并清理。// 清理旧地图 void ClearMap() { foreach (Transform child in mapRoot) { Destroy(child.gameObject); } // 如果有单独的连线父物体也要清理 foreach (Transform child in lineRoot) { Destroy(child.gameObject); } }这段清理代码要放在生成逻辑的最前面而且Destroy是延迟执行的如果同一帧内又生成了新节点旧节点可能还没被销毁。保险的做法是用DestroyImmediate但只在编辑器模式下用运行时还是用Destroy配合一帧延迟。5. 进阶技巧让地图更有“设计感”的两种改法5.1 用固定种子复现地图随机生成的地图有个问题调试的时候每次跑出来的都不一样很难对比两次修改的效果。Demo 里如果用的是Random.Range可以换成System.Random并传入一个固定种子这样每次生成的结果完全一致。等调好参数之后再把种子改成随机的。// 用固定种子初始化随机数 System.Random rng new System.Random(seed); int roll rng.Next(0, total);把seed暴露到 Inspector 里调试时填一个固定值发布时设为 0 表示随机种子。这样你改一个参数重新生成就能直接看出这个参数对地图结构的影响不用靠记忆去对比。5.2 手动干预在生成结果上做局部替换完全随机的地图有时候会生成一些“看起来不太合理”的路线比如连续三个精英节点。与其反复调权重表不如在生成之后加一个后处理步骤扫描整张地图如果发现连续两个精英节点就把第二个替换成战斗或商店。这种后处理逻辑比调权重更直接也更容易控制。// 后处理避免连续精英 void PostProcessElite(ListListNode map) { for (int floor 1; floor map.Count; floor) { foreach (var node in map[floor]) { if (node.type NodeType.Elite) { // 检查上一层是否有精英节点连到它 bool prevElite node.inEdges.Exists(e e.from.type NodeType.Elite); if (prevElite) { node.type NodeType.Battle; // 降级为普通战斗 } } } } }这个后处理可以扩展成一套规则连续休息不超过两个、商店不出现在第一层、Boss 前一层至少有一个休息节点。规则越多地图越“像人设计的”但也要注意别把随机性压得太死否则每局地图都差不多Roguelike 的乐趣就没了。从那以后我每次调地图生成参数都会先固定种子跑十遍把生成结果截图拼在一起看整体分布确认没有明显的结构问题之后再换成随机种子跑。这个习惯帮我省了很多“改完参数感觉不对但又说不清哪里不对”的时间。希望帮到你。本文还有配套的精品资源点击获取