第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);
}

三条规则值得记住:

  1. 原生优先:安卓 WebView 里 speechSynthesis 不存在,只靠它在安卓版会全程没旁白 —— 这是打包方案(第 14 章)里「为什么用原生 TTS」的原因;
  2. 语速按语言区分:中文 0.9、英文 0.95,比默认语速慢 —— 面向小朋友;
  3. 静默降级:设备没装语音包、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)。