大型Minecraft关卡系统设计:程序化全自动地牢生成(Bukkit API & 图论)

前言

近几年国外涌现了许多诸如MythicDungeons的优秀地牢插件,以MD为例,其在近期加入了程序化地牢生成的功能,如果笔者未曾记错的话,国外做此类副本生成的插件最早可以追溯到几年前的DungeonMMO,但与此同时,国内包括网易MC在内的几乎所有服务器还在使用传统的固定地牢方案,比如单例副本的教父插件DungeonPlus(已经不再活跃),近期的高版本新地牢插件QueDP也未曾提供过程序化副本生成的功能,看起来此概念在国内还未曾散布开来,故笔者自己写了一个类似MD的插件,并计划在此文章将原理公布并详细介绍

注:笔者的项目不开源(大部分的原因是因为本项目的一部分地牢生成是通过修改MC服务端源码实现的),但本文章包含大部分的核心代码

为什么你的地牢需要"长出来"

程序化生成的价值,以及我们要构建的这套系统的全貌

从一个无奈的需求说起

假设读者正在开发一个 Minecraft 副本插件,策划提了一个看似简单的需求:做一个地牢,玩家从入口进去,一路探索,最后击败 Boss。
读者可能会想:
::: align-center
这不难啊?

:::

于是打开 WorldEdit,花一下午手搓了十来个房间,仔细摆好位置,用走廊连起来,测试通过,上线。
然后你的策划活爹说:
::: align-center
玩家反馈每次进去都一样,没意思,能不能每次布局都不同?

:::

这时候问题来了——手工摆放十个房间是可行的,但要实现"每次都不一样",需要多少套预设?一百套?一千套?就算真的肝出来,玩家玩多了照样会腻。更麻烦的是,如果后期要加新房间、调整难度曲线、修改房间出现概率,每一套预设都得改一遍。这种维护成本是指数级增长的。

所以我们需要换一个思路:
::: align-center
不是"摆放"地牢,而是让地牢自己"长出来"。

:::

程序化生成到底在做什么

所谓程序化生成(Procedural Generation),说白了就是用算法代替人工。给定一些规则和素材,让程序自动组合出结果。这不是什么新概念。1980 年的 Rogue 就用程序化生成创造了无限的地牢,后来的 Diablo、Minecraft、Dead Cells、Hades 都在用类似的技术。

对于 Minecraft 地牢来说,程序化生成能带来三件事:

其一,无限的变化

每次生成的布局都不同。玩家永远不知道下一个房间是什么,这种"未知感"是预设方案很难做到的。当然,这里的"无限"是相对的——变化的上限取决于房间池的丰富程度和算法的复杂度。

其二,可控的随机

注意,是"可控的"随机,不是纯粹的乱数。我们可以设定规则:

  • Boss 房只能出现在最后
  • 宝箱房最多两个
  • 商店必须在前半段
  • 走廊不能连续出现三个

随机是手段,可控是目的。

其三,配置驱动

想调整生成规则?改配置文件。想加新房间?往房间池里扔一个。想做个新主题的地牢?复制一份配置,换一批房间素材。这种灵活性在长期运营中价值很大。

核心问题:房间怎么拼起来

程序化地牢生成的核心挑战,其实就一个问题:怎么让房间正确地拼接在一起。

这个问题比看上去要复杂。考虑以下情况:

  1. 房间有不同的尺寸,大房间和小房间怎么对齐?
  2. 房间可能需要旋转,旋转后坐标怎么算?
  3. 房间不能重叠,怎么检测碰撞?
  4. 有些房间只能从特定方向进入,怎么保证朝向正确?
  5. Boss 房只能有一个入口,怎么限制连接数?

最朴素的做法是"找空地放房间,然后挖走廊连起来"。这种方案能跑,但结果往往很丑——走廊会穿墙、拐弯莫名其妙、房间朝向混乱。

笔者采用的方案是连接器系统:每个房间预先定义好"可以连接的位置"(类似电器的插口),生成时房间通过连接器对接。这样连接关系是预设计的,结果可控得多。

后面的文章会详细展开这套机制。

系统架构概览

在正式开始讲技术细节之前,先给读者一个全貌。整个地牢生成系统可以分成四个阶段:

复制代码
房间定义  →  布局规划  →  布局生成  →  世界放置
系统架构.png

第一阶段:房间定义

定义有哪些房间可用。每个房间包含:

  • 基本信息:ID、尺寸、结构文件
  • 连接器列表:哪些位置可以对接其他房间
  • 生成规则:权重、出现次数限制、深度约束

这是"素材准备"阶段,决定了地牢的内容丰富度。

第二阶段:布局规划

根据配置,规划这次生成要用哪些房间、用多少个、什么布局算法。

比如配置说"8-12 个房间,主干+分支布局",规划器就会:

  1. 从房间池里挑选 8-12 个房间
  2. 保证起点房间、终点房间、特殊房间被选中
  3. 选择对应的布局引擎

第三阶段:布局生成

布局引擎接管,开始真正的生成工作。它会:

  1. 放置起始房间
  2. 根据算法逻辑,依次放置后续房间
  3. 处理坐标变换、碰撞检测
  4. 输出布局结果:每个房间放在哪、旋转多少度、和谁连接

这一阶段的输出是纯数据,还没有真正的方块。

第四阶段:世界放置

拿着布局结果,把房间结构实际放进 Minecraft 世界:

  1. 加载房间的结构文件(NBT/Schematic)
  2. 应用坐标变换(位移、旋转、镜像)
  3. 放置方块

这一步涉及性能优化(异步放置、FastAsyncWorldEdit 加速等),但逻辑上是最直观的。

用伪代码表示整个流程:

kotlin 复制代码
// 阶段一:定义房间(通常从配置文件加载)
val roomPool = loadRoomDefinitions("dungeon_rooms.yml")

// 阶段二:规划
val planner = ProceduralPlanner(roomPool)
val request = planner.prepare(
    config = ProceduralConfig(
        minRooms = 8,
        maxRooms = 12,
        layoutType = LayoutType.BRANCHING
    )
)

// 阶段三:生成布局
val engine = BranchingLayoutEngine()
val layout = engine.generate(request)
// layout.placements: 房间放置列表
// layout.connections: 房间连接列表

// 阶段四:放置到世界
layout.placements.forEach { placement ->
    val structure = loadStructure(placement.roomId)
    placeStructure(
        world = targetWorld,
        structure = structure,
        origin = placement.origin,
        rotation = placement.rotation,
        mirror = placement.mirror
    )
}

看起来很简单?魔鬼在细节里。

四种布局思路

布局引擎是整个系统的核心,它决定了房间的排列方式。笔者实现了四种不同的布局算法,每种都有自己的"性格":

蔓延式(Minecrafty)

从起始房间开始,用队列一层层往外扩展。每次取一个未处理的连接器,尝试在那个位置接一个新房间,新房间的连接器再入队。

生成结果像藤蔓一样自然蔓延,没有明显的主线,适合探索型地牢。

主干+分支式(Branching)

先生成一条"主干道"——从起点到终点的主路线。然后从主干上随机长出若干分支。

这种布局有清晰的主线,适合有流程感的地牢。可以通过参数控制主干长度、分支数量、分支深度。

网格迷宫式(GridMaze)

先在抽象的网格上生成迷宫拓扑(DFS、Prim、Kruskal 等都可以),然后把每个网格节点替换成实际房间。

生成结果是经典的迷宫感,路径曲折、死胡同多。

随机游走式(RandomWalk)

随机选一个已有房间,随机选一个方向扩展。通过"边界偏好"参数控制是倾向于从边缘扩展还是从任意位置扩展。

生成结果比较有机、不规则,适合洞穴风格。

这四种算法没有优劣之分,只有适用场景的不同。后面的文章会逐一详细讲解。

这个系列的结构

接下来的文章会按以下顺序展开:

  1. 房间与连接器:房间的数据模型设计,连接器系统的原理
  2. 拼接的数学:方向匹配、坐标变换、碰撞检测的实现
  3. 四种布局引擎:蔓延式、主干分支式、网格迷宫式、随机游走式的详细讲解
  4. 迷宫算法:DFS、Prim、Kruskal、Sidewinder 等算法的原理与实现
  5. 房间选择策略:权重系统、深度约束、出现次数控制
  6. 环路与拓扑:如何在树形结构上添加循环
  7. 特殊房间处理:起点、终点、Boss 房的放置逻辑
  8. 落地实现:从布局数据到 Minecraft 方块
  9. 收尾:锁钥系统、调试技巧、扩展方向

笔者会尽量用清晰的推导过程来讲解,而不是直接甩结论。目标是读完这个系列,读者能理解每个设计决策背后的原因,并有能力自己实现一套。

这个系列不涉及的内容

有些东西超出了本系列的范围:

  • Minecraft 插件开发基础:假设读者已经精通Bukkit API和NMS(net.minecraft.server)、OBC(org.bukkit.craftbukkit)
  • 美术与关卡设计:怎么设计一个好看的房间是美术的工作(很显然,笔者不适合干这个,这也是为什么本系列没有很多的图片)
  • 战斗与玩法:怪物生成、战利品掉落、任务触发等是独立的系统(这不是我们讲解的范畴)

好,前言到此为止。下一章开始讲房间定义的核心——连接器系统。

房间不只是一堆方块

房间的数据模型设计,以及连接器系统的原理

最朴素的想法为什么行不通

刚开始做程序化地牢时,笔者的第一反应很直接:房间不就是一堆方块吗?存个 ID、坐标、尺寸就够了吧?

kotlin 复制代码
data class Room(
    val id: String,
    val width: Int,   // X 方向
    val height: Int,  // Y 方向
    val length: Int   // Z 方向
)

生成的时候,随机找个位置,检查会不会和已有房间重叠,不重叠就放下去。房间之间怎么连?找两个最近的房间,沿直线挖一条走廊。

这个方案能跑。但跑出来的结果很难看:

问题一:走廊丑得没法看

自动挖出来的走廊完全不考虑建筑美学。它可能从房间的天花板穿出去,可能斜着切过另一个房间的墙角,可能在地下绕一个莫名其妙的弯。玩家走进去会觉得这不像人造的地牢,更像被虫子蛀过的木头。

问题二:房间朝向丢失

很多房间是有"方向性"的。比如一个大厅,入口在南边,王座在北边,这是设计师有意为之的。但如果随机摆放,入口可能对着墙,王座可能面向角落。房间的叙事感就没了。

问题三:连接关系不可控

假设我们想实现"Boss 房只能有一个入口"或"宝箱房必须从走廊进入"这样的规则,在"找最近点挖走廊"的方案里几乎无法做到。因为连接关系完全是事后决定的,设计师无法在设计房间时就规定它的连接规则。

这些问题的根源在于:
::: align-center
我们把房间当成了"一堆方块",信息量太少。房间不只是几何体,它还承载着"从哪里可以进、从哪里可以出"的语义信息。

:::

连接器:从电器插口得到的启发

换一个角度思考。

电器为什么能随意组合?因为它们有标准化的接口。三孔插头配三孔插座,USB-A 配 USB-A Type-C 配 Type-C,不用操心内部电路怎么实现。

房间也可以这样。我们给每个房间定义若干"连接器"(Connector),每个连接器代表一个可以对接其他房间的位置。生成地牢时,房间通过连接器相连,就像把乐高积木扣在一起。

连接器需要哪些信息?

位置:连接器在房间内的坐标。比如"房间南墙中央,地面高度"。
朝向:连接器面向哪个方向。朝南的连接器意味着"可以从南边接入另一个房间"。

这两个信息就足够描述"两个房间怎么对接"了。

kotlin 复制代码
data class Connector(
    val id: String,           // 连接器的唯一标识
    val position: Vector3i,   // 在房间局部坐标系中的位置
    val direction: Direction  // 朝向:NORTH, SOUTH, EAST, WEST, UP, DOWN
)

enum class Direction {
    NORTH,  // -Z
    SOUTH,  // +Z
    EAST,   // +X
    WEST,   // -X
    UP,     // +Y
    DOWN    // -Y
}

一个房间可以有多个连接器:

kotlin 复制代码
val hallRoom = Room(
    id = "grand_hall",
    size = Vector3i(15, 8, 20),
    connectors = listOf(
        Connector("south_entrance", Vector3i(7, 0, 0), Direction.SOUTH),
        Connector("north_exit", Vector3i(7, 0, 19), Direction.NORTH),
        Connector("east_balcony", Vector3i(14, 0, 10), Direction.EAST)
    )
)

这个大厅有三个连接器:南边入口(7, 0, 0)、北边出口(7, 0, 19)、东边阳台(14, 0, 10)。数字代表的是房间内部的局部坐标,(0, 0, 0) 是房间的原点。

连接器对接的几何原理

两个连接器要能对接,必须满足一个条件:朝向相反。

直观理解:如果房间 A 的连接器朝南(SOUTH),那它期望南边有东西可以接。房间 B 要接上去,它的连接器必须朝北(NORTH),这样两个房间才是"面对面"的。

复制代码
        房间 A                              房间 B
   ┌─────────────┐                    ┌─────────────┐
   │             │                    │             │
   │             ├── SOUTH    NORTH ──┤             │
   │             │      ↓      ↑      │             │
   └─────────────┘      └──────┘      └─────────────┘
                        对接点

判断两个方向是否相反的代码:

kotlin 复制代码
fun isOpposite(a: Direction, b: Direction): Boolean {
    return when (a) {
        Direction.NORTH -> b == Direction.SOUTH
        Direction.SOUTH -> b == Direction.NORTH
        Direction.EAST -> b == Direction.WEST
        Direction.WEST -> b == Direction.EAST
        Direction.UP -> b == Direction.DOWN
        Direction.DOWN -> b == Direction.UP
    }
}

对接时,两个连接器的世界坐标需要相邻。假设房间 A 的连接器在世界坐标 (100, 64, 200),朝向 SOUTH,那么房间 B 的连接器应该在 (100, 64, 201)——刚好在 A 连接器的南边一格——且朝向 NORTH。

计算"某个方向的邻接坐标":

kotlin 复制代码
fun directionVector(direction: Direction): Vector3i {
    return when (direction) {
        Direction.NORTH -> Vector3i(0, 0, -1)
        Direction.SOUTH -> Vector3i(0, 0, 1)
        Direction.EAST -> Vector3i(1, 0, 0)
        Direction.WEST -> Vector3i(-1, 0, 0)
        Direction.UP -> Vector3i(0, 1, 0)
        Direction.DOWN -> Vector3i(0, -1, 0)
    }
}

// 锚点连接器的世界坐标 + 朝向偏移 = 目标位置
val targetPos = anchorConnector.worldPos + directionVector(anchorConnector.direction)
// 如果一个新房间的连接器放在 targetPos 这个位置,那么我们就可以判断这两个房间是可以连接的

标签系统:更精细的匹配规则

只用朝向判断有时不够用。

假设我们有"大门"和"小门"两种连接器,"大走廊"和"小走廊"两种房间。大门应该连大走廊,小门应该连小走廊,混搭会很奇怪(比如一个气派的城堡大门后面接着一条狭窄的矿道)。

怎么表达这种约束?给连接器加上"标签"(Tags)。

kotlin 复制代码
data class Connector(
    val id: String,
    val position: Vector3i,
    val direction: Direction,
    val tags: List<String> = emptyList()  // 新增
)

使用示例:

kotlin 复制代码
// 大厅的主入口,只接受"大型"连接
val mainGate = Connector(
    id = "main_gate",
    position = Vector3i(7, 0, 0),
    direction = Direction.SOUTH,
    tags = listOf("large", "grand")
)

// 侧门,接受"中型"或"小型"连接
val sideDoor = Connector(
    id = "side_door",
    position = Vector3i(14, 0, 10),
    direction = Direction.EAST,
    tags = listOf("medium", "small")
)

// 大走廊的入口
val largeCorridor = Connector(
    id = "entry",
    position = Vector3i(4, 0, 0),
    direction = Direction.SOUTH,
    tags = listOf("large")
)

// 小走廊的入口
val smallCorridor = Connector(
    id = "entry",
    position = Vector3i(2, 0, 0),
    direction = Direction.SOUTH,
    tags = listOf("small")
)

匹配逻辑变成:

kotlin 复制代码
fun canConnect(a: Connector, b: Connector): Boolean {
    // 朝向必须相反
    if (!isOpposite(a.direction, b.direction)) {
        return false
    }

    // 如果双方都没有标签,直接通过
    if (a.tags.isEmpty() && b.tags.isEmpty()) {
        return true
    }

    // 如果一方没有标签,也通过(无标签意味着"百搭")
    if (a.tags.isEmpty() || b.tags.isEmpty()) {
        return true
    }

    // 双方都有标签,则至少要有一个共同标签
    return a.tags.any { it in b.tags }
}

这样 mainGate(tags: large, grand)可以连 largeCorridor(tags: large),但不能连 smallCorridor(tags: small)。

标签系统的好处是语义化和可扩展。想加新的约束?定义新标签就行。比如 "outdoor" 表示室外连接,"secret" 表示密道入口,"vertical" 表示垂直通道……

选择器.png

白名单与黑名单:精确控制

标签是软性的匹配规则。有时候我们需要更硬性的约束:

  • Boss 房的入口只能连走廊,不能直接连另一个 Boss 房
  • 起始房间只能连普通房间,不能连终点房间
  • 商店房间禁止出现在 Boss 房旁边

这些规则用标签很难表达(得给每种房间都打一堆标签,很快就会混乱)。更直接的方式是使用白名单和黑名单。

kotlin 复制代码
data class Connector(
    val id: String,
    val position: Vector3i,
    val direction: Direction,
    val tags: List<String> = emptyList(),
    val whitelist: List<String> = emptyList(),  // 只允许连接这些房间 ID
    val blacklist: List<String> = emptyList()   // 禁止连接这些房间 ID
)

白名单的语义是"如果非空,则只有名单上的房间才能连接"。黑名单的语义是"名单上的房间禁止连接"。

kotlin 复制代码
// Boss 房的入口
val bossEntrance = Connector(
    id = "entrance",
    position = Vector3i(12, 0, 0),
    direction = Direction.SOUTH,
    whitelist = listOf("corridor_large", "corridor_medium"),  // 只接受走廊
    blacklist = listOf("boss_arena")  // 不能连另一个 Boss 房
)

兼容性检查的完整逻辑:

kotlin 复制代码
fun compatible(
    anchor: Connector,       // 已放置房间的连接器
    target: Connector,       // 待连接房间的连接器
    targetRoomId: String     // 待连接房间的 ID
): Boolean {
    // 1. 朝向必须相反
    if (!isOpposite(anchor.direction, target.direction)) {
        return false
    }

    // 2. 检查锚点的白名单
    if (anchor.whitelist.isNotEmpty() && targetRoomId !in anchor.whitelist) {
        return false
    }

    // 3. 检查锚点的黑名单
    if (targetRoomId in anchor.blacklist) {
        return false
    }

    // 4. 检查目标的白名单(目标也可能限制谁能连它)
    if (target.whitelist.isNotEmpty() && anchor.roomId !in target.whitelist) {
        return false
    }

    // 5. 检查目标的黑名单
    if (anchor.roomId in target.blacklist) {
        return false
    }

    // 6. 标签匹配(如果都有标签的话)
    if (anchor.tags.isNotEmpty() && target.tags.isNotEmpty()) {
        if (anchor.tags.none { it in target.tags }) {
            return false
        }
    }

    return true
}

读者可能会注意到,检查是双向的——不仅锚点可以限制谁能连上来,待连接的房间也可以限制它能连到谁身上。这种双向约束让规则设计更灵活。

连接成功率:引入随机性

有些房间设计了多个连接器,但我们不希望每个连接器都被用上。比如一个十字路口房间有四个出口,但如果每个出口都接了房间,地牢会变得太密集、太复杂。

怎么让一些连接器"偶尔不工作"?加一个成功率字段:

kotlin 复制代码
data class Connector(
    // ...前面的字段
    val successChance: Double = 1.0  // 0.0 ~ 1.0,默认 100% 成功
)

生成时,即使找到了兼容的连接器配对,也要掷骰子:

kotlin 复制代码
fun tryConnect(anchor: Connector, target: Connector, random: Random): Boolean {
    // 先检查兼容性
    if (!compatible(anchor, target)) return false

    // 掷骰子
    if (random.nextDouble() > anchor.successChance) {
        return false  // 这次不连了
    }

    // 目标连接器也有自己的成功率
    if (random.nextDouble() > target.successChance) {
        return false
    }

    return true
}

把 successChance 设成 0.7 意味着这个连接器有 70% 的概率被使用,30% 的概率保持封闭(变成死胡同或墙)。

这个机制可以用来控制地牢的"稀疏程度"。全部设成 1.0,地牢会很密集;降低一些连接器的成功率,地牢会更开阔,有更多死胡同。

房间的完整定义

把前面的内容整合起来,一个房间的完整数据结构:

kotlin 复制代码
data class RoomDef(
    // 基本信息
    val id: String,                     // 唯一标识,如 "entrance_hall"
    val name: String,                   // 显示名称,如 "入口大厅"
    val type: RoomType,                 // 类型:ROOM, CORRIDOR, START, END, SPECIAL
    val structureFile: String,          // 结构文件路径

    // 几何信息
    val size: Vector3i,                 // 尺寸 (x, y, z)
    val origin: Vector3i = Vector3i(0, 0, 0),  // 旋转中心点

    // 连接信息
    val connectors: List<ConnectorDef>, // 连接器列表

    // 生成规则
    val weight: Int = 1,                // 权重,影响被选中的概率
    val occurrences: OccurrenceRange = OccurrenceRange(),  // 出现次数限制
    val depth: OccurrenceRange = OccurrenceRange()         // 深度限制
)

data class ConnectorDef(
    val id: String,
    val position: Vector3i,
    val direction: Direction,
    val tags: List<String> = emptyList(),
    val whitelist: List<String> = emptyList(),
    val blacklist: List<String> = emptyList(),
    val successChance: Double = 1.0
)

data class OccurrenceRange(
    val min: Int = 0,   // 至少生成几个
    val max: Int = 0    // 最多生成几个,0 表示不限
)

enum class RoomType {
    ROOM,      // 普通房间
    CORRIDOR,  // 走廊
    START,     // 起始房间
    END,       // 终点房间
    SPECIAL    // 特殊房间(宝箱、商店等)
}

几个字段需要解释:

type(房间类型)

不同类型的房间在生成逻辑中有特殊待遇:

  • START:起始房间,必须是第一个被放置的,且深度为 0
  • END:终点房间,通常放在最深处
  • CORRIDOR:走廊,在房间选择时有特殊逻辑(避免连续放走廊或连续放房间)
  • SPECIAL:特殊房间,生成时会优先保证被选中

origin(旋转中心)

房间可能需要旋转,旋转是绕着某个点进行的。默认是 (0, 0, 0),即房间的角落。但有些房间绕中心旋转更自然(比如圆形竞技场),这时可以把 origin 设成房间的几何中心。

下一篇会详细讲旋转变换的数学。

weight(权重)

权重越高,这种房间被选中的概率越大。注意,权重不是概率——权重 2 不是说有 200% 的概率被选中,而是说在候选池里,这种房间的"份额"是别人的两倍。

后面讲房间选择策略时会详细展开。

occurrences(出现次数)

  • min = 0, max = 0:不限制(可以不出现,也可以出现任意次)
  • min = 1, max = 1:必须恰好出现一次(比如 Boss 房)
  • min = 2, max = 5:至少 2 次,最多 5 次

depth(深度限制)

深度是指房间距离起点有多远(经过几个房间)。

  • min = 0, max = 3:只能出现在前 4 层(深度 0~3)
  • min = 5, max = 0:只能出现在深度 5 及以后
  • min = 0, max = 0:不限制

这个机制可以实现"商店只在前期出现"、"Boss 只在后期出现"这样的设计意图。

配置文件示例

实际项目中,房间定义通常写成配置文件。以 YAML 为例:

yaml 复制代码
rooms:
  # 起始房间
  - id: entrance_hall
    name: 入口大厅
    type: START
    structure: structures/entrance_hall.nbt
    size: [15, 8, 15]
    origin: [7, 0, 7]
    connectors:
      - id: north_exit
        position: [7, 0, 14]
        direction: NORTH
        tags: [large]
      - id: east_exit
        position: [14, 0, 7]
        direction: EAST
        tags: [medium]
        successChance: 0.6

  # 走廊
  - id: stone_corridor
    name: 石质走廊
    type: CORRIDOR
    structure: structures/stone_corridor.nbt
    size: [5, 4, 12]
    origin: [2, 0, 6]
    weight: 3
    occurrences:
      min: 2
      max: 8
    connectors:
      - id: south_entry
        position: [2, 0, 0]
        direction: SOUTH
        tags: [medium, small]
      - id: north_entry
        position: [2, 0, 11]
        direction: NORTH
        tags: [medium, small]

  # Boss 房
  - id: boss_arena
    name: Boss 竞技场
    type: END
    structure: structures/boss_arena.nbt
    size: [25, 12, 25]
    origin: [12, 0, 12]
    occurrences:
      min: 1
      max: 1
    depth:
      min: 5
    connectors:
      - id: entrance
        position: [12, 0, 0]
        direction: SOUTH
        tags: [large]
        whitelist: [stone_corridor, grand_corridor]
        blacklist: [boss_arena]

  # 宝箱房
  - id: treasure_room
    name: 宝箱房
    type: SPECIAL
    structure: structures/treasure_room.nbt
    size: [9, 6, 9]
    origin: [4, 0, 4]
    weight: 2
    occurrences:
      max: 2
    connectors:
      - id: entrance
        position: [4, 0, 0]
        direction: SOUTH
        tags: [small, medium]

读者可以注意几个设计要点:

  1. Boss 房的 depth.min = 5:保证它不会出现在前 5 层
  2. Boss 房的 blacklist 包含自己:防止两个 Boss 房相连
  3. 走廊的 weight = 3:走廊被选中的概率是普通房间的 3 倍,让地牢更有"通道感"
  4. 宝箱房的 occurrences.max = 2:最多两个,稀缺才有价值

小结

这一篇讲了房间定义的核心概念:

  1. 连接器是房间对外暴露的"接口",包含位置和朝向
  2. 两个连接器对接时,朝向必须相反
  3. 标签系统实现软性的匹配规则
  4. 白名单/黑名单实现硬性的约束
  5. successChance 控制连接的随机性
  6. type、weight、occurrences、depth 控制房间的生成规则

有了这套数据模型,接下来的问题是:怎么把两个房间真正"拼"到一起?这涉及坐标变换、旋转镜像、碰撞检测——下一篇的主题。

让房间"拼"起来

方向匹配、坐标变换、碰撞检测的实现

放置房间的本质问题

上一篇讲了连接器的概念:每个房间有若干"插口",房间通过插口对接。这一篇要解决的问题是:给定一个已放置房间的连接器(锚点),怎么把新房间正确地接上去?

这个问题分三步:

  1. 找到兼容的连接器配对:新房间的哪个连接器可以对上锚点?
  2. 计算新房间的放置位置:新房间应该放在哪里,才能让两个连接器严丝合缝?
  3. 检测碰撞:新房间会不会和已有房间重叠?

如果只考虑"不旋转、不镜像"的简单情况,这三步都不难。但实际上,房间通常需要旋转(甚至镜像)才能接上去。

为什么需要旋转

考虑一个具体场景:

锚点连接器朝向 EAST(东),意味着它期望东边有房间接入。新房间有一个朝向 SOUTH(南)的连接器。

按照"朝向必须相反"的规则,EAST 的对面是 WEST,而新房间的连接器朝向 SOUTH,不匹配。

但如果把新房间顺时针旋转 90°,原来朝 SOUTH 的连接器就会变成朝 WEST,此时就能和朝 EAST 的锚点对接了。

复制代码
旋转前:                     旋转后(顺时针 90°):

    N                              N
    │                              │
 W──┼──E  连接器朝 S            W──┼──E  连接器朝 W
    │          ↓                   │          ←
    S                              S

所以,放置房间时需要遍历所有可能的旋转角度(0°、90°、180°、270°),找到一个能让连接器方向匹配的旋转。

方向的旋转变换

Minecraft 世界是离散的,旋转只能是 90° 的整数倍。顺时针旋转 90° 时,各个方向的变换关系是:

原方向 旋转后方向
NORTH EAST
EAST SOUTH
SOUTH WEST
WEST NORTH
UP/DOWN 不变

这个映射很简单,用 when 表达式就能实现。旋转 180° 就是调用两次 90° 旋转,270° 就是三次。

坐标的旋转变换

方向变了,连接器的位置也要跟着变。

假设房间尺寸是 (sizeX, sizeY, sizeZ) = (10, 5, 8),有一个连接器在局部坐标 (7, 0, 0)。顺时针旋转 90° 后,这个连接器会跑到哪里?

直观理解:顺时针旋转 90° 相当于"原来的 Z 轴变成新的 X 轴,原来的 X 轴反向变成新的 Z 轴"。

核心公式(顺时针旋转 90°):

复制代码
x' = z
y' = y(高度不变)
z' = sizeX - 1 - x

为什么 z' = sizeX - 1 - x 而不是 -x?因为旋转后房间的尺寸也变了——原来的 sizeX 变成了新的 sizeZ。为了保持坐标在房间范围内(非负),需要这样调整。

四种旋转的变换公式:

旋转角度 变换公式
0° (x, y, z)
90° (z, y, sizeX - 1 - x)
180° (sizeX - 1 - x, y, sizeZ - 1 - z)
270° (sizeZ - 1 - z, y, x)

镜像变换

除了旋转,有时还需要镜像。比如一个"L 形"房间,镜像后可以变成"反 L 形",增加了布局的多样性。

镜像通常沿 X 轴进行(左右翻转):

  • 坐标变换:x' = sizeX - 1 - x,Y 和 Z 不变
  • 方向变换:EAST ↔ WEST,其他不变

重要:如果同时需要旋转和镜像,顺序是先镜像、后旋转。

计算房间放置位置

现在可以回到最初的问题:给定锚点连接器,把新房间接上去。

假设:

  • 锚点连接器在世界坐标 anchorWorldPos,朝向 anchorDir
  • 新房间有一个连接器,局部坐标 connectorLocalPos
  • 经过变换后,连接器朝向变成了 anchorDir 的反方向

推导过程:

  1. 计算变换后连接器的局部坐标 transformedPos
  2. 连接器需要放在锚点的"对面",即 targetPos = anchorWorldPos + directionVector(anchorDir)
  3. 房间原点 = targetPos - transformedPos

一个直观的例子

假设我们有:

  • 锚点在世界坐标 (100, 64, 200),朝向 EAST
  • 新房间尺寸 (10, 5, 8),有一个连接器在局部坐标 (0, 0, 4),朝向 WEST

因为 EAST 和 WEST 是相反的,这个连接器不需要旋转就能匹配。

计算放置位置:

  1. 变换后连接器位置 = (0, 0, 4)(没旋转,不变)
  2. 目标位置 = (100, 64, 200) + (1, 0, 0) = (101, 64, 200)
  3. 房间原点 = (101, 64, 200) - (0, 0, 4) = (101, 64, 196)

验证:房间原点在 (101, 64, 196),连接器在房间内的 (0, 0, 4),所以连接器的世界坐标是 (101, 64, 200)。这刚好在锚点东边一格,符合预期。

碰撞检测

算出房间位置后,还要检查会不会和已放置的房间重叠。

最简单的方法是用轴对齐包围盒(AABB) 检测。每个房间占据一个长方体区域,检查两个长方体是否相交:

kotlin 复制代码
data class AABB(val min: Vector3i, val max: Vector3i) {
    fun intersects(other: AABB): Boolean {
        // 在任意轴上完全分离则不相交
        if (max.x < other.min.x || min.x > other.max.x) return false
        if (max.y < other.min.y || min.y > other.max.y) return false
        if (max.z < other.min.z || min.z > other.max.z) return false
        return true
    }
}

注意旋转后包围盒也变了。原来 (10, 5, 8) 的房间,旋转 90° 后变成 (8, 5, 10)。计算时需要把房间的 8 个角点都变换一下,然后取最小/最大值得到新的包围盒。

完整的放置流程

把前面的内容串起来,尝试在锚点处放置房间的逻辑是:

  1. 遍历所有可能的 旋转×镜像×连接器 组合
  2. 对每个组合:
    • 计算变换后的连接器方向,检查是否和锚点相反
    • 检查标签、白名单、黑名单等兼容性
    • 计算房间放置位置
    • 计算包围盒,检测碰撞
  3. 找到第一个不碰撞的方案就返回成功
  4. 都不行就返回失败

四种布局算法,四种风格

蔓延式、主干分支式、网格迷宫式、随机游走式的原理与实现

布局引擎在做什么

前几篇讲了怎么定义房间、怎么把两个房间接起来。但"按什么顺序、什么规则接"——这是布局引擎的职责。

布局引擎的输入是:

  • 房间池:有哪些房间可用
  • 生成配置:要生成多少房间、有什么约束

输出是:

  • 房间放置列表:每个房间放在哪、旋转多少度
  • 连接关系列表:哪些房间之间有通道

不同的布局算法会产生风格迥异的地牢。笔者实现了四种:

算法 特点 适合场景
蔓延式(Minecrafty) 像藤蔓一样向外扩展 自由探索型地牢
主干分支式(Branching) 先拉主干,再长分支 有流程感的关卡
网格迷宫式(GridMaze) 先生成迷宫拓扑,再填房间 经典迷宫
随机游走式(RandomWalk) 随机方向扩展 洞穴、矿井

下面逐一展开。

蔓延式布局(Minecrafty)

核心思想

从起始房间开始,用 BFS(广度优先搜索) 的方式向外扩展。每次从队列里取出一个房间,遍历它的未使用连接器,尝试在每个连接器上接新房间。新房间再入队,继续扩展。

这种算法生成的地牢像藤蔓或树根,从起点向四面八方蔓延,没有明显的"主线"。

算法流程

复制代码
1. 放置起始房间,加入队列
2. while 队列不空 且 房间数未达标:
   a. 取出队首房间
   b. 遍历其未使用的连接器
   c. 对每个连接器,尝试放置新房间
   d. 成功则标记连接器为已用,新房间入队
3. 返回布局结果

特点

优点:

  • 实现简单,容易理解
  • 生成结果自然,没有刻意的痕迹
  • 天然支持各种房间尺寸混搭

缺点:

  • 没有"主线"的概念,不太适合有明确流程的关卡
  • 可能生成很"散"的布局

生成效果示意

复制代码
        ┌───┐
        │ R │
    ┌───┼───┼───┐
    │ R │ R │ R │
┌───┼───┼───┼───┘
│ R │ S │ R │
└───┼───┼───┘
    │ R │
    └───┘

S = 起始房间,从 S 向四周蔓延

主干分支式布局(Branching)

核心思想

分两个阶段:

  1. 生成主干:从起始房间出发,尽量往一个方向走,形成一条"主路"
  2. 生成分支:从主干上随机挑几个房间,各自向外延伸若干步

这种布局有清晰的主线,玩家可以沿主干一路推进,分支提供额外的探索内容。

关键参数

  • trunkMin/trunkMax:主干房间数范围
  • branchCount:分支条数
  • branchDepth:每条分支的长度
  • straightness:直走倾向(0~1),越高主干越直

算法流程

复制代码
阶段一:生成主干
1. 放置起始房间
2. 循环直到达到主干目标长度:
   a. 选择一个连接器(优先选和上一步方向相同的)
   b. 放置新房间
   c. 记录方向,继续

阶段二:生成分支
1. 从主干随机选若干房间作为分支起点
2. 对每个起点,用类似的方式延伸若干步

straightness 参数的效果

straightness = 1.0:主干几乎是直线

复制代码
S ─ T ─ T ─ T ─ T ─ T ─ E

straightness = 0.3:主干会拐弯

复制代码
S ─ T ─ T
        │
        T ─ T
            │
            T ─ E

生成效果示意

复制代码
                        ┌───┐
                        │ B │  ← 分支末端
                        └─┬─┘
                          │
┌───┬───┬───┬───┬───┬───┬─┴─┬───┐
│ S │ T │ T │ T │ T │ T │ T │ E │  ← 主干
└───┴───┴─┬─┴───┴───┴───┴───┴───┘
          │
        ┌─┴─┐
        │ B │  ← 另一条分支
        └───┘

S: 起始房间 E:结束房间

网格迷宫式布局(GridMaze)

核心思想

这种方法分两步:

  1. 在抽象网格上生成迷宫:用经典的迷宫生成算法(DFS、Prim 等)在一个 N×M 的网格上生成迷宫拓扑
  2. 把格子替换成实际房间:遍历网格,在每个格子位置放置房间,根据通道关系连接

这种方法的好处是可以复用大量成熟的迷宫算法,而且生成的地牢有经典的"迷宫感"。

算法流程

复制代码
1. 根据目标房间数计算网格尺寸
2. 用迷宫算法生成网格拓扑(哪些格子之间有通道)
3. BFS 遍历网格:
   a. 对每个格子,放置一个房间
   b. 根据迷宫拓扑中的通道关系连接房间
4. 返回布局结果

不同迷宫算法的"性格"

DFS:长走廊、少分岔,像蛇

复制代码
┌─────────────┐
│ ┌─────┐ ┌─┐ │
│ │ ┌─┐ │ │ │ │
│ │ │ └─┘ │ │ │
│ │ └─────┘ │ │
│ └─────────┘ │
└─────────────┘

Prim:短走廊、多分岔,均匀扩散

复制代码
┌─┬───┬─┬───┐
│ │   │ │   │
├─┼─┬─┼─┴─┬─┤
│   │     │ │
├───┼─┬───┼─┤
│   │ │   │ │
└───┴─┴───┴─┘

下一篇会详细讲各种迷宫算法的区别。

随机游走式布局(RandomWalk)

核心思想

从起始位置开始,每一步随机选择一个方向扩展。已占用的位置不能重复占用,走不动了就换个位置继续。

这种算法生成的布局非常不规则,像是有机生长的洞穴或蚁穴。

关键参数

  • boundaryBias:边界偏好(0~1)
    • 接近 1.0:倾向于从"边界"节点扩展,生成更蔓延的形状
    • 接近 0.0:从任意已有节点扩展,生成更紧凑的团块

算法流程

复制代码
1. 放置起始节点,加入边界列表
2. while 节点数未达标:
   a. 根据 boundaryBias 选择扩展起点(边界 or 任意)
   b. 随机选一个未占用的相邻方向
   c. 在那个方向放置新节点
   d. 更新边界列表
3. 把抽象节点替换成实际房间

boundaryBias 的效果

boundaryBias = 0.9:从边缘扩展,形成触手状

复制代码
      ┌───┐
      │   │
  ┌───┼───┘
  │   │
┌─┴─┬─┴─┐
│ S │   │
└───┴───┴───┐
            │
        ┌───┴───┐
        │       │
        └───────┘

boundaryBias = 0.3:更紧凑的团块

复制代码
┌───┬───┬───┐
│   │   │   │
├───┼───┼───┤
│   │ S │   │
├───┼───┼───┤
│   │   │   │
└───┴───┴───┘

如何选择布局算法

没有"最好"的算法,只有"最合适"的算法:

场景 推荐算法
有主线剧情的副本 主干分支式
自由探索的地牢 蔓延式
经典迷宫、解谜关卡 网格迷宫式
洞穴、蚁穴、矿井 随机游走式

也可以组合使用。比如用主干分支式生成主体结构,然后用随机游走式在某些区域生成洞穴。

迷宫算法小百科

DFS、Prim、Kruskal、Sidewinder 等算法的原理与特点

为什么要了解迷宫算法

上一篇提到,网格迷宫式布局会先在抽象网格上生成迷宫拓扑,再把格子替换成房间。迷宫的"形状"直接决定了地牢的体验:

  • 长走廊多还是短走廊多?
  • 死胡同多还是岔路多?
  • 是均匀分布还是有明显的"主路"?

不同的迷宫生成算法会产生截然不同的结果。了解每种算法的特点,才能为你的地牢选择最合适的"性格"。

迷宫的本质:生成树

在讲具体算法之前,先理解一个关键概念:完美迷宫是网格图的一棵生成树。

把迷宫想象成一个网格,每个格子是一个节点,相邻格子之间可能有墙或通道。

"完美迷宫"是指:

  1. 任意两个格子之间恰好有一条路径(没有环路)
  2. 所有格子都是连通的

这正是"树"的定义——连通且无环。所以,生成迷宫就是在网格图上选择一些边,使得这些边构成一棵生成树。不同的选择策略就是不同的迷宫算法。

DFS 生成树

原理

从一个随机格子开始,沿着一个方向一直走,直到走不动了(周围都是已访问的格子),然后回溯到上一步,尝试其他方向。这就是"深度优先"的含义——先走到尽头,再回头。

算法流程

复制代码
1. 从随机位置开始,入栈
2. while 栈不空:
   a. 取栈顶节点
   b. 获取其未访问的邻居(随机打乱)
   c. 如果有邻居:选第一个,打通墙,入栈
   d. 如果没邻居:出栈(回溯)

特点

  • 长走廊:因为总是沿一个方向走到底
  • 少分岔:只有回溯时才会产生分岔
  • "河流"风格:像蜿蜒的河流,主干明显

生成效果

复制代码
┌─────────────────────┐
│ ┌───────────┐ ┌───┐ │
│ │ ┌───────┐ │ │   │ │
│ │ │ ┌───┐ │ │ │ │ │ │
│ │ │ │   │ │ │ │ │ │ │
│ │ │ └─┘ │ │ │ │ │ │ │
│ │ └─────┘ │ └─┘ │ │ │
│ └─────────┴─────┘ │ │
└───────────────────┘ │

很适合做需要"一路深入"的地牢。

随机化 Prim 生成树

原理

维护一个"边界墙"列表。开始时把起点的所有墙加入列表。每次随机选一面墙,如果墙另一边的格子没访问过,就打通这面墙,并把新格子的墙加入列表。

和 DFS 的区别:DFS 总是从当前位置出发,Prim 从所有边界墙中随机选择。这导致 Prim 的扩展更"均匀"。

算法流程

复制代码
1. 从随机位置开始,把其邻居加入边界列表
2. while 边界列表不空:
   a. 随机选一条边界边
   b. 如果目标未访问:打通墙,把新节点的邻居加入边界
   c. 否则跳过

特点

  • 短走廊:因为每次从所有边界中随机选择
  • 多分岔:扩展比较均匀,岔路口多
  • "爆炸"风格:从中心向四周均匀扩散

生成效果

复制代码
┌─┬───┬─┬───┬─┐
│ │   │ │   │ │
├─┼─┬─┴─┼─┬─┴─┤
│   │   │ │   │
├─┬─┼───┼─┴─┬─┤
│ │ │   │   │ │
├─┴─┼─┬─┴───┼─┤
│   │ │     │ │
└───┴─┴─────┴─┘

很适合需要频繁做选择的迷宫。

随机化 Kruskal 生成树

原理

借鉴最小生成树的 Kruskal 算法。一开始,每个格子各自为一个集合。把所有潜在的墙放入列表并打乱。依次取出每面墙,如果墙两边的格子属于不同集合,就打通墙并合并集合。

需要用到并查集优化来高效判断和合并集合。

特点

  • 均匀分布:边是随机选择的,没有从某个点扩散的感觉
  • 中等走廊长度:介于 DFS 和 Prim 之间
  • "公平"风格:每条边被选中的概率相等

和 Prim 的对比

Prim 从一个点向外扩展,有"中心"的感觉。Kruskal 没有中心,边是完全随机选择的。Kruskal 的结果往往更"杂乱"。

Binary Tree 二叉树算法

原理

遍历每个格子,对于每个格子,随机选择"向南"或"向东"(二叉链表)打通墙壁。

之所以叫"二叉树",是因为生成的迷宫可以看作一棵每个节点最多有两个子节点的树。

生成效果

复制代码
┌───────────────────┐
│                   │
├───┬───┬───┬───┬───┤
│   │   │   │   │   │
├───┼───┼───┼───┼───┤
│   │   │   │   │   │
└───┴───┴───┴───┴───┘

这种偏向性对于某些游戏设计来说是缺点,但如果你想要有偏向性(比如引导玩家向某个方向走),反而是优点。

Sidewinder 侧风算法

原理

一行一行地处理。在每一行内,维护一个"当前组"。对于每个格子:

  • 掷硬币决定是否"结束当前组"
  • 如果结束,从当前组里随机选一个格子向南打通墙,然后开始新组
  • 如果不结束,向东打通墙

第一行特殊处理:永远向东打通墙。

特点

  • 水平偏好:走廊倾向于水平延伸
  • 第一行是通廊:整个第一行是打通的
  • 比 Binary Tree 更均匀:没有那么明显的对角线偏好

生成效果

复制代码
┌───────────────────┐
│                   │  ← 第一行完全打通
├───────┬───────┬───┤
│       │       │   │
├───┬───┴───┬───┴───┤
│   │       │       │
└───┴───────┴───────┘

适合需要"横向探索"感的地牢。

Growing Tree 生长树算法

原理

维护一个"活跃节点"列表。每次从列表中选一个节点,把它的一个未访问邻居加入迷宫。如果某个节点没有未访问邻居了,就移除。

关键在于怎么从列表中选节点:

  • 总是选最后一个(最新的)→ 等价于 DFS
  • 总是选第一个(最老的)→ 等价于 BFS
  • 随机选 → 等价于 Prim
  • 混合策略 → 可调节的特性

特点

  • 可调节:通过改变选择策略,可以得到从 DFS 到 Prim 之间的任何特性
  • 实验友好:调参看效果很方便

选择策略的影响

策略 效果
总是选最新 ≈ DFS,长走廊
总是选最老 ≈ BFS,短走廊
总是随机 ≈ Prim,短走廊、多分岔
50% 最新 + 50% 随机 中等走廊长度

算法对比总结

算法 走廊长度 死胡同 方向偏好 复杂度 适合场景
DFS 长 少 无 O(n) 蜿蜒的河流式地牢
Prim 短 多 无 O(n log n) 频繁做选择的迷宫
Kruskal 中等 中等 无 O(n log n) 无明显特征的迷宫
Binary Tree 中等 多 强(东南) O(n) 引导向特定方向
Sidewinder 长(水平) 中等 水平 O(n) 横向探索地牢
Growing Tree 可调 可调 无 O(n) 需要精细调控时

不是随便选房间的

权重系统、深度约束、出现次数控制——让生成结果符合设计意图

房间选择的挑战

前面几篇讲了怎么把房间拼起来、怎么组织布局结构。但有一个问题被略过了:在某个位置放房间时,从房间池里选哪一个?

如果完全随机选择,会出现各种问题:

  • Boss 房可能出现在第一层
  • 同一种房间可能出现 10 次
  • 商店可能在地牢最深处
  • 所有房间都是走廊

程序化生成的核心矛盾是:随机性带来变化,但可控性保证设计意图。房间选择策略就是在这两者之间找平衡。

笔者实现了四种控制机制:权重、深度约束、出现次数限制、类型偏好。

权重系统

基本概念

每种房间有一个"权重"值,权重越高,被选中的概率越大。

但注意,权重不是概率。如果房间 A 的权重是 3,房间 B 的权重是 1,不是说 A 有 300% 的概率被选中。而是说 A 的"份额"是 B 的三倍——A 有 75%(3/4)的概率被选中,B 有 25%(1/4)。

加权随机选择

算法很简单:

  1. 计算所有候选的权重总和
  2. 生成一个 0 到总和之间的随机数
  3. 依次累加权重,当累加值超过随机数时,选中当前候选

权重提升机制

如果某种房间设置了"最少出现 N 次",但当前数量还不够,它的有效权重会被提升。

举个例子:宝箱房权重是 2,要求至少出现 2 次。如果还没出现过,有效权重 = 2 × 3 = 6(boost 系数 = 3)。

这样可以保证"必须出现"的房间有更高概率被选中。

深度约束

什么是深度

"深度"是指房间距离起点有多远,以房间数量计。起始房间的深度是 0,直接连着它的深度是 1,以此类推。

复制代码
[起始] → [走廊] → [大厅] → [宝箱] → [Boss]
  0        1        2        3        4

为什么需要深度约束

关卡设计中,不同类型的房间应该出现在不同的阶段:

  • 商店应该在前期,让玩家有机会购买装备
  • Boss 房应该在后期,作为最终挑战
  • 休息点可以出现在中期,给玩家喘息机会

深度约束就是告诉生成器"这种房间只能出现在深度 X 到 Y 之间"。

配置示例

yaml 复制代码
rooms:
  - id: shop
    depth:
      min: 1
      max: 3   # 只在深度 1~3 出现

  - id: boss_arena
    depth:
      min: 5   # 只在深度 5 及以后出现
      max: 0   # 0 表示无上限

出现次数限制

每种房间可以设置出现次数的上下限:

  • min:至少出现几次。如果没达到,权重会被提升。
  • max:最多出现几次。达到后,这种房间不再参与选择。

特殊房间的强制保证

有些房间是"必须出现"的,比如 Boss 房。如果布局算法跑完了,Boss 房还没出现怎么办?

策略一:预选机制

在生成开始前,先确定要用哪些房间。预选时强制包含必须出现的房间。

策略二:替换机制

如果必须出现的房间没被放置,从已放置的房间中替换一个普通房间。

类型偏好:走廊的特殊处理

问题:走廊连走廊

如果不做特殊处理,可能出现这种情况:

复制代码
[房间] → [走廊] → [走廊] → [走廊] → [走廊] → [房间]

四个走廊连在一起,玩家跑很久都遇不到有内容的房间,体验很差。

解决方案:走廊偏好机制

当从走廊出发时,优先选择非走廊房间。当从非走廊房间出发时,优先选择走廊。

这样生成的布局会呈现"房间-走廊-房间-走廊"的节奏,更加舒适。

完整的房间选择流程

把前面的机制串起来:

复制代码
1. 获取所有候选房间
2. 排除起始房间(深度 > 0 时)
3. 应用深度过滤
4. 应用出现次数过滤
5. 应用连接器白名单/黑名单
6. 应用走廊偏好
7. 检查是否需要优先放置特殊房间
8. 加权随机选择

加点环路

打破树形结构,让地牢更有探索感

树形结构的问题

前面讲的布局算法——无论是蔓延式、主干分支式还是迷宫式——生成的都是树形结构。树形结构有一个特点:任意两个房间之间只有一条路径。

这意味着:

  • 玩家不能"绕路"
  • 错过的房间必须原路返回
  • 没有捷径、没有惊喜
  • 探索感较弱

现实中的地下城往往不是树形的。可能有暗门通向之前去过的地方,可能有多条路线通往同一个目的地。这种"环路"让探索更有乐趣。

什么是环路

环路是指地牢中存在多条路径连接同一对房间。换句话说,布局图中有"环"。

复制代码
没有环路(树形):

A ─ B ─ C
    │
    D ─ E

A 到 E 只有一条路:A→B→D→E

有环路:

A ─ B ─ C
│   │
D ─ E

A 到 E 有两条路:A→B→D→E 或 A→D→E

添加环路的时机

环路应该在基础布局生成之后添加。原因:

  1. 基础布局算法生成的是生成树,保证连通性
  2. 在此基础上添加额外的边,不会破坏连通性,只会增加路径选择

寻找可以添加环路的位置

思路:遍历所有房间的未使用连接器,看有没有刚好"对上"的——位置相邻、方向相反、但目前没有连接。

具体步骤:

  1. 建立 位置→连接器 的映射
  2. 遍历所有未使用的连接器
  3. 计算"对面"的位置(连接器位置 + 方向向量)
  4. 看那个位置有没有方向相反、也未使用的连接器
  5. 如果有,这就是一个可以添加环路的候选

添加环路连接

找到候选位置后,根据概率决定是否添加:

复制代码
1. 找到所有候选位置,打乱顺序
2. for 每个候选:
   a. 如果已添加数量达到上限,停止
   b. 掷骰子,如果 > loopChance,跳过
   c. 检查兼容性(白名单、黑名单)
   d. 添加连接,标记连接器为已使用

参数调节

两个关键参数:

loopChance(环路概率)

每个候选位置有多大概率被添加环路。

  • 0.05 ~ 0.10:少量环路,基本保持树形结构
  • 0.10 ~ 0.20:适中,有一些捷径但不会迷路
  • 0.20 ~ 0.50:大量环路,迷宫感强烈

maxLoops(最大环路数)

最多添加几条额外连接。可以设为 0 表示不限制(由概率决定)。

笔者的建议:对于 10~15 个房间的地牢,1~3 个环路比较合适。

环路的游戏设计意义

环路不只是技术实现问题,它影响玩家体验:

捷径

玩家打通一条密道后可以快速回到之前的区域。这在需要多次往返的关卡设计中很有用。

包抄

在有怪物的地牢里,环路让玩家可以绕后偷袭,增加战术深度。

迷路

环路太多会让玩家分不清方向。如果这是你想要的(恐怖游戏?),环路就是好东西;如果不是,要控制数量。

惊喜

"咦,这条路通到这里来了!"这种发现感是线性地牢给不了的。

特殊房间怎么保证放进去

起点、终点、Boss 房的放置逻辑

特殊房间的特殊在哪

在房间定义中,有几种类型是"特殊"的:

  • START(起点):玩家进入地牢的第一个房间
  • END(终点):地牢的最终目标,通常是 Boss 房或出口
  • SPECIAL:宝箱房、商店、神殿等有特殊功能的房间

这些房间之所以特殊,是因为它们有硬性要求:

  • 起点必须是第一个放置的房间
  • 终点必须存在且位于合理位置
  • 特殊房间必须出现(设计师说要有宝箱,就得有宝箱)

普通房间可以随机选择、可多可少。特殊房间不行——它们是地牢设计的"锚点"。

起点房间的处理

起点的处理相对简单:它永远是第一个被放置的房间。

流程:

  1. 从起点候选池中随机选择一个
  2. 放在原点 (0, 0, 0),不旋转、不镜像
  3. 起点房间的深度永远是 0

终点房间的处理

终点的处理复杂一些。我们希望终点:

  1. 位于地牢的"尽头"——最好是最深处
  2. 不要出现在前几层
  3. 如果配置了特定的终点房间,必须使用它

策略一:在生成过程中放置

在房间选择时,当接近目标数量时,优先选择终点房间。

具体做法:

  • 如果 placedCount >= targetCount - 1,允许放置终点
  • 如果允许且还没放终点,优先从终点候选中选择

策略二:后处理——选择最远叶子

如果生成完毕后发现没有终点房间,可以把"最远的叶子节点"改造成终点。

"叶子节点"是指只有一个连接的房间(死胡同)。"最远"是指深度最大。

流程:

  1. 检查是否已有终点房间,有则跳过
  2. 用 BFS 计算每个房间的深度
  3. 找到所有叶子节点(连接数 ≤ 1)
  4. 选择深度最大的叶子,标记为终点

特殊房间(SPECIAL)的保证

特殊房间(宝箱、商店等)的处理策略是预选保证 + 权重提升。

预选阶段

在生成开始前,先确定要放置哪些房间。这时强制包含特殊房间:

复制代码
1. 强制包含 SPECIAL 类型的房间
2. 确保有起点和终点
3. 补充其他房间到目标数量

生成阶段的优先处理

在选择下一个房间时,如果有特殊房间还没放,优先放特殊房间。

终点放置位置的优化

主干末端策略(用于 Branching 布局)

在主干分支布局中,可以把终点放在主干的末端,而不是随便哪个叶子。这样保证玩家沿主干走到底就能到达终点。

分支末端策略

也可以让终点出现在某条分支的末端,增加探索感。需要玩家找到正确的分支才能到达终点。

配置中可以指定:

yaml 复制代码
branching:
  trunkMin: 6
  trunkMax: 10
  endRooms: [boss_arena]  # 这些房间放在分支末端

调试:检查特殊房间是否正确放置

生成完成后,做一个验证检查:

  1. 检查起点:有且只有一个
  2. 检查终点:至少有一个
  3. 检查特殊房间:minOccurrences > 0 的房间是否都放置了

如果验证失败,输出警告或重新生成。

把布局变成真正的方块

从布局数据到 Minecraft 世界的落地实现

布局结果是什么

前面八篇讲的都是"怎么规划布局"。布局引擎跑完后,输出的是一份数据:

kotlin 复制代码
data class LayoutResult(
    val placements: List<RoomPlacement>,     // 房间放置列表
    val connections: List<RoomConnection>    // 房间连接列表
)

data class RoomPlacement(
    val roomId: String,       // 房间定义 ID
    val placementId: String,  // 放置实例 ID
    val origin: Vector3i,     // 世界坐标原点
    val rotation: Int,        // 旋转角度
    val mirror: Boolean       // 是否镜像
)

这份数据告诉我们"哪个房间放在哪、旋转多少度",但还没有真正的方块。

这一篇讲怎么把这份"图纸"变成 Minecraft 里实实在在的建筑。

结构数据的存储格式

每个房间对应一个"结构文件",里面存储了房间的方块信息。常见格式有:

Minecraft NBT 结构

原版 Minecraft 的结构方块可以保存为 .nbt 文件。这是最通用的格式,包含:

  • 方块调色板(palette)
  • 方块列表(blocks)
  • 尺寸信息(size)

WorldEdit Schematic

WorldEdit 的 .schem 或 .schematic 格式在建筑师群体中更流行,因为 WorldEdit 更方便选区和保存。

自定义格式

如果有特殊需求(比如需要额外的元数据),也可以自定义格式。

坐标变换的再次应用

放置结构时,需要对每个方块应用旋转和镜像变换。

第三篇讲过的变换公式在这里再次派上用场:

  • 方块位置需要变换
  • 方块状态(朝向、连接状态等)也需要变换

比如一个朝北的门,旋转 90° 后应该朝东。Minecraft 提供了 BlockState.rotate() 和 BlockState.mirror() 方法来处理这个。

基础放置实现

最简单的放置方式——直接遍历方块,逐个设置:

复制代码
for 每个方块 in 结构:
    1. 变换坐标
    2. 计算世界坐标 = origin + 变换后坐标
    3. 变换方块状态
    4. 放置方块

这种方式简单直接,但有性能问题——大量方块操作会卡主线程。

性能优化

问题分析

一个中等大小的房间可能有几千个方块。一个地牢有十几个房间,就是几万个方块操作。

在 Minecraft 服务端,方块操作涉及:

  1. 区块加载
  2. 方块更新事件
  3. 光照重算
  4. 红石更新
  5. 客户端同步

每个方块都触发这些流程,性能会非常差。

方案一:批量操作(太复杂)

先收集所有方块更改,然后批量应用,禁用物理和光照更新。完成后统一重算光照。

方案二:WorldEdit / FAWE

WorldEdit(特别是其异步分支 FAWE)专门优化了大量方块操作。如果服务器装了 WorldEdit,可以借用它的能力。

FAWE 的优势是异步执行,不会卡主线程。但要注意它的异步特性——放置完成的时机和主线程不同步。

运行时数据构建

方块放置完成后,还需要建立一些运行时数据供游戏逻辑使用:

kotlin 复制代码
data class ProceduralRuntimeGraph(
    val instanceId: UUID,                     // 副本实例 ID
    val dungeonId: String,                    // 地牢定义 ID
    val worldName: String,                    // 世界名称
    val rooms: List<RuntimeRoomNode>,         // 房间节点
    val connections: List<RuntimeConnection>  // 连接关系
)

这份数据用于:

  • 判断玩家当前在哪个房间
  • 触发房间进入/离开事件
  • 管理门的开关状态
  • 小地图显示

完整的生成流程

把前面的内容串起来:

复制代码
1. 规划布局
   - 创建 Planner,准备生成请求

2. 生成布局
   - 选择布局引擎(Minecrafty/Branching/GridMaze/RandomWalk)
   - 执行生成,得到 LayoutResult

3. 放置结构
   - 遍历 placements
   - 加载每个房间的结构文件
   - 应用变换,放置方块

4. 构建运行时数据
   - 计算每个房间的世界坐标包围盒
   - 构建连接器的世界坐标
   - 返回 RuntimeGraph

收尾与展望

一些补充话题和后续可以探索的方向

回顾

这个系列从"为什么需要程序化生成"讲起,覆盖了:

  1. 房间与连接器的数据模型
  2. 坐标变换与碰撞检测
  3. 四种布局算法
  4. 六种迷宫生成算法
  5. 房间选择策略
  6. 环路添加
  7. 特殊房间处理
  8. 结构放置

到这里,一个能用的程序化地牢生成系统已经完整了。但还有一些话题没有展开,这里做个补充。

锁钥系统

基本概念

在一些经典地牢游戏中,玩家需要找到钥匙才能打开对应的门。这种"锁钥系统"增加了探索的深度——不再是一路直冲终点,而是要四处搜寻。

在程序化生成中,锁钥系统的核心是依赖关系:

复制代码
获得银钥匙 → 打开银门 → 获得金钥匙 → 打开金门 → Boss

实现思路

在连接器上定义门类型:

kotlin 复制代码
data class DoorDef(
    val type: String,       // 门类型:NORMAL, LOCKED, ONE_WAY
    val keyItemId: String?  // 如果是锁门,需要什么钥匙
)

data class ConnectorDef(
    // ... 其他字段
    val door: DoorDef? = null
)

生成时,在某些连接上放置锁门,并确保钥匙在门的"前面"(即不穿过这扇门就能拿到)。

这个逻辑比较复杂,可以简化为:

  1. 标记某些房间为"钥匙房",某些连接为"锁门"
  2. 生成时确保钥匙房的深度小于对应锁门的深度

运行时管理

门的状态需要在运行时追踪:

kotlin 复制代码
class DoorManager {
    private val doorStates = mutableMapOf<String, DoorState>()

    fun tryOpen(playerId: UUID, doorId: String): Boolean {
        val door = doorStates[doorId] ?: return false

        if (door.isLocked) {
            // 检查玩家是否有钥匙
            if (playerHasKey(playerId, door.keyItemId)) {
                door.isLocked = false
                consumeKey(playerId, door.keyItemId)
                return true
            }
            return false
        }

        return true
    }
}

调试技巧

程序化生成的调试比普通代码更难——每次运行结果都不一样。这里分享一些技巧。

固定随机种子

最重要的调试手段。给生成器传入固定的种子,保证每次生成结果相同。

kotlin 复制代码
val config = ProceduralDef(
    seed = 12345L,  // 固定种子
    // ...
)

发现 bug 后,记下当时的种子,就能稳定复现。

打印生成日志

记录每一步的决策:

kotlin 复制代码
fun pickRoom(...) {
    log.debug("深度 $depth,候选房间: ${candidates.map { it.id }}")
    val chosen = pickWeightedRoom(candidates, ...)
    log.debug("选择: ${chosen?.id}")
    return chosen
}

可视化布局

把布局画出来,比盯着数字直观得多。一个简单的 ASCII 可视化:

kotlin 复制代码
fun visualizeLayout(layout: LayoutResult) {
    // 找到所有房间的坐标范围
    val minX = layout.placements.minOf { it.origin.x }
    val maxX = layout.placements.maxOf { it.origin.x + roomSize(it).x }
    // ... 类似处理 Z

    // 画一个 2D 俯视图
    for (z in minZ..maxZ) {
        for (x in minX..maxX) {
            val room = findRoomAt(layout, x, z)
            print(if (room != null) roomSymbol(room) else ".")
        }
        println()
    }
}

游戏内标记

用粒子或方块标记连接器位置,检查是否正确:

kotlin 复制代码
fun debugDrawConnectors(room: RuntimeRoomNode) {
    room.connectors.forEach { connector ->
        // 在连接器位置生成粒子
        world.spawnParticle(
            Particle.FLAME,
            connector.worldPos.x + 0.5,
            connector.worldPos.y + 0.5,
            connector.worldPos.z + 0.5,
            10
        )
    }
}

踩过的坑

生成的房间太少

可能原因:

  1. 碰撞太多:房间尺寸大,放不下。尝试缩小房间或增大生成区域。
  2. 连接器配置问题:方向、标签不匹配。检查连接器定义。
  3. 约束太严格:深度/出现次数限制太死。放宽约束。

某种房间从不出现

可能原因:

  1. 权重太低:其他房间权重太高,抢走了份额。
  2. 深度不匹配:房间的 depth 范围和实际生成的深度不重叠。
  3. 白名单/黑名单冲突:被连接器过滤掉了。

Boss 房出现在前面

可能原因:

  1. 没设置 depth.min:给 Boss 房加上最小深度限制。
  2. 预选逻辑问题:检查是否正确保证了终点房间放在后面。

房间重叠

这是 bug,检查:

  1. 碰撞检测逻辑:AABB 计算是否正确。
  2. 坐标变换:旋转后的包围盒是否正确计算。

扩展方向

这套系统还可以往很多方向扩展:

垂直地牢

目前的连接器只有水平方向(东西南北)。加入 UP/DOWN 方向,就能做多层地牢。

kotlin 复制代码
enum class Direction {
    NORTH, SOUTH, EAST, WEST,
    UP,    // 向上通道(楼梯、电梯)
    DOWN   // 向下通道
}

需要注意的是,垂直连接的"对齐"逻辑和水平不太一样。

多主题混合

一个地牢可以有多个"区域",每个区域用不同的房间池。比如:

  • 区域 1:石砖风格(深度 0~3)
  • 区域 2:地狱岩风格(深度 4~7)
  • 区域 3:末地风格(深度 8+)

实现上,可以根据深度切换房间池:

kotlin 复制代码
fun selectRoomPool(depth: Int): List<RoomDef> {
    return when {
        depth <= 3 -> stoneBrickRooms
        depth <= 7 -> netherRooms
        else -> endRooms
    }
}

动态难度

根据玩家表现调整生成参数。比如:

  • 玩家死亡次数多 → 减少怪物房
  • 玩家通关太快 → 增加迷宫复杂度

与其他系统联动

地牢生成只是开始。后续可以联动:

  • 怪物生成:根据房间类型和深度决定刷什么怪
  • 战利品系统:根据房间类型决定掉落
  • 任务系统:在特定房间触发任务事件

在线生成

如果生成太慢,可以考虑:

  • 预生成一批布局缓存起来
  • 使用后台线程生成
  • 流式生成(玩家还没到的区域慢慢生成)

这套方案的局限

这套方案也有局限:

  1. 不适合开放世界:适合封闭的副本地牢,不适合无边界的世界
  2. 房间尺寸差异大时不好看:一个 5×5 的房间和一个 50×50 的房间连在一起会很奇怪
  3. 没有自动生成走廊:走廊也是预制的房间,不是自动挖出来的
  4. 对美术资源依赖大:房间好不好看取决于预制结构的质量(现在知道为什么没图片了吧)

结语

程序化生成是一个深坑,这个系列只是入门。希望这个系列对读者有帮助。如果你真的做出了一个程序化地牢,欢迎分享。

游客

全部评论 (0)

暂无评论,快来抢沙发吧~