news 2026/10/26 23:45:22

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Unity Roguelike爬塔地图算法:可复现随机与连通性校验

简介:面向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 List<MapNode> Children = new List<MapNode>(); public List<MapNode> Parents = new List<MapNode>(); } [Serializable] public class MapLayout { public int Seed; // 复现整张图的唯一凭证 public List<List<MapNode>> Floors = new List<List<MapNode>>(); // Floors[floor] = 该层节点集合 public List<MapNode> AllNodes = new List<MapNode>(); // 扁平索引,方便遍历和存档 }

Floors用二维列表而不是字典,是因为生成阶段需要频繁按层遍历,列表的随机访问比哈希快且不产生 GC 噪声。AllNodes是冗余结构,但序列化时只存它一份、读回时再按Floor分组重建Floors,这样存档体积会小很多。字段都留成 public 是为了 Unity Inspector 里能直接看,正式项目里我一般会加[SerializeField]配合属性封装,Demo 阶段不折腾这些。

2.3 可复现的种子与存档结构

“同一颗种子出同一张图”是这类生成器的底线。C# 的System.Random在不同 .NET 版本下算法实现并不保证一致,如果将来要做跨端(比如服务器校验地图),就要换成自实现的线性同余或 PCG,Demo 内单机跑用System.Random够用。存档只写三样东西:种子、节点类型、连接关系。节点坐标可以从连接关系反推,省一半体积。

[Serializable] public class MapSnapshot { public int Seed; public List<NodeSnapshot> Nodes = new List<NodeSnapshot>(); } [Serializable] public class NodeSnapshot { public int Floor; public int Column; public MapNodeType NodeType; public List<int> ChildrenIndex; // 指向 Nodes 数组下标 }

读档时先按Seed重建骨架,再逐节点覆写NodeType,这样即使版本升级改了类型分配权重,老存档的房间分布也不会漂移。这段逻辑我放在一个独立的MapSerializer里,生成器不碰 IO,职责干净。

3. 路径骨架生成:让每条路线都能从起点走到 BOSS

3.1 路径生长:从起点列按偏移向上走

路径生长的核心是“逐层向上,每次在左、中、右三个候选列里挑一个,且不允许与已有边形成交叉”。交叉判定是关键:如果一条边从(f, c)走到(f+1, c+1),而另一条边从(f, c+1)走到(f+1, 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 List<int>(); 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 Stack<MapNode>(); var visited = new HashSet<MapNode>(); 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 List<MapNode>()); 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的权重、解锁层、去重开关全部塞进一个ScriptableObject,MapGenerator只负责读取。这样做的副作用是生成器代码会变“薄”,但换来的好处是同一套算法可以服务多张不同风格的地图,比如新手图和后期图只是配置不同。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)到(floor+1, c±1)的整段边入集合,并在生长时检查目标节点是否已经挂在“反向父节点”下。另一种更省事的做法是限制每条路径每两层最多偏移一次,牺牲一点随机性换取零交叉。

5.3 出生点孤岛:第一层走不出去

现象:地图生成完第一层只有一个节点,且它没有子节点,玩家点进去卡死。 原因:StartPathCount设得比ColumnCount大,多条路径挤到同一列上反复GetOrCreateNode,Children去重把边全吃掉了;或者某次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 次 + 哈希比对 + 失败重试上限”这三步,哪怕需求方只说“随便生成一下”。地图算法这种东西,没验收过的随机就是薛定谔的随机。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 23:45:12

SAM/CLIP/BLIP多模态原理与可调试实战:从概念断层到工业部署

1. 这不是又一个“大模型速成班”&#xff1a;为什么2027年还在讲SAM、CLIP、BLIP&#xff0c;反而成了最硬核的入门路径&#xff1f;你点开这个标题&#xff0c;大概率是被“2小时吃透”“保姆级”“附源码”这几个词拽进来的。但我要先泼一盆常温水&#xff1a;这门课真不是让…

作者头像 李华
网站建设 2026/10/26 23:44:03

t3code是幻觉:拆解Electron/iOS/大模型调试中的认知陷阱

1. “t3code”不是工具名&#xff0c;而是开发者认知错位的典型切口 最近在几个技术社群里频繁看到“t3code”这个词被当作某个具体工具、CLI 或 Electron 应用来讨论——有人问“t3code 怎么安装”&#xff0c;有人贴报错“t3code command not found”&#xff0c;还有人发截…

作者头像 李华
网站建设 2026/10/26 23:44:03

Java图书管理系统GUI版:Swing+JDBC+MySQL课设实战解析

简介&#xff1a;面向计算机专业学生的Java课程设计/期末大作业参考实现&#xff0c;提供一套完整可运行的图书管理GUI系统&#xff0c;覆盖图书入库、借阅归还、查询统计等核心业务&#xff0c;附数据库建表脚本与初始数据&#xff0c;可直接导入使用。压缩包共37个文件&#…

作者头像 李华
网站建设 2026/10/26 23:41:35

t3code:T3 Stack驱动的CLI+Electron一体化开发工具生态

1. “t3code”不是工具名&#xff0c;而是开发者社区里一个正在成型的CLI生态代号最近两周&#xff0c;我在几个前端技术群和Electron开发者论坛里反复看到“t3code”这个词——它既没出现在npm registry的包列表里&#xff0c;也没在GitHub上搜到对应仓库。但有趣的是&#xf…

作者头像 李华
网站建设 2026/10/26 23:40:32

t3code:轻量级跨端构建系统,一套Web代码三端部署

1. 项目概述&#xff1a;t3code 是什么&#xff0c;它解决的到底是什么问题&#xff1f;t3code 这个名字乍一看像某个开源工具、CLI 命令行套件&#xff0c;或是某款跨平台开发框架的代号——但它既不是 npm 上广为人知的包&#xff0c;也不是 GitHub trending 榜单里的明星项目…

作者头像 李华
网站建设 2026/10/26 23:39:46

Claude Code Mods 实战:用 TypeScript 扩展终端智能体

1. 从终端里长出来的“外挂”&#xff1a;Claude Code Mods 到底在解决什么问题第一次听到 Claude Code Mods 这个词&#xff0c;很多人会下意识把它当成某种插件市场里的扩展包。其实不是。它更像是一套“给 Claude Code 这个终端里的编程助手加装工具、改界面、换交互方式”的…

作者头像 李华