第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,也不需要新建对象。

四件事值得逐条解释:

  1. game 是闭包变量,不是模块级变量。每次 defineGame() 生成一个独立的模块对象,理论上一页可以同时挂两个玩法(当前没用到,但接口允许)。
  2. scenes 是一个接收 ctx 的函数,而不是数组。因为场景需要用到 ctx.task.levelScript——关卡数据是外壳在 mount 之前才加载好的,工厂函数只能在拿到 ctx 之后才能构造场景类。
  3. mount 是 async 但内部并没有 await:createPhaserGame 是同步返回实例的。声明成 async 是为了满足契约,也给将来「等首帧渲染完再 resolve」留了口子。
  4. 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'),
};

三个约束同时生效:

  1. 类型是 Record<TaskId, GameLoader>:TASK_IDS 里少一个键,typecheck 直接报 missing property;
  2. 值必须是函数:Vite 才能把每个 import() 切成独立 chunk;写成 import brush from '...' 会把十个玩法打进同一个 chunk;
  3. 子路径由 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),调试桥不该替它兜底。

三个设计要点:

  1. 形状固定为 { events, save, lastError? }。文件头注释写得很直接:「不要往上面加字段(加之前先叫上 QA 一起改)」——因为这是测试与开发的共同契约,随便加字段会让断言悄悄失效。
  2. 生产环境也开着。它只有事件名与存档快照,不含任何个人信息,也不需要联网。反过来,如果只在 DEV 开,就无法验证生产产物里的懒加载与事件顺序。
  3. 事件是扁平字符串: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 应用外壳——小屋、任务、装扮、日记、家长页与玩法宿主,它们如何共享同一份状态。


← 上一章 目录 下一章 →