ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Roguelike随机地下城:门生成与最终房间选择的后处理实践

Roguelike随机地下城:门生成与最终房间选择的后处理实践

这套系列写到第二篇,我打算把两块看着不大、实际特别影响手感的东西拎出来聊:门怎么设,以及最终房间怎么选。如果你也在做 Roguelike 随机地下城,地图生成完之后肯定会遇到这两个问题——门装歪了、最终房间离出生点两步路、Boss 房四通八达,这些我全踩过。这篇就把我的做法、踩坑记录和调参过程完整放出来。

1. 为什么把门和最终房间选择放在同一步处理

程序化生成地下城的流程,通常可以粗暴分成“主干生成”和“后处理加工”两个阶段。主干生成只解决一件事:房间该长在哪、走廊怎么连,最终输出一堆房间矩形和连接信息。看起来地图已经成型了,其实这时候还不能直接进游戏玩,因为玩家根本没法判断“这一局到底要去哪”,也经常会被一面墙卡住。

门和最终房间选择,就是这种典型的后处理工作。门负责把房间之间的走廊洞口变成可交互的关卡物件,最终房间负责给整张地图一个明确的目标点。两者都发生在“布局已经定下来、但还没变成可玩关卡”的这个过渡时间点,所以放在一起讲最合适,因为它们都强依赖房间之间的连接数据。

我见过有人喜欢在房间划分阶段就顺手把门生成完,这个做法我劝你先别学。原因是:布局阶段的走廊往往还会被切割、合并或调整,房间边界不是最终形态,提前生成的门很容易留在墙里。还有一点,门在 Roguelike 里不只是装饰,它经常承担“节奏控制”的作用,比如锁门、机关门、单向门,这些玩法逻辑最好是独立于布局生成的,否则后期加功能会很难受。

所以我的实现顺序是:先生成房间和走廊,再做一次全图的连通性分析,然后挑选最终房间,最后统一生成门。这样门的方向、位置,甚至哪些门要保留,都可以参考最终房间的判定结果来过滤。

2. 设置门:从走廊洞口到可交互关卡物件

2.1 门的数据来源与核心结构

很多刚上手的朋友会以为,门是从“房间墙上的洞口”长出来的。这个理解不算错,但缺少一个关键点:门的数据应该从走廊与房间的接触关系反推出来,而不是在生成地图时手工记录。

你可以这样想:走廊本质上是一串矩形,当这一串矩形穿过某个房间的外墙时,就会在墙面上形成一个洞口。等布局稳定之后,我们把所有走廊和房间的接触段找出来,每一个接触段就是一个天然的门位。这个做法最直接的好处是,不管你之后怎么调整走廊形状,只要连接关系还在,门总能重新算出来。

我在自己的模拟项目 X 里是这样定义数据结构的:

public class Room { public int Id; public Bounds Bounds; public List<int> Neighbors; public RoomType Type; } public class Corridor { public int Id; public int RoomAId; public int RoomBId; public List<Rect> Segments; } public class DoorInstance { public Vector3 Position; public Quaternion Rotation; public int RoomAId; public int RoomBId; public int CorridorId; }

比较关键的是Corridor里的Segments字段。很多地下城生成算法会把走廊保存成一条中心线或者一个完整的合并矩形,但这样在做门的时候会出问题:一条走廊可能穿过不止一个房间边界。我建议走廊用矩形段列表保存,这样每个接触段都能独立处理。

2.2 门的方向、碰撞与开关细节

门生成时最容易翻车的是方向。地编软件里摆门可以靠肉眼调整,程序生成就必须自己算方向了。

我的做法是:找到走廊矩形段与房间接触的那条边,取这条边的法线作为门的前方向。比如走廊从北往南进入房间,接触段落在房间北墙,那么门的前方向就应该是朝南。

foreach (var corridor in dungeon.Corridors) { var roomA = GetRoom(corridor.RoomAId); var roomB = GetRoom(corridor.RoomBId); foreach (var segment in corridor.Segments) { var contact = FindWallContact(segment, roomA, roomB); if (contact == null) continue; var door = new DoorInstance { Position = contact.Center, Rotation = Quaternion.LookRotation(contact.Normal), RoomAId = corridor.RoomAId, RoomBId = corridor.RoomBId, CorridorId = corridor.Id }; doors.Add(door); } }

注意,这个方向只决定了门本身朝向,不代表门板上锁和开门的动作方向。在房间比较小、走廊很窄的情况下,我强烈建议门朝走廊外开,或者干脆做侧滑门。朝房间内开很容易出现一个尴尬场面:玩家进门之后发现门板挡住了半边房间,转身都费劲。

碰撞体也是重灾区。如果门是独立于房间和走廊的物体,它和两侧墙体之间很容易产生一条细缝或重叠区。细缝会让子弹和小体积特效穿出去,重叠区则会让玩家在贴墙时被碰撞体“推”得轻微位移。我的建议是,门板碰撞体宽度比洞口宽度小一点点,留出 2 到 5 厘米的物理余量,同时门的碰撞体不要做太厚,能挡住门板即可。

2.3 门与房间类型的联动规则

设置门的时候,还要考虑房间类型。比如出生点房间通常不需要门,因为玩家从那里出发,给它装门反而会影响初始动线。商店房、奖励房最好保留门,玩家进进出出需要明确的边界感。最终房间则要特殊处理,我后文会详细说。

这些规则不要散落在各处,最好统一在门生成的循环里做过滤:

bool IsRoomFinal(Room room) => room.Type == RoomType.Final; bool IsRoomSpawn(Room room) => room.Type == RoomType.Spawn; // 过滤规则 if (IsRoomSpawn(roomA)) continue; if (IsRoomSpawn(roomB)) continue;

这样后续想新增“某类房间不允许有门”的规则,只改这一处过滤逻辑就够了。

3. 最终房间选择:从“最远”到“合适”

3.1 方案一:BFS 最远距离

最朴素的想法,是把玩家出生点作为起点,对整个连通图做一次广度优先搜索,取距离最远的房间作为最终房间。

这个方案能跑通,但实际效果很一般。只要地图稍微狭长一点,最终房间就会落在一条直走廊的尽头,玩家全程直线跑图,根本没有 Roguelike 该有的探索感。还有另一种翻车情况:地图只有几间大房间,最远距离差距很小,最后选出来的房间一看就跟出生点隔了一堵墙,毫无仪式感。

所以,BFS 距离只能作为基础信息,不能直接当答案。

3.2 方案二:叶子节点优先

后来我试过只看叶子节点,也就是邻居数量小于等于 1 的房间。这个思路是对的,因为最终房间通常应该是“路的终点”,玩家进去之后就要面对收尾战斗,不应该有太多岔路。

但叶子节点也有问题。在一个环比较多的地图里,叶子房间可能离出生点很近,而且叶子房间的形态差异很大:有的是个小储物间,有的是一大片开阔战斗场。只靠叶子判断,选出来的最终房间面积可能不够用,打 BOSS 时镜头里全是墙,体验很憋屈。

叶子可以当作一个加分项,但不能是唯一标准。

3.3 方案三:加权评分选房(推荐)

我现在稳定在用的方案,是给候选房间打一个综合分。核心约束条件有这几个:

  • 不能是出生房间;
  • 与出生房间的图距离不低于阈值,我习惯用“总房间数的 60%”和“最小 6 间”取大值;
  • 优先选叶子节点或低度节点;
  • 优先选路径转折多的房间,避免一路直跑;
  • 优先选面积大的房间,给战斗留空间;
  • 如果场景里有商店、奖励房这类关键房间,候选点离它们太近要扣分。

评分函数大致长这样:

float ComputeScore(Room candidate, Dictionary<int, int> dist, float maxDist) { float normalizedDepth = dist[candidate.Id] / maxDist; float leafBonus = candidate.Neighbors.Count <= 1 ? 1.0f : 0.0f; float turniness = ComputeTurniness(startId, candidate.Id); float areaBonus = Mathf.Clamp01(candidate.Bounds.Area() / maxArea); float nearSpecialPenalty = ComputeNearSpecialPenalty(candidate); float score = 0.4f * normalizedDepth + 0.2f * leafBonus + 0.2f * turniness + 0.1f * areaBonus - 0.1f * nearSpecialPenalty; return score; }

说一下ComputeTurniness的实现思路。不要真去计算二维几何角度,那样代价高还容易出边界问题。简单做法是:在 BFS 时记录每个节点的父节点,重建从出生房间到候选房间的路径,然后逐个比较相邻两步的方向向量,方向变了就把转向次数加 1,最后再做归一化。这个“伪曲折度”已经足够反映路径形态了。

3.4 最终房间入口的唯一化处理

房间选完之后,还需要做一步:把最终房间的入口唯一化。

道理很简单。如果最终房间四面八方都有门,玩家可能从侧门绕进去,战斗触发就成了问题。常规做法是,最终房间只保留一个与主路径相连的入口门,其他方向的门在生成阶段直接丢弃。

这一步放在门生成循环里做最简单:

if (IsRoomFinal(roomA) && !IsMainEntrance(door)) continue; if (IsRoomFinal(roomB) && !IsMainEntrance(door)) continue;

IsMainEntrance的判断标准,可以是最短路径上进入该房间的那道门。计算最短路径时用前面 BFS 时留下的父节点链回溯,就能精确知道玩家是从哪条走廊进入最终房间的。

入口唯一化还有一个好处:以后想给最终房间加“关门战斗”玩法,只需要在这个门挂触发事件,不需要再做多余处理。

4. 实操流程与核心代码

4.1 前置数据结构

先把底层跑通。房间和走廊的数据在布局阶段就要保证干净,尤其是Neighbors列表,它应该只包含通过走廊直接连通的房间,不能包含间接相连或者根本没连通的房间。

如果你还停留在“房间矩形重叠就算连通”的阶段,建议先把连通逻辑重构掉。Roguelike 地下城里的连通,一定要以走廊是否存在为准,而不是几何上是否贴在一起。否则门和最终房间的判定全都会被带偏。

4.2 门的生成流程

完整生成门的时候,我建议按照下面这个顺序执行:

  1. 遍历所有走廊;
  2. 对走廊的每一段矩形,找出与两侧房间墙体的接触段;
  3. 如果接触段存在,创建DoorInstance;
  4. 套用过滤规则:出生房间不生成门,最终房间只保留主入口门;
  5. 把门的位置和方向记录到关卡数据中,后续再实例化门模型。

一个很值得注意的细节:走廊的Segments可能不止一段。如果走廊拐了个弯,它可能分别与房间 A 和房间 B 的墙产生接触,这时候要分别生成两道门,而不是只生成一道门夹在中间。第一版我就是在这个细节上翻车的,出来的门直接悬在半空中。

public static List<DoorInstance> GenerateDoors(DungeonData dungeon) { var doors = new List<DoorInstance>(); foreach (var corridor in dungeon.Corridors) { var roomA = dungeon.GetRoom(corridor.RoomAId); var roomB = dungeon.GetRoom(corridor.RoomBId); foreach (var segment in corridor.Segments) { var contact = FindWallContact(segment, roomA, roomB); if (contact == null) continue; var door = new DoorInstance { Position = contact.Center, Rotation = Quaternion.LookRotation(contact.Normal), RoomAId = corridor.RoomAId, RoomBId = corridor.RoomBId, CorridorId = corridor.Id }; if (IsSpawnRoom(roomA) || IsSpawnRoom(roomB)) continue; if (IsFinalRoom(roomA) && !IsFinalMainEntrance(door, dungeon)) continue; if (IsFinalRoom(roomB) && !IsFinalMainEntrance(door, dungeon)) continue; doors.Add(door); } } return doors; }

4.3 最终房间选择实现

最终房间选择的入口函数,大致分成两步。第一步 BFS 算距离,第二步根据约束条件和权重打分。

public Room PickFinalRoom(DungeonData dungeon) { var start = dungeon.SpawnRoom; var dist = BFS(start); var candidates = dungeon.Rooms .Where(r => r.Id != start.Id) .Where(r => dist.ContainsKey(r.Id)) .Where(r => dist[r.Id] >= Mathf.Max(6, dungeon.Rooms.Count * 0.6f)) .ToList(); Room best = null; float bestScore = float.MinValue; foreach (var candidate in candidates) { float score = ComputeScore(candidate, dist); if (score > bestScore) { bestScore = score; best = candidate; } } return best; }

BFS 的实现我就不贴完整代码了,思路就是标准的先进先出队列,记录dist[current] + 1。重要的是,在 BFS 过程中额外保存一份父节点表,这个表后面既要用来算路径转折次数,也要用来判断最终房间的主入口门是哪一个,一举两得。

4.4 权重初始值与调参建议

权重值我一开始用的是0.4 / 0.2 / 0.2 / 0.1 / 0.1,跑了一段时间后发现,地图房间数少的时候,距离权重要再拉高一些;房间数超过 30 的时候,叶子节点的权重反而更重要,因为大图里叶子节点分布更均匀,更容易选出有意义的终点。

这里有个强烈建议:不要做“肉眼调参”。你盯着一张看起来不错的图反复调,大概率会过拟合到那张图上。正确做法是做一个批量测试开关,一次生成 200 张地图,统计最终房间离出生点的平均距离、叶子节点占比、失败率等指标。我在项目里就是靠这种批量数据来判断权重的。

如果你还不确定怎么开始,建议先跑一个最低限度的版本:只保留距离权重的 1.0,其他全部归零,确保最终房间一定能选出来。然后再逐步加叶子、拐弯、面积这些维度,每一步都看一下生成的图有没有变好。

5. 常见问题与排查技巧

5.1 高频问题速查表

这些是我实际排查过的问题,高频出现,建议直接收藏。

症状原因解决方式
门嵌在墙角用走廊整体中心做门位,没用接触段中心改为遍历走廊的分段矩形,寻找与房间墙面的实际接触段
门的方向时反时正法线方向没有统一取“走廊指向房间内部”的方向对法线做一次方向校验,确保 Door 的 forward 指向玩家常见来向
门板打开后卡住门朝房间内侧开,且房间面积过小改成朝走廊外开,或使用侧滑门
最终房间离出生点太近距离阈值太低,或权重里距离占比不够提高最小距离阈值,或把w1提到 0.5 以上
最终房间有两个以上入口门生成阶段没有做入口唯一化基于 BFS 父节点回溯,过滤掉非主入口门
最终房间选不出来地图连通性被破坏,部分房间不可达检查走廊生成逻辑,确认Neighbors只记录真实走廊连通
评分看起来都一样所有候选距离相等,或maxDist被算成 0打印每个候选的dist值,检查 BFS 是否只覆盖了出生点附近

5.2 调试可视化的独门心得

程序生成的东西,光看数据很难发现问题。我在项目里会加一个调试开关,用不同颜色画出三类信息:

  • 所有候选房间,按评分高低用颜色区分,评分越高颜色越亮;
  • 最终房间主入口门的方位,画一个明显的箭头;
  • 每道门的方向线,长度统一,用来快速找门朝向异常的实例。

这个开关在编辑器里打开,只要跑一版生成,就能肉眼扫出绝大多数问题。比反复看日志有效率得多。

还有一个小技巧,门方向有问题的图,通常都是因为走廊分段矩形的宽度大于房间墙体的厚度。门位和法线自然就歪了。遇到这种情况,直接检查走廊分段矩形的宽度值,把它限制在合理范围内,比在门生成逻辑里打补丁更省心。

6. 写在最后的实操建议

我自己的习惯,是先不挂最终美术资源,用一堆占位盒体把门和最终房间跑通。因为程序生成的翻车往往不在算法本身,而在“生成后处理”这一步。占位盒体环境下可以很清楚地看到门的朝向、门的开口位置、最终房间是否孤悬在地图末端,这些问题一旦接上美术模型,再排查就麻烦很多。

还有一点,门的开关逻辑和最终房间的触发逻辑,尽量做成与混乱的生成算法解耦。我见过不少项目把“开门”直接写成“检测玩家靠近就播放动画”,看单张图没问题,批量生成 50 张图之后就会有门卡死、触发不灵的情况。我的做法是把门的状态机独立出来,生成算法只负责位置和方向,交互逻辑全部交给关卡运行时。

最后再分享一个我踩过几次坑之后养成的习惯:每次调整完权重,先跑 200 次无头生成测试,专门统计不合格地图的比例,比如门穿墙率、最终房间距离过近率、房间不可达率,一次跑完再改参数。这个做法比站在编辑器里一张张看图可靠得多,省下的时间足够你多调三轮参数。

返回列表