引言
Roguelike 的核心不是战斗系统,而是「每一局的地图都不一样」这件事能否稳定成立。程序化生成看起来只是调几个随机数,实际要做到可复现、可连通、可存档、不掉帧,每一步都有坑。本文用 Defold 的 tilemap 与工厂组件,把从随机种子到可玩关卡的完整链路走一遍。
前置阅读:Tilemap 碰撞与关卡设计 、寻路与 AI 。
目录
- 1. 程序化生成要解决什么
- 2. 随机数与确定性种子
- 3. 三种地牢生成算法
- 4. 用 Tilemap 落地
- 5. 房间图与连通性
- 6. 运行时摆放实体
- 7. 种子存档与可复现
- 8. 生成性能与分块
- 9. 速查表
- 相关阅读
- 延伸阅读
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/2 | y 轴要取负 |
| 房间+走廊 | BSP 分割 | 天然连通 |
| 洞穴 | 元胞自动机 4-5 规则 | 需连通校验 |
| 连通校验 | 洪水填充,可达数 < 90% 就重试 | 限重试次数 |
| 摆实体 | factory.create(url, world_pos) | 查 grid 判断可走 |
| 存档 | 只存种子 + 差异 | 读档重新生成 |
| 性能 | 先算 grid,写入分帧 | 单帧 < 2ms |
一句话记忆:种子决定一切、grid 是权威数据、tilemap 只是渲染、连通性必须校验——把这四件事做对,Roguelike 的地图系统就稳了。
相关阅读
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。