第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; // 把控制权交还外壳
}
三个设计点值得展开:
content是已经翻译好的。外壳调用getAllusion(id, locale)合并译文补丁后再交给游戏,所以游戏代码里不存在语言分支 —— 新增一种语言 = 加一份翻译 JSON,游戏一行不改(第 11 章)。services是唯一的环境出口。游戏禁止直接碰localStorage/fetch/wx.*,需要用什么都从 context 上拿。这是将来上微信小游戏或云存档时的唯一低成本出口。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 应用外壳。