第04章 - 核心契约与游戏生命周期

4.1 契约层的位置

packages/game-kit 是整个仓库里最薄、也最重要的包:它只有一份 contract.ts(约 130 行),不含 Phaser、不含浏览器 API,两侧都依赖它:

  • 游戏侧:每个游戏包只认识 GameContext 与 GameModule,不知道外壳长什么样、进度存在哪里;
  • 外壳侧:Vue 只通过 registry + mount(ctx) 使用游戏,不知道任何具体玩法。

这样做的直接收益是两边可以并行开发、独立替换:换外壳(比如做微信小游戏壳)不用动游戏;加游戏不用动外壳。

packages/game-kit/src/contract.ts
├─ GameManifest      # 游戏自述:id、标题、模板、设计分辨率、年龄、技能、年代
├─ GameEventMap      # 事件表:场景、幕、选择、提示、完成、卡片、退出
├─ GameContext       # 游戏运行时拿到的一切:容器、内容、服务、语言、调试开关
├─ GameModule        # 游戏的对外接口:mount / unmount / pause / resume
├─ PlatformServices  # 平台出口:storage / audio / lifecycle / share / telemetry / now
└─ 存档类型           # SaveData / AllusionProgress / ReviewState / SAVE_SCHEMA_VERSION

4.2 GameManifest:游戏的自述文件

export interface GameManifest {
  id: string;                        // 'caochong-elephant',与典故内容 id 一致
  title: string;                     // '曹冲称象'
  version: string;                   // semver,用于存档迁移
  template: TemplateId;              // 主玩法模板 id,用于筛选与展示
  orientation: 'portrait' | 'landscape';
  designSize: { width: number; height: number };   // 逻辑分辨率 750×1334
  ageRange: [number, number];        // [7, 10]
  sessionSeconds: [number, number];  // 单局时长 [150, 300]
  skills: string[];                  // ['观察', '等量代换', '浮力']
  curriculum: { dynasty: string; yearApprox?: number; figures: string[] };
  assets: { cover: string };         // 阶段 0 全部 'none'(无图片资源)
}

一个真实例子(games/pangu/src/manifest.ts):

import type { GameManifest } from '@chags/game-kit';
import { DESIGN } from '@chags/templates';

export const manifest: GameManifest = {
  id: 'pangu',
  title: '盘古开天辟地',
  version: '0.1.0',
  template: 'grow',
  orientation: 'portrait',
  designSize: { width: DESIGN.width, height: DESIGN.height },
  ageRange: [7, 10],
  sessionSeconds: [150, 300],
  skills: ['想象', '坚持', '联想'],
  curriculum: { dynasty: '上古', yearApprox: -9999, figures: ['盘古'] },
  assets: { cover: 'none' },
};

关键点:

  • id 必须与内容 JSON 的 id 一致。外壳用它把「内容」与「游戏包」对起来:getAllusion(manifest.id, locale) 找不到内容会直接抛错。注册表一致性由单测强制(第 13 章);
  • designSize 用 DESIGN 常量(750×1334),所有坐标与判定都在这套逻辑分辨率里,缩放是 Phaser 的事(Scale.FIT + CENTER_BOTH);
  • curriculum.yearApprox 与内容里的年代相同,用于典故馆排序与将来的历史长卷;
  • 阶段 0 的美术策略是零图片资源,所以 assets.cover 是 'none',封面在大厅里由 DOM 直接用文字渲染。

4.3 GameContext:游戏运行时拿到的一切

export interface GameContext {
  container: HTMLElement;            // Vue 提供的挂载容器
  manifest: GameManifest;
  content: AllusionContent;          // ★ 已经是目标语言的翻译结果
  services: PlatformServices;        // 存档 / 音频 / 生命周期 / 分享 / 遥测
  locale: 'zh-CN' | 'en';            // 语音与格式化用
  reducedMotion: boolean;            // prefers-reduced-motion
  debug?: DebugOptions;              // ?autoplay=1&seed=... 解析结果
  emit<K extends keyof GameEventMap>(type: K, payload: GameEventMap[K]): void;
  requestExit(): void;               // 把控制权交还外壳
}

三个设计点值得展开:

  1. content 是已经翻译好的。外壳调用 getAllusion(id, locale) 合并译文补丁后再交给游戏,所以游戏代码里不存在语言分支 —— 新增一种语言 = 加一份翻译 JSON,游戏一行不改(第 11 章)。
  2. services 是唯一的环境出口。游戏禁止直接碰 localStorage / fetch / wx.*,需要用什么都从 context 上拿。这是将来上微信小游戏或云存档时的唯一低成本出口。
  3. debug 让每个游戏支持无头直达。autoplay 由各模板/场景消费(自动走完流程),seed 当前透传预留。

4.4 PlatformServices:平台出口的接口形状

/** 唯一的平台出口:游戏不许直接碰 localStorage / fetch / wx.* */
export interface PlatformServices {
  storage: ProgressStore;         // Web: IndexedDB   | 小游戏: wx.setStorage
  audio: AudioBus;                // 含 iOS 解锁与静音策略
  lifecycle: LifecycleBus;        // 前后台切换、暂停
  share(opts: ShareOptions): Promise<void>;
  telemetry: Telemetry;           // 默认 no-op(本地缓冲,绝不上报个人信息)
  now(): number;
}

各子接口的要点:

export interface ProgressStore {
  load(): Promise<SaveData>;
  save(data: SaveData): Promise<void>;
}

export interface AudioBus {
  unlock(): Promise<void>;                      // iOS / 微信必须由用户手势触发解锁
  play(key: SfxKey): void;                      // 六种合成音效
  tone(freq: number, durationMs: number): void; // 指定音高的一声(进度"听得见")
  speak(text: string, locale: Locale): void;    // 旁白:按语言选嗓音
  stopSpeech(): void;
  setMuted(muted: boolean): void;
  isMuted(): boolean;
}

export interface LifecycleBus {
  onPause(cb: () => void): () => void;
  onResume(cb: () => void): () => void;
}

export interface Telemetry {
  track<K extends keyof GameEventMap>(type: K, payload: GameEventMap[K]): void;
  flush(): Promise<void>;
}

SfxKey 只有六个:tap / correct / wrong / splash / unlock / complete。这六个音效全部由 WebAudio 振荡器实时合成(零音频文件),具体合成方式见第 9 章。

注意 now():所有需要计时的地方(长按进度、等待玩法、空闲提示、会话时长)都通过 services.now() 而不是 Date.now()。这样时钟可注入、可测试,而且和「逻辑层禁用 Date.now」的 lint 规则相互配合。

4.5 GameEventMap:事件就是「可观测性协议」

export interface GameEventMap {
  'session:start':  { at: number };
  'scene:enter':    { scene: string };
  'beat:enter':     { beatId: string };
  'choice:made':    { beatId: string; choiceId: string; correct: boolean; latencyMs: number };
  'hint:used':      { beatId: string; level: number };
  'level:complete': {
    levelId: string;
    stars: 0 | 1 | 2 | 3;
    mistakes: number;
    correct: number;
    total: number;
    durationMs: number;
  };
  'card:unlocked':  { allusionId: string; cardId: string };
  'session:exit':   { reason: 'complete' | 'quit' | 'background'; progress: number };
}

这些事件是四个消费者共用的协议:

消费者 用途
外壳(PlayView) 收到 level:complete 时把星星与评级写进 ProgressManager
调试桥 每次 emit 追加到 window.__chags.events(生产环境也开着,因为不含个人信息)
端到端测试 用 events(page) 断言流程走到了哪一步 —— 不靠像素、不靠时序猜测
遥测 Telemetry.track 只做本地缓冲,绝不上报

契约还规定了事件的粒度约定:beat:enter 的 beatId 被各模板复用来报告关键节点,例如:

  • story 模板报告每一幕的 beat.id;
  • substitution 模板报告 aboard / mark / substitute / weighed;
  • tap 模板报告 milestone:<id> 与 complete;
  • place / sort 模板报告每个槽位/物品的 id;
  • reward 模板报告 card:unlocked 与 level:complete。

端到端测试因此可以写「到河边时尚未通关」「三条里程碑**按序各出现一次」这类断言 —— 哪怕把暂停逻辑删掉、把里程碑合并,测试也能照出来。

4.6 GameModule:生命周期四件套

export interface GameModule {
  manifest: GameManifest;
  mount(ctx: GameContext): Promise<void>;   // 内部 new Phaser.Game(...)
  unmount(): Promise<void>;                 // 必须 game.destroy(true),零全局残留
  pause(): void;
  resume(): void;
}

生命周期的正确性依赖宿主(Vue)与游戏(Phaser)两边配合:

时机 宿主(PlayView) 游戏(defineGame 产物)
进入路由 动态 import 游戏包 —
挂载 mount(createGameContext(...)) new Phaser.Game({ scenes: [story, ...stages, reward] })
切后台 (由 platform.lifecycle 通知) game.loop.sleep()
回前台 — game.loop.wake()
离开路由 await unmount() game.destroy(true) 并置空

pause() / resume() 实现得很朴素 —— loop.sleep() / loop.wake(),但它们是必须存在的钩子:安卓壳里切后台、原生返回键、将来微信小游戏的后台切换都要用到。宿主在卸载时的 disposed 标记与 game.destroy(true) 同样关键,否则 HMR 或路由切换会叠出多个 canvas 与 RAF 循环(第 16 章有复现记录)。

4.7 defineGame:三段式装配

绝大多数游戏包的 index.ts 只有二三十行,因为「分幕故事 + 玩法 + 典故卡」这三段外加上面整张生命周期表的处理,被收进了 defineGame():

export interface GameDefinition {
  manifest: GameManifest;
  /** 分幕插图:每个典故自己画 */
  drawBeat?: (g, index, rect) => void;
  /** 玩法场景。拿到内容与一局状态,返回这一局的场景编排。 */
  gameplay: (ctx: GameContext, session: SessionState) => GameplayStage;
}

export interface GameplayStage {
  entry: string;                             // 分幕之后进入哪一幕
  scenes: Phaser.Types.Scenes.SceneType[];   // 中间的玩法场景(一到多个)
}

export function defineGame(definition: GameDefinition): GameModule {
  let game: Phaser.Game | null = null;

  return {
    manifest: definition.manifest,

    async mount(ctx) {
      const session = createSessionState(ctx.services.now());
      const stage = definition.gameplay(ctx, session);
      game = createPhaserGame(ctx, {
        scenes: [
          createStoryScene(ctx, {
            beats: ctx.content.story,
            nextSceneKey: stage.entry,
            drawBeat: definition.drawBeat,
          }),
          ...stage.scenes,
          createRewardScene(ctx, {
            allusionId: ctx.content.id,
            title: ctx.content.title,
            pinyin: ctx.content.pinyin,
            moral: ctx.content.moral,
            keywords: ctx.content.keywords,
            source: ctx.content.source,
            session,
          }),
        ],
      });
    },

    async unmount() {
      game?.destroy(true);
      game = null;
    },

    pause() { game?.loop.sleep(); },
    resume() { game?.loop.wake(); },
  };
}

于是「一个典故」在游戏包里只剩两件事:怎么画分幕、玩法场景怎么拼。盘古的例子:

// games/pangu/src/index.ts
export default defineGame({
  manifest,
  drawBeat,

  gameplay: (ctx, session) => ({
    entry: SCENE_KEYS.chaos,
    scenes: [
      // 开:连点蛋壳,混沌炸开(这个场景是盘古专属,不进模板库)
      createChaosScene(ctx, { nextSceneKey: SCENE_KEYS.tap, tapsToBreak: 4 }),
      // 撑:把天一点点顶上去,撑够一万八千年
      createTapScene(ctx, {
        level: tapLevel(ctx.content),
        session,
        nextSceneKey: SCENE_KEYS.place,
        titleKey: 'scene.hold',
        drawStage: drawHoldStage,
        drawFinale: drawHoldFinale,
      }),
      // 化:把自己一件件放回世界
      createPlaceScene(ctx, {
        level: placeLevel(ctx.content),
        session,
        nextSceneKey: SCENE_KEYS.reward,
        drawPiece,
        drawOrb,
        reveal: ctx.content.keyMoment.reveal,
      }),
    ],
  }),
});

这段代码值得逐行读:

  • entry: SCENE_KEYS.chaos —— 两幕故事结束后进入的第一个玩法场景;
  • 三个 create*Scene 工厂返回的是场景类,不是实例,交给 defineGame 统一排进 Phaser 的 scene 列表;
  • 每个场景都拿到 nextSceneKey,负责在自己结束时启动下一个 —— 场景间的串联是显式的;
  • level: tapLevel(ctx.content) —— 关卡数据从内容 JSON 里取(stageScript(content, 'tap') + zod 解析),游戏包不写死数字;
  • drawStage / drawPiece / drawOrb / drawBeat 全是绘制回调,模板只提供循环与判定,画面完全由游戏包画 —— 这是「四个坚持类典故(盘古/精卫/夸父/愚公)共用 tap 模板却毫不相干」的根本保证。

4.8 一次完整运行的时序

把上面所有零件拼起来,一位玩家从大厅点进「盘古开天辟地」到回到大厅的时序是:

HallView      点击「开始玩」→ navigate('/play/pangu')
router        hash 变化 → useRoute 更新
PlayView      registry['pangu']() 动态 import(独立 chunk)
              createGameContext(manifest, host, locale, hooks)
                ├─ content = getAllusion('pangu', locale)   ← 已翻译
                ├─ services = platform(IndexedDB/WebAudio/TTS)
                └─ debug = readDebugOptions()               ← ?autoplay 等
              mod.mount(ctx)
defineGame    createStoryScene + [chaos, tap, place] + createRewardScene
              new Phaser.Game({ scene: [...] })

story scene   session:start → beat:enter:s1 → beat:enter:s2 → chaos
chaos scene   连点 4 次 → beat:enter:burst → tap
tap scene     连点 30 次 → level 进度 → beat:enter:complete → place
place scene   6 个光球归位 → beat:enter:<slotId> ×6 → reward
reward scene  card:unlocked + level:complete → 显示典故卡
              「收下」→ session:exit → ctx.requestExit()
PlayView       hooks.onExit → navigate('/') → onBeforeUnmount → unmount()
defineGame    game.destroy(true)
外壳           level:complete 时已写入 ProgressManager → IndexedDB(刷新后仍在)

这条链路上的每一个事件名、每一次跳转,在端到端测试里都有断言(第 13 章),这就是「可观测」的含义:流程的每一步都是协议的一部分,而不是实现细节。

4.9 契约如何强制三条铁律

回到第一章的三条架构铁律,看看契约在工程上如何兜底:

铁律 契约/工程手段
逻辑与渲染分离 模板提供 *.logic.ts 纯函数;lint 在逻辑目录禁用 Math.random 与 Date.now
游戏不碰环境 GameContext.services 是唯一出口;游戏包里没有 localStorage / fetch 的直接引用
无头直达 DebugOptions + 各模板/场景的 autoplay 分支 + window.__chags 观测桥

4.10 小结

  • game-kit 是两侧共同依赖的契约:GameManifest 自述、GameContext 注入、GameModule 生命周期、GameEventMap 观测协议;
  • defineGame() 把「故事 + 玩法 + 典故卡 + 生命周期」标准化,游戏包只回答「怎么画、怎么拼」;
  • 事件的粒度到「每一幕、每个槽位、每条里程碑」,让自动化测试能对流程本身下断言;
  • 宿主(Vue)与游戏(Phaser)之间只通过契约交互,shallowRef、destroy(true)、disposed 是宿主侧的三条硬性纪律。

下一章进入契约的宿主一侧:Vue 应用外壳。