☰
Unity 杀戮尖塔地图生成算法:带约束的随机图与路径交叉检测
2026/10/16 3:21:00 网站建设 项目流程

简介:本资源是面向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 开始 Dictionary<NodeType, int> GetWeightTable(int floorIndex, int totalFloors) { var table = new Dictionary<NodeType, 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(Dictionary<NodeType, 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); } // 生成一层连线时,反复尝试直到没有交叉 List<Edge> GenerateEdges(List<Node> upper, List<Node> lower) { List<Edge> edges = new List<Edge>(); 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。
  • MapConfig:ScriptableObject,存放层数、每层节点数范围、权重表等参数。

把参数抽到 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(List<List<Node>> 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,权重表里写的是Store,RollNodeType遍历的时候永远匹配不到。另一个原因是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(List<List<Node>> 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 的乐趣就没了。

从那以后我每次调地图生成参数,都会先固定种子跑十遍,把生成结果截图拼在一起看整体分布,确认没有明显的结构问题之后,再换成随机种子跑。这个习惯帮我省了很多“改完参数感觉不对但又说不清哪里不对”的时间。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询