☰
杀戮尖塔路线算法拆解:Unity地图生成Demo工程
2026/10/16 7:04:32 网站建设 项目流程

简介:本资源是面向Unity开发者的「杀戮尖塔」式地图生成算法实现Demo,适合已掌握C#基础、希望学习Roguelike关卡随机生成的中级开发者参考。项目基于Unity引擎,用C#完整实现了房间生成与路线连接两大核心逻辑:房间数量按起点层动态配置、中间层在最小值与起点数量乘二减一之间取值、Boss层固定为1;房间位置在平铺基础上叠加横纵随机偏移。路线部分通过检索最近上层房间、断路检索补全上层对象列表、再逐层链接的方式构建连通路径。压缩包共144个文件,约10.3MB,包含5个cs脚本、21个asset配置、34个png贴图及prefab预制体、json与xml数据文件等,覆盖工程配置与场景资源。目前已有1081人学习下载,读者可借此理解地图分层、随机偏移与断路补连的完整实现思路,并直接运行调试。

1. 地图生成不是随机撒点:这个 Demo 把杀戮尖塔的路线算法拆成了可运行工程

如果你玩过杀戮尖塔,大概率有过这种体验:明明想走问号房赌事件,结果路线被精英怪堵死,只能硬着头皮打。这种“被地图安排”的感觉,恰恰说明它的地图生成不是简单随机——每一层节点怎么连、房间类型怎么分配、路线怎么保证不交叉,背后是一套带约束的图生成算法。这个 Unity Demo 干的事,就是把那套算法从商业项目里剥离出来,做成一个能跑、能看、能改的最小工程。它适合两类人:一类是想在 Roguelike 里复刻类似路线系统的独立开发者,另一类是想学图生成算法怎么落到 Unity 里的学生或转行选手。工程里没有战斗、没有卡牌,只有地图,但恰恰因为砍掉了所有干扰项,你才能看清路线是怎么一层一层长出来的。

2. 工程结构与技术选型:为什么用 Unity 做算法验证

2.1 目录拆解:算法层和表现层是分开的

拿到源码包后,先别急着打开场景。我一般会先扫一眼目录,判断这个工程是“能改的”还是“只能看的”。这个 Demo 的结构比较干净,核心目录大致是这样:

Assets/ ├── Scripts/ │ ├── Core/ │ │ ├── MapGenerator.cs // 地图生成主入口 │ │ ├── MapNode.cs // 节点数据模型 │ │ ├── MapLayer.cs // 层管理器 │ │ └── RoomType.cs // 房间类型枚举 │ ├── Config/ │ │ └── MapConfig.cs // 可调参数(层数、宽度、权重) │ ├── View/ │ │ ├── MapRenderer.cs // 把数据画成节点和连线 │ │ └── NodeView.cs // 单个节点的点击、高亮 │ └── Utils/ │ └── PathValidator.cs // 路线合法性校验 ├── Prefabs/ │ ├── NodePrefab.prefab │ └── LinePrefab.prefab ├── Scenes/ │ └── MapDemo.unity └── Resources/ └── MapConfig.asset // 参数配置资产

这个分法的好处是:Core里全是纯 C# 逻辑,不继承MonoBehaviour,你甚至可以把它抽出来放到控制台程序里跑。View只负责把MapNode列表渲染成可见的节点和连线。很多新手做地图生成时会把算法和渲染揉在一个脚本里,改一个参数就要重新调半天 UI,这个 Demo 避开了那个坑。

2.2 技术选型:为什么是 Unity 而不是纯算法脚本

有人会问,一个地图生成算法,用 Python 写个脚本跑出来不就行了,为什么要套 Unity?我的理解是,这个 Demo 的定位不是“算法演示”,而是“可交互的算法验证工具”。Unity 在这里承担三个角色:第一,可视化调试,你能实时看到每一层节点怎么分布、连线有没有交叉;第二,参数热更新,改MapConfig里的数值,重新生成就能看到差异,不用改代码;第三,交互反馈,点击节点能看到它的类型、坐标、连接关系,这对理解算法行为帮助很大。

从技术栈看,这个工程用的是 Unity 的 2D 模式,渲染层没有用 Tilemap,而是用LineRenderer画连线、用 Sprite 做节点。这么做的好处是节点位置可以自由计算,不受网格限制。如果你后续想换成网格布局,只需要改MapRenderer里的坐标映射逻辑,算法层不用动。

提示:打开工程前先确认 Unity 版本。这类 Demo 通常用 LTS 版本开发,版本差异过大时LineRenderer的参数名可能有变化,导致连线不显示。

2.3 核心数据模型:节点和层是怎么定义的

理解算法之前,先看数据模型。MapNode是整个生成过程的基本单元,它不继承MonoBehaviour,就是一个普通类:

// MapNode.cs public class MapNode { public int LayerIndex; // 第几层,从 0 开始 public int NodeIndex; // 层内索引 public RoomType Type; // 房间类型:战斗、精英、商店、事件、Boss public List<MapNode> NextNodes; // 指向下一层的连接 public List<MapNode> PrevNodes; // 上一层指向自己的连接 public Vector2 Position; // 用于渲染的坐标 public MapNode(int layer, int index) { LayerIndex = layer; NodeIndex = index; NextNodes = new List<MapNode>(); PrevNodes = new List<MapNode>(); } }

这里有几个设计点值得注意。NextNodes和PrevNodes是双向维护的,生成时先建NextNodes,再反向补PrevNodes,这样校验路线时不用反复遍历整张图。Position字段放在数据模型里而不是渲染层,是为了让布局算法和生成算法解耦——你可以换一套布局策略,只要改Position的计算方式。

RoomType是个枚举,定义了房间类型。Demo 里通常包含Battle、Elite、Shop、Event、Rest、Boss这几种。类型分配不是随机的,而是带权重和约束的,比如第一层不能出精英,Boss 层只有一个节点,这些规则在MapGenerator里控制。

3. 地图生成算法拆解:从分层到连线的完整链路

3.1 分层策略:每层节点数怎么定

地图生成的第一步是决定每一层放几个节点。杀戮尖塔的地图是 15 层左右,每层节点数在 2 到 4 之间浮动,中间层宽、首尾层窄。这个 Demo 用了一个带约束的随机策略:

// MapGenerator.cs 片段 private List<MapLayer> GenerateLayers(MapConfig config) { var layers = new List<MapLayer>(); for (int i = 0; i < config.LayerCount; i++) { int width; if (i == 0 || i == config.LayerCount - 1) { width = 1; // 首层和 Boss 层固定单节点 } else { // 中间层在 minWidth 和 maxWidth 之间随机 width = Random.Range(config.MinWidth, config.MaxWidth + 1); } layers.Add(new MapLayer(i, width)); } return layers; }

LayerCount控制总层数,MinWidth和MaxWidth控制中间层的宽度范围。这里有个细节:首层和末层强制为 1,是为了保证起点唯一、终点唯一。如果你想让起点有多个选择,可以把首层的判断去掉,但那样路线校验的逻辑要跟着改。

参数调整时要注意,MaxWidth不要设得太大。层宽超过 5 之后,连线交叉的概率会急剧上升,视觉上会变成一团乱麻。我一般会把MaxWidth控制在 4 以内,配合后面的连线算法,出来的地图比较清爽。

3.2 连线规则:怎么保证路线不交叉、不跳层

连线是这套算法里最容易翻车的部分。随机连线的结果往往是线条交叉、节点被跨层连接,看起来像蜘蛛网。这个 Demo 用了一个“相邻层就近连接 + 交叉检测”的策略:

// MapGenerator.cs 片段 private void ConnectLayers(MapLayer current, MapLayer next) { foreach (var node in current.Nodes) { // 每个节点至少连一条线 int connectCount = Random.Range(1, 3); var candidates = next.Nodes .OrderBy(n => Mathf.Abs(n.NodeIndex - node.NodeIndex)) .ToList(); for (int i = 0; i < connectCount && i < candidates.Count; i++) { var target = candidates[i]; if (!WouldCross(node, target, current, next)) { node.NextNodes.Add(target); target.PrevNodes.Add(node); } } } }

connectCount控制每个节点向下连几条线,通常 1 到 2 条。candidates按索引距离排序,优先连“正下方”或“斜下方”的节点,这样线条不会拉得太远。WouldCross是交叉检测,判断新连线是否和已有连线在几何上相交。如果相交就跳过,换下一个候选。

这里有个常见问题:如果某个节点所有候选都交叉,它可能一条线都连不出去,变成死节点。解决办法是在ConnectLayers结束后做一次兜底检查,给没有NextNodes的节点强制连一条最近的线,忽略交叉检测。这个兜底逻辑在PathValidator里。

3.3 房间类型分配:权重和约束怎么配合

连线完成后,每个节点还是“空”的,需要分配房间类型。类型分配不是纯随机,而是带权重和位置约束的:

// MapGenerator.cs 片段 private void AssignRoomTypes(List<MapLayer> layers, MapConfig config) { for (int i = 0; i < layers.Count; i++) { foreach (var node in layers[i].Nodes) { if (i == 0) { node.Type = RoomType.Battle; continue; } if (i == layers.Count - 1) { node.Type = RoomType.Boss; continue; } // 精英和商店有层数限制 var weights = new Dictionary<RoomType, float>(config.BaseWeights); if (i < config.EliteMinLayer) weights[RoomType.Elite] = 0; if (i < config.ShopMinLayer) weights[RoomType.Shop] = 0; node.Type = WeightedRandom(weights); } } }

BaseWeights是每种房间的基础权重,比如战斗 50、事件 20、精英 15、商店 10、休息 5。EliteMinLayer和ShopMinLayer控制精英和商店最早出现的层数,避免前期就遇到高强度房间。WeightedRandom按权重抽类型,权重为 0 的直接跳过。

这套分配逻辑有个隐含约束:同一层不限制重复类型。也就是说,一层里可能同时出现两个商店。如果你想让每层类型不重复,需要在分配时加一个已用类型的集合,抽到重复就重抽。这个改动不大,但会影响地图的多样性,看你的设计目标。

3.4 路线校验:从起点到 Boss 是否连通

生成完成后,必须做一次连通性校验。如果起点到 Boss 没有通路,整张地图就是废的。PathValidator用 BFS 做这件事:

// PathValidator.cs public bool Validate(MapNode start, MapNode boss) { var visited = new HashSet<MapNode>(); var queue = new Queue<MapNode>(); queue.Enqueue(start); visited.Add(start); while (queue.Count > 0) { var current = queue.Dequeue(); if (current == boss) return true; foreach (var next in current.NextNodes) { if (!visited.Contains(next)) { visited.Add(next); queue.Enqueue(next); } } } return false; }

如果校验失败,MapGenerator会重新生成,直到通过为止。这里要设一个最大重试次数,比如 10 次,避免极端参数下无限循环。重试次数用完后,可以降级到“强制连通”模式,手动补一条从起点到 Boss 的路径。

注意:BFS 校验的是“存在通路”,不是“所有节点都可达”。如果你需要保证每个节点都能从起点走到,把visited集合和总节点数比较即可。

4. 运行与调试:把 Demo 跑起来并看到地图

4.1 场景初始化:MapRenderer 怎么把数据画出来

打开MapDemo.unity场景,核心挂载脚本是MapRenderer。它在Start里调用MapGenerator生成数据,然后遍历所有节点和连线,实例化预制体:

// MapRenderer.cs 片段 void Start() { var config = Resources.Load<MapConfig>("MapConfig"); var generator = new MapGenerator(config); var layers = generator.Generate(); foreach (var layer in layers) { foreach (var node in layer.Nodes) { var go = Instantiate(nodePrefab, transform); go.transform.localPosition = node.Position; var view = go.GetComponent<NodeView>(); view.Bind(node); } } foreach (var layer in layers) { foreach (var node in layer.Nodes) { foreach (var next in node.NextNodes) { DrawLine(node.Position, next.Position); } } } }

nodePrefab和linePrefab在 Inspector 里拖入。DrawLine用LineRenderer在两个坐标之间画一条直线。节点位置Position是在生成阶段算好的,MapRenderer只负责摆放,不做布局计算。

如果你运行后看不到节点,先检查nodePrefab是否赋值、MapConfig是否在Resources目录下。这两个是新手最常漏的。

4.2 参数调优:改哪几个值能看到明显变化

MapConfig是 ScriptableObject,可以在 Inspector 里直接改。几个关键参数:

参数含义建议范围
LayerCount总层数10 ~ 20
MinWidth中间层最小宽度2 ~ 3
MaxWidth中间层最大宽度3 ~ 5
EliteMinLayer精英最早出现层4 ~ 6
ShopMinLayer商店最早出现层3 ~ 5
BaseWeights各类型基础权重按设计调

改LayerCount和MaxWidth的视觉反馈最明显。把MaxWidth从 3 调到 5,地图会立刻变宽,连线密度上升。改EliteMinLayer则会影响前期难度曲线,调大之后前几层不会出精英。

调参时建议一次只改一个值,重新运行看差异。同时改多个参数,出了问题很难定位是哪个引起的。

4.3 交互反馈:点击节点能看到什么

NodeView挂了点击事件,点击节点会在控制台输出它的层索引、节点索引、房间类型和连接关系:

// NodeView.cs 片段 public void OnClick() { Debug.Log($"Layer {node.LayerIndex}, Index {node.NodeIndex}, " + $"Type {node.Type}, Next {node.NextNodes.Count}, " + $"Prev {node.PrevNodes.Count}"); }

这个输出对调试很有用。比如你发现某个节点没有NextNodes,就能定位到连线阶段的兜底逻辑没生效。或者某个节点的PrevNodes数量异常,说明反向补边时漏了。

如果你想在界面上直接显示类型,可以给NodeView加一个Text组件,在Bind时把node.Type.ToString()赋上去。这个改动很小,但对观察类型分布帮助很大。

5. 避坑与常见问题:生成失败、连线交叉、类型异常

5.1 地图生成后一片空白

现象:运行场景后,Hierarchy 里能看到节点实例,但 Game 视图里什么都没有。

原因:节点预制体的localPosition被设置成了世界坐标,而 Canvas 或相机的坐标系不匹配。或者MapRenderer的transform有缩放,导致节点被缩到看不见。

解决:确认nodePrefab的 RectTransform 锚点设置正确,MapRenderer的localScale保持为 1。如果用的是世界空间 Canvas,检查相机位置是否覆盖了节点坐标范围。

5.2 连线交叉严重,像蜘蛛网

现象:节点之间的连线大量交叉,视觉上无法辨认路线。

原因:MaxWidth设得太大,或者ConnectLayers里的connectCount上限太高,导致每个节点连出太多线。

解决:把MaxWidth降到 4 以内,connectCount上限设为 2。如果仍然交叉,可以在WouldCross里加更严格的检测,比如禁止跨两个索引以上的连线。

5.3 精英怪出现在第一层

现象:第一层就刷出了精英房间,难度曲线崩了。

原因:EliteMinLayer设成了 0 或 1,或者BaseWeights里精英的权重没有在低层被清零。

解决:检查AssignRoomTypes里的层数判断逻辑,确认i < config.EliteMinLayer时权重被置为 0。同时确认EliteMinLayer的值大于 1。

5.4 生成卡死,控制台无报错

现象:点击生成后 Unity 卡住,没有任何输出。

原因:Validate返回 false 后无限重试,没有设最大重试次数。

解决:在MapGenerator的重试循环里加计数器,超过 10 次就跳出,并输出一条警告日志。同时检查LayerCount和MaxWidth是否设成了极端值,比如LayerCount为 1 或MaxWidth为 0。

5.5 节点类型全是战斗

现象:生成的地图里几乎所有节点都是战斗房。

原因:BaseWeights里战斗的权重远高于其他类型,或者WeightedRandom的实现有 bug,总是返回第一个类型。

解决:检查BaseWeights的数值比例,战斗权重不要超过总权重的 60%。同时检查WeightedRandom的累加逻辑,确认随机值落在正确的区间。

6. 二次开发:把地图算法接到你自己的 Roguelike 里

这个 Demo 最有价值的地方,是它把地图生成拆成了“数据生成”和“表现渲染”两层。你要把它接到自己的项目里,核心工作是替换MapRenderer,保留MapGenerator和MapNode。具体来说,你的游戏可能用 Tilemap 或者 UI 网格来展示地图,那就在MapRenderer的位置写一个新的渲染器,遍历layers里的节点,按自己的布局规则摆放。

如果你想让地图支持“已走过的路径高亮”,可以在MapNode里加一个IsVisited字段,在NodeView的点击事件里更新它,然后让渲染器根据这个字段改节点颜色。这个改动不涉及生成算法,属于纯表现层扩展。

另一个常见的改造方向是动态难度。你可以在AssignRoomTypes里接入一个难度曲线函数,根据当前层数调整精英和战斗的权重。比如前 5 层战斗权重高,中间层事件权重高,后 5 层精英权重高。这个逻辑用AnimationCurve配置最方便,在MapConfig里加一个曲线字段,AssignRoomTypes里采样即可。

验证改造是否成功,我一般会做三件事:第一,把LayerCount设成 15,连续生成 20 次,看有没有生成失败的情况;第二,统计每层房间类型的分布,确认没有某一类型占比超过 70%;第三,手动走一遍从起点到 Boss 的路径,确认没有断头路。这三步走完,基本能保证地图生成是稳定的。

从那以后我每次改地图生成参数,都会强制走一遍“生成 20 次 + 统计分布 + 手动走路径”的流程,不再凭感觉调参。希望帮到你。

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

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

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

立即咨询