7.2 关卡与内容管线
上一节把玩法链跑通了,但玩家的位置、微光的坐标都是从代码里硬编码出来的。真实的切片必须让内容从代码里出来:关卡由编辑器产出、由运行时加载,改一个数字不该重新编译引擎。
内容管线的第一道契约,就是编辑器产出什么、运行时就必须能读什么。它和卷三讲过的编辑器序列化(helio-subsys-08-3 场景序列化格式
)是同一件事的两端:编辑器那端负责把场景树写出来,运行时这端负责把它读回来并变成引擎认识的东西。本节只做运行时这端,并且把「读回来」拆成三个互不越界的步骤。
《微光岛》的关卡刻意保持最小:一个小岛、一批微光、一批树、两个手工摆件。范围一旦膨胀成「可编辑的完整世界」,就会滑向引擎编辑器本身——那是卷三的主题,不是这一节。
本节的目标可以概括成一句可验证的话:给同一个种子,任何机器上都长出同一个岛。这一句同时覆盖了三件事——格式必须带版本号(否则旧文件读不了)、生成必须确定(否则同种子不同结果)、装配必须可统计(否则无从验证)。下面每一节都对着这一句展开,并且全部在本机真跑。
需要提前说明的是:本节讲的「内容」只有几何位置,不含美术资产。贴图、模型、音频这些资源的导入与压缩是第 1 章的主题,这里不重复;本节关心的只有一件事——位置数据从文件到引擎的那条路径。
把「位置」和「资产」分开,也是刻意的:位置数据小、可读、可手写,适合用来验证管线的正确性;资产数据大、二进制、需要压缩,适合用来验证管线的吞吐。两者是同一套「文件 → 运行时」思路的两个极端,本节先用最简单的一端把管线打通,另一端在第 1 章已经打通。
关卡数据格式:版本号 + 参数 + 摆件
关卡文档是一份 JSON,只有四个顶层字段:version、island(种子 / 尺寸 / 密度)、spawn(出生点)、props(手工摆件)。它有意做得极扁,因为扁平的格式最容易校验、最容易做版本迁移,也最容易让非程序员手改。
三个设计决定值得说明:
- 种子而不是坐标。微光和树的位置不写进文件,只写一个
seed。运行时用种子程序化生成——文件因此可以只有几百字节,而岛屿可以任意大。代价是生成算法一旦改动,旧种子的布局就会变,所以生成算法本身也要版本化(本节的LEVEL_VERSION就是为此预留的)。 - 校验前置于使用。加载器不信任文件里的任何数字:尺寸必须为正、种子必须是整数、出生点和摆件必须落在小岛范围内。任何一条不过就抛错,绝不「读进来一半」。这和卷三讲导入器的思路一致(见
helio-subsys-02-1离线导入 ):坏内容必须在加载期就死,不能拖到运行时才崩。 - 摆件与生成物分开。
props是编辑器里手工摆的(宝箱、路牌),glimmers/trees是程序生成的。两者来源不同,所以运行时也分开保存,只有在「翻译成渲染实例」时才汇到一条流里。
字段表如下,每个字段都对应加载器里的一条校验:
| 字段 | 类型 | 校验 | 越界的后果 |
|---|---|---|---|
version | 整数 | 必须等于 LEVEL_VERSION | 未知版本直接拒绝加载 |
island.seed | 整数 | 必须是整数 | 非整数种子会让生成不可复现 |
island.size | 正数 | 必须 > 0 | 尺寸为 0 会让整座岛退化成一点 |
island.glimmerCount | 非负整数 | 不能为负 | 负数会让生成循环越界 |
island.treeCount | 非负整数 | 不能为负 | 同上 |
spawn | 二维点 | 落在 [-size/2, size/2] 内 | 出生点落到岛外,玩家一进场就掉下去 |
props[].kind | 非空字符串 | 不能为空 | 空类型名无法映射到任何 mesh |
一份最小的关卡文档长这样,微光和树一个坐标都没写,全靠种子生成:
// snippet: 编辑器导出的关卡文档(SAMPLE_LEVEL)
{
"version": 1,
"island": { "seed": 20261010, "size": 20, "glimmerCount": 9, "treeCount": 12 },
"spawn": { "x": 0, "z": 0 },
"props": [
{ "kind": "chest", "x": -7, "z": 6 },
{ "kind": "sign", "x": 5, "z": -6 }
]
}
为什么不让编辑器直接导出微光的绝对坐标,而是只给种子?因为「绝对坐标」把生成算法锁死在了导出那一刻——你没法在不重新导出全部关卡的前提下调整生成规则。给种子则相反:文件描述意图(这里要 9 个微光),运行时负责实现。这条「数据描述意图、代码负责实现」的原则,和第 7.1 节里「输入只写速度、移动负责积分」是同一种分工。
还有一点:校验失败的信息必须是可定位的。加载器抛出的每一条错误都带上了出错的字段名(island.size、spawn、prop chest),而不是一句笼统的「关卡非法」。理由和第 5.3 节插件加载失败一样——错误信息是给内容作者看的,不是给日志看的。作者看到「prop chest 越界」,立刻知道是那个宝箱摆到岛外了;看到「关卡非法」,只能反过来读引擎源码。
加载器只做三件事
运行时的加载器被刻意限制成三个纯函数,每一步都不碰 GPU、不碰 ECS:
parseLevel:解析 + 校验,返回一个被信任的LevelDoc。buildLevel:把文档里的种子喂给scatter,程序化生成微光与树,合并手工摆件,返回一个Level。toSceneInstances+assembleLevel:把Level翻译成渲染器能消费的实例列表,并送进渲染卷的装配流水线(剔除 + 排序,见helio-render-18-1渲染器装配 ),返回绘制统计。
「只做三件事」是刻意的限制。加载器不生成网格、不上传 GPU、不碰 ECS——它只把「文件里的意图」变成「内存里的数据」,至于这份数据怎么变成画面,是渲染器的事。这条界线一旦模糊,加载器就会长成一个什么都干的怪物,而它恰恰是最需要保持简单、最需要能被单测轰击的那一层。
这条链的好处是每一段都可单独测试:解析器可以拿坏 JSON 反复轰,生成器可以断言「同种子逐位相同」,装配段可以断言剔除数量。下一节的 L2 渲染拿到的,就是这个 assembleLevel 产出的实例列表。
把「解析 + 生成 + 翻译」拆开还有一个工程上的好处:每一段都能被独立替换。将来如果生成算法要从「抖动网格」升级成「泊松盘采样」,只改 scatter 一个函数,解析器和装配段一行都不用动;如果关卡格式要从 JSON 换成二进制,只改 parseLevel。三段之间没有共享的可变状态,所以替换永远是局部的。
三段之间用数据而不是调用串起来,是刻意的:parseLevel 只认识字符串,buildLevel 只认识 LevelDoc,toSceneInstances 只认识 Level。任何一段想偷看别的段的内部状态,都会在类型上被挡住。这和第 5.1 节的模块边界检查器是同一种约束——只不过这里靠的是类型系统,而不是脚本。
还有一个容易被忽略的细节:assembleLevel 复用的 assembleFrame 是渲染卷的装配流水线。内容管线没有自己写一套剔除,而是把「内容」直接喂给「渲染」,这正说明垂直切片的意义——每一层都被真实使用,而不是各写各的玩具。
可运行产物:程序化小岛生成与装配
整个内容管线在下面这个文件里,纯 TS、Node 26 直跑。scatter 用 hashUnit(来自渲染卷的精灵批次工具)做确定性抖动:点铺在抖动网格上,抖动限制在格子的 60% 内,保证不跨格、不重叠。
// file: src/game/level-pipeline.ts
// tier: L1
// verify: node src/game/level-pipeline.ts
//
// 《微光岛》关卡与内容管线。编辑器导出的关卡是一份带版本号的 JSON:小岛生成参数
// (种子 / 尺寸 / 密度)+ 出生点 + 手工摆件。运行时的加载器只做三件事:解析并校验、
// 用种子程序化生成小岛、把结果翻译成渲染器能消费的实例列表。全部确定性、无随机。
import { assembleFrame, type SceneInstance } from '../render/app/renderer-assembly.ts';
import { hashUnit } from '../render/ui2d/sprite-batch.ts';
export interface PropDef { readonly kind: string; readonly x: number; readonly z: number }
export interface LevelDoc {
readonly version: number;
readonly island: { readonly seed: number; readonly size: number; readonly glimmerCount: number; readonly treeCount: number };
readonly spawn: { readonly x: number; readonly z: number };
readonly props: readonly PropDef[];
}
export interface Vec2 { readonly x: number; readonly z: number }
export interface Level {
readonly spawn: Vec2;
readonly glimmers: readonly Vec2[];
readonly trees: readonly Vec2[];
readonly props: readonly PropDef[];
}
export const LEVEL_VERSION = 1;
/** 解析并校验编辑器导出的关卡。越界或未知版本一律抛错,绝不静默降级。 */
export function parseLevel(text: string): LevelDoc {
let raw: unknown;
try { raw = JSON.parse(text); } catch { throw new Error('关卡不是合法 JSON'); }
const doc = raw as Partial<LevelDoc>;
if (doc.version !== LEVEL_VERSION) {
throw new Error(`不支持的关卡版本:${String(doc.version)}(期望 ${LEVEL_VERSION})`);
}
const isl = doc.island;
if (isl === undefined || !(isl.size > 0)) throw new Error('island.size 必须为正');
if (!Number.isInteger(isl.seed)) throw new Error('island.seed 必须是整数');
if (isl.glimmerCount < 0 || isl.treeCount < 0) throw new Error('生成数量不能为负');
const half = isl.size / 2;
if (doc.spawn === undefined || Math.abs(doc.spawn.x) > half || Math.abs(doc.spawn.z) > half) {
throw new Error('spawn 必须落在小岛范围内');
}
for (const p of doc.props ?? []) {
if (typeof p.kind !== 'string' || p.kind.length === 0) throw new Error('prop.kind 不能为空');
if (Math.abs(p.x) > half || Math.abs(p.z) > half) throw new Error(`prop ${p.kind} 越界`);
}
return doc as LevelDoc;
}
/** 程序化生成:用种子把点铺在抖动网格上,同种子必然得到同一组坐标。 */
export function scatter(seed: number, size: number, count: number, salt: number): Vec2[] {
if (count === 0) return [];
const half = size / 2;
const cols = Math.ceil(Math.sqrt(count));
const cell = size / cols;
const out: Vec2[] = [];
for (let i = 0; i < count; i++) {
const cx = i % cols;
const cz = Math.floor(i / cols);
// 抖动限制在格子的 60% 内,保证不跨格、不重叠
const jx = (hashUnit(i, seed ^ salt) - 0.5) * cell * 0.6;
const jz = (hashUnit(i, seed ^ (salt + 101)) - 0.5) * cell * 0.6;
const x = Math.max(-half, Math.min(half, -half + cell * (cx + 0.5) + jx));
const z = Math.max(-half, Math.min(half, -half + cell * (cz + 0.5) + jz));
out.push({ x, z });
}
return out;
}
/** 由关卡文档构建运行时关卡:手工摆件 + 程序化生成的微光与树木。 */
export function buildLevel(doc: LevelDoc): Level {
const { seed, size, glimmerCount, treeCount } = doc.island;
return {
spawn: { x: doc.spawn.x, z: doc.spawn.z },
glimmers: scatter(seed, size, glimmerCount, 0x9e37),
trees: scatter(seed, size, treeCount, 0x51ed),
props: doc.props,
};
}
/** 把关卡翻译成渲染器实例:微光 / 树 / 摆件各有自己的 meshId。 */
export function toSceneInstances(level: Level): SceneInstance[] {
const out: SceneInstance[] = [];
let id = 0;
const push = (p: Vec2, meshId: number): void => {
out.push({ id: id++, meshId, materialId: 0, position: [p.x, 0, p.z], scale: 0.8, alpha: false, visible: true });
};
for (const g of level.glimmers) push(g, 1);
for (const t of level.trees) push(t, 2);
for (const p of level.props) push({ x: p.x, z: p.z }, p.kind === 'chest' ? 3 : 4);
return out;
}
/** 俯视正交投影:把 [-extent,extent] 映射到裁剪空间,列主序 4x4。 */
export function orthoViewProj(extent: number): number[] {
return [1 / extent, 0, 0, 0, 0, 0, -1 / 20, 0, 0, 1 / extent, 0, 0, 0, 0, 1, 1];
}
export interface LevelStats { readonly glimmers: number; readonly trees: number; readonly props: number; readonly drawn: number; readonly culled: number }
/** 装配一帧:把关卡实例送进剔除 + 排序,返回统计。 */
export function assembleLevel(level: Level, extent: number): LevelStats {
const frame = assembleFrame(toSceneInstances(level), orthoViewProj(extent), [0, 20, 0], 100);
return {
glimmers: level.glimmers.length, trees: level.trees.length, props: level.props.length,
drawn: frame.stats.drawn, culled: frame.stats.culled,
};
}
/** 示例关卡:编辑器导出的样子。 */
export const SAMPLE_LEVEL: LevelDoc = {
version: LEVEL_VERSION,
island: { seed: 20261010, size: 20, glimmerCount: 9, treeCount: 12 },
spawn: { x: 0, z: 0 },
props: [{ kind: 'chest', x: -7, z: 6 }, { kind: 'sign', x: 5, z: -6 }],
};
if ((import.meta as { main?: boolean }).main) {
const doc = parseLevel(JSON.stringify(SAMPLE_LEVEL));
const stats = assembleLevel(buildLevel(doc), 10);
console.log(`[level] seed=${doc.island.seed} size=${doc.island.size}`);
console.log(`[level] glimmers=${stats.glimmers} trees=${stats.trees} props=${stats.props}`);
console.log(`[level] drawn=${stats.drawn} culled=${stats.culled}`);
const a = buildLevel(doc).glimmers.map((g) => `${g.x.toFixed(3)},${g.z.toFixed(3)}`).join(' ');
const b = buildLevel(doc).glimmers.map((g) => `${g.x.toFixed(3)},${g.z.toFixed(3)}`).join(' ');
console.log(`[level] deterministic=${a === b}`);
}
本机 node src/game/level-pipeline.ts 的实跑输出:
[level] seed=20261010 size=20
[level] glimmers=9 trees=12 props=2
[level] drawn=23 culled=0
[level] deterministic=true
种子 20261010 生成 9 个微光 + 12 棵树,加上 2 个手工摆件,共 23 个实例;extent=10 的正交盒能装下整座岛,所以 culled=0。把正交盒收窄,就能看到剔除真的在起作用:
extent 12 -> drawn=23 culled=0
extent 10 -> drawn=23 culled=0
extent 8 -> drawn=22 culled=1
extent 6 -> drawn=12 culled=11
配套单测覆盖解析校验(未知版本 / 出生点越界 / 摆件越界各一例)、程序化生成(同种子相同 / 不同种子不同 / 全部落在范围内)、以及装配统计,共 9 条断言,npx vitest run src/game/level-pipeline.test.ts 全过。
注意 extent=6 那一行:23 个实例只剩 12 个进绘制列表,11 个被剔除。这个数字本身不重要,重要的是它可复现——同一个种子、同一个 extent,任何机器上跑出来都是 drawn=12 culled=11。内容管线的全部价值就建立在「同一份数据在任何机器上产出同一个世界」之上。
为什么不直接做一个关卡编辑器
看到这里很容易想「顺手把编辑器也做了」。《微光岛》刻意不做,理由有三条:
- 编辑器是另一条产品线。卷三用了整整一章讲编辑器与运行时的关系、文档模型、命令与撤销。把编辑器塞进一个垂直切片,只会让切片失去「小到能一眼看完」这个优点。
- 内容格式不依赖编辑器。本节验证的是「文件 → 运行时」这条边,它可以用手写 JSON 验证,也可以用编辑器验证。先用最小的方式把这条边钉死,将来接任何编辑器都不用改运行时。
- 版本迁移先于工具。有了
version字段和加载期校验,将来编辑器升级格式时,运行时的迁移逻辑可以先行到位,而不必等编辑器一起发版。
一句话:垂直切片验证的是管线,不是工具。管线通了,工具随时可以换。
小结
内容管线的价值在于「把内容从代码里赶出去」:关卡是数据、生成靠种子、校验在加载期完成。这样引擎才能一边保持零随机、一边接纳外部内容。它也是切片能持续长大的前提——以后加一张新岛,只需要多一个 JSON 文件,而不是多一段 if 判断。
下一节我们回到画面,把本节产出的关卡真正渲染出来,并给玩法加上震动、粒子、音效与转场这些打磨。
回看这三节的关系:7.1 让状态能动、7.2 让内容能进、7.3 让反馈能出。三者合起来,才是一个引擎垂直切片该有的完整闭环——从数据进来,到状态推进,再到画面出去。任何一环缺失,切片都只能验证引擎的一部分;三环齐备,才能声称「这台引擎真的跑起来了」。
阅读导航:上一节:7.1 玩法装配:收集、昼夜、存档 · 下一节:7.3 打磨与表现 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。