☰
Unity爬塔地图生成算法:从随机种子到可复现的完整Demo
2026/10/20 2:51:12 网站建设 项目流程

简介:一套基于Unity与C#实现的杀戮尖塔式地图生成算法Demo,面向需要实现Roguelike随机关卡布局的Unity开发者。其中完整演示了地图房间生成与路线拼接两大核心逻辑:起点层房间数量动态配置,中间层房间数量限制在最小2与起点数量两倍减一之间,首领层固定为1个房间;每个房间在层内平铺后加入随机横向与纵向偏移,路线通过向上检索最近的上层房间建立连接,并对下层断路情况按距离就近补齐,确保所有房间最终连通。压缩包共144个文件,10.3MB,包含C#脚本、Prefab预制体、Asset配置文件、PNG贴图及工程设置文件等,目录结构清楚,可直接在Unity中运行查看生成效果,便于对照源码理解各环节细节。场景中提供了可运行的示例,修改房间数量、偏移幅度等参数即可观察地图形态变化。目前已有1083人学习,适合正在研究地图生成、希望获取可运行范例并迁移到自研项目的初中级Unity开发者。

1. 跑通一张爬塔地图需要多少代码:一个可复现的Unity算法Demo

先抛一个反直觉的结论:杀戮尖塔式地图的复杂度,根本不在图形和寻路,而在层与层之间的那些边——怎么确保每条路都通,又怎么让每次生成的图都长得不一样。很多开发者在Unity里找这类算法软件/插件时,搜来的代码多半只会生成树状结构,拿到爬塔玩法里根本跑不动。这个Demo的实际价值,是把完整的地图生成收敛为两个输入:一个随机种子和一份参数资产。换种子,整张图全变;改参数,布局密度和难度曲线跟着变。适合两类人:一类是想做Roguelike卡牌、却卡在关卡结构上的开发者,另一类是已经在做爬塔图、想找一个可复现生成方案的熟手。它没有复杂的美术资源依赖,核心就是几段C#代码,打开Scene视图就能直接看线路效果。

2. 工程骨架与数据结构:地图是如何被建模成可序列化数据的

2.1 项目目录与模块划分

解压工程后第一件事不是找场景,而是看目录划分。这个Demo的代码组织非常克制,我用树状图列一下默认结构:

Assets/ ├── Scripts/ │ ├── MapCore/ │ │ ├── MapData.cs │ │ ├── MapNode.cs │ │ ├── MapLevel.cs │ │ ├── MapGenerator.cs │ │ ├── MapGenerateParams.cs │ │ └── NodeType.cs │ ├── Runtime/ │ │ ├── StageMapView.cs │ │ ├── NodeView.cs │ │ └── NodeDispatcher.cs │ └── Editor/ │ ├── MapDataEditor.cs │ └── MapBatchValidator.cs ├── Settings/ │ └── DefaultMapParams.asset └── Scenes/ └── MapPreview.unity

把MapCore和Runtime拆开是这个项目最值得学习的地方。MapCore里全是纯逻辑类,不依赖MonoBehaviour,MapData、MapNode只作为可序列化数据结构存在,方便做单测和批量验证;Runtime层才挂视图组件,负责把数据画到场景或UI上。Editor目录里的MapBatchValidator会批量跑1000个种子来检查可达性,这在调算法参数时几乎是必备工具。

整个Demo真正核心的文件不超过六个,其余都是辅助展示。我建议读者拿到代码后先把MapGenerator.cs和MapData.cs通读一遍,再去打开场景看效果——不然你会被Scene视图里密密麻麻的连线绕晕。

2.2 数据模型:MapNode、MapLevel与MapData

先说MapNode,这个类承载了节点在算法阶段和运行阶段的所有信息:

using System; using System.Collections.Generic; using UnityEngine; [Serializable] public class MapNode { public int nodeId; // 全局唯一ID,生成时递增分配 public int levelIndex; // 第几层,0为起点层 public int columnIndex; // 层内列索引,仅用于算法排序和UI排列 public Vector2 position; // 逻辑坐标,用于编辑器可视化和UI适配 public NodeType type; // 节点类型 public bool isOnMainPath; // 生成阶段标记,用于类型分配 [SerializeField] private List<int> nextNodeIds = new List<int>(); public IReadOnlyList<int> NextNodeIds => nextNodeIds; public void AddNext(int nodeId) => nextNodeIds.Add(nodeId); }

nodeId是关键设计。所有边都存int ID而不是直接引用对象,序列化友好,也能避免场景重载之后引用失效的玄学问题。levelIndex和columnIndex共同构成节点在逻辑网格上的位置,position则是由这两个索引换算成世界坐标后的结果。isOnMainPath是生成阶段的临时标记,用于后面分配节点类型时给骨架路径上的节点更高战斗权重。

MapLevel和MapData就比较直白了:

using System; using System.Collections.Generic; using UnityEngine; [Serializable] public class MapLevel { public int levelIndex; public List<MapNode> nodes = new List<MapNode>(); } [CreateAssetMenu(menuName = "MapData", fileName = "NewMapData")] public class MapData : ScriptableObject { public int usedSeed; public int floorCount; public List<MapLevel> levels = new List<MapLevel>(); public MapNode GetNodeById(int id) { for (int l = 0; l < levels.Count; l++) { var level = levels[l]; for (int i = 0; i < level.nodes.Count; i++) { if (level.nodes[i].nodeId == id) return level.nodes[i]; } } return null; } }

用ScriptableObject存地图数据,而不是JSON或二进制文件,最大的好处是能直接用Unity的Undo系统,在编辑器里改参数后可以撤销;运行时加载也不必自己做字节解析。GetNodeById是线性查找,节点总数顶多七八十个,性能完全够用,没必要上字典。

2.3 节点类型与编辑器预览

节点类型是典型的爬塔分类,枚举定义如下:

public enum NodeType { Start, // 起点,唯一 Battle, // 普通战斗 Elite, // 精英战斗 Event, // 随机事件 Shop, // 商店 Rest, // 休息点 Boss // 关底Boss }

Demo在Scene视图里直接画节点和连线的实现,靠的是MapDataEditor里的一段Gizmos代码:

private void OnDrawGizmos() { if (mapData == null) return; for (int l = 0; l < mapData.levels.Count; l++) { var level = mapData.levels[l]; for (int i = 0; i < level.nodes.Count; i++) { var node = level.nodes[i]; Gizmos.color = GetNodeColor(node.type); Gizmos.DrawSphere(node.position, 0.28f); var nextIds = node.NextNodeIds; for (int j = 0; j < nextIds.Count; j++) { var nextNode = mapData.GetNodeById(nextIds[j]); if (nextNode != null) Gizmos.DrawLine(node.position, nextNode.position); } } } }

GetNodeColor是一个简单的switch映射,比如战斗节点用红色、商店用黄色、休息用绿色。这段代码的价值在于调试体验:每调整一次参数,Scene视图会实时刷新,节点和连线立刻可见。初学者最容易忽略的一点是,Gizmos坐标是逻辑坐标,直接映射到世界坐标,它不等于最终UI上的屏幕坐标,后面第5章会专门讲这个坑。

3. 核心算法拆解:从随机种子到完整路线的五步流水线

3.1 参数资产与种子初始化

生成一张地图,入口参数只有两个:随机种子和MapGenerateParams资产。后者是ScriptableObject,所有可调项都在Inspector里加了Range约束:

using UnityEngine; [CreateAssetMenu(menuName = "MapGenerateParams", fileName = "MapParams")] public class MapGenerateParams : ScriptableObject { [Header("层数")] [Range(6, 16)] public int minFloor = 9; [Range(6, 16)] public int maxFloor = 12; [Header("每层节点数")] [Range(2, 4)] public int minNodesPerFloor = 3; [Range(3, 6)] public int maxNodesPerFloor = 5; [Header("布局")] [Range(0.5f, 2.0f)] public float horizontalSpread = 1.2f; [Range(0f, 0.5f)] public float jitterSize = 0.25f; [Range(1.0f, 2.5f)] public float verticalSpacing = 1.6f; }

这里有个细节:minFloor和maxFloor是闭区间,random.Next(minFloor, maxFloor + 1)才能取到maxFloor本身。每层节点数同理。为什么不用UnityEngine.Random?因为它是一个全局静态随机源,任何其他脚本调用都会污染你的序列,无法保证同一种子生成同一张图。System.Random实例化后是独立的,种子里塞进所有随机分支。

生成入口长这样:

public MapData Generate(int seed, MapGenerateParams p) { System.Random random = new System.Random(seed); List<MapLevel> levels = BuildLevels(random, p); // 第一步:建层 EnsureMainPath(levels, random); // 第二步:搭骨架路径 ConnectAdjacentLevels(levels, random); // 第三步:补充分支边 AssignNodeTypes(levels, random); // 第四步:分配节点类型 MapData data = AssembleMapData(levels, seed); // 第五步:组装资产 return data; }

顺序是有讲究的。很多新手喜欢先生成所有节点类型,再连边,结果发现主路径上全是商店或休息点。这个Demo先把骨架路径搭好、标记好,再分配类型,就能保证玩家必经之路上以战斗为主。五个步骤对应五个独立方法,每个都能单独调试。

3.2 主路径优先:先保证玩家一定能到Boss

骨架路径是整个生成流程的定海神针。它的任务简单粗暴:从起点层的唯一节点开始,每层选一个目标节点,逐层向上走,最后连到Boss。这样无论后面的随机分支怎么乱,玩家一定有一条从起点到Boss的通路。

private void EnsureMainPath(List<MapLevel> levels, System.Random random) { // 第0层只有一个起点节点,从它出发 MapNode current = levels[0].nodes[0]; // 中间层:levels.Count - 1 是Boss层,所以只遍历到倒数第二层 for (int l = 1; l < levels.Count - 1; l++) { MapLevel nextLevel = levels[l]; int currentCol = current.columnIndex; // 关键:delta 允许 -1 到 2,让路径有左右摆动 int delta = random.Next(-1, 3); int targetCol = Mathf.Clamp(currentCol + delta, 0, nextLevel.nodes.Count - 1); MapNode target = nextLevel.nodes[targetCol]; current.AddNext(target.nodeId); target.isOnMainPath = true; current = target; } // 最后一个中间层的节点直接连Boss current.AddNext(bossNode.nodeId); }

delta的取值解释一下:random.Next(-1, 3)返回 -1、0、1、2 四个值,也就是说每次主路径最多左右偏移一个节点,偶尔会往右多走一格。如果delta范围是-1到1,路径看起来就像一条笔直的树干,缺少爬塔该有的螺旋感;加了2之后,路径会偶尔往右斜一大步,配合后续分支线,整张图才丰富。Clamp保证了当某层节点数很少、当前列又靠近边缘时,targetCol不会越界。

3.3 层间连接与反向补边:消灭孤岛节点

骨架路径只保证一条路能通,而爬塔地图的乐趣在于分支选择。这一步做的事是:对每一层节点,向下一层随机连1到2条边,候选条件是列索引差不超过1。连完之后做一次反向检查,确保下一层每个节点至少有一个父节点。

private void ConnectAdjacentLevels(List<MapLevel> levels, System.Random random) { // boss层索引是 levels.Count - 1,这里遍历到倒数第二层 for (int l = 0; l < levels.Count - 2; l++) { MapLevel above = levels[l]; MapLevel below = levels[l + 1]; // 正向:上层每个节点随机连1-2个下层节点 for (int i = 0; i < above.nodes.Count; i++) { MapNode node = above.nodes[i]; int edgeCount = random.Next(1, 3); List<MapNode> candidates = new List<MapNode>(); for (int j = 0; j < below.nodes.Count; j++) { if (Mathf.Abs(below.nodes[j].columnIndex - node.columnIndex) <= 1) candidates.Add(below.nodes[j]); } Shuffle(candidates, random); int connectCount = Mathf.Min(edgeCount, candidates.Count); for (int k = 0; k < connectCount; k++) node.AddNext(candidates[k].nodeId); } EnsureNoIsland(above, below, random); } }

Shuffle是一个就地洗牌,实现方式可以是Fisher-Yates。这里对candidates做洗牌,是为了避免每次都连同一列的两个节点,让分支更分散。

真正的关键在EnsureNoIsland函数:

private void EnsureNoIsland(MapLevel above, MapLevel below, System.Random random) { // 检查下一层每个节点是否至少有一条入边 bool[] hasParent = new bool[below.nodes.Count]; for (int i = 0; i < above.nodes.Count; i++) { var node = above.nodes[i]; for (int j = 0; j < node.NextNodeIds.Count; j++) { int childId = node.NextNodeIds[j]; for (int k = 0; k < below.nodes.Count; k++) { if (below.nodes[k].nodeId == childId) { hasParent[k] = true; break; } } } } // 没有父节点的节点,从上层找一个列距离最近的节点强制补边 for (int i = 0; i < hasParent.Length; i++) { if (hasParent[i]) continue; MapNode orphan = below.nodes[i]; MapNode bestParent = null; float bestScore = float.MaxValue; for (int j = 0; j < above.nodes.Count; j++) { float colDist = Mathf.Abs(above.nodes[j].columnIndex - orphan.columnIndex); float score = colDist + (float)random.NextDouble() * 0.3f; if (score < bestScore) { bestScore = score; bestParent = above.nodes[j]; } } bestParent.AddNext(orphan.nodeId); } }

分数里加一个0.3以内随机扰动,是让"最近节点"不止一个选项时不会总选同一个。三层嵌套循环看起来效率低,但地图最大也就十几层乘六七个节点,最坏几百次比较,可以忽略。如果哪天要做超大地图,可以用邻接表缓存来优化,现在没必要。

3.4 类型分配:权重抽样让难度曲线可控

节点连边完成后,地图的结构就是一张真图了。接下来按类型填充每个节点。我用的方式是权重抽样,而不是均匀随机:

private void AssignNodeTypes(List<MapLevel> levels, System.Random random) { for (int l = 1; l < levels.Count - 1; l++) // 跳过起点层和Boss层 { var level = levels[l]; for (int i = 0; i < level.nodes.Count; i++) { var node = level.nodes[i]; bool isMain = node.isOnMainPath; // 主路径节点:战斗和精英权重更高 node.type = WeightedPick(random, isMain ? MainPathWeights : NormalWeights); } } }

WeightedPick先把权重累加成一个总数,再随机落点。主路径上的战斗权重我一般调到50,精英20,事件15,商店10,休息5;普通分支则反过来,战斗35,事件25,休息15,商店15,精英10。这样玩家就算全程无脑走主路,也会遇到足够的战斗资源去构建卡组。

4. 玩法对接:地图数据如何驱动场景、商店与战斗分发

4.1 从生成结果到可复用资产

Demo的Editor脚本里通过ContextMenu实现了重新生成功能,不用切到玩法场景再手动拖:

[ContextMenu("Regenerate Map")] private void Regenerate() { if (mapGenerator == null || mapParameters == null || mapData == null) return; MapData newData = mapGenerator.Generate(seed, mapParameters); EditorUtility.CopySerialized(newData, mapData); EditorUtility.SetDirty(mapData); }

CopySerialized会把新生成的MapData完整复制到已有资产上,保留原来的资产引用,这样所有引用了这个资产的场景、Prefab都不需要重新绑定。如果直接新建一个MapData替换字段,场景里已经拖好的引用对象就全丢了。这是一个值得养成的习惯——编辑器脚本里更新ScriptableObject内容时,优先CopySerialized而不是替换引用。

4.2 节点分发:从MapNode走到对应场景

爬塔地图不像开放世界那样可以自由移动,玩家点一个可达节点后,游戏要加载对应场景。Demo用一个NodeDispatcher做类型到场景的分发:

using UnityEngine; using UnityEngine.SceneManagement; public class NodeDispatcher : MonoBehaviour { private System.Collections.Generic.Dictionary<NodeType, string> sceneMap = new System.Collections.Generic.Dictionary<NodeType, string>() { { NodeType.Battle, "BattleScene" }, { NodeType.Elite, "EliteScene" }, { NodeType.Shop, "ShopScene" }, { NodeType.Event, "EventScene" }, { NodeType.Rest, "RestScene" }, { NodeType.Boss, "BossScene" } }; public void EnterNode(MapNode node) { if (node == null) return; // 记录当前节点,回来时才能定位玩家在地图上的位置 GameContext.CurrentNodeId = node.nodeId; if (sceneMap.TryGetValue(node.type, out string sceneName)) { SceneManager.LoadScene(sceneName); } } }

这里我把NodeType.Start的场景跳转去掉了,因为起点层只在开局出现,不参与分发。GameContext是一个静态类,保存当前节点ID,回到地图场景后用这个ID恢复玩家的位置和已走路径。字典的好处是扩展方便,加一种新节点类型只要加一个枚举值和一条映射。

4.3 可达性高亮:玩家下一个能去哪格

玩家回到地图层时,需要立刻看到哪些节点是当前可走的。Demo的做法是用BFS从当前节点往下游遍历,收集所有可达节点,再给对应的NodeView切换高亮材质:

private HashSet<int> GetReachableNodeIds(MapNode from) { HashSet<int> reachable = new HashSet<int>(); HashSet<int> visited = new HashSet<int>(); Queue<int> queue = new Queue<int>(); queue.Enqueue(from.nodeId); visited.Add(from.nodeId); while (queue.Count > 0) { int currentId = queue.Dequeue(); reachable.Add(currentId); MapNode current = mapData.GetNodeById(currentId); var nextIds = current.NextNodeIds; for (int i = 0; i < nextIds.Count; i++) { int nextId = nextIds[i]; if (visited.Contains(nextId)) continue; visited.Add(nextId); queue.Enqueue(nextId); } } return reachable; }

BFS在这里可以收敛成"从当前节点所有可达的后继节点中找下一层节点"的做法,但直接遍历全部后继更通用——万一以后引入跨层跳转的特殊节点,这个逻辑也不用改。注意visited的用途:爬塔地图是有向无环图,理论上不会出现环,但以防万一,加了visited能防止重复入队导致的死循环。

5. 踩坑与排查:地图生成最常翻车的五个现场

5.1 现象:重启编辑器后地图完全变样

明明在Inspector里点了一次Regenerate,也看到地图正常显示了,但重启Unity后再打开场景,地图线条全乱了。

原因基本可以锁定在生成逻辑被挂在了OnValidate或OnEnable里。这两个回调会在脚本重载、属性变化时反复触发,每次都重新跑一遍随机流程,生成结果又没有持久化,等于每次看到的都是新图。

解决方法是把生成动作从生命周期回调里摘出来,只保留在ContextMenu或Inspector的自定义按钮里手动触发,生成结果立刻写入MapData资产并SetDirty。如果确实想开局生成,就把生成结果保存下来,下次加载场景时直接读资产,不再重新生成。

5.2 现象:部分节点成了孤岛,永远走不到

这是连边算法不健全的典型症状。地图上某几个节点没有任何入边,玩家从起点往上爬时根本看不到它们,线路图里孤零零飘着几个点。

原因是层间连接只做了正向连接——上层主动连下层,而下一层节点数量多于上层时,尾部几个节点自然没有任何父节点。这在杀戮尖塔式的非对称分层里非常常见。

解决方法是补上EnsureNoIsland反向检查,逐层确认每个节点至少有一条入边。需要注意检查的是入边而不是出边,因为玩家永远是从下层往上层走,没有父节点等于不可达。

提示:如果补边后还是出现孤岛,先检查列索引是不是从1而不是从0开始。索引一旦错位,所有距离判断都会整体偏移。

5.3 现象:主路径是一条贴边直线,非常丑

骨架路径生成后,从起点到Boss几乎是一条垂直直线,完全没有尖塔那种蜿蜒感。

原因一眼就能看出来:生成主路径时选择下一层节点的逻辑是currentCol + 0,delta永远取到0,或者用的是最近列而非带偏置的随机列。

解决方法是把delta范围放宽到random.Next(-1, 3),同时Clamp边缘。如果你觉得两条主路径长得太像,可以在种子固定的情况下额外加一个"主轴摆动幅度"参数,让每一段的偏移范围随层数变化,比如上层偏移小、下层偏移大,曲线会更自然。

5.4 现象:运行预览正常,发布后节点错位

编辑器里Scene视图的Gizmos节点位置是对的,但打包出来的游戏里,地图UI上的节点全跑到屏幕外,或者挤在左下角。

原因在于Gizmos用的是逻辑坐标,直接当世界坐标画在Scene里没问题,但UI的RectTransform用的是屏幕空间坐标体系。把逻辑坐标直接赋值给anchoredPosition,不做视口归一化,发布环境一旦改了分辨率就崩。

解决方法是写一个适配器,把逻辑坐标映射到0到1的视口区间,再乘以UI画布的大小。具体做法是拿到所有节点的坐标最小最大值,做一个归一化映射,再叠加UI偏移。这一步不要偷懒,否则做出来的地图只在固定分辨率下正常。

5.5 现象:类型分布永远不均匀,商店和休息点太少

跑了几十次生成,发现整张图几乎全是战斗和精英节点,商店和休息点难得一见。

原因是类型分配用了均匀随机:每个类型概率相等,而战斗选项占多数后,其它类型自然被稀释。均匀随机适合选哪个都不影响功能的情况,但地图设计是要讲究资源稀缺度的。

解决方法是改用权重抽样。用累加权重的方式,把商店、休息、事件的概率单独控制;同时主路径和分支路径走两套权重表,主路径上保证战斗为主,分支路径上给资源点更高配额。权重表调节时,建议先调整张图的总体比例,再调主路径偏差,两个参数分开处理。

6. 改造思路:把Demo地图扩展成你的肉鸽核心关卡

6.1 把节点类型从枚举改成策略接口

Demo的NodeType是枚举,配上NodeDispatcher的字典分发,做小Demo够了。但真要塞进一个长线更新的游戏,枚举加switch很快就会变成维护地狱。我建议第一步就改成策略接口:

public interface IMapEvent { void OnEnter(MapNode node, GameContext ctx); } public sealed class BattleEvent : IMapEvent { public void OnEnter(MapNode node, GameContext ctx) { // 根据当前层数和节点ID设置战斗难度 ctx.LoadScene("BattleScene"); } }

每个Event类型一个类,注册到同一个事件管理器里。新增一种节点类型时不用动任何已有代码,只加类,这个模式在项目变大后会明显减少排查时间。

6.2 用权重表调难度曲线

改造到这一步,参数配置已经不适合全堆在Inspector里了。我会做一张可配置的权重表,让策划自己去填数值:

节点类型分支路径权重主路径权重说明
Battle3550主路径保证战斗密度
Elite1020精英只能放在主路径末端附近
Event2515事件给变数
Shop1510商店不宜频繁出现
Rest155休息点稀缺才有决策价值

主路径终段可以限制精英只出现在后半层,具体做法是给权重表加一个"允许出现的最低层数"字段,AssignNodeTypes时过滤掉不满足条件的类型。

6.3 用批量验证兜底

改参数是高风险操作,肉眼检查几张图根本不够。Demo里预留了MapBatchValidator的入口,我的习惯是把它扩展成一个完整回归工具,每次改完生成逻辑后跑一遍:

[MenuItem("Tools/Map Batch Validate")] public static void BatchValidate() { for (int seed = 0; seed < 1000; seed++) { var map = generator.Generate(seed, parameters); if (!MapGraphEvaluator.HasPathFromStartToBoss(map)) { Debug.LogError($"seed {seed} failed: no path to boss"); return; } int orphanCount = MapGraphEvaluator.CountOrphanNodes(map); if (orphanCount > 0) { Debug.LogError($"seed {seed} failed: {orphanCount} orphans"); return; } } Debug.Log("1000 seeds all passed"); }

MapGraphEvaluator里写两个静态方法:一个从起点BFS到Boss,一个统计没有入边的节点数。这两个断言能挡住大多数生成回归问题。我一开始拿到这个Demo时,只顾着看Gizmos线好不好看,没意识到"种子可复现+批量验证"才是把地图系统真正交给策划的前提。后来有一次调verticalSpacing参数,感觉图上节点太挤就直接改了,结果忘了跑批量验证,测试包里就出过一次"Boss不可达"的翻车。从那以后,我每次调整生成参数都会强制把BatchValidate完整跑一遍,再让美术去看布局——希望帮到你。

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

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

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

立即咨询