首页 / 资讯中心 / 文章详情

Unity Roguelike爬塔地图算法:可复现随机与连通性校验

Unity Roguelike爬塔地图算法:可复现随机与连通性校验 ★ FEATURED ARTICLE
简介面向Unity开发者与Roguelike关卡设计学习者的《杀戮尖塔》地图算法实现Demo使用C#在Unity中还原分层房间与路线连接的核心思路适合想研究随机地图拓扑、房间布局和可配置生成规则的初中级开发者参考。项目围绕房间数量动态配置、每层X/Y随机偏移、最近上层房间检索、断路补链与层级连接等逻辑展开能帮助读者理解地图从房间铺设到路径贯通的完整生成过程。压缩包共144个文件包含C#脚本、Unity场景与预制体、asset配置、png贴图、json/xml数据及工程设置等整体约10.3MB目录保留完整工程结构便于直接打开对照学习。目前已有1085人学习下载可作为Roguelike地图生成模块的算法参考与二次开发起点。1. 每次开图都想重开一局这套方案要解决什么做过爬塔式关卡的同学大概率都遇到过同一个尴尬场面地图一展开玩家三两眼就算出唯一一条活路其余分支全是摆设更糟的是有些节点走到最后根本连不上终点只能强制重开。排查下来问题往往不在随机数而在生成器的骨架——路径没有真正分叉、房间类型没有规则约束、种子没有留存。这套基于 Unity 的地图算法 Demo 就想把“每局不重复、每局可复现”两件事同时做到用固定的层列网格约束随机用路径生长算法保证连通用类型规则塑造节奏。它适合两类人一类是正在做 Roguelike 卡牌或地牢关卡课程设计、需要一套能跑通、能截图、能讲清原理的地图模块另一类是已经写了随机生成、但地图经常裂成孤岛或走两步就到 BOSS 的从业者。整个 Demo 不依赖任何第三方插件纯 C# 完成导进 Unity 点一次生成就能看到结果。2. 数据结构设计层、列、节点、边怎么建模2.1 为什么用 7 列 × 15 层的固定网格而不是自由撒点自由撒点看起来最“随机”但它有两个致命问题一是玩家看不出分支结构二是生成器很难保证从底到顶的连通。把地图约束在一个固定列宽的网格上随机只发生在“往左、直行、往右”这三个偏移里可控性立刻上一个台阶。列宽取 7 是个经验值——奇数保证中间有中轴列玩家心理上有一条“主路”层数取 15 则是把节奏切成三段前段熟悉规则中段拉开资源差距末段给休息与精英让位。这组参数不是玄学改动任何一个都会直接影响分支密度与通关时长我在 Demo 里把它拆成可配置项参数默认值作用调整影响FloorCount15地图总层数0 起始层14 为休息层15 为 BOSS越长越肝越短越像关卡内小地图ColumnCount7横向列数越大分支越散越小越像单行道StartPathCount6从底部起的路径条数决定底部入口数量Seed运行时指定复现凭证同种子必出同图注意列宽取偶数会让中轴分裂成两条玩家在视觉上找不到“正中路”除非你刻意要那种左右对峙的镜像感否则建议保持奇数。网格化的另一个好处是存档与调试。每个节点都有确定的(Floor, Column)坐标生成完可以直接把整张图丢进一张二维表里打印出来肉眼检查哪层断了、哪层挤成一团。自由撒点做不到这一点你只能对着坐标一个个点排查成本高一个量级。2.2 节点与边的类定义节点不保存“上一层是谁”而是保存父子双向引用。这种结构在生成阶段方便向上生长在运行时也方便做“从当前节点回看已走过的历史”这类 UI 需求。using System; using System.Collections.Generic; public enum MapNodeType { Monster, // 普通战斗 Elite, // 精英 Shop, // 商店 RestSite, // 休息点 Treasure, // 宝箱 Unknown, // 未知事件 Boss // 最终层 } [Serializable] public class MapNode { public int Floor; // 所在层0 为起始 public int Column; // 所在列0 ~ ColumnCount-1 public MapNodeType NodeType; // 双向引用生成时向上生长运行时可回溯 public ListMapNode Children new ListMapNode(); public ListMapNode Parents new ListMapNode(); } [Serializable] public class MapLayout { public int Seed; // 复现整张图的唯一凭证 public ListListMapNode Floors new ListListMapNode(); // Floors[floor] 该层节点集合 public ListMapNode AllNodes new ListMapNode(); // 扁平索引方便遍历和存档 }Floors用二维列表而不是字典是因为生成阶段需要频繁按层遍历列表的随机访问比哈希快且不产生 GC 噪声。AllNodes是冗余结构但序列化时只存它一份、读回时再按Floor分组重建Floors这样存档体积会小很多。字段都留成 public 是为了 Unity Inspector 里能直接看正式项目里我一般会加[SerializeField]配合属性封装Demo 阶段不折腾这些。2.3 可复现的种子与存档结构“同一颗种子出同一张图”是这类生成器的底线。C# 的System.Random在不同 .NET 版本下算法实现并不保证一致如果将来要做跨端比如服务器校验地图就要换成自实现的线性同余或 PCGDemo 内单机跑用System.Random够用。存档只写三样东西种子、节点类型、连接关系。节点坐标可以从连接关系反推省一半体积。[Serializable] public class MapSnapshot { public int Seed; public ListNodeSnapshot Nodes new ListNodeSnapshot(); } [Serializable] public class NodeSnapshot { public int Floor; public int Column; public MapNodeType NodeType; public Listint ChildrenIndex; // 指向 Nodes 数组下标 }读档时先按Seed重建骨架再逐节点覆写NodeType这样即使版本升级改了类型分配权重老存档的房间分布也不会漂移。这段逻辑我放在一个独立的MapSerializer里生成器不碰 IO职责干净。3. 路径骨架生成让每条路线都能从起点走到 BOSS3.1 路径生长从起点列按偏移向上走路径生长的核心是“逐层向上每次在左、中、右三个候选列里挑一个且不允许与已有边形成交叉”。交叉判定是关键如果一条边从(f, c)走到(f1, c1)而另一条边从(f, c1)走到(f1, c)两条线在视觉上会打成一个 X玩家一眼就能看出是 bug。private void GrowPath(MapLayout layout, HashSet(int, int, int, int) edges, int startColumn) { int column startColumn; for (int floor 0; floor FloorCount; floor) { MapNode node GetOrCreateNode(layout, floor, column); if (floor FloorCount - 1) break; // 到了顶层就结束 int nextColumn PickNextColumn(edges, floor, column); MapNode next GetOrCreateNode(layout, floor 1, nextColumn); // 双向连边并做去重避免同一对节点被重复连 if (!node.Children.Contains(next)) node.Children.Add(next); if (!next.Parents.Contains(node)) next.Parents.Add(node); edges.Add((floor, column, floor 1, nextColumn)); column nextColumn; } } private int PickNextColumn(HashSet(int, int, int, int) edges, int floor, int column) { var candidates new Listint(); for (int offset -1; offset 1; offset) { int target column offset; if (target 0 || target ColumnCount) continue; // 越界剔除 // 交叉判定镜像边已存在则跳过 if (edges.Contains((floor, target, floor 1, column))) continue; candidates.Add(target); } if (candidates.Count 0) return column; // 兜底直行 return candidates[_rng.Next(candidates.Count)]; }GetOrCreateNode按(Floor, Column)查重查不到就新建。这意味着多条路径可以在中途合流到同一个节点视觉上就是两条线汇成一条——这是有意为之合流点能让玩家感觉“路线选择收束了”节奏上更有层次。edges集合只用于交叉判定不参与后续逻辑生成结束就可以丢掉。3.2 交叉判定与连通性兜底交叉判定只能挡住“同一步的镜像边”挡不住“路径被后续生长挤到墙角”。最后一定要跑一遍连通性自检从每个第 0 层节点做一次深度优先看能不能到达第 14 层任何一个节点只进不出就直接判定生成失败并换种子重来。public bool Validate(MapLayout layout) { // 1. 每个第 0 层节点都必须能到顶层 foreach (var start in layout.Floors[0]) { if (!CanReachTop(start, FloorCount - 1)) return false; } // 2. 不允许出现“只有入边没有出边”的死路节点顶层除外 for (int f 0; f FloorCount - 1; f) { foreach (var n in layout.Floors[f]) { if (n.Children.Count 0) return false; } } return true; } private bool CanReachTop(MapNode node, int targetFloor) { var stack new StackMapNode(); var visited new HashSetMapNode(); stack.Push(node); while (stack.Count 0) { var cur stack.Pop(); if (cur.Floor targetFloor) return true; if (!visited.Add(cur)) continue; foreach (var child in cur.Children) stack.Push(child); } return false; }验证失败的处理策略有讲究。直接while (!Validate(...))重生成在小概率下会撞上死循环血泪经验是加一个最大重试次数比如 20 次超过就换Seed 1再走一轮同时打一条警告日志。这样生成器的稳定性从“大概率能行”变成“确定能行”。3.3 一次完整的生成调用链把上面几块拼起来对整个生成入口做一次收口逻辑就很清楚建Random→ 逐条路径生长 → 分配类型 → 校验 → 失败重试。public MapLayout Generate(int seed) { for (int attempt 0; attempt MaxRetry; attempt) { _rng new System.Random(seed attempt); var layout new MapLayout { Seed seed attempt }; for (int i 0; i FloorCount; i) layout.Floors.Add(new ListMapNode()); var edges new HashSet(int, int, int, int)(); for (int i 0; i StartPathCount; i) GrowPath(layout, edges, i); // 起始列依次取 0 ~ 5 AssignNodeTypes(layout); Flatten(layout); // 填充 AllNodes if (Validate(layout)) return layout; Debug.LogWarning($地图生成第 {attempt 1} 次失败换种子重试); } throw new Exception(地图生成失败超过最大重试次数); }MaxRetry 20在我实测里几乎没触发过因为 7 列 × 15 层的约束下交叉概率本身不高。Flatten只是把Floors里的节点压平进AllNodes方便后面序列化与 UI 遍历逻辑一行foreach就够。4. 房间类型分配规则约束与难度曲线4.1 楼层硬规则第 0 层战斗、第 8 层宝箱、第 14 层休息类型分配别一上来就纯随机。先钉死几个关键楼层第 0 层必须是普通战斗让玩家先碰一次怪熟悉节奏第 8 层固定宝箱作为整张地图的中点奖励第 14 层固定休息点BOSS 前留一口气。这三条是“硬规则”写死在PickType的最前面不参与权重计算。private MapNodeType PickType(MapNode node, int floor) { if (floor 0) return MapNodeType.Monster; if (floor 8) return MapNodeType.Treasure; if (floor 14) return MapNodeType.RestSite; // 其余楼层走权重池 return RollFromPool(node, floor); }4.2 权重池与类型去重中间楼层的类型靠权重池抽但抽之前要做一次“去重降权”如果某个类型和父节点重复就把它从候选池里剔掉。这条规则能有效防止一条路上连着三四个商店或者连着五次战斗玩家的体验会平顺很多。private MapNodeType RollFromPool(MapNode node, int floor) { var pool new List(MapNodeType type, int weight) { (MapNodeType.Monster, 45), (MapNodeType.Unknown, 22), }; if (floor 4) pool.Add((MapNodeType.Shop, 12)); // 商店从第 4 层解锁 if (floor 5) pool.Add((MapNodeType.Elite, 14)); // 精英从第 5 层解锁 if (floor 13) pool.Add((MapNodeType.RestSite, 18)); // 末段加休息权重 // 去重父节点已是该类型则剔除 var filtered pool.Where(p !node.Parents.Any(par par.NodeType p.type)).ToList(); if (filtered.Count 0) filtered pool; // 全被剔光就退回原池 int total filtered.Sum(p p.weight); int roll _rng.Next(total), acc 0; foreach (var p in filtered) { acc p.weight; if (roll acc) return p.type; } return MapNodeType.Monster; }Shop从第 4 层解锁、Elite从第 5 层解锁不是拍脑袋而是经验值太靠前玩家没有金币和牌组基础进商店只能干瞪眼精英太靠前几乎等于强制掉血体验很差。13 层开始拉升RestSite的权重是为了让“休息还是精英”这个末段抉择更有存在感。4.3 难度曲线从战斗密度到精英位置楼层和难度的关系可以做成一张可调的表而不是散落在代码的 if 里。Demo 里用一个AnimationCurve或简单的分段函数来控制战斗密度本质上就是让“第几层该有多少战斗”这件事变成可视化可调参的。下面的经验值可以直接作为起点楼层区间战斗密度精英商店设计意图0 ~ 3高无无熟悉节奏攒资源4 ~ 7中少量1 ~ 2 个开始出现选择压力8———固定宝箱层中点奖励9 ~ 12中高明显增多1 ~ 2 个难度爬坡13 ~ 14低多少决战前休整提示这张表比代码更值得反复调。类型分配写死在代码里就意味着改难度必须重新编译我一般会把权重、解锁楼层、去重规则都抽到一个MapConfig的 ScriptableObject 里策划同学改完保存即可不用碰 C#。4.4 配置化把规则从代码里抽出来把Monster / Elite / Shop / RestSite / Unknown的权重、解锁层、去重开关全部塞进一个ScriptableObjectMapGenerator只负责读取。这样做的副作用是生成器代码会变“薄”但换来的好处是同一套算法可以服务多张不同风格的地图比如新手图和后期图只是配置不同。Demo 里这一块做了简化只把权重和解锁层抽出去去重逻辑留在代码里真正要上线时建议连去重规则也配置化否则策划会一直来找你改if (floor 4)那个魔数。5. 避坑与常见问题排查5.1 同一颗种子跑出两张地图现象调试时用同一个seed跑两次打印出来的节点类型顺序对不上玩家视角就是“读档后地图变了”。 原因System.Random被多个系统共享UI 层某次随机调用把序列消耗掉了或者生成器内部用了UnityEngine.Random和System.Random混着来两条序列根本不同步。 解决生成器只持有一个私有_rng所有随机都从它出外部要随机另开实例。C# 的Random不是线程安全的多线程环境还得加锁或每线程一个实例这一点在 Demo 里没暴露但上线前务必检查。5.2 路径中段出现“H 字交叉”现象地图走到中层时某两条边画出来像一个横着的H玩家会觉得路线穿模了。 原因交叉判定只挡了同一步的镜像边但没挡住“两条路径在相邻两列各走各的、然后在下一层又交叉回来”的复合形态。 解决判定不能只看一步要把(floor, c)到(floor1, c±1)的整段边入集合并在生长时检查目标节点是否已经挂在“反向父节点”下。另一种更省事的做法是限制每条路径每两层最多偏移一次牺牲一点随机性换取零交叉。5.3 出生点孤岛第一层走不出去现象地图生成完第一层只有一个节点且它没有子节点玩家点进去卡死。 原因StartPathCount设得比ColumnCount大多条路径挤到同一列上反复GetOrCreateNodeChildren去重把边全吃掉了或者某次PickNextColumn兜底返回了column本身结果自连边被过滤。 解决StartPathCount永远 ≤ColumnCount兜底分支要返回一个“离当前列最近的合法列”而不是原地生成末尾强制调用Validate失败即换种子。5.4 精英扎堆在前几层现象第 3 层连着三个精英玩家直接劝退。 原因权重池里Elite的解锁层写成了 3或者去重逻辑只比父节点、没比兄弟节点同一层的多个节点各自独立抽取自然撞车。 解决把精英解锁层抬到 5同时在同层做一次“最多 N 个精英”的配额检查更稳妥的做法是把同层所有节点的类型抽完后再统一做一次冲突消解。5.5 生成器卡死进度条不动现象点了生成按钮Editor 直接卡住连日志都不打。 原因while (!Validate(...))没有重试上限在某个极端ColumnCount配置下永远生成不出合法图或者路径生长里while条件写错column没被更新。 解决所有循环都加上最大次数超过就抛出带Seed信息的异常Editor 里再加一层超时监控超过 200ms 就打断并提示换参数。我现在的习惯是任何带随机重试的生成器第一版就把重试上限和日志写进去后悔药比调试便宜。6. 验收与答辩怎么证明这套地图算法是可复现的验收这类生成器最容易翻车的不是“能不能跑”而是“能不能证”。演示的时候随手点一次生成、地图看起来挺漂亮评委问一句“同一颗种子再跑一次给我看看”如果两次结果不一致前面讲的所有设计都站不住。所以验收环节我一般从三个维度做验收项判定方法合格线可复现性同一Seed连续生成 100 次逐节点比对NodeType与边集合100 次完全一致连通性对每次结果跑一遍Validate全部通过无死路节点分支结构统计每层节点数、平均出度、累计路径数中间层节点数 ≥ 4无单列断层可复现性这项最值得花时间因为它能一次性暴露所有隐式随机源。做法很简单加一个 Editor 菜单项循环 100 次生成并把每个节点的(Floor, Column, NodeType)拼成字符串做哈希哈希一致才算过。真正跑起来通常会抓到一两个漏网的UnityEngine.Random修完之后再跑就稳了。答辩时评委最爱问的两个问题一个是“你这套随机和纯随机有什么区别”另一个是“如果玩家抱怨某局太难你怎么定位”。第一个问题的回答落点在约束网格约束了分布、交叉判定约束了结构、类型规则约束了节奏三层约束叠起来才让“随机”变得可玩。第二个问题就是种子——只要玩家报出局内显示的那串Seed你就能在本地一键复现整张地图定位到具体是哪一层的类型分配出了偏差。还有一个容易被忽略的细节把生成结果做一次可视化导出把每张图打印成一张 PNG 存到Assets/Screenshots下。答辩 PPT 里放一组“100 次生成的缩略图墙”比任何文字描述都有说服力评委一眼就能看出你的地图确实每局都不一样、但结构始终稳定。从那以后我每次做生成器类的功能都强制走一遍“固定种子跑 100 次 哈希比对 失败重试上限”这三步哪怕需求方只说“随便生成一下”。地图算法这种东西没验收过的随机就是薛定谔的随机。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站