第09章 - 平台适配层与音频实现
9.1 平台层的组成
packages/platform 是「游戏不碰环境」这条铁律的落点:它把存储、音频、生命周期、分享、遥测全部实现为 PlatformServices(第 4 章的接口),由外壳在创建 GameContext 时注入。它由两个文件组成:
packages/platform/src/
├─ runtime.ts # 运行环境探测:web / tauri / capacitor(不 import 任何壳 SDK)
└─ web.ts # Web 实现:IndexedDB / WebAudio / TTS / visibilitychange / share
index.ts 只是把两者导出。这个包不依赖 Phaser、不依赖 Vue,所以桌面壳、安卓壳、将来的小程序壳都能复用同一份实现(或只替换其中一两个子实现)。
9.2 运行环境探测:面向三种壳
同一份站点产物(apps/site/dist)会被三种壳加载:浏览器、Tauri 桌面壳、Capacitor 安卓壳。runtime.ts 用「运行时桥接对象」探测当前环境:
export type RuntimeKind = 'web' | 'tauri' | 'capacitor';
export function detectRuntime(): RuntimeKind {
const w = window as RuntimeWindow;
if (w.__TAURI_INTERNALS__ !== undefined || w.__TAURI__ !== undefined) return 'tauri';
if (w.Capacitor?.isNativePlatform?.() === true) return 'capacitor';
return 'web';
}
export function isNativeRuntime(): boolean {
return detectRuntime() !== 'web';
}
文件头注释写明了设计理由:
这里不 import 任何壳的 SDK:原生插件全部通过运行时桥接对象取用,好处是 Web 包体积零增长,也不会有插件在浏览器里报错。
两个原生能力的取用方式:
/**
* 安卓系统 TTS(@capacitor-community/text-to-speech 注册到桥上)。
* 必须走原生:**Android WebView 没有实现 Web Speech API**,
* 只靠 speechSynthesis 的话,安卓版会全程没有旁白。
*/
export function nativeTextToSpeech(): NativeTextToSpeech | null {
const plugin = capacitor()?.Plugins?.['TextToSpeech'];
return (plugin as NativeTextToSpeech | undefined) ?? null;
}
export function nativeApp(): NativeApp | null {
const plugin = capacitor()?.Plugins?.['App'];
return (plugin as NativeApp | undefined) ?? null; // 安卓返回键
}
nativeApp() 的消费方是外壳的 backButton.ts(第 5 章)——浏览器里返回 null,直接跳过,没有分支污染。
9.3 存档存储:IndexedDB + 内存降级
createIdbProgressStore() 返回一个 ProgressStore:
const DB_NAME = 'chags';
const STORE_NAME = 'kv';
const SAVE_KEY = 'save';
/** IndexedDB 存档;不可用时自动降级为内存(测试 / 隐私模式) */
export function createIdbProgressStore(): ProgressStore {
let memory: SaveData | null = null;
const dbPromise = openDb();
return {
async load(): Promise<SaveData> {
const db = await dbPromise;
if (!db) return memory ?? emptySave(Date.now());
return new Promise((resolve) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const request = tx.objectStore(STORE_NAME).get(SAVE_KEY);
request.onsuccess = () => resolve(migrate(request.result, Date.now()));
request.onerror = () => resolve(emptySave(Date.now()));
});
},
async save(data: SaveData): Promise<void> {
memory = data; // 写穿内存:即使 DB 挂了,本次会话仍一致
const db = await dbPromise;
if (!db) return;
/* put 到 'save' 键 */
},
};
}
设计要点:
- 所有失败路径都返回「空档」而不是抛错 —— 存档坏了也绝不能让孩子进不去游戏;
- 读路径过
migrate()(第 10 章):schemaVersion 不匹配或结构不对的旧档会安全降级; - 写路径先写内存:隐私模式 / 无 IndexedDB 的环境(自动化测试)中,同一次会话内的行为与正常环境一致;
- 数据库结构只有一个 object store (
kv),一个键 (save) —— MVP 无后端,不需要更复杂的设计。
9.4 音频总线:零音频文件的音效
阶段 0 的音频策略是零资源文件:音效用 WebAudio 振荡器实时合成,旁白用浏览器 TTS。六个音效的合成配方(createWebAudioBus):
| SfxKey | 合成方式 | 语义 |
|---|---|---|
tap |
三角波 560Hz / 80ms | 一般点击 |
correct |
660Hz → 880Hz(间隔 100ms) | 做对了 |
wrong |
三角波 220Hz / 200ms | 需要再想想 |
splash |
锯齿波 300Hz / 250ms | 入水(大象、石头) |
unlock |
523 → 659 → 784Hz 三连 | 里程碑(换代) |
complete |
523 → 659 → 784 → 1046Hz 四连 | 完成 |
底层的合成函数是标准的三参数包络:
function tone(freq: number, duration: number, delay = 0, type: OscillatorType = 'sine'): void {
const ctx = context();
if (!ctx || muted) return;
const start = ctx.currentTime + delay;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, start);
gain.gain.setValueAtTime(0.0001, start);
gain.gain.exponentialRampToValueAtTime(0.22, start + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
osc.connect(gain).connect(ctx.destination);
osc.start(start);
osc.stop(start + duration + 0.05);
}
除固定音效外还有可调音高的一声 tone(freq, durationMs):tap 模板用它做「进度听得见」——盘古每撑高一丈,音调就高一点(300 + eased * 520)。
解锁与静音
async unlock(): Promise<void> {
const ctx = context();
if (ctx && ctx.state === 'suspended') await ctx.resume();
// iOS 上语音合成同样需要一次用户手势内的调用
if (typeof speechSynthesis !== 'undefined') {
speechSynthesis.resume();
}
}
unlock() 由外壳在第一次 pointerdown 时调用(第 5 章),之后游戏内可以自由播音。setMuted(true) 会连带停掉正在读的语音。
将来换成真音频文件时,要改的只有这个 AudioBus 实现:接口(play / tone / speak / setMuted)保持不变,游戏代码一行不动。
9.5 TTS 旁白:两条路径 + 静默降级
speak(text: string, locale: Locale): void {
if (muted) return;
// ① 原生 TTS(安卓):WebView 没有 Web Speech API,必须走系统语音
const native = nativeTextToSpeech();
if (native) {
void native.speak({
text, lang: speechLang(locale),
rate: locale === 'en' ? 0.95 : 0.9, pitch: 1.05, volume: 1,
}).catch(() => {
// 安卓设备没装语音包时会失败;静默降级,绝不让游戏卡住
});
return;
}
// ② Web Speech API(桌面 WebView2 / 浏览器)
if (typeof speechSynthesis === 'undefined') return;
speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = speechLang(locale); // zh-CN / en-US
utterance.rate = locale === 'en' ? 0.95 : 0.9;
utterance.pitch = 1.05;
// 切语言时优先挑该语言的嗓音,没有就用系统默认
const voices = speechSynthesis.getVoices();
const match = voices.find((v) => v.lang.toLowerCase().startsWith(locale === 'en' ? 'en' : 'zh'));
if (match) utterance.voice = match;
speechSynthesis.speak(utterance);
}
三条规则值得记住:
- 原生优先:安卓 WebView 里
speechSynthesis不存在,只靠它在安卓版会全程没旁白 —— 这是打包方案(第 14 章)里「为什么用原生 TTS」的原因; - 语速按语言区分:中文 0.9、英文 0.95,比默认语速慢 —— 面向小朋友;
- 静默降级:设备没装语音包、API 不存在,都不报错、不卡流程,最差情况只留下音效。
游戏侧使用 BaseScene.speak(text),由 Toolkit 代取 ctx.locale;模板里对旁白的调用统一挂在 if (!this.autoplay) 下 —— 无头测试不放语音。
9.6 生命周期总线(已实现,尚未接线)
export function createVisibilityLifecycle(): LifecycleBus {
return {
onPause(cb: () => void): () => void {
const handler = () => { if (document.visibilityState === 'hidden') cb(); };
document.addEventListener('visibilitychange', handler);
return () => document.removeEventListener('visibilitychange', handler);
},
onResume(cb: () => void): () => void { /* visible 时回调 */ },
};
}
需要说明的是当前状态:LifecycleBus 已实现,GameModule.pause()/resume()(loop.sleep()/wake())也已实现,但两端尚未接线 —— 外壳还没有在 visibilitychange 时调用游戏的 pause/resume。切后台时 Phaser 自身会因 RAF 停止而冻结画面,所以当前体验没有明显问题;接线是将来做「后台暂停 + 恢复提示」或微信小游戏时的既定入口。这类「接口先行、接线后补」的模块在项目里会被明确记录,而不是装作已经完成。
9.7 遥测与分享:隐私优先
/** 默认不采集任何信息,只在本地留一份事件轨迹,方便调试与将来做家长报告 */
export function createLocalTelemetry(): Telemetry {
const buffer: { type: string; at: number }[] = [];
return {
track(type): void {
buffer.push({ type, at: Date.now() });
if (buffer.length > 500) buffer.shift();
},
async flush(): Promise<void> {
// 刻意不发送:儿童产品默认不上报
},
};
}
- 遥测只记录事件类型与时间,最多 500 条,滚动丢弃;
flush()是空实现 —— 「什么都不发」是儿童产品的默认值,不是没做完; - 分享走
navigator.share(有则调、无则静默):
async share(opts): Promise<void> {
const nav = navigator as Navigator & { share?: (data) => Promise<void> };
if (typeof nav.share === 'function') {
await nav.share({ title: opts.title, text: opts.text, url: opts.url });
}
},
9.8 时钟注入:now()
PlatformServices.now 是 () => Date.now() 的一层薄封装,它的意义不在实现而在约束:所有模板与逻辑代码取时间一律通过 services.now()(或作为参数注入),配合 lint 禁止 Date.now(),保证:
- 单测里可以注入假时钟,测试确定性;
- 慢设备上的长按判定以墙钟为准(夸父的教训),而不是帧率相关的累计值;
- 未来如果做「家长报告」之类的功能,时间源可以统一替换。
9.9 替换实现:这是留好的门
平台层的全部价值在「将来换实现不改游戏」这一刻兑现。三个具体的替换场景:
| 场景 | 替换点 | 游戏侧改动 |
|---|---|---|
| 云存档 | 换 ProgressStore 实现(在 load/save 里加远端同步即可) |
无 |
| 真人配音 | 换 AudioBus.speak 实现(按 locale 取音频文件而非 TTS) |
无 |
| 微信小游戏 | 新增一个 wx 版 PlatformServices(存储换 wx.setStorage,音频换 wx.createInnerAudioContext) |
无 |
项目对最后一项有诚实的备注:微信小游戏不是「换个适配器就能上」,还需要处理 WebGL 上下文差异、包体限制、资源加载器替换;如果小游戏是硬性首发要求,应当先做 PoC 再定(技术方案 3.8)。
9.10 小结
runtime.ts做三种壳的环境探测,全部通过运行时桥接,不把任何壳 SDK 打进 Web 包;- 存档是 IndexedDB + 内存降级 + 迁移兜底,所有失败路径都安全;
- 音频是「振荡器合成音效 + TTS 旁白」的零资源方案,安卓走原生 TTS、iOS 依赖手势解锁,全部静默降级;
- 遥测默认不发、分享尽力而为,都是儿童产品的隐私底线;
LifecycleBus与 pause/resume 已实现但尚未接线,属于「接口先行」的预留。
下一章继续沿数据向下:进度存档与复习调度(packages/progress)。