简介:面向 Unity 与 C# 开发者及 Roguelike 地图生成学习者的杀戮尖塔式地图算法 Demo,可用于理解分层房间布局与路径连接的可运行实现。项目围绕房间数量、位置与路线生成展开:起点层数量可配置,中间层按最小值 2、最大值随起点数量变化,Boss 层固定单房间;房间坐标在层内平铺后叠加横纵随机偏移,路线通过检索最近上层房间、处理断路并建立上下层链接,适合拆解地图生成逻辑或改造为原型验证模块。压缩包共 144 个文件,约 10.3MB,含 Unity 场景与预制体、C# 脚本、asset 配置、png 贴图、json 数据及工程解决方案等,保留较完整工程结构。已有 1085 人学习下载,可对照代码复现房间生成、断路检索与连线规则,并扩展关卡节奏和随机性。
1. 地图不是随机画线:一套能落地的 Unity 卡牌爬塔地图生成思路
很多人做卡牌爬塔类项目时,第一版地图是这么写的:每层随机 2~4 个节点,节点之间随机连线。跑起来看着挺像,玩两把就露馅——要么整张图全是直线,从头到尾没有分岔;要么前后左右交叉成一团乱麻,玩家根本判断不出这条线通向哪。更难受的是,Boss 前一层刷出个商店,等于白送一次补给;两个精英怪叠在同一条路径上,难度曲线直接断裂。
这套 Unity 地图算法 Demo 解决的就是这件事:它不是美术资源,也不是完整游戏,而是一套可复用的生成逻辑,把「分层、分岔、路径连通、节点类型约束」这几件事拆成可调参数的模块。适合两类人:一是正在做卡牌爬塔原型、需要先跑通核心循环的独立开发者;二是想读懂分层图生成算法、打算自己改造规则的从业者。它的价值不在于“随机”,而在于“可控的随机”——你能规定每层几个节点、路径最多几岔、精英怪最早出现在第几层、Boss 前必须避开哪些类型。
我把它从结构到代码拆开跑了一遍,下面按「先理解图的数据结构 → 再执行生成 → 配置校验 → 排错 → 进阶改造」的顺序写,每一步都能照着复现。需要说明的是,它更适合当骨架而不是成品:节点表现、战斗接敌、事件内容都要你自己接,但地图这块最容易翻车的地方,它已经踩过一遍了。
2. 分层图模型与生成管线:节点、边、约束到底怎么组织
2.1 为什么用「分层有向无环图」而不是纯随机树
先说选型。爬塔地图在数学上就是一张有向无环图(DAG),而且是分层 DAG:第 i 层只能连第 i+1 层,不能回退、不能跨层。为什么不直接用树?因为树每个节点只有一个父节点,路径合并就做不出来。而爬塔体验的关键之一就是“分岔之后再合并”——玩家选了左边,走到第 4 层发现和右边的人汇合到同一个 Boss 前房间,这种收束感是设计出来的,不是随机的。
纯随机生成 DAG 的问题在于连通性无法保证。常见做法是反过来:先按层铺节点,再从下层往上“找父亲”,保证每个节点至少有一个入边,这样天然不会出现孤岛。这套 Demo 采用的就是自底向上的连边策略,配合一个“路径不交叉”的检测,避免出现视觉上难以辨认的交叉线。
数据结构上,最小可用集合是三个字段:节点 id、所在层 layer、节点类型 nodeType,外加一个邻接表记录出边。不建议用二维数组存地图,因为每层节点数不固定,数组会有大量空位,而且后续做路径追溯时非常别扭。
| 字段 | 类型 | 作用 | 常见取值 |
|---|---|---|---|
| id | int | 全局唯一标识 | 0 ~ N-1 |
| layer | int | 所在层数,决定连线合法性 | 0 ~ floorCount-1 |
| nodeType | enum | 节点行为 | 战斗/精英/商店/休息/宝箱/Boss |
| next | List | 出边集合 | 指向 layer+1 的节点 |
有两点容易被忽略:一是第 0 层通常固定为 1 个起点,最后一层固定 1 个 Boss,这两层不做随机;二是中间层的节点类型要在“连边完成之后”再分配,否则你先定了类型再去连边,很容易出现某个商店节点没有任何入边、玩家永远走不到的情况。
2.2 生成流程的四步管线
整套逻辑我把它归成四步,顺序不能换:
第一步,初始化分层骨架。按配置里的 floorCount 建层,每层节点数在 minNodesPerFloor 和 maxNodesPerFloor 之间随机,第 0 层和最后一层强制为 1。
第二步,自底向上连边。从最后一层往前遍历,为每个节点在下一层挑选 1~2 个父节点,同时保证每个下层节点至少有 1 条出边。这一步的核心是“补边”:先随机连,连完检查有没有出度为 0 的节点,有就补一条到下一层最近的合法节点。
第三步,合法性校验。检查三件事:有没有孤岛、有没有跨层边、有没有入度为 0 的非起点节点。任意一条不满足就整张图重生成,而不是局部修补——局部修补的代码复杂度远高于重跑一次。
第四步,按规则分配节点类型。先按层设置权重,再叠加硬约束,比如第 1 层不出精英、Boss 前一层不出商店、同一层精英数量不超过 1。
下面是骨架生成的核心逻辑,用 C# 写,思路与 Demo 一致:
// 生成分层骨架:只负责节点与层,不涉及类型 public List<MapNode> BuildSkeleton(int floorCount, int minPerFloor, int maxPerFloor, int seed) { var rng = new System.Random(seed); // 固定种子,方便复现问题 var nodes = new List<MapNode>(); for (int layer = 0; layer < floorCount; layer++) { // 首尾两层强制单节点,保证入口与 Boss 唯一 int count = (layer == 0 || layer == floorCount - 1) ? 1 : rng.Next(minPerFloor, maxPerFloor + 1); for (int i = 0; i < count; i++) { nodes.Add(new MapNode { id = nodes.Count, layer = layer, nodeType = NodeType.Battle, // 先占位,第四步再改 next = new List<int>() }); } } return nodes; }这段只解决“铺点”。参数上,floorCount 建议 12~15,低于 10 时分支来不及展开,高于 18 单局时长会明显拉长;minPerFloor 取 2、maxPerFloor 取 4 是比较稳的区间,取到 5 以上时屏幕横向排布会挤,需要配合相机缩放。seed 参数一定要显式传进来并存档,否则玩家反馈“这张图有问题”你根本无法复现——这是血泪经验。
2.3 连边时必须守住的三条不变式
连边是整套算法里最容易出 bug 的地方。我把必须守住的条件列成三条不变式,写完连边函数后逐条断言一遍:
- 任意非最后一层节点,出度 ≥ 1;
- 任意非第 0 层节点,入度 ≥ 1;
- 任意一条边,起点层 + 1 == 终点层。
实现上,最省事的写法是给每个节点维护一个 parentCount,连边时同步累加,最后统一扫一遍找出 parentCount 为 0 的节点做补救。下面是从上层往下层连边的片段:
// 自底向上连边:为每个上层节点挑选 1~2 个下层父亲 void ConnectLayers(List<MapNode> nodes, int floorCount, System.Random rng) { var byLayer = nodes.GroupBy(n => n.layer) .ToDictionary(g => g.Key, g => g.ToList()); for (int layer = floorCount - 2; layer >= 0; layer--) { foreach (var node in byLayer[layer]) { var candidates = byLayer[layer + 1]; int linkCount = rng.Next(1, 3); // 每个节点最多分两岔 // 按索引邻近优先,避免长距离交叉线 int baseIdx = Mathf.Clamp(node.id % candidates.Count, 0, candidates.Count - 1); var picked = new HashSet<int>(); for (int k = 0; k < linkCount && picked.Count < candidates.Count; k++) { int idx = (baseIdx + k) % candidates.Count; if (picked.Add(idx)) node.next.Add(candidates[idx].id); } } } }这里baseIdx用node.id % candidates.Count是简化版做法,效果是让上层节点倾向于连“正下方”的下层节点,视觉上路径更顺。真实项目里更严谨的做法是按节点在层内的归一化横向坐标算距离,取最近的 1~2 个。linkCount 上限设成 2 是有意为之:设成 3 时地图会迅速膨胀成网状,玩家选择疲劳,而且交叉线数量失控。连完之后立刻跑一遍不变式检查,失败就换种子重跑,通常 3 次以内能出合法图。
3. 节点类型分配与参数配置:难度曲线是靠规则压出来的
3.1 权重表 + 硬约束的两段式分配
节点类型不能纯按权重随机。纯随机的后果很具体:某张图第 2 层连着两个商店,第 3 层三个精英,玩家要么暴富要么暴毙,难度曲线完全不由你控制。这套 Demo 用的是两段式:先按层给基础权重,再用硬约束做修正。
我一般会维护一张按层的权重表,长这样:
| 层区间 | 战斗 | 精英 | 商店 | 休息 | 宝箱 |
|---|---|---|---|---|---|
| 1~3 | 0.85 | 0 | 0.05 | 0.05 | 0.05 |
| 4~7 | 0.65 | 0.15 | 0.10 | 0.05 | 0.05 |
| 8~11 | 0.55 | 0.20 | 0.10 | 0.13 | 0.02 |
| 12~Boss 前 | 0.45 | 0.25 | 0.12 | 0.15 | 0.03 |
表里的数字不是标准答案,是起点。要点在于前三层精英权重必须为 0——玩家还没成型,遇到精英就是劝退;休息节点越往后权重越高,因为长线战斗的血量损耗是累积的。
硬约束写在权重之后,优先级高于权重:同一层精英不超过 1 个;Boss 前一层不允许商店和宝箱;第 0 层固定为起点(无类型);任意一条从起点到 Boss 的路径上,商店数量不超过 3。这条“路径内上限”是最容易被漏掉的,单看每一层都合规,整条路径串起来就变成无限补给。
3.2 用路径枚举做约束校验
上面那条路径内上限,实现方式是枚举所有从起点到 Boss 的路径。层数 15、每层 3 个节点的情况下,路径总数在几百到几千量级,枚举完全扛得住。写法就是深度优先,每到一层累加计数,超限就标记该类型回退重抽:
// 枚举所有 root -> boss 路径,校验单路径上的节点类型上限 bool ValidatePathLimit(List<MapNode> nodes, NodeType type, int limit) { var map = nodes.ToDictionary(n => n.id); var root = nodes.First(n => n.layer == 0); var paths = new List<List<int>>(); void Dfs(int curId, List<int> path, int typeCount) { path.Add(curId); if (map[curId].nodeType == type) typeCount++; if (typeCount > limit) { path.RemoveAt(path.Count - 1); return; } if (map[curId].next.Count == 0) // 到达末层 paths.Add(new List<int>(path)); else foreach (var nx in map[curId].next) Dfs(nx, path, typeCount); path.RemoveAt(path.Count - 1); } Dfs(root.id, new List<int>(), 0); return paths.Count > 0; // 无合法路径则判定失败 }这段的关键在剪枝:typeCount > limit时直接回溯,不用走完整条路径,实际耗时会比全枚举低一个量级。参数 limit 我一般在商店上取 3、精英上取 4,休息节点不设上限(回血多不至于破坏平衡)。如果校验不通过,处理方式是“把超限的那个节点重抽类型”,而不是重生成整张图——地图结构是好的,只需换某个格子的内容,重生成反而浪费。
3.3 配置化:把魔数从代码里赶出去
走到这一步就明显能感觉到,层数、每层节点数、权重、约束上限全是散落的魔数。我建议一开始就抽成 ScriptableObject,字段命名和语义一一对应:
[CreateAssetMenu(menuName = "Map/GenConfig")] public class MapGenConfig : ScriptableObject { public int floorCount = 14; // 总层数,含首尾 public int minNodesPerFloor = 2; public int maxNodesPerFloor = 4; public int maxBranchPerNode = 2; // 单节点最大出度 public int maxShopPerPath = 3; // 单路径商店上限 public int maxElitePerPath = 4; public int eliteStartFloor = 3; // 精英最早出现的层 public AnimationCurve restWeightByLayer; // 休息权重随层数上升 }好处不只是“好改”。调参时你会反复做同一件事:把 floorCount 从 14 改到 16 看难度曲线,把 maxBranchPerNode 从 2 改到 3 看地图密度。配置化之后这些改动不进代码、不触发重编译,改完点 Play 就能看结果。AnimationCurve存休息权重是我个人偏好,比表格直观,横轴是层进度、纵轴是权重,画一条上升曲线就行。
还有一个细节:生成器里所有 Random 实例必须用同一个 seed 派生。常见做法是主 seed 生成后,各子步骤用seed + stepIndex另开实例,这样既保证整体可复现,又不会因为某一步多抽了几次随机数导致后面全乱。
4. 排错与避坑:五类高频翻车现场
4.1 现象:地图上出现走不到的死节点
原因基本是两个:一是节点类型先于连边分配,某个节点被定为商店后,连边阶段它恰好没被任何上层节点选中;二是连边只做了“上层有出边”的检查,漏了“下层有入边”。解决方式是入度和出度双向检查,任一为 0 就触发补边,补边优先选同层索引最近的合法节点,补完再跑一遍全图校验。我在项目里把这个检查做成生成后第一道断言,没过就直接抛异常并打印 seed,而不是静默容忍。
4.2 现象:同一张 seed 在编辑器里正常,打包后地图不一样
原因几乎总是随机数来源不一致。System.Random与UnityEngine.Random是两套实现,序列不同;另外 Dictionary 的遍历顺序在部分运行环境下不保证稳定,如果你的连边逻辑依赖dict.Keys的遍历顺序,就会出现“代码没改但图变了”。解决方式:生成过程只用System.Random,且所有依赖顺序的地方改用List排序后遍历,排序键显式指定(比如按 id 升序)。这条踩过一次就再也忘不掉,复现问题的成本极高。
4.3 现象:路径交叉严重,玩家看不清连线
交叉线不是算法错误,是视觉问题,但根因在生成端。当上层节点用id % count做邻近选择、而下层节点数在层间剧烈波动时,连线会大角度斜穿。解决办法有两个:一是限制相邻层节点数差值不超过 2;二是把邻近判断从“索引取模”换成“归一化横坐标距离”,即x = indexInLayer / (layerCount - 1),取距离最近的两个。改完交叉数量通常能降一半以上,剩下的少量交叉用人字形折线画法遮掉即可。
4.4 现象:难度曲线在某一段突然陡峭
表现是玩家在第 8 层前后集中掉血。查下来往往是精英节点的层分布没有约束,只在类型上限制了“同层最多 1 个”,但没限制“连续两层都出精英”。补一条约束:精英节点所在层的间隔不小于 2,且精英层不与休息层相邻。这类约束不需要写进生成主流程,放在类型分配后的二次修正里跑一遍即可,改动面小、风险可控。
4.5 现象:改了配置但游戏里没生效
ScriptableObject 的经典坑。运行时修改的是内存里的实例,退出 Play 模式后改动丢失;反过来,如果在 Play 模式下改了资产并保存,有时会污染工程内的默认配置。习惯做法是:调参阶段复制一份配置资产专门用于试玩,正式配置只读不写;代码里加载配置时打一条日志把关键参数值打出来,确认加载的是你以为的那份。这条听起来琐碎,但它能省掉大量“我明明改了”的无效排查。
5. 进阶改造:把生成器和验证器分开,再补一层自动校验
骨架跑通之后,我做的第一件事是把“生成”和“校验”拆成两个独立类:MapGenerator只负责按配置产出图,MapValidator接收一张图并返回问题列表。拆开的好处是验证逻辑可以脱离生成流程单独跑——你可以在编辑器里对一张手改过的图做校验,也可以用历史 seed 批量回归。
第二件事是写一个批量回归脚本。爬塔地图的问题往往不是“生成失败”,而是“生成出来的图不好玩”,这只能靠统计发现。我一般会跑 200 个连续 seed,统计四个指标:
| 指标 | 统计口径 | 健康区间参考 |
|---|---|---|
| 平均路径数 | 起点到 Boss 的路径条数均值 | 8 ~ 20 |
| 单路径商店均值 | 每条路径上商店数量 | 1.5 ~ 2.5 |
| 精英层间隔最小值 | 相邻两个精英层的层差 | ≥ 2 |
| 死节点数 | 入度或出度为 0 的节点 | 0 |
跑完看分布而不是看单值。比如平均路径数 12 但方差极大,说明有的图只有 2 条路、有的图 40 条,玩家体验波动会很剧烈,这时就该收紧 maxBranchPerNode 或加大层间节点数差限制。批量脚本本身很简单,一个 EditorWindow 加个循环即可:
// 编辑器批量回归:连续 seed 生成并汇总指标 [MenuItem("Tools/Map/RunRegression")] static void RunRegression() { var cfg = AssetDatabase.LoadAssetAtPath<MapGenConfig>("Assets/Config/MapGen.asset"); int ok = 0, fail = 0; var pathCounts = new List<int>(); for (int seed = 1; seed <= 200; seed++) { var gen = new MapGenerator(cfg, seed); var map = gen.Generate(); var issues = MapValidator.Validate(map, cfg); if (issues.Count > 0) { fail++; Debug.LogWarning($"seed {seed}: {string.Join(",", issues)}"); } else { ok++; pathCounts.Add(MapValidator.CountPaths(map)); } } Debug.Log($"成功 {ok} / 失败 {fail} / 平均路径 {pathCounts.Average():F1}"); }这里有个容易忽略的点:失败的 seed 要打印具体问题,而不是只计数。因为失败大多是同一类原因(比如某层节点数过少导致补边补不出来),你看到 10 条同样的警告,就知道该调的是节点数下限,而不是去改连边算法。
第三件事是一个具体技巧:给地图加一层“路径签名”。对每条从起点到 Boss 的路径,按节点类型序列拼成字符串(如 B-B-E-S-R-B),去重后看有多少种不同的路径组成。这个指标比路径条数更能反映实际体验——20 条路径如果全是同一个类型序列,玩家等于没得选。我在一个模拟项目里加了这个统计后才发现,权重表调得再细,只要类型分配是按层独立随机的,路径签名种类就会明显偏少,后来改成“按路径上下文做一次类型互斥”才把多样性拉起来。
从那以后我每次改地图相关参数,都强制先跑一遍 200 seed 回归再看效果,绝不靠单张图拍脑袋判断。地图算法这种带随机性的东西,单次结果的说服力基本为零,玄学调参的最后都是自己骗自己。希望这套拆解能帮到你,把骨架拿去改成你要的形态就好。
本文还有配套的精品资源,点击获取