7.1 玩法装配:收集、昼夜、存档
前六章把引擎工程化了:能打包、能测试、能压性能、能降级、能裁剪、能调试。这一章要回答一个此前一直绕开的问题——把这些零件拼起来,到底能做出什么。答案是《微光岛》:一个用来验证引擎的垂直切片。
垂直切片的范围必须钉死。它不是一款游戏,而是「一个小岛场景 + 少量机制」:玩家在 16×16 的岛屿平面上走动,收集散落的微光,看着昼夜从正午走到黄昏,随时能把进度存下来再读回来。多一样机制都不加——切片的目的是把引擎的每一层都真实地跑一遍,而不是堆积玩法。
本节做的是这个切片的装配:把内核的时间与固定步长(卷一)、输入回放、收集判定、昼夜推进接成一条每帧 update 链,并让这条链完全确定。确定性不是锦上添花,它是切片能被测试的前提:同一份输入跑两遍必须逐位相同,否则前面几章的黄金图像、回放回归全都无从谈起。
本章与站内既有内容的分工
| 站内既有内容 | 它讲什么 | 本节的分工 |
|---|---|---|
helio-kernel-18-2 第一个能走动的方块 | 内核如何把时间、移动、渲染装配成最小可运行 demo | 本节把单个方块升级成完整玩法链:输入 → 移动 → 收集 → 昼夜 |
helio-render-18-2 昼夜光照 | 时刻 t 到太阳方向、天空梯度的完整光照公式 | 本节只取 dayT 这个标量并推进它,光照公式一律不重推 |
helio-subsys-08-3 场景序列化格式 | 编辑器里一整棵场景树怎么存盘 | 本节讲的是玩家存档槽的往返与迁移,不是场景文件 |
一句话概括分工:本站讲「引擎零件怎么造」的文章已经很多,本节只做「把零件接成切片」这一件事,并且每一步都在本机真跑。
玩法链:一条固定顺序的 update
装配的第一条规矩是顺序固定。四个系统按「输入 → 移动 → 收集 → 昼夜」推进,顺序写死在 runGameplay 里,不允许子系统自己决定何时跑——这一点与卷一的子系统拓扑序一脉相承:运行期不做依赖解析,启动时就把顺序钉死。
- 输入只写速度,绝不直接改位置。位置一律由移动积分出来,这样「同一帧内先输入后移动」成为一条不可违反的不变量。
- 收集用距离平方比较,命中即置位并计数。它读的是移动之后的位置,所以「走进微光」这一帧就会判定成功。
- 昼夜把游戏时间累进
dayT,跨过 1 就进位到新的一天。它只碰一个标量,和渲染层的天空公式完全解耦。
第二条规矩是零随机、零真实时钟。整条链里没有 Math.random(),也没有 performance.now();时间是每帧喂进来的固定步长。这两条合起来,才让「同输入两遍逐位相同」成为可断言的事实,而不是碰运气。
可运行产物:确定性玩法链
整个玩法链在下面这个文件里,纯 TS、Node 26 直跑。它导出四个系统、一个把状态压成位掩码的 glimmerMask、以及跑完整段模拟的 runGameplay;末尾的 main 块把同一份回放输入跑两遍并打印校验和。
// file: src/game/glimmer-sim.ts
// tier: L1
// verify: node src/game/glimmer-sim.ts
//
// 《微光岛》玩法链:把「输入 → 移动 → 收集 → 昼夜」四件事按固定顺序接成一条
// 每帧 update。全部纯函数式推进,不读真实时钟、不含随机——同一份回放输入跑两遍
// 必须逐位相同。这就是《微光岛》作为引擎垂直切片的最小可验证内核。
export interface InputSample { readonly dx: number; readonly dz: number }
export interface Glimmer { readonly x: number; readonly z: number; collected: boolean }
/** 一次模拟的全部可变状态。四个系统按固定顺序就地推进它。 */
export interface GameState {
playerX: number;
playerZ: number;
vx: number;
vz: number;
glimmers: Glimmer[];
collected: number;
dayT: number; // 一天中的时刻 [0,1)
day: number; // 第几天,从 1 开始
}
export interface GameplayConfig {
readonly stepSeconds: number;
readonly speed: number;
readonly islandHalf: number;
readonly collectRadius: number;
readonly dayLengthSeconds: number;
}
export const DEFAULT_CONFIG: GameplayConfig = {
stepSeconds: 1 / 60,
speed: 4,
islandHalf: 8,
collectRadius: 1.2,
dayLengthSeconds: 60, // 现实 60 秒 = 游戏内一整天
};
/** 确定性初始状态:微光摆在 3×3 网格上,玩家从中心出发。 */
export function initialState(): GameState {
const glimmers: Glimmer[] = [];
for (let r = -1; r <= 1; r++) {
for (let c = -1; c <= 1; c++) glimmers.push({ x: c * 4, z: r * 4, collected: false });
}
return { playerX: 0, playerZ: 0, vx: 0, vz: 0, glimmers, collected: 0, dayT: 0.25, day: 1 };
}
/** 系统 1:输入。只写速度,不直接改位置——位置一律由移动系统积分。 */
export function inputSystem(s: GameState, sample: InputSample, cfg: GameplayConfig): void {
s.vx = sample.dx * cfg.speed;
s.vz = sample.dz * cfg.speed;
}
/** 系统 2:移动。积分速度,并把位置钳制在岛屿平面内(纯比较,确定性)。 */
export function moveSystem(s: GameState, dt: number, cfg: GameplayConfig): void {
s.playerX = Math.max(-cfg.islandHalf, Math.min(cfg.islandHalf, s.playerX + s.vx * dt));
s.playerZ = Math.max(-cfg.islandHalf, Math.min(cfg.islandHalf, s.playerZ + s.vz * dt));
}
/** 系统 3:收集。距离平方比较,命中即置位并计数,一次一格。 */
export function collectSystem(s: GameState, cfg: GameplayConfig): number {
const r2 = cfg.collectRadius * cfg.collectRadius;
let got = 0;
for (const g of s.glimmers) {
if (g.collected) continue;
const dx = g.x - s.playerX;
const dz = g.z - s.playerZ;
if (dx * dx + dz * dz <= r2) { g.collected = true; s.collected += 1; got += 1; }
}
return got;
}
/** 系统 4:昼夜。把游戏时间累进 dayT,跨过 1 就进位到新的一天。 */
export function dayNightSystem(s: GameState, dt: number, cfg: GameplayConfig): void {
s.dayT += dt / cfg.dayLengthSeconds;
while (s.dayT >= 1) { s.dayT -= 1; s.day += 1; }
}
/** 把微光收集位压成一个位掩码,便于存进扁平存档。 */
export function glimmerMask(s: GameState): number {
let m = 0;
s.glimmers.forEach((g, i) => { if (g.collected) m |= 1 << i; });
return m >>> 0;
}
export interface RunResult { readonly frames: number; readonly state: GameState; readonly checksum: number }
/** 跑一段确定性模拟:每帧固定一个物理步,顺序 输入 → 移动 → 收集 → 昼夜。 */
export function runGameplay(input: readonly InputSample[], cfg: GameplayConfig = DEFAULT_CONFIG): RunResult {
const s = initialState();
let checksum = 0;
for (const sample of input) {
inputSystem(s, sample, cfg);
moveSystem(s, cfg.stepSeconds, cfg);
collectSystem(s, cfg);
dayNightSystem(s, cfg.stepSeconds, cfg);
checksum = (checksum * 31 + Math.round(s.playerX * 1000) + Math.round(s.playerZ * 1000)
+ s.collected + Math.round(s.dayT * 1000)) % 2147483647;
}
return { frames: input.length, state: s, checksum };
}
/** 预录输入:每 60 帧换一次方向,走一条恰好经过六个微光的矩形回路。 */
export function recordedInput(frames: number): InputSample[] {
const dirs: readonly (readonly [number, number])[] = [
[1, 0], [0, 1], [-1, 0], [0, -1], [-1, 0], [0, 1],
];
const out: InputSample[] = [];
for (let i = 0; i < frames; i++) {
const d = dirs[Math.floor(i / 60) % dirs.length]!;
out.push({ dx: d[0], dz: d[1] });
}
return out;
}
if ((import.meta as { main?: boolean }).main) {
const input = recordedInput(360);
const a = runGameplay(input);
const b = runGameplay(input);
console.log(`[glimmer] frames=${a.frames} collected=${a.state.collected} ` +
`x=${a.state.playerX.toFixed(4)} z=${a.state.playerZ.toFixed(4)}`);
console.log(`[glimmer] day=${a.state.day} dayT=${a.state.dayT.toFixed(4)} ` +
`mask=${glimmerMask(a.state).toString(2)}`);
console.log(`[glimmer] checksum A=${a.checksum} B=${b.checksum} deterministic=${a.checksum === b.checksum}`);
}
本机 node src/game/glimmer-sim.ts 的实跑输出:
[glimmer] frames=360 collected=6 x=-4.0000 z=4.0000
[glimmer] day=1 dayT=0.3500 mask=111111000
[glimmer] checksum A=1837037562 B=1837037562 deterministic=true
360 帧(每帧 1/60 秒)走完矩形回路,恰好收集 6 个微光,位掩码 111111000 与计数 6 对得上;两次运行的校验和都是 1837037562。dayT 从 0.25 走到 0.35,还没跨过一天。配套单测覆盖了确定性、收集计数、昼夜进位,共 6 条断言,npx vitest run src/game/glimmer-gameplay.test.ts 全过。
存档往返与旧档迁移
存档槽是一份扁平记录:角色属性 + 玩家位置 + 时刻 + 微光位掩码。位掩码是关键——它把「哪几个微光被收了」压成一个 i32,于是整份存档没有任何嵌套结构,可以直接走内核的类型注册表 + JSON。
// file: src/game/glimmer-save.ts
// tier: L1
// verify: npx vitest run src/game/glimmer-gameplay.test.ts
//
// 《微光岛》存档。存档槽是一份扁平记录:角色属性 + 玩家位置 + 时刻 + 微光位掩码。
// 序列化走内核的类型注册表 + JSON(可读优先),往返后字段逐一相等;旧版本存档沿
// 迁移链 v1 → v2 → v3 升级,缺字段或未知版本一律抛错,绝不静默降级。
import { TypeRegistry } from '../core/registry.ts';
import { deserializeFromJson, serializeToJson } from '../core/json-serde.ts';
import { MigrationRegistry, migrate, validateAgainst } from '../core/migration.ts';
import { glimmerMask, type GameState } from './glimmer-sim.ts';
export const SAVE_TYPE = 'GlimmerSave';
export const SAVE_VERSION = 3;
/** 存档槽的类型描述:字段全部扁平化,微光状态压进位掩码。 */
export function buildSaveRegistry(): TypeRegistry {
const reg = new TypeRegistry();
reg.register({
name: SAVE_TYPE,
version: SAVE_VERSION,
fields: [
{ name: 'name', kind: 'string' },
{ name: 'health', kind: 'i32' },
{ name: 'mana', kind: 'i32' },
{ name: 'level', kind: 'i32' },
{ name: 'x', kind: 'f32' },
{ name: 'z', kind: 'f32' },
{ name: 'dayT', kind: 'f32' },
{ name: 'day', kind: 'i32' },
{ name: 'mask', kind: 'i32' },
],
});
return reg;
}
/** 从模拟状态抽出一份可序列化的存档。 */
export function captureSave(s: GameState): Record<string, unknown> {
return {
name: 'glimmer', health: 100, mana: 50, level: 1,
x: s.playerX, z: s.playerZ, dayT: s.dayT, day: s.day, mask: glimmerMask(s),
};
}
/** 存档往返:serialize → JSON 字符串 → parse → deserialize,返回还原后的字段。 */
export function saveRoundTrip(s: GameState, reg: TypeRegistry): Record<string, unknown> {
const json = serializeToJson(reg, SAVE_TYPE, captureSave(s));
const text = JSON.stringify(json); // 真正落盘的那一步:编成字符串再读回来
const parsed = JSON.parse(text) as Record<string, unknown>;
return deserializeFromJson(reg, parsed);
}
/** 存档槽的迁移链:v1 → v2 → v3。复用内核的通用迁移注册表,链本身由游戏定义。 */
export function buildGlimmerMigrations(): MigrationRegistry {
const mig = new MigrationRegistry();
// v1 → v2:hp 改名 health;新增 mana 默认 50。位置与微光掩码原样保留。
mig.add({
from: 1, to: 2,
apply: (raw) => ({
$type: raw.$type, $v: 2, name: raw.name, health: raw.hp, mana: 50,
x: raw.x, z: raw.z, dayT: raw.dayT, day: raw.day, mask: raw.mask,
}),
});
// v2 → v3:删掉 gold;新增 level 默认 1。
mig.add({
from: 2, to: 3,
apply: (raw) => ({
$type: raw.$type, $v: 3, name: raw.name, health: raw.health, mana: raw.mana, level: 1,
x: raw.x, z: raw.z, dayT: raw.dayT, day: raw.day, mask: raw.mask,
}),
});
return mig;
}
/** 载入一份旧存档:先迁移到当前版本,再按注册表校验字段齐全。 */
export function loadLegacySave(reg: TypeRegistry, raw: Record<string, unknown>): Record<string, unknown> {
const { data, applied } = migrate(buildGlimmerMigrations(), raw, SAVE_VERSION);
validateAgainst(reg, SAVE_TYPE, data);
return { ...data, applied: applied.join(',') };
}
一次实跑的存档与迁移结果:
save {"$type":"GlimmerSave","$v":3,"name":"glimmer","health":100,"mana":50,"level":1,"x":-4.000000000000006,"z":4.000000000000005,"dayT":0.34999999999999565,"day":1,"mask":504}
rtEqual true
migrated {"$type":"GlimmerSave","$v":3,"name":"glimmer","health":100,"mana":50,"level":1,"x":3.5,"z":-2,"dayT":0.5,"day":2,"mask":5,"applied":"1->2,2->3"}
rtEqual true 说明「序列化 → JSON 字符串 → 解析 → 反序列化」之后字段逐一相等(浮点也逐位一致,因为 JSON 往返的是同一串十进制)。旧档迁移复用卷一的通用迁移注册表(见 helio-kernel-17-3 版本与迁移
),链本身由游戏定义:v1 的 hp 改名成 health、补 mana,v2 再删 gold、补 level,applied 如实报出走过的两级。缺字段或未知版本一律抛错,绝不静默降级。
小结
玩法装配的成果不是「能玩」,而是「能被测试地玩」:一条零随机的 update 链、一份扁平可往返的存档、一条会自己执行的迁移链。下一节我们离开运行时的状态推进,去看内容是怎么进来的——关卡数据格式与程序化小岛生成。
阅读导航:上一节:回到目录 · 下一节:7.2 关卡与内容管线 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。