这套系列写到第二篇我打算把两块看着不大、实际特别影响手感的东西拎出来聊门怎么设以及最终房间怎么选。如果你也在做 Roguelike 随机地下城地图生成完之后肯定会遇到这两个问题——门装歪了、最终房间离出生点两步路、Boss 房四通八达这些我全踩过。这篇就把我的做法、踩坑记录和调参过程完整放出来。1. 为什么把门和最终房间选择放在同一步处理程序化生成地下城的流程通常可以粗暴分成“主干生成”和“后处理加工”两个阶段。主干生成只解决一件事房间该长在哪、走廊怎么连最终输出一堆房间矩形和连接信息。看起来地图已经成型了其实这时候还不能直接进游戏玩因为玩家根本没法判断“这一局到底要去哪”也经常会被一面墙卡住。门和最终房间选择就是这种典型的后处理工作。门负责把房间之间的走廊洞口变成可交互的关卡物件最终房间负责给整张地图一个明确的目标点。两者都发生在“布局已经定下来、但还没变成可玩关卡”的这个过渡时间点所以放在一起讲最合适因为它们都强依赖房间之间的连接数据。我见过有人喜欢在房间划分阶段就顺手把门生成完这个做法我劝你先别学。原因是布局阶段的走廊往往还会被切割、合并或调整房间边界不是最终形态提前生成的门很容易留在墙里。还有一点门在 Roguelike 里不只是装饰它经常承担“节奏控制”的作用比如锁门、机关门、单向门这些玩法逻辑最好是独立于布局生成的否则后期加功能会很难受。所以我的实现顺序是先生成房间和走廊再做一次全图的连通性分析然后挑选最终房间最后统一生成门。这样门的方向、位置甚至哪些门要保留都可以参考最终房间的判定结果来过滤。2. 设置门从走廊洞口到可交互关卡物件2.1 门的数据来源与核心结构很多刚上手的朋友会以为门是从“房间墙上的洞口”长出来的。这个理解不算错但缺少一个关键点门的数据应该从走廊与房间的接触关系反推出来而不是在生成地图时手工记录。你可以这样想走廊本质上是一串矩形当这一串矩形穿过某个房间的外墙时就会在墙面上形成一个洞口。等布局稳定之后我们把所有走廊和房间的接触段找出来每一个接触段就是一个天然的门位。这个做法最直接的好处是不管你之后怎么调整走廊形状只要连接关系还在门总能重新算出来。我在自己的模拟项目 X 里是这样定义数据结构的public class Room { public int Id; public Bounds Bounds; public Listint Neighbors; public RoomType Type; } public class Corridor { public int Id; public int RoomAId; public int RoomBId; public ListRect 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, Dictionaryint, 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 门的生成流程完整生成门的时候我建议按照下面这个顺序执行遍历所有走廊对走廊的每一段矩形找出与两侧房间墙体的接触段如果接触段存在创建DoorInstance套用过滤规则出生房间不生成门最终房间只保留主入口门把门的位置和方向记录到关卡数据中后续再实例化门模型。一个很值得注意的细节走廊的Segments可能不止一段。如果走廊拐了个弯它可能分别与房间 A 和房间 B 的墙产生接触这时候要分别生成两道门而不是只生成一道门夹在中间。第一版我就是在这个细节上翻车的出来的门直接悬在半空中。public static ListDoorInstance GenerateDoors(DungeonData dungeon) { var doors new ListDoorInstance(); 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 次无头生成测试专门统计不合格地图的比例比如门穿墙率、最终房间距离过近率、房间不可达率一次跑完再改参数。这个做法比站在编辑器里一张张看图可靠得多省下的时间足够你多调三轮参数。
阅读完成 · 觉得有帮助?