第09章 - 平台适配层与音频实现
平台层(packages/platform)是游戏代码与真实运行环境之间唯一的接缝。存档、音效、朗读、前后台切换、本地事件记录全部从这里出去;十个玩法与八个模板里没有一处 localStorage、IndexedDB、fetch。这一层只有四个源文件——web.ts、runtime.ts、index.ts 与外壳的 shell/platform.ts——却决定了三件事能不能成立:进度不会静默丢失、声音在没有音频文件时也能响、同一份站点产物能在浏览器、Tauri 桌面壳与 Capacitor 安卓壳里都工作。
9.1 PlatformServices:游戏唯一被允许触碰环境的入口
契约在 packages/game-kit/src/contract.ts,平台实现只负责把它填满:
// packages/game-kit/src/contract.ts
export interface PlatformServices {
storage: ProgressStore;
audio: AudioBus;
lifecycle: LifecycleBus;
telemetry: Telemetry;
now(): number;
}
五个成员各有明确边界:
| 成员 | 能力 | 游戏侧用法 |
|---|---|---|
storage |
load() / save(data) |
只在读到 GameContext 时拿到,实际存档由外壳的 ProgressManager 调用;玩法只发 task:complete,不自己写存档 |
audio |
unlock / play / tone / speak / stopSpeech / setMuted / isMuted |
场景里放音效、念旁白;按钮文案与语音都走同一个静音状态 |
lifecycle |
onPause / onResume,各自返回解绑函数 |
宿主在切后台时暂停场景并停朗读,回前台再恢复 |
telemetry |
track(type, payload) / flush() |
只留在本机,默认不上报 |
now() |
当前毫秒时间戳 | 让纯逻辑层可以注入假时钟;测试不依赖真实时间 |
ProgressStore 本身只有两个方法,读与写都是异步的:load() 返回一份 SaveData,save(data) 负责落盘。平台层刻意不提供“按 key 读写”的通用 KV 接口,因为那会诱使界面层自己拼存档;只给整份对象,迁移与体检就只能集中在 @bamboo/progress。now() 同样不是装饰:所有日期判断都以它为准,单测注入一个固定时间戳,就能稳定复现“跨天衰减”“连续三天得贴纸”这类与真实时钟强相关的行为,而不必用假定时器。
游戏侧拿到的是构造好的上下文,而不是全局对象:
// apps/site/src/shell/createGameContext.ts(节选)
return {
container,
manifest,
task,
services: platform,
locale,
reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
debug: readDebugOptions(),
completedBefore,
// …
};
这条约束还有一层“依赖方向”的意义:game-kit 只声明接口,platform 只实现接口,games/* 只消费接口。三者谁都不 import 谁的具体实现,所以替换实现不会产生循环依赖,也不会把 IndexedDB 或语音 API 的兼容代码拖进 Phaser 的 chunk。打包结果也能验证这一点:vendor-phaser 里没有平台层代码,平台层里也没有一行 import Phaser。
这条约束的价值在换壳时才显现:要加云存档,只需换掉 storage 的实现;要加真配音,只改 audio;玩法与模板一行不用动。反过来,如果某天在 games/daily 里看到 window.localStorage,那就是一个架构 bug,而不是“小技巧”。
9.2 三种运行环境:探测而不依赖 SDK
同一份 apps/site/dist 会被三种壳加载:浏览器(含 PWA)、Tauri 2 桌面壳、Capacitor 8 安卓壳。平台层不 import 任何壳的 SDK,只在运行时看全局标记:
// packages/platform/src/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';
}
三种环境真正的差异只有两处:有没有原生 TTS、有没有系统返回键。渲染、路由、存档、语音降级策略全部共用同一套代码,所以“同一份 dist 喂给两个壳”不是口号,而是默认路径。Web 包不会因为多了一个桌面壳就变大,桌面壳也不会因为多了一个安卓插件而在启动时报“插件未定义”。
不 import SDK 带来两个直接收益:Web 包体积零增长;浏览器里不会因为插件未注册而抛错。需要原生能力时,从运行时桥接对象上按名字取,取不到就返回 null:
// packages/platform/src/runtime.ts
export interface NativeTextToSpeech {
speak(options: {
text: string; lang?: string; rate?: number; pitch?: number;
volume?: number; category?: string;
}): Promise<void>;
stop(): Promise<void>;
}
export function nativeTextToSpeech(): NativeTextToSpeech | null {
const plugin = capacitor()?.Plugins?.['TextToSpeech'];
return (plugin as NativeTextToSpeech | undefined) ?? null;
}
export interface NativeApp {
addListener(event: 'backButton', callback: () => void): void;
exitApp(): Promise<void>;
}
export function nativeApp(): NativeApp | null {
const plugin = capacitor()?.Plugins?.['App'];
return (plugin as NativeApp | undefined) ?? null;
}
nativeTextToSpeech() 不是可选的锦上添花:Android WebView 没有实现 Web Speech API。如果只依赖 speechSynthesis,安卓版会全程没有旁白,而且不会报错。第 9.6 节会看到 speak() 如何优先走原生桥。
9.3 IndexedDB 存档:三秒超时与内存兜底
存档只有一份,位置固定:
// packages/platform/src/web.ts
const DB_NAME = 'bamboobuddy';
const STORE_NAME = 'kv';
const SAVE_KEY = 'save';
打开数据库的代码比常规写法多了一个计时器,因为无痕模式、存储被禁、旧标签页占着版本升级时,indexedDB.open 的回调可能永远不来:
function openDb(): Promise<IDBDatabase | null> {
return new Promise((resolve) => {
if (typeof indexedDB === 'undefined') { resolve(null); return; }
const timer = setTimeout(() => resolve(null), 3000);
const request = indexedDB.open(DB_NAME, 1);
const settle = (db: IDBDatabase | null): void => { clearTimeout(timer); resolve(db); };
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) db.createObjectStore(STORE_NAME);
};
request.onsuccess = () => settle(request.result);
request.onerror = () => settle(null);
request.onblocked = () => settle(null);
});
}
为什么不用 localStorage?一是容量只有几 MB,日记录累积后容易顶到上限;二是它只有同步 API,写入时会阻塞主线程,正好卡在 Phaser 渲染的关键帧上;三是隐私模式下的行为并不一致。IndexedDB 的事务模型更适合“整份存档一次写入”,失败也能通过 onerror / onabort 明确感知。
打开失败不抛错,而是降级为内存存档:孩子这次仍然能玩,只是关掉页面后进度消失。这个选择是刻意的——启动流程绝不能挂在一个可能永不回调的 API 上。如果 open 被其他标签页阻塞,也只按失败处理,不会去弹“请关闭其他标签页”之类的技术提示;对 4–8 岁的使用者来说,那不是他能处理的错误。
数据库只有 1 个版本、1 个对象仓库、1 个键。没有按天分表,也没有把每局记录单独存一条:整份存档是一个普通对象,大小在几十 KB 量级,一次事务写完,天然避免“写了一半”的中间态。日记录只保留最近 30 天,这一步在 migrate() 与 pruneDays() 里完成(第 10 章会展开),平台层只负责把最终对象放进 save 这个键。
读取时还有两层兜底:load() 把数据库里的原始对象交给 @bamboo/progress 的 migrate() 体检,迁移或解析抛错就返回空档;写入时先更新内存,再尝试写库,写失败会把异常抛回调用方。后者是第 9.4 节的主题。
另外,createWebPlatform() 在可用时会申请一次持久存储:
if (typeof navigator !== 'undefined' && navigator.storage?.persist) {
void navigator.storage.persist();
}
移动端浏览器在存储压力下可能清理 IndexedDB,申请持久化能降低概率,但不能保证;所以真正的保障仍然是“写失败要说出来”。
9.4 写失败必须被看见
“存档静默丢失”是这类产品最坏的结果:孩子今天做了五件小事,第二天打开什么都没有。平台层不掩盖错误——save() 在事务失败时 reject:
await new Promise<void>((resolve, reject) => {
try {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).put(data, SAVE_KEY);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error ?? new Error('存档写入失败'));
tx.onabort = () => reject(tx.error ?? new Error('存档写入被中止'));
} catch (cause) {
reject(cause instanceof Error ? cause : new Error(String(cause)));
}
});
外壳的 Pinia store 把所有写操作包进同一个出口:
// apps/site/src/stores/progress.ts(节选)
async function run<T>(action: () => Promise<T>): Promise<T | null> {
try {
const result = await action();
unsaved.value = false;
return result;
} catch (cause) {
unsaved.value = true;
console.warn('[bamboo] 存档写入失败:本次进度可能不会保留', cause);
return null;
} finally {
syncSave();
}
}
失败后界面给出两句人话之一:初始化失败是“存档读取失败,本次从头开始。”;写入失败是“这次可能没存上,请检查浏览器的存储空间。”。玩法的奖励 toast 里也会追加一句“这次可能没存上,请让家长看一看”(complete.saveWarn)。注意这里的顺序:奖励照常发放、动画照常播放,只是额外提示。孩子不该因为存储配额满而失去这一局的反馈。
真实会触发写入失败的场景至少有四类:浏览器隐私模式、存储配额耗尽、用户手动清除了站点数据、以及某些 WebView 在后台被系统冻结后事务中止。它们的共同点是“界面看起来一切正常”,所以必须由产品主动说出来。家长页也提供了对应的出口:长按 3 秒清除本机存档,清空后会立刻落盘(ProgressManager.clear()),而不是只把内存对象换掉。
9.5 零音频文件的音效总线
整个仓库没有一张位图、没有一个音频文件。音效由 WebAudio 振荡器实时合成,Phaser 自己的音频系统直接关掉:
// packages/phaser-kit/src/createGame.ts
audio: { noAudio: true },
AudioBus 暴露 12 个音效键(SfxKey):tap、correct、retry、pop、bubble、water、chew、jump、sleep、unlock、complete、sparkle。每个键对应一个短促的合成图案:
| 音效 | 听感 | 实现要点 |
|---|---|---|
tap |
轻点 | 560 Hz 三角波,70 ms |
correct |
做对了 | 660 Hz 后接 880 Hz,两音上行 |
retry |
再试一次 | 392 Hz → 330 Hz,下行但不刺耳 |
pop / bubble |
泡泡 | 760 Hz 单音 / 620 Hz 与 760 Hz 叠奏 |
water |
冲水 | 520 Hz 锯齿波 + 700 Hz 正弦波 |
chew |
咀嚼 | 220 Hz 与 190 Hz 方波 |
jump |
跳跃 | 520 Hz → 780 Hz 三角波,上行 |
sleep |
入睡 | 440 Hz 与 330 Hz 两个长音 |
unlock |
解锁 | C5–E5–G5 三音 |
complete |
完成 | C5–E5–G5–C6 四音 |
sparkle |
星星 | 1175 Hz / 1568 Hz 高频轻响 |
合成核心是一个带包络的 tone():起始增益压到 0.0001 再指数上升到目标音量,避免方波与锯齿波产生“咔哒”爆音;结束时再指数衰减回 0.0001。AudioContext 是延迟创建的:第一次真正需要出声时才 new,避免页面一加载就占用音频设备;如果浏览器没有 AudioContext(或 webkitAudioContext),context() 返回 null,所有音效静默跳过,调用方不需要写 try/catch。
振荡器类型也不是随手选的。sine 用于气泡、星星、完成音,最柔和;triangle 用于轻点、跳跃与“再试一次”,比正弦多一点存在感又不刺耳;square 只用于咀嚼,短促且音量压到 0.08;sawtooth 只用于水流,配合低增益模拟“沙沙”的质感。低频轰鸣被明确排除,因为它在小扬声器上容易失真,也容易让家长觉得吵。
function tone(freq: number, duration: number, delay = 0,
type: OscillatorType = 'sine', gainLevel = 0.18): 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(gainLevel, 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);
}
所有图案都是“柔和的正向反馈”:没有低频轰鸣,也没有失败音效。唯一的“负向”音效 retry 也只是两个温和的下行音,不模拟错误蜂鸣。
iOS 与微信要求音频必须由用户手势解锁,因此 unlock() 会唤醒被挂起的 AudioContext,并顺手 speechSynthesis.resume()。外壳在第一次 pointerdown 时调用一次:
// apps/site/src/App.vue
function unlockAudio(): void { void platform.audio.unlock(); }
onMounted(() => {
window.addEventListener('pointerdown', unlockAudio, { once: true });
});
静音状态由 setMuted() 统一管理:置位后 tone() 直接返回,同时停掉正在进行的朗读。这样“关声音”不会只关掉下一次音效,而是立刻让耳边安静下来。
9.6 TTS:原生桥、浏览器语音与静默降级
朗读接口返回一个必须 resolve 的句柄:
export type SpeechOutcome = 'ended' | 'unavailable';
export interface SpeechHandle {
readonly done: Promise<SpeechOutcome>;
}
“两种情况都必须 resolve”是界面不卡死的前提:如果有设备既不触发 onend 也不触发 onerror,按钮会永远停在“正在听”。实现里用 settled 标记保证只结算一次。
speak() 分三级降级:
- 原生 TTS(安卓壳):
nativeTextToSpeech()存在时优先调用,语言用speechLang(locale),失败catch成unavailable; - 浏览器 Web Speech:
speechSynthesis与SpeechSynthesisUtterance都存在才走; - 静默:两者都没有,立即返回
unavailable。
// packages/platform/src/web.ts(节选)
speak(text: string, locale: Locale): SpeechHandle {
if (muted) return { done: Promise.resolve('unavailable') };
const native = nativeTextToSpeech();
if (native) {
const done = native.speak({
text,
lang: speechLang(locale),
rate: locale === 'en' ? 0.95 : 0.92,
pitch: 1.08,
volume: 0.8,
}).then((): SpeechOutcome => 'ended')
.catch((): SpeechOutcome => 'unavailable');
return { done };
}
if (typeof speechSynthesis === 'undefined' ||
typeof SpeechSynthesisUtterance === 'undefined') {
return { done: Promise.resolve('unavailable') };
}
speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = speechLang(locale);
utterance.rate = locale === 'en' ? 0.95 : 0.92;
utterance.pitch = 1.08;
utterance.volume = 0.8;
const voices = speechSynthesis.getVoices();
const prefix = locale === 'en' ? 'en' : 'zh';
const match = voices.find((voice) => voice.lang.toLowerCase().startsWith(prefix));
if (match) utterance.voice = match;
// …onend / onerror 结算 done
}
几个细节值得单独说:
- 语言标签必须映射:
locale是'zh-CN' | 'en',而'en'不是合法的 BCP-47 语音标签。speechLang()把它映射成'en-US',zh-CN原样保留。 - 语速与音量偏保守:0.92–0.95 倍速、0.8 音量、1.08 音高。孩子常在耳边使用设备,默认音量容易“炸响”;慢一点也更容易听清。
- 嗓音按前缀挑:浏览器返回的语音列表里,
zh-CN与en-US往往各有多个;用startsWith('zh')/startsWith('en')找第一个匹配项,找不到就用系统默认。 - 静音时立刻停:
setMuted(true)会调用stopSpeech();原生桥走native.stop(),浏览器走speechSynthesis.cancel()。 - 先取消再播:浏览器分支在
speak()前先cancel(),避免连续点日记页的朗读按钮时两段声音叠在一起。
日记页是这条链路最典型的消费者。它把今天所有日记行用空格拼成一段,再调用 platform.audio.speak();按钮在 done 结算前显示“听一听”,结算后恢复“听竹竹说”。如果设备没有语音包,done 会以 unavailable 结束,按钮照常恢复,不会永远停在“正在听”。静音时函数直接返回 { done: Promise.resolve('unavailable') },既不朗读,也不改变界面状态。
这里还有一条儿童向的取舍:朗读不阻塞操作。与“听完故事才能玩”的叙事游戏不同,日常模仿需要即时反馈,孩子可以一边听引导一边动手;语音只是并行的提示通道。
朗读文本的长度也需要控制。任务详情里的 intro / done / retry 都是一两句短句,日记行也不长;没有人会把整段关卡说明交给 TTS。短句的好处不只是听得懂:TTS 在部分安卓设备上排队延迟明显,句子越短,孩子点完到听见声音的间隔越短,反馈才像“立刻发生”。
9.7 生命周期与二十分钟休息提醒
前后台切换用 visibilitychange 实现,接口是两个返回解绑函数的订阅:
export function createVisibilityLifecycle(): LifecycleBus {
return {
onPause(cb: () => void): () => void {
const handler = (): void => {
if (document.visibilityState === 'hidden') cb();
};
document.addEventListener('visibilitychange', handler);
return () => document.removeEventListener('visibilitychange', handler);
},
onResume(cb: () => void): () => void {
const handler = (): void => {
if (document.visibilityState === 'visible') cb();
};
document.addEventListener('visibilitychange', handler);
return () => document.removeEventListener('visibilitychange', handler);
},
};
}
外壳有两处消费它。App.vue 在回到前台时补一次跨天刷新:
offResume = platform.lifecycle.onResume(() => {
void progress.refresh();
});
应用可能在平板上挂一整夜,如果不补这一次,第二天打开会看到昨天的状态与昨天的日记录。PlayView.vue 则在切后台时暂停场景并停朗读,回前台再恢复:
offPause = platform.lifecycle.onPause(() => {
mod.value?.pause();
platform.audio.stopSpeech();
});
offResume = platform.lifecycle.onResume(() => {
mod.value?.resume();
});
休息提醒不放在平台层,而是由“玩耍时长”驱动。玩法页每 30 秒记一次:
playTimer = setInterval(() => { void progress.addSeconds(30); }, 30_000);
store 里的阈值与判定:
export const REST_SECONDS = 20 * 60;
const restDue = computed(
() => todaySeconds.value >= REST_SECONDS && !todayRecord.value.restDismissed,
);
小屋页据此显示一次提醒卡片,点“知道啦”后写入 restDismissed,当天不再出现。这里有三个刻意的限制:只统计玩法页的时长(在小屋点按钮不算)、一天只提醒一次、提醒文案是“和竹竹一起看看远处的绿色”,不是警告。它服务于家长,但不打断孩子。
之所以是 20 分钟而不是 15 或 30 分钟:单局 30–90 秒,20 分钟大约等于连续玩二十局上下,已经足以让家长意识到“该歇一会儿了”;再短会频繁打断,再长就失去意义。提醒也不做成全屏弹窗,而是小屋页里一张可点掉的卡片——孩子回到小屋才看到,不会在游戏中途被强制中断。
9.8 本地 telemetry 与调试桥
Telemetry 的实现只有二十来行,且 flush() 是空实现:
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() 保留在接口里,是为了给未来留一个明确的“如果要上报,只能从这里出去”的出口;当前实现刻意留空,代码评审时这一行空函数本身就是一条隐私声明。
调试桥与 telemetry 是两件事:telemetry 记录的是“发生过什么类型的事件”,调试桥记录的是“具体哪一个场景、哪一个任务、哪一种退出原因”,并且额外暴露最新存档快照。后者只给开发与自动化看,不参与任何产品逻辑;它的字段形状被当作契约维护,加字段之前要先改端到端测试。
同一条事件还会进调试桥 window.__bamboo:
// apps/site/src/shell/createGameContext.ts
emit(type, payload) {
platform.telemetry.track(type, payload);
recordDebugEvent(type, detailOf(type, payload));
hooks.onEvent(type, payload);
},
调试桥形状固定为 { events, save, lastError? },events 里是 'session:start'、'scene:enter:bathroom'、'task:complete:brush' 这样的字符串。它生产环境也开着,因为只有事件名与存档快照,不含个人信息;端到端测试靠它判断“这一局真的完成了”,而不是靠截图。
9.9 装配与测试替身
真正的装配只有一行:
// apps/site/src/shell/platform.ts
import { createWebPlatform } from '@bamboo/platform';
export const platform = createWebPlatform();
但“可替换”不是为了好看,而是为了测试。ProgressManager 的构造函数接收 ProgressStore 与时钟:
new ProgressManager(store, () => platform.now());
单测里换成内存实现,就能精确控制“读档抛错”“写盘失败”“跨了两天”等场景:
// packages/progress/src/logic.test.ts(节选)
function memoryStore(initial: SaveData | null = null): ProgressStore & { writes: number } {
const store = {
current: initial,
writes: 0,
async load(): Promise<SaveData> {
if (!store.current) throw new Error('empty');
return store.current;
},
async save(data: SaveData): Promise<void> {
store.current = data;
store.writes += 1;
},
};
return store;
}
原生返回键的测试替身更能说明问题。installBackButtonHandler(route, bridge, goHome) 把原生桥与跳转都做成参数,浏览器里传 null 即可:
// apps/site/src/shell/backButton.ts
export function installBackButtonHandler(
route: Ref<Route>,
bridge: BackBridge | null = nativeApp(),
goHome: NavigateTo = navigate,
): void {
if (bridge === null) return;
bridge.addListener('backButton', () => {
if (route.value.name !== 'home') { goHome('/'); return; }
void bridge.exitApp();
});
}
backButton.test.ts 用假桥覆盖了五种情形:游戏/任务/装扮/日记/家长页按返回都只回小屋、已经在小屋才退出、监听只注册一次、路由变化后仍按最新路由判断、浏览器里没有原生桥时不报错。这段逻辑以前在真机上才暴露问题——“孩子在游戏里误按返回键,应用直接关掉”——现在被五个用例钉住。
测试替身还解决了一个更隐蔽的问题:router.ts 在模块加载时就读取 window.location.hash,在 Node 环境里直接 import 会崩。backButton.test.ts 先 vi.stubGlobal('window', …),再用动态 import() 加载被测模块,顺序不能反。这类“测试基础设施”的细节不会出现在产品截图里,却决定了原生路径有没有回归保护。
9.10 小结
平台层把四件容易散落各处的脏活收进一个包:
- 存档:IndexedDB 三秒超时、写失败抛出、内存兜底,配合外壳显示“这次可能没存上”;
- 声音:12 个 WebAudio 合成音效,零音频文件,iOS 手势解锁;
- 朗读:原生 TTS → 浏览器语音 → 静默三级降级,语言标签映射,静音立即停止;
- 生命周期与边界:前后台暂停/恢复、跨天刷新、20 分钟休息提醒、本地 telemetry 与调试桥。
这一层没有一条业务规则:它不知道什么是刷牙、什么是成长值,也不知道“连续三天”意味着什么。它只回答“东西存在哪里、声音怎么出、什么时候该停”。正因为如此,换存储、换语音、换壳都不会波及玩法;而玩法里出现环境 API,也就成了一眼能看出的越界。
从验收角度看,平台层的测试不追求覆盖率数字,而是盯着“最坏情况”:数据库打不开、写入被拒绝、没有语音包、浏览器不支持 WebAudio、原生桥不存在。这五种情况都不会让应用崩溃,只会让某一种能力降级,并在界面上用一句人话交代清楚。这是儿童产品与普通 Demo 的分界线。
下一章进入数据与规则的核心:这些服务被谁调用、一次完成如何变成竹笋、成长值与连续天数,以及“今天推荐什么”为什么每次打开都一样。