Defold 程序化生成与 Roguelike 关卡

从随机数的确定性讲起,给出 Defold 里做程序化生成与 Roguelike 关卡的完整方法:BSP、元胞自动机、随机游走三种地牢算法,用 tilemap.set_tile 落地到瓦片地图,房间图与连通性保证,运行时摆放实体,种子存档与可复现,以及生成性能与分块策略。

引言

Roguelike 的核心不是战斗系统,而是「每一局的地图都不一样」这件事能否稳定成立。程序化生成看起来只是调几个随机数,实际要做到可复现、可连通、可存档、不掉帧,每一步都有坑。本文用 Defold 的 tilemap 与工厂组件,把从随机种子到可玩关卡的完整链路走一遍。

前置阅读:Tilemap 碰撞与关卡设计 、寻路与 AI 。


目录


1. 程序化生成要解决什么

可复现   同一个种子必须生成同一张图,否则存档、联机、回放全部失效
可连通   任何两个房间之间必须有路径,否则玩家会被卡死
可调节   难度、房间数、宝藏密度要能按层数调参
可存档   只存种子和少量差异,不存整张地图
不卡顿   生成过程不能一次阻塞几百毫秒

这五条里,可复现和可连通是最容易翻车的两条。前者取决于你用什么随机数,后者取决于算法本身是否保证连通。


2. 随机数与确定性种子

1. Lua 的 math.random 不够用

Defold 的脚本层是 Lua,math.random 来自 Lua 标准库。它的问题是:不同 Lua 版本、不同平台上的随机序列可能不一致。你在 macOS 编辑器里调试出的地图,可能在 Android 上完全变样。

-- 能跑,但跨平台不保证一致
math.randomseed(12345)
local n = math.random(1, 100)

2. 自己写一个确定性 PRNG

要做「种子决定地图」,就用自己实现的线性同余或 xorshift,全部用整数运算,跨平台结果一致:

-- rng.lua
local M = {}
function M.new(seed)
    return setmetatable({ state = seed % 2147483647 }, { __index = M })
end
function M.next(self)                      -- 32 位 LCG
    self.state = (self.state * 16807) % 2147483647
    return self.state
end
function M.int(self, n)  return (self.next(self) % n) + 1 end   -- [1, n]
function M.float(self)   return (self.next(self) - 1) / 2147483646 end
return M

使用:

local rng = require("rng")
local r = rng.new(20261006)
print(r:int(100), r:float())

所有影响地图结构的随机数都必须走这个 rng;只有纯表现层的抖动(粒子偏移、音效变调)才可以用 math.random。

3. 种子的来源

local seed = os.time()                          -- 每局新种子
local seed = tonumber(os.date("%Y%m%d"))        -- 每日挑战

-- 玩家输入的种子码转成整数
local function seed_from_string(s)
    local h = 0
    for i = 1, #s do h = (h * 31 + string.byte(s, i)) % 2147483647 end
    return h
end

3. 三种地牢生成算法

1. BSP 分割

最适合「房间 + 走廊」的经典地牢。把整张图递归对半切,切到足够小后在每个叶子区域里放一个房间:

local function split(node, depth)
    if depth <= 0 or node.w < 16 or node.h < 16 then
        node.room = {
            x = node.x + rng:int(math.max(1, node.w - 8)),
            y = node.y + rng:int(math.max(1, node.h - 8)),
            w = rng:int(6) + 4, h = rng:int(6) + 4,
        }
        return
    end
    -- 沿长边切一刀,递归两半,最后用走廊连接两子节点的房间中心
end

BSP 的优点是天然连通(相邻子区域总能连上),房间规整、易读;缺点是布局偏「方块感」,不够有机。

2. 元胞自动机

最适合洞穴。先按概率随机撒墙,再反复迭代平滑:

local function smooth(grid, w, h)
    local ng = {}
    for y = 1, h do
        ng[y] = {}
        for x = 1, w do
            local walls = 0
            for dy = -1, 1 do for dx = -1, 1 do
                if not (dx == 0 and dy == 0) then
                    local nx, ny = x + dx, y + dy
                    if nx < 1 or ny < 1 or nx > w or ny > h
                       or grid[ny][nx] == 1 then walls = walls + 1 end
                end
            end end
            -- 4-5 规则:邻居墙 >= 5 变墙,<= 3 变空
            if walls >= 5 then ng[y][x] = 1
            elseif walls <= 3 then ng[y][x] = 0
            else ng[y][x] = grid[y][x] end
        end
    end
    return ng
end

跑 4~5 轮就能得到连片的洞穴。注意元胞自动机不保证连通,需要用第 5 节的洪水填充做一次校验和修补。

3. 随机游走

最简单,适合做通道型的洞窟:

local function drunkards_walk(grid, w, h, x, y, steps)
    for i = 1, steps do
        grid[y][x] = 0                      -- 挖空
        local d = rng:int(4)
        if d == 1 then x = math.max(2, x - 1)
        elseif d == 2 then x = math.min(w - 1, x + 1)
        elseif d == 3 then y = math.max(2, y - 1)
        else y = math.min(h - 1, y + 1) end
    end
end

它没有任何全局约束,可能留下大片未挖区域,必须配连通性校验。

怎么选:要规整房间加走廊用 BSP(天然连通);要有机关闭的洞穴用元胞自动机;要蜿蜒通道用随机游走。后两者都要配连通性校验。


4. 用 Tilemap 落地

1. 读写瓦片

生成结果最终要写进 tilemap。核心就两个函数:

local MAP = "/level#map"
-- 写:url, layer, x, y, tile, flags
tilemap.set_tile(MAP, "ground", x, y, 1)                  -- 1 = 地板
tilemap.set_tile(MAP, "walls", x, y, 5)                   -- 5 = 墙
tilemap.set_tile(MAP, "ground", x, y, 1, tilemap.H_FLIP)  -- 随机翻转减少重复感
local t = tilemap.get_tile(MAP, "ground", x, y)           -- 读

tilemap.get_bounds(url) 返回 x, y, width, height(单位是瓦片),用它来循环整张图:

local bx, by, bw, bh = tilemap.get_bounds(MAP)
for y = 0, bh - 1 do
    for x = 0, bw - 1 do
        local solid = grid[y + 1][x + 1] == 1
        tilemap.set_tile(MAP, "walls", x, y, solid and 5 or 0)
    end
end

2. 瓦片坐标与世界坐标

tilemap 的瓦片坐标从 0 开始,原点在 tilemap 组件的左上角。实体要摆到某个瓦片中心:

local TILE = 32   -- 与 tile source 的瓦片尺寸一致

local function tile_to_world(tx, ty)
    local origin = go.get_position("/level")
    return vmath.vector3(
        origin.x + tx * TILE + TILE / 2,
        origin.y - ty * TILE - TILE / 2,   -- 注意 y 轴方向
        0)
end

踩坑:Defold 的屏幕与世界坐标 y 轴向上,而瓦片坐标 y 轴向下。忘记取负号,实体会被摆到地图镜像位置。

3. 碰撞

瓦片碰撞不在脚本里设置,而是在 tile source 里:给每个瓦片勾选 Collision、选形状与碰撞组。tilemap 所在的 game object 挂上 Collision Object 组件后,这些瓦片就自动成为碰撞形状。之后用 physics.raycast 做视线判断即可。


5. 房间图与连通性

1. 洪水填充校验

不管用哪种算法,生成后都要做一次连通性校验:从起点洪水填充,看能到达多少格。

local function flood(grid, w, h, sx, sy)
    local seen, count = {}, 0
    local stack = { { sx, sy } }
    while #stack > 0 do
        local p = table.remove(stack)
        local x, y = p[1], p[2]
        if x >= 1 and y >= 1 and x <= w and y <= h
           and grid[y][x] == 0 and not seen[y * w + x] then
            seen[y * w + x] = true; count = count + 1
            for _, d in ipairs({ {1,0}, {-1,0}, {0,1}, {0,-1} }) do
                stack[#stack + 1] = { x + d[1], y + d[2] }
            end
        end
    end
    return count, seen
end

2. 修补策略

统计可走格总数 N 与洪水填充可达数 M,若 M < N * 0.9 说明有孤立区域。修补有两条路:从最近的可达格挖走廊过去,或者直接丢弃这次生成换个种子重来。Roguelike 里「重试到合格为止」完全可接受,只要重试上限设好(比如 10 次),失败概率可以忽略。

3. 房间图的进阶用法

把房间当成节点、走廊当成边,就得到一张图。之后可以用 BFS 找最远房间放 Boss,用生成树算法决定哪些走廊真的需要挖,按图距离分配难度与奖励,或要求出生点到出口的最短路径不小于阈值。


6. 运行时摆放实体

1. 用工厂生成

地图结构写进 tilemap 后,实体用工厂按坐标生成:

local function spawn_entities(self)
    for _, room in ipairs(self.rooms) do
        factory.create("#chest_factory", tile_to_world(room.cx, room.cy))
        -- 按房间大小决定敌人数
        local n = math.min(4, math.floor(room.w * room.h / 30))
        for i = 1, n do
            local ex, ey = room.x + rng:int(room.w), room.y + rng:int(room.h)
            factory.create("#enemy_factory", tile_to_world(ex, ey))
        end
    end
end

2. 摆之前先问三个问题

1. 这个位置可走吗?(查 grid,不要查 tilemap,更快)
2. 会不会挡住唯一的通路?(关键格要标记为 reserved)
3. 与出生点距离够远吗?(防止开局就被贴脸)

保留一份内存里的 grid 二维表作为「权威数据」,tilemap 只是它的渲染结果。查询逻辑一律走 grid,比 tilemap.get_tile 快得多。

3. 清理旧关卡

重新生成前必须清干净,否则对象会累积:

function regenerate(self, seed)
    go.delete_all(self.spawned)      -- 删掉上一局所有实体
    self.spawned = {}
    self.rng = rng.new(seed)
    self:generate()
    self:spawn_entities()
end

7. 种子存档与可复现

1. 只存种子

存档里不要存整张地图,只存种子 + 玩家改动:

local save = {
    seed = self.seed,
    floor = self.floor,
    player = { x = self.player.x, y = self.player.y, hp = self.hp },
    opened_chests = self.opened_chests,   -- 只存差异
}
sys.save("save1", save)

读档时用同一个种子重新生成地图,再套用差异即可还原。

2. 保证可复现的三条纪律

1. 所有结构相关的随机都走同一个 rng 实例,且调用顺序固定
2. 生成过程中不要依赖遍历 table 的顺序(pairs 顺序不保证)
3. 不要在地图生成里插入与帧率相关的逻辑(如按 dt 决定迭代次数)

第 2 条尤其隐蔽:pairs 遍历顺序在不同运行中可能不同,如果生成逻辑依赖它,同一颗种子也会生成不同的图。需要顺序时用 ipairs 或显式排序。


8. 生成性能与分块

1. 别一帧生成完

一张 100×100 的地图,光 tilemap.set_tile 就是一万次调用。一次性做完会造成明显卡顿。两种拆法:

-- 方案 A:分帧,每帧写入若干行
function update(self, dt)
    if self.gen_row and self.gen_row <= self.map_h then
        for x = 0, self.map_w - 1 do
            self:write_tile(x, self.gen_row - 1)
        end
        self.gen_row = self.gen_row + 1
    end
end

推荐方案 B:先算完完整 grid(纯 Lua 表运算,很快),再用 timer 分批把 set_tile 写进 tilemap。慢的是 set_tile 调用次数,不是算法本身。

2. 只在需要时生成

- 大地图:按区域(chunk)生成,玩家靠近才生成
- 小地图:整张一次生成,但写入分帧
- 已探索区域:不再重算,缓存结果

3. 度量

local t0 = os.clock()
self:generate()
print(string.format("gen: %.1f ms", (os.clock() - t0) * 1000))

把生成耗时打到日志里,确保单帧写入不超过 2~3 毫秒。超过就继续拆帧。


9. 速查表

需求做法备注
确定性随机自写 LCG/xorshift,别用 math.random跨平台一致
写瓦片tilemap.set_tile(url, layer, x, y, tile)x/y 从 0 开始
翻转瓦片加 flag tilemap.H_FLIP减少重复感
读瓦片tilemap.get_tile(url, layer, x, y)慢,优先查 grid
地图尺寸tilemap.get_bounds(url)返回瓦片数
瓦片转世界origin + tx*TILE + TILE/2y 轴要取负
房间+走廊BSP 分割天然连通
洞穴元胞自动机 4-5 规则需连通校验
连通校验洪水填充,可达数 < 90% 就重试限重试次数
摆实体factory.create(url, world_pos)查 grid 判断可走
存档只存种子 + 差异读档重新生成
性能先算 grid,写入分帧单帧 < 2ms

一句话记忆:种子决定一切、grid 是权威数据、tilemap 只是渲染、连通性必须校验——把这四件事做对,Roguelike 的地图系统就稳了。


相关阅读

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「defold」更多文章

  1. Defold 行为树与游戏 AI 决策
  2. Defold 材质与着色器语言详解
  3. Defold HTML5 导出与 Web 性能优化