第04章 - 核心契约与游戏生命周期
4.1 三份契约:GameModule、GameContext、事件与存档
packages/game-kit/src/contract.ts 一共 201 行,是整个仓库唯一一份「谁都要遵守、谁都不拥有」的文件。它没有一行运行时代码(除了 speechLang() 这个纯函数与几个常量),全部是类型。可以把它分成四组:
| 分组 | 类型 | 作用 | 谁实现 | 谁消费 |
|---|---|---|---|---|
| 身份 | GameManifest / Stars |
一个玩法的元信息与星级 | 每个 game 包 | 外壳、BaseScene |
| 事件 | GameEventMap |
一局游戏里允许发出的五种事件 | 模板场景 | 外壳、telemetry、调试桥 |
| 平台 | PlatformServices / AudioBus / LifecycleBus / Telemetry |
唯一的对外出口 | packages/platform |
所有场景 |
| 生命周期 | GameContext / GameModule |
挂载参数与模块接口 | 外壳构造 / defineGame 实现 |
PlayView |
| 存档 | SaveData / PetState / TaskProgress / DayRecord / ProgressStore |
存档的纯数据形状 | packages/progress |
外壳、platform、e2e |
契约单独成包、且只有类型,是三个决定的结果。第一,它必须被所有包引用,所以不能带任何副作用(一旦顶层有 import 'phaser' 或 new SomeService(),引用它就等于引用那份副作用)。第二,它必须能被 Node 环境的单测直接 import,所以不能用 window、不能用 DOM 类型做必填的运行时依赖。第三,它必须稳定——契约改一次,外壳、模板、平台层、e2e 都要跟着改,所以把「容易变」的东西(关卡脚本、主题色、文案)全部赶到别的包里。
这也是为什么存档类型会出现在这里而不是 packages/progress:ProgressStore 是平台层要实现的接口,SaveData 是进度层要读写的形状,两者都不能反向依赖。把「共同语言」放在最底层的包,是唯一不会出现环的做法。
生命周期接口
// packages/game-kit/src/contract.ts(节选)
export interface GameModule {
manifest: GameManifest;
mount(ctx: GameContext): Promise<void>;
unmount(): Promise<void>;
pause(): void;
resume(): void;
}
四个方法对应四种时刻,没有多余的能力:
mount是异步的,因为它可能要等 Phaser 建实例、等场景 preload;外壳会await它,之后再认为「这一局开始了」;unmount必须真正释放资源(game.destroy(true)),否则路由切走后 canvas 与requestAnimationFrame循环还在跑;pause/resume对应前后台切换。安卓上按 Home 键、iOS 上滑出控制中心都会触发,实现是game.loop.sleep()与game.loop.wake()——睡的是主循环,不是场景状态。
身份与星级
export interface GameManifest {
id: TaskId;
title: string;
version: string;
template: TemplateId;
designSize: { width: number; height: number };
ageRange: [number, number];
}
export type Stars = 0 | 1 | 2 | 3;
这里有一个细节:title 与 ageRange 的唯一事实来源是内容 JSON,manifest 只是把身份信息随包带出来,所以 brush.ts 里会看到 title: summary.title——不是复制,而是引用。目前场景里真正被读到的是 id(发事件时用)与 designSize(建 Phaser 实例时用),title 与 ageRange 更像是「随身携带的身份证」,为将来的 HUD 与年龄分层留位置。
Stars 的类型允许 0,但产品口径里没有 0 颗星:rewards.ts 的 starsFor() 最低返回 1,phaser-kit 的 starsForSession() 同样最低 1。保留 0 是为了让类型表达完整的取值域(比如未来的「未完成」状态),而不是给失败留位置。
五种事件
export interface GameEventMap {
'session:start': { at: number };
'scene:enter': { scene: string };
/** 做错/需要重来时发一次(不惩罚,只用于复盘与自动化观测) */
'task:retry': { taskId: TaskId; step: string };
'task:complete': {
taskId: TaskId;
stars: Stars;
mistakes: number;
correct: number;
total: number;
durationMs: number;
};
/** replay = 孩子点了「再玩一次」:外壳原地重开这一局,不用回任务页再找 */
'session:exit': { reason: 'complete' | 'quit' | 'replay'; progress: number };
}
五个事件的分工非常明确,不允许扩展(要加事件先改契约,再改调试桥,再改 e2e 断言):
| 事件 | 谁发 | 什么时候发 | 外壳拿它做什么 |
|---|---|---|---|
session:start |
每个模板的 create() |
紧跟 scene:enter 之后 |
不处理;进遥测与调试桥 |
scene:enter |
每个模板的 create() |
场景创建的第一件事 | 不处理;调试桥标记进入了哪个场景 |
task:retry |
BaseScene.noteMistake() |
做错一次 | 不处理;只记观测,不改存档 |
task:complete |
BaseScene.finishTask() |
一局唯一收尾 | 写存档、发奖励、弹 toast |
session:exit |
BaseScene.exit() |
点「收下」或「再玩一次」 | 不处理事件本身;退出走 requestExit 的 hook |
「一局只有一个完成信号」是这套契约最重要的约束。模板不允许自己拼奖励、不允许自己写存档、也不允许绕过 finishTask() 直接退出——这保证了十个玩法的结算路径完全一致,也保证了「加一个新模板不会顺便发明一套新的奖励口径」。
session:exit 的 progress 字段目前恒为 1(一局只有「走完」才会发这个事件),保留它是为了将来支持分段进度;replay 这个原因则是真实在用的:孩子点「再玩一次」时,外壳原地卸载再挂载,不刷新页面、不回任务页。
平台出口
export interface PlatformServices {
storage: ProgressStore;
audio: AudioBus;
lifecycle: LifecycleBus;
telemetry: Telemetry;
now(): number;
}
五个成员缺一不可:storage 读写存档、audio 播音效与旁白、lifecycle 订阅前后台、telemetry 记本地事件、now() 提供时钟。最后一个看起来多余,其实是「逻辑可测试」的关键——场景与纯逻辑层都不许调 Date.now(),时间一律从 services.now() 来,测试里可以固定它。
AudioBus 里有两个方法值得注意:
export interface AudioBus {
unlock(): Promise<void>;
play(key: SfxKey): void;
tone(freq: number, durationMs: number): void;
speak(text: string, locale: Locale): SpeechHandle;
stopSpeech(): void;
setMuted(muted: boolean): void;
isMuted(): boolean;
}
unlock() 是为 iOS / 微信准备的:音频必须由一次用户手势解锁,外壳在 App.vue 的第一次 pointerdown 上调用它。speak() 返回的不是 void,而是一个带 done: Promise<SpeechOutcome> 的句柄,且约定两种结果都必须 resolve——真的念完了是 ended,设备念不出来(静音、没装语音包、无头浏览器)是 unavailable。如果它永远不 resolve,界面就会永远停在「正在听」上,这是第 9 章会展开的一个真实故障。
SfxKey 是十二个音效的联合类型(tap / correct / retry / pop / bubble / water / chew / jump / sleep / unlock / complete / sparkle),由 WebAudio 现场合成,没有音频文件。
存档形状
存档类型也放在契约里,因为它是外壳、平台层、进度层与 e2e 的共同语言:
export const SAVE_SCHEMA_VERSION = 1;
export interface SaveData {
schemaVersion: number;
updatedAt: number;
pet: PetState;
tasks: Record<TaskId, TaskProgress>;
/** 只保留最近 30 天,避免存档无限增长 */
days: Record<string, DayRecord>;
}
PetState 有 13 个字段,值得完整看一遍,因为后面每一章都会引用其中的某几个:
| 字段 | 含义 | 细节 |
|---|---|---|
name |
角色名 | 固定「竹竹」,不做改名 |
xp |
成长值 | 四阶段:0 / 60 / 160 / 300 |
stats |
五维状态 | 饱腹 / 精力 / 清洁 / 心情 / 健康,0–100,下限 10 |
lastDecayDay |
上次衰减日期 | 跨天才衰减,绝不半天掉状态 |
shoots |
竹笋 | 任务奖励,可解锁也可喂 |
fragments |
装扮碎片 | 每完成第 3 个不同任务得一枚 |
unlocked |
已解锁物件 | 默认物件免费自带,不记在这里 |
stickers |
贴纸 | 同一任务连续 3 天完成得一张 |
intimacy |
亲密度 | 摸头 +1、抱抱 +2、击掌 +2 |
careDays |
今日亲昵动作 | action → 日期,用来判断「今天做过没」 |
equipped |
穿戴 | 五个槽位:hat / bib / outfit / backpack / shoes |
room |
小窝 | 五个槽位:wallpaper / bed / shelf / toyCorner / rug |
feeds |
累计喂食次数 | 日记与家长页里的一个小数字 |
ProgressStore 只有两个方法:load() 与 save(data)。把读写收窄到两个方法,是为了让「换一个存储实现」变成一件小事——现在有 IndexedDB 实现与内存实现(无痕模式兜底、单测用),将来加云存档也只是再写一个实现。
4.2 defineGame() 与 manifest
十个玩法入口加起来不到 250 行,因为它们共用同一个工厂:
// packages/templates/src/defineGame.ts
export interface GameDefinition {
manifest: GameManifest;
/**
* 这一局的场景编排。关卡脚本在构建期由 content:check 用模板 schema 校验,
* 运行时不再跑 zod(否则 zod + 全部模板 schema 会进每个游戏 chunk)。
*/
scenes: (ctx: GameContext) => Phaser.Types.Scenes.SceneType[];
}
export function defineGame(definition: GameDefinition): GameModule {
let game: Phaser.Game | null = null;
return {
manifest: definition.manifest,
async mount(ctx: GameContext): Promise<void> {
game = createPhaserGame(ctx, { scenes: definition.scenes(ctx) });
},
async unmount(): Promise<void> {
game?.destroy(true);
game = null;
},
pause(): void {
game?.loop.sleep();
},
resume(): void {
game?.loop.wake();
},
};
}
十个玩法入口加起来只有二百多行,这个数字本身就是设计目标。如果每个玩法都要自己写 new Phaser.Game(...)、自己挂 destroy、自己处理 pause / resume,那么「十个玩法行为一致」就只能靠 review 保证;现在它由 defineGame 的唯一实现保证,玩法入口只剩下「用哪个模板、把哪份关卡数据交给它」这一件事。
工厂模式还有一个不显眼的好处:unmount() 里 game = null 之后,模块对象仍然可以被重复 mount。这正是「再玩一次」能原地重开的前提——不需要重新 import,也不需要新建对象。
四件事值得逐条解释:
game是闭包变量,不是模块级变量。每次defineGame()生成一个独立的模块对象,理论上一页可以同时挂两个玩法(当前没用到,但接口允许)。scenes是一个接收ctx的函数,而不是数组。因为场景需要用到ctx.task.levelScript——关卡数据是外壳在mount之前才加载好的,工厂函数只能在拿到ctx之后才能构造场景类。mount是async但内部并没有await:createPhaserGame是同步返回实例的。声明成async是为了满足契约,也给将来「等首帧渲染完再 resolve」留了口子。unmount用destroy(true),参数true表示连同 canvas 一起移除。少这个参数,路由切换后会残留一个透明的 canvas,挡住新页面的点击——这类问题在真机上表现为「按钮点不动」,非常难查。
defineGame 最终调用 createPhaserGame,配置只有几行:
// packages/phaser-kit/src/createGame.ts
export function createPhaserGame(ctx: GameContext, opts: CreateGameOptions): Phaser.Game {
const { width, height } = ctx.manifest.designSize;
return new Phaser.Game({
type: Phaser.AUTO,
parent: ctx.container,
width,
height,
backgroundColor: THEME.ink,
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH,
},
audio: { noAudio: true },
scene: opts.scenes,
});
}
audio: { noAudio: true } 是一个容易被忽略的决定:Phaser 自带的音频系统被关掉了,因为音效走 PlatformServices.audio(WebAudio 合成 + TTS)。关掉它,Phaser 就不会再建一套 SoundManager,也就不会出现「两套音频上下文互相打断」或「浏览器把两个 AudioContext 都挂起」的情况。
manifest 里为什么有 version
version 目前恒为 '0.1.0',看起来是形式主义。它存在的理由是给未来的内容版本留位置:如果某天某个玩法的关卡脚本升级了 schema,version 可以让外壳判断「这个旧存档里的记录还算不算数」。现在不用,是因为存档里只记任务维度的星星与次数,不记关卡版本。
4.3 GameContext 由谁构造、注入什么
GameContext 是外壳递给游戏的唯一一个对象。它是一个普通对象,不是类实例,这样可以序列化、可以打日志、也可以在测试里手工构造。
export interface GameContext {
container: HTMLElement;
manifest: GameManifest;
task: TaskDetail;
services: PlatformServices;
locale: Locale;
reducedMotion: boolean;
debug?: DebugOptions;
/** 这个任务之前完成过没有(外壳从存档里读) */
completedBefore: boolean;
emit<K extends keyof GameEventMap>(type: K, payload: GameEventMap[K]): void;
requestExit(reason?: 'quit' | 'replay'): void;
}
构造它的是 apps/site/src/shell/createGameContext.ts,唯一调用方是 PlayView.vue:
export async function createGameContext(
manifest: GameManifest,
container: HTMLElement,
hooks: GameHostHooks,
completedBefore: boolean,
locale: Locale,
): Promise<GameContext> {
const task = await loadTask(manifest.id, locale);
if (!task) throw new Error('找不到玩法内容:' + manifest.id);
return {
container,
manifest,
task,
services: platform,
locale,
reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
debug: readDebugOptions(),
completedBefore,
emit(type, payload) {
platform.telemetry.track(type, payload);
recordDebugEvent(type, detailOf(type, payload));
hooks.onEvent(type, payload);
},
requestExit(reason) {
hooks.onExit(reason);
},
};
}
逐个字段看:
| 字段 | 从哪来 | 为什么这样设计 |
|---|---|---|
container |
PlayView 里的 div[data-testid="play-host"] |
Phaser 的 parent 需要一个真实 DOM 节点 |
manifest |
registry 里加载出来的模块 |
游戏自己的元信息,不需要再查内容 |
task |
await loadTask(id, locale) |
已经合并好中文原文 + 英文补丁的完整内容 |
services |
shell/platform.ts 的单例 |
存档 / 音频 / 生命周期 / 遥测都在这里 |
locale |
当前语言 | 场景用它建取词器、挑语音 |
reducedMotion |
matchMedia |
尊重系统的「减少动效」设置 |
debug |
readDebugOptions() |
autoplay / instant,且只在开发与自动化下有效 |
completedBefore |
progress.completedBefore(id) |
面板上可以显示「又做到啦」 |
emit |
三个出口的组合 | 遥测、调试桥、宿主回调 |
requestExit |
宿主回调 | 游戏不直接操作路由 |
export interface GameHostHooks {
onExit: (reason?: 'quit' | 'replay') => void;
onEvent: <K extends keyof GameEventMap>(type: K, payload: GameEventMap[K]) => void;
}
两个 hook 就是游戏与外壳之间的全部通信面。游戏不 import 路由、不 import store、不 import 存档——它只知道这两个函数。
emit 的三段式分发值得单独强调:先遥测、再调试桥、最后宿主回调。顺序固定,因为调试桥的条目是端到端断言的依据,必须在宿主处理(可能触发导航)之前写进去。如果某天顺序反了,session:exit 的断言会偶发拿不到——这是典型的「顺序即语义」。
4.4 registry 动态 import 与 PlayView 的挂载 / 卸载
外壳认识玩法的唯一入口是 registry:
// apps/site/src/registry.ts
export type GameLoader = () => Promise<{ default: GameModule }>;
export const registry: Record<TaskId, GameLoader> = {
brush: () => import('@bamboo/game-daily/brush'),
wash: () => import('@bamboo/game-daily/wash'),
feed: () => import('@bamboo/game-daily/feed'),
dress: () => import('@bamboo/game-daily/dress'),
bath: () => import('@bamboo/game-daily/bath'),
sleep: () => import('@bamboo/game-daily/sleep'),
tidy: () => import('@bamboo/game-daily/tidy'),
sport: () => import('@bamboo/game-daily/sport'),
emotion: () => import('@bamboo/game-daily/emotion'),
learn: () => import('@bamboo/game-daily/learn'),
};
三个约束同时生效:
- 类型是
Record<TaskId, GameLoader>:TASK_IDS里少一个键,typecheck 直接报 missing property; - 值必须是函数:Vite 才能把每个
import()切成独立 chunk;写成import brush from '...'会把十个玩法打进同一个 chunk; - 子路径由
games/daily/package.json的exports锁死:./brush→./src/games/brush.ts,写错路径在构建期就失败。
PlayView.vue 是这个接缝的另一半。它的核心逻辑可以压成四段:
// apps/site/src/views/PlayView.vue(节选)
const props = defineProps<{ taskId: TaskId }>();
const host = ref<HTMLDivElement | null>(null);
/** ★ 必须用 shallowRef:ref 会深度代理整个 Phaser 对象图 */
const mod = shallowRef<GameModule | null>(null);
let disposed = false;
async function start(): Promise<void> {
if (disposed || !host.value) return;
loading.value = true;
try {
// 先等读档完成,再挂游戏:深链直达时,秒通关的写入不能被旧快照盖掉
await progress.init();
const load = registry[props.taskId];
if (!load) {
error.value = t('play.notFound') + props.taskId;
return;
}
const loaded = (await load()).default;
if (disposed || !host.value) return;
mod.value = loaded;
const context = await createGameContext(
loaded.manifest,
host.value,
{ onExit: handleExit, onEvent: handleEvent },
progress.completedBefore(props.taskId),
locale.current,
);
if (disposed || !host.value) return;
await loaded.mount(context);
bindLifecycle();
} catch (cause) {
error.value = t('play.loadFailed');
recordDebugError(cause instanceof Error ? cause.message : String(cause));
} finally {
loading.value = false;
}
}
onBeforeUnmount(async () => {
disposed = true;
releaseLifecycle();
platform.audio.stopSpeech();
await mod.value?.unmount();
mod.value = null;
});
四条纪律,每一条都对应一类真实故障:
| 纪律 | 不遵守的后果 |
|---|---|
shallowRef 持有 GameModule |
Vue 会深度代理整个 Phaser 对象图,轻则卡顿,重则抛「无法代理」 |
await progress.init() 在挂载之前 |
深链直达时,一局秒通关的写入可能被旧快照覆盖,表现为「玩完没加次数」 |
await mod.value?.unmount() |
canvas 与 rAF 循环残留,回到任务页后仍在后台跑 |
disposed 标记 + !host.value 复查 |
慢网下组件已卸载、import 才返回,挂到已销毁的容器上 |
还有两个不在上面片段里、但同样关键的细节:
App.vue给PlayView的key带上了语言::key="route.taskId + ':' + locale.current"。切语言会重建整个玩法(重新加载 Phaser 实例),避免出现半中半英的场景。- 退出时奖励 toast 至少露 1.2 秒。
handleExit()里如果发现 toast 还在,会延迟 1200ms 再导航——否则孩子刚做完就跳页,根本看不见「竹笋 +2」。
4.5 一局游戏的完整事件序列
把上面所有东西串起来,一局 autoplay 刷牙的真实事件顺序是:
点击任务卡 → hash 变成 #/play/brush
└─ PlayView.start()
├─ await progress.init() 读档
├─ await import('@bamboo/game-daily/brush') 加载玩法 chunk
├─ await loadTask('brush', locale) 加载关卡详情 chunk
├─ createGameContext(...) 构造上下文
└─ await mod.mount(ctx) → defineGame.mount → new Phaser.Game
└─ ScrubScene.create()
├─ emit('scene:enter', { scene: 'scrub' })
├─ emit('session:start',{ at: now })
├─ … 玩家操作 / autoplay 模拟输入 …
├─ emit('task:retry', { taskId, step }) ← 每次做错
└─ finishTask()
├─ emit('task:complete', { stars, mistakes, … })
├─ 显示完成卡片(三颗星 + 两个按钮)
└─ emit('session:exit', { reason, progress: 1 })
└─ ctx.requestExit() → PlayView.handleExit()
├─ replay → 卸载重挂(原地重开)
└─ complete → navigate('/tasks')
还有一条路径不在上面的序列里:孩子点左上角「←」,确认弹层后再走 leaveNow()——先停朗读、再 navigate('/tasks'),然后由 onBeforeUnmount 卸载 Phaser 实例。注意这条路径不会发 session:exit,也不会写存档:中途退出不算完成,这是产品口径,不是漏洞。
契约里 requestExit() 与 session:exit.reason 都保留了 'quit' 这个取值,但当前没有任何场景真的发它——属于「接口先留好、场景还没用上」的状态。遇到这类字段时,正确的做法是查一遍全仓有没有生产者(grep 一次就知道),而不是照着类型猜行为。写教程和读代码都一样:类型说明可能性,调用点才说明事实。
对应的责任链:
| 环节 | 代码位置 | 说明 |
|---|---|---|
发 task:complete |
BaseScene.finishTask() |
一局只发一次(completionShown 守卫) |
收 task:complete |
PlayView.handleEvent() → finishTask(stars) |
只处理这一种事件,其余忽略 |
| 写存档 | progress.completeTask(id, stars) |
经 ProgressManager,计算奖励与连续天数 |
| 弹 toast | PlayView.showToast() |
竹笋 / 碎片 / 新贴纸,最多三行 |
| 卸载 | onBeforeUnmount 或 restart() |
unmount() + 清空容器 |
BaseScene.finishTask() 的实现解释了为什么「没有模板会忘记结算」:
protected finishTask(opts: CompletionOptions = {}): void {
if (this.completionShown) return;
this.completionShown = true;
const stars = opts.stars ?? starsForSession(this.session);
const durationMs = this.ctx.services.now() - this.session.startedAt;
this.ctx.emit('task:complete', {
taskId: this.ctx.manifest.id,
stars,
mistakes: this.session.mistakes,
correct: this.session.correct,
total: Math.max(this.session.total, 1),
durationMs,
});
this.sfx('complete');
if (!this.reducedMotion) this.cameras.main.flash(260, 255, 250, 220);
this.showCompletionOverlay(stars, opts.praise ?? this.ctx.task.done);
if (this.autoplay) {
this.time.delayedCall(opts.autoExitMs ?? 450, () => this.exit('complete'));
}
}
注意最后三行:autoplay 与真实操作走的是同一个 finishTask(),唯一区别是前者在 450ms 后自动 exit()。这就是「自动通关能通过 ⇒ 真实操作也能通过」的结构性保证。
4.6 window.__bamboo 调试桥
端到端测试不读 DOM 去猜「玩到第几步了」,而是读一个挂在 window 上的观测对象:
// apps/site/src/shell/debug.ts
export interface BambooDebugBridge {
/** 事件序列:'session:start' / 'scene:enter:<scene>' / 'task:complete:<id>' … */
events: string[];
/** 最新存档快照(每次存档变化都换成一个新对象) */
save: SaveData;
/** 最近一次载入失败的技术详情 */
lastError?: string;
}
const MAX_EVENTS = 600;
export function initDebugBridge(now = Date.now()): BambooDebugBridge {
const w = window;
if (!w.__bamboo) w.__bamboo = { events: [], save: emptySave(now) };
return w.__bamboo;
}
export function recordDebugEvent(type: string, detail?: string): void {
const bridge = initDebugBridge();
bridge.events.push(detail ? type + ':' + detail : type);
if (bridge.events.length > MAX_EVENTS) bridge.events.shift();
}
事件数组上限 600 条,超出后从头丢弃。这个数字不是拍的:一局刷牙大约产生 4–6 条事件(scene:enter / session:start / 若干 task:retry / task:complete / session:exit),20 条端到端用例串行跑在一个页面上也不会爆;而如果一局里出现了上千条事件,那本身就是 bug(比如某个补间每帧都在发 retry),调试桥不该替它兜底。
三个设计要点:
- 形状固定为
{ events, save, lastError? }。文件头注释写得很直接:「不要往上面加字段(加之前先叫上 QA 一起改)」——因为这是测试与开发的共同契约,随便加字段会让断言悄悄失效。 - 生产环境也开着。它只有事件名与存档快照,不含任何个人信息,也不需要联网。反过来,如果只在 DEV 开,就无法验证生产产物里的懒加载与事件顺序。
- 事件是扁平字符串:
task:complete:brush、scene:enter:scrub、session:exit:complete。这样断言不用关心 payload 的类型,也不受存档结构迁移影响。
调试桥在 main.ts 里第一件事就被初始化,早于 Vue 挂载——因为 e2e 可能在一打开页面、用户还没点任何东西时就来读 window.__bamboo.save。每次存档变化后,progress store 会调用 setDebugSave(progressManager.snapshot()),换成一个新对象;如果复用同一个对象,Vue 的响应式与 e2e 的 page.evaluate 都可能读到被就地修改的旧引用。
载入失败时,路由路径是这样的:
catch (cause) {
error.value = t('play.loadFailed'); // 给孩子:一句人话 + 两个出口
recordDebugError(cause instanceof Error ? cause.message : String(cause)); // 给自动化
console.error('[bamboo] 玩法载入失败:' + props.taskId, cause); // 给开发
}
三种受众、三种粒度,缺一不可。最忌讳的是把那串技术细节直接显示在屏幕上——孩子看不懂,家长会以为坏了。
4.7 最小 game 入口逐行读
十个玩法入口的结构完全一致,下面是刷牙这一条(20 行):
// games/daily/src/games/brush.ts
import { getTaskSummary } from '@bamboo/content';
import { defineGame } from '@bamboo/templates/core';
import { DESIGN } from '@bamboo/templates/layout';
import { createScrubScene } from '@bamboo/templates/scrub';
import type { ScrubLevel } from '@bamboo/templates/scrub.logic';
const summary = getTaskSummary('brush')!;
/** 刷牙:房间、工具、目标点全部来自内容 JSON 的 levelScript */
export default defineGame({
manifest: {
id: 'brush',
title: summary.title,
version: '0.1.0',
template: summary.template,
designSize: { width: DESIGN.width, height: DESIGN.height },
ageRange: summary.ageRange,
},
scenes: (ctx) => [createScrubScene(ctx, ctx.task.levelScript as ScrubLevel)],
});
逐行拆开:
| 行 | 做了什么 | 为什么这样写 |
|---|---|---|
import { getTaskSummary } |
从摘要表里取这条任务 | 走主入口而不是 /full,避免 zod 进游戏 chunk |
import { defineGame } |
取工厂函数 | 从 @bamboo/templates/core 子路径进;barrel 还导出 placeholder 场景,它会连带引入 phaser-kit 与 layout |
import { DESIGN } |
750×1334 的设计尺寸 | 布局常量只有一份,e2e 也用同一份 |
import { createScrubScene } |
取刷洗场景工厂 | 一个玩法只 import 自己用到的场景 |
import type { ScrubLevel } |
取关卡脚本类型 | import type 不会产生运行时 import |
getTaskSummary('brush')! |
取摘要,断言非空 | 内容由 content:check 在构建期保证存在;运行时不做兜底 |
manifest.id |
与内容 id 一致 | 这个 id 同时是存档键与 registry 键 |
template: summary.template |
模板 id 来自内容 | 内容的唯一事实来源是 JSON,代码不重复声明 |
scenes: (ctx) => [...] |
构造场景类 | 关卡脚本此时才可用 |
ctx.task.levelScript as ScrubLevel |
类型断言 | 构建期已用 zod 校验,运行时不再解析 |
最后一行值得多说一句。为什么是 as 而不是先 parse 一遍?因为 zod 的 z.object() 是真实的运行时开销:如果把八个模板的 schema 都带进游戏 chunk,每个玩法都要为「已经校验过的数据」再付一次解析成本与体积,而校验结论在构建期就已经确定了。这个取舍写在 defineGame.ts 的注释里,也写在 learn.ts 的注释里:
// games/daily/src/games/learn.ts(节选)
const summary = getTaskSummary('learn');
if (!summary) throw new Error('缺少任务内容:learn');
export default defineGame({
manifest: { id: 'learn', title: summary.title, version: '0.1.0', template: summary.template,
designSize: { width: DESIGN.width, height: DESIGN.height }, ageRange: summary.ageRange },
scenes: (ctx) => [createLearnScene(ctx, ctx.task.levelScript as LearnLevel)],
});
十条入口里出现了两种写法:brush 用 !,learn 用显式 throw。两者在行为上等价(都是「缺内容就立刻失败」),差别只是错误信息——throw new Error('缺少任务内容:learn') 在控制台里更可读。如果要把它们统一,建议统一成后者,因为断言失败时 ! 只会留下一句 Cannot read properties of undefined。
新增一个玩法要动的地方
把这一章的内容收束成一张清单:新增一条任务,最少要改四处——packages/content/src/tasks/<id>.json(摘要)、packages/content/src/details/<id>.game.json(关卡)、games/daily/src/games/<id>.ts(入口)、apps/site/src/registry.ts(一行动态 import)。如果需要新模板,还要在 TEMPLATE_IDS、*.logic.ts、场景文件与 schemas.ts 四处登记。少任何一处,typecheck 或 content:check 都会在同一分钟内报出来。
契约讲完了,接下来该看那个「构造 context、持有 Phaser、处理事件」的家伙了:Vue 应用外壳——小屋、任务、装扮、日记、家长页与玩法宿主,它们如何共享同一份状态。