# SoundGame Web 引擎 API 手册 本文档面向在 `web/` 下开发新游戏的 agent/开发者。Web 引擎是 Android 引擎 (`docs/engine-api.md`、`docs/architecture.md`)的移植:同一套 6 手势、 同一套三层音频、同一套 Game 契约。纯 vanilla JS ES module,**无构建步骤、 无 npm 依赖**,任何静态 HTTP 服务器托管即可运行。 总览: ``` web/ ├── index.html # 游戏大厅(深色风格,5 个游戏入口 + 用户/设置区) ├── serve.sh # 本地托管脚本(node web/server/server.js) ├── engine/ # 共享引擎 —— 所有游戏复用,勿修改 │ ├── gesture.js # 6 手势识别(触摸+鼠标,内置基础震动反馈) │ ├── audio.js # Web Audio 三层音频引擎 + 启动遮罩(自动应用用户设置) │ ├── settings.js # 全局用户设置(voice/speed/bgm/guide,localStorage 持久化) │ ├── haptics.js # navigator.vibrate 震动封装(预定义震型 + iOS 视觉降级) │ ├── guide.js # 全局向导层(状态签名 earcon / turn cue / 看门狗 / 纠错) │ ├── game.js # Game / StateMachineGame 基类 │ ├── style.css # 全屏黑底 + 封面铺满 + 屏蔽系统手势 + 闪光降级样式 │ └── vendor/soundtouch.js # SoundTouch 0.3.0(voice 变速不变调,LGPL v2.1) ├── server/ # 用户系统服务器(静态托管 + 注册/登录/进度 API) │ ├── server.js │ └── README.md # API 文档 ├── template/ # 最小可运行模板游戏(复制它开工) │ ├── index.html │ ├── game.js │ ├── cover.png │ └── audio/{bgm,voice,sfx}/ # 含一个测试音 sfx/beep.ogg └── <游戏名>/ # 每个游戏一个目录(目录约定见下) ``` ## 目录约定(每个游戏必须遵守) ``` web// ├── index.html # 入口页,引用 ../engine/ 下的引擎 ├── game.js # 游戏逻辑(导出继承 Game 的类) ├── cover.png # 封面图,1080x1920 竖图,文件名固定 └── audio/ ├── bgm/*.ogg # 背景音乐(循环) ├── voice/*.ogg # 对白/语音(平铺,过渡期兼容布局) ├── voice/male/*.ogg # 男声版(settings.voice='male' 时优先加载) ├── voice/female/*.ogg # 女声版(默认,优先加载) └── sfx/*.ogg # 音效(短促) ``` - 音频 id 形如 `'bgm/theme'`、`'voice/intro'`、`'sfx/hit'`, `play()` 自动拼 `audio/.ogg`(相对游戏页面)。 - **iOS 兼容**:`.ogg` 在部分 iOS Safari 上无法解码(见下文"移动浏览器的坑")。 id 不带扩展名时引擎依次尝试 `.ogg → .m4a → .mp3 → .wav`, 所以只要给同名文件放一份 `.m4a` 或 `.mp3` 副本即可自动回退,代码不用改。 - 资产缺失只 `console.error` 静默跳过,**不会抛异常**(对齐 Android)。 拼错 id 不会崩,只能靠浏览器 DevTools 控制台发现——调试时务必开着控制台。 ## 快速开始 复制模板开工: ```bash cp -r web/template web/ ``` 然后改 `web//index.html` 的标题和 import 类名,替换 `game.js` 逻辑, 放入自己的 `cover.png` 和 `audio/` 资产。入口页的标准接线(模板已写好): ```html
``` ## 音频解锁流程(必须遵守,否则无声) iOS Safari 和 Chrome 都要求 AudioContext 必须在一次真实用户手势中创建/恢复。 引擎用启动遮罩解决: 1. 页面加载后 `showStartOverlay()` 显示全屏封面 + "轻触开始"(呼吸动画, 提示文字位于屏幕 30% 高度处,避开封面图底部文字区); 2. 用户第一次点按遮罩 → 引擎在手势回调里 `new AudioContext()` + `resume()`; 3. 遮罩移除,Promise resolve → 你调 `game.start()` 接管。 **任何 `play()` 在解锁前调用只会 console.warn 并跳过。** 解锁后再 `attachGestures`。开始那次点按不会被游戏误收,引擎双保险: 遮罩对触摸 `preventDefault`(不再合成鼠标事件),且 gesture.js 在 document 捕获级记录全页面最后一次触摸,700ms 内忽略一切合成鼠标回退 (早期版本只靠 root 级守卫,解锁 tap 的合成 click 会穿透成"单击跳旁白")。 ## engine/audio.js — `WebAudioEngine` ```js new WebAudioEngine(basePath = 'audio/', opts?) ``` | 构造 opts | 默认 | 含义 | |---|---|---| | `bgmVolume` / `voiceVolume` / `sfxVolume` | 1 | 各层基础音量 0~1 | | `duckVolume` | 0.2 | 对白播放时 BGM duck 到的比例 | | `fadeMs` | 800 | BGM 交叉淡入时长 ms | | `duckMs` | 300 | duck / 恢复时长 ms | 方法: - `unlock(): Promise` — 创建/恢复 AudioContext。已在用户手势中时可用; 一般用 `showStartOverlay()` 代替。 - `showStartOverlay({ cover, text = '轻触开始' }): Promise` — 启动遮罩,resolve 时音频已解锁。 - `play(id, opts?)` — 统一入口,按 id 前缀(`bgm/` `voice/` `sfx/`)分发: - 公共:`opts.volume`(0~1,乘层音量) - BGM:`opts.fadeMs`、`opts.loop = true`;同一首重复 `play` 不重启 - voice:`opts.onComplete`(**自然播完**才触发;被 `stopVoice()` 或新 voice 顶掉时**不触发**,对齐 Android——用这一点实现"打断即作废整条语音链"); `opts.queue = true` 排队等当前对白播完(默认 false 直接顶掉) - sfx:即发即弃,天然支持并发叠加 - `prewarm(ids: string[])` — 预解码一批音频,消除首次播放的网络+解码延迟。 建议 `start()` 里把所有短音效 prewarm 一遍。 (与 Android 差异:Web 端**不需要**音量 0 假播预热,解码缓存即可。) - `stopBgm(fadeOutMs = 800)` — 淡出后释放 BGM。 - `stopVoice()` — 打断当前对白并清空排队,不触发 onComplete,BGM 恢复。 - `skipVoice()` — **跳过当前对白但让语音链继续**:停掉当前 voice 并视为 这句自然播完——触发其 onComplete(游戏层的链播靠它推进)并继续等待队列。 与 stopVoice 的差异:stopVoice 作废整条链,skipVoice 只跳当前句。 游戏层自行绑定手势(如长按/特定滑动的"跳过这句")。 - `stopAll()` — stopBgm(200) + stopVoice()(sfx 很短,不主动停)。 - `isVoicePlaying(): boolean` — 当前是否有对白在播(公开只读;游戏判定 "单击是否跳句"等语境用它,不要读私有字段 `_voice`)。 - `setRateBoostEnabled(on: boolean)` — 长按快进(旁白临时 1.7 倍速)的 per-game 开关,默认开启。rhythm/space 的对局/演奏期传 `false` 豁免 (长按是潜在演奏输入),回菜单/结算/退出时传 `true` 恢复。 三层行为(对齐 Android DefaultAudioEngine): | 层 | 播放器 | 特性 | |---|---|---| | BGM | AudioBufferSourceNode(双实例轮换) | 循环;切换时新源 0→满、旧源 →0 交叉淡入,淡出后 stop | | voice | SoundTouch 时间拉伸管线(见下) | duck BGM 到 20%(300ms 渐降),播完 300ms 恢复;onComplete 驱动链播 | | sfx | AudioBufferSourceNode 即发即弃 | 预解码 AudioBuffer 缓存,低延迟,可叠加;总线基础增益 0.8 | **爆音防护**:master 链末端挂 `DynamicsCompressorNode` 限幅 (threshold -6dB / ratio 12 / attack 3ms / release 250ms),sfx 总线基础 增益 0.8。BGM+voice+多个 sfx 并发叠加超过 0dBFS 时由限幅器压限, 不再在 destination 硬削波(爆音/突然最大音量的根治)。 **voice 变速不变调(SoundTouch 管线)**:voice 层播放走 `vendor/soundtouch.js`(SoundTouch 0.3.0,LGPL v2.1,ScriptProcessorNode 实现):`WebAudioBufferSource → SimpleFilter(SoundTouch tempo) → ScriptProcessorNode → gain → voiceBus`。`settings.speed` 只改 tempo 不改 pitch,1.0/1.35/1.7 均不变调;自然结束由"源消耗位置 ÷ tempo"定时器判定, onComplete/打断/排队语义与旧 BufferSource 实现完全一致。注意 voice 有 ~100ms 处理延迟(对白场景无感),且 ScriptProcessorNode 已废弃但全平台 (含 iOS Safari)可用。 **长按快进(游戏零改动)**:voice 播放中长按屏幕 → 旁白临时 1.7 倍速 (不变调),松开恢复 `settings.speed`。由 gesture.js 派发的 window 事件 (`soundgame:longpress` / `soundgame:longpressend`)驱动,audio.js 内置监听。 **状态 BGM 约定**:游戏按状态(菜单/探索/战斗/结算)直接 `play('bgm/xxx')` 切换即可——引擎 crossfade 已够用;`settings.bgm=0` 时 切换只更新"想播"不加载不播放,用户重新打开 BGM 时自动补播最后一个状态 BGM。游戏层无需为此写任何判断。 **用户设置自动生效(settings.js,游戏代码无需改动):** - `settings.voice`(`'male'|'female'`,默认 `'female'`):voice 层路径解析—— `play('voice/intro')` 依次尝试 `audio/voice//intro.{ogg,m4a,mp3,wav}`, 全部找不到再回退平铺的 `audio/voice/intro.*`(过渡期资产未分性别也能播)。 缓存键含性别,游戏中途切换音色下一句即用新性别资产。 - `settings.speed`(`1.0|1.35|1.7|2.2`,默认 `1.0`):voice 层 SoundTouch tempo(**不变调**),在创建播放实例时读取,**从下一句生效** (进行中的句子不变速——除非长按快进,见上)。2.2 为视障档。 - `settings.bgm`(`1.0|0.3|0`,默认 `1.0`):BGM 层独立增益总线。 改动对**进行中的 BGM 立即生效**(150ms 渐变);调到 `0` 立即淡出停止 且此后 `play('bgm/...')` 只记意图不加载不播放;从 `0` 调回非零时, 若此前有想播的 BGM 会自动补播。 - `settings.guide`(`'full'|'standard'|'off'`,默认 `'full'`):向导帮助等级, `engine/guide.js` 每次动作实时读取(改动即刻生效,无需重启游戏)。 ## engine/guide.js — `Guide`(全局向导层) 语音承载语义、earcon 只做粗标记的全局向导:状态签名 earcon(L3)+ 回合标记 (A2)+ 静默看门狗(L4)+ 纠错语音(A4)。设计裁定与 8 句新增语音见 `docs/design/14-guide-layer.md`;逐游戏接线表见 `research/03-schemes/schemes.md` §2。 ```js import { Guide } from '../engine/guide.js'; this.guide = new Guide(audio); // 游戏构造时创建一次 guide.level(); // 'full' | 'standard' | 'off'(实时读 settings.guide) guide.state('battle'); // 进入宏观状态播签名 earcon(menu/explore/battle/ // choice/settle/danger,全项目统一 8 音词表, // 运行时 WebAudio 合成,0.25~0.4s,增益 ≈0.12) guide.turn({ voice, haptic }); // 轮到你:turn earcon + 可选 confirm 轻震 + // full 档可选语音(queue:true,绝不顶掉在播语音) guide.watchdog({ arm: true, at: [8000, 20000], say: ['gd_x', null], onEscalate: (n) => ..., cond: () => ... }); // 静默看门狗:每级到点播 nudge 轻 earcon(+ say[n-1] // 语音,full 档)再调 onEscalate(n);级数用尽自动停; // cond()=false 静默取消;任何手势 disarm() 复位 guide.error({ voice, onceKey, skipHaptic }); // 纠错:error 震(游戏层已震则 skipHaptic) // + full 档语音(onceKey 同局只播一次) guide.disarm(); // 复位看门狗(手势入口调用) guide.setEventHook(fn); // 转写 harness 用:fn({type,name,voiceId}) ``` - 等级语义:`full` = earcon+语音+震动;`standard` = earcon+震动(无语音); `off` = 全部 API 静默 no-op,**不挂任何定时器**(回归测试/老手零干扰)。 - 向导语音一律 `queue:true` 插播,绝不打断游戏正在播的内容。 ## engine/settings.js — `settings` 全局用户设置单例,localStorage 持久化(key: `'soundgame.settings'`), 同域下大厅与所有游戏页共享;跨标签页通过 `storage` 事件同步。 ```js import { settings } from '../engine/settings.js'; settings.get(); // -> { voice: 'male'|'female', speed: 1|1.35|1.7|2.2, bgm: 1|0.3|0, guide: 'full'|'standard'|'off' } settings.set({ speed: 1.35 }); // 部分更新;非法键/值被忽略(console.warn) const off = settings.subscribe((snapshot, changed) => { ... }); // off() 退订 ``` - 非法值永远不会进入存储——读入时逐项校验,坏数据自动回退默认值。 - 游戏侧一般**不需要**直接用本模块:audio.js 已自动应用三项设置。 需要自定义响应(如设置页 UI)时才 `subscribe`。 - 大厅会在用户登录后把设置同步到服务器(`PUT /api/progress {game:'_settings'}`)。 ## engine/haptics.js — `vibrate` `navigator.vibrate` 封装,预定义震型: | 名称 | 震型 (ms) | 建议用途 | |---|---|---| | `confirm` | `[20]` | 点按确认 | | `error` | `[60,40,60]` | 操作错误 | | `success` | `[100]` | 过关/成功 | | `warning` | `[30,50,30,50,30]` | 警告/危险逼近 | | `swipe` | `[10]` | 滑动识别成功 | | `heartbeat` | `[40,100,40]` | 心跳/倒计时紧张感 | | `pickup` | `[15]` | 拾取/获得物品 | | `hit` | `[80]` | 受击/终结性失败 | | `beat` | `[8]` | 节拍锚点(最轻,如 simon 序列逐音) | | `danger` | `[25,40,25,40,25]` | 危险逼近 | ```js import { vibrate, hapticsSupported } from '../engine/haptics.js'; vibrate('error'); // 预定义名称 vibrate([50, 30, 50]); // 自定义震型 ``` - **视觉降级(iOS 等无 vibrate 平台)**:`hapticsSupported === false` 时 `vibrate()` 自动改为全屏边框闪光(`#haptics-flash` 覆盖层,样式在 style.css):震型 → 颜色/闪烁次数(confirm 白×1 / error 红×2 / success 绿×1 / warning 橙×3 / heartbeat 粉×2 / pickup 青×1 / hit 强红×1 / danger 红×3 快 / swipe、beat 极淡白×1)。游戏代码无需判断平台。 - gesture.js 已内置基础反馈:滑动→`swipe`,tap→`confirm`,doubletap→`confirm`。 **游戏逻辑层只需在语义事件(error/success/warning/heartbeat 等)处自行调用**, 不要对手势本身重复调 `vibrate('confirm')`/`vibrate('swipe')`,否则会双重震动。 ## engine/gesture.js — `attachGestures` ```js const detach = attachGestures(element, onGesture, config?) // onGesture: (g: 'up'|'down'|'left'|'right'|'tap'|'doubletap' // |'longpress'|'longpressend'|'tapcancel') => void // ('tapcancel' 仅 immediateTap 模式补发,见下) // detach(): 解除绑定 ``` 判定参数(`DEFAULT_GESTURE_CONFIG`,与 Android `GestureConfig` 对齐): | 参数 | 默认 | 含义 | |---|---|---| | `swipeMinDistance` | 64 px | 累积位移阈值(≈ Android 64dp,按 160dpi 等效) | | `swipeMinVelocity` | 400 px/s | 平均速度阈值(位移 / 拖动时长) | | `doubleTapWindow` | 300 ms | 双击判定窗口 | | `tapSlop` | 24 px | 位移小于此才算点按 | | `longpressMs` | 450 ms | 按住不动超过此值触发 `longpress` | | `immediateTap` | false | true = 按下即发 tap,不等双击窗口(演奏类 opt-in,见下) | - 方向取位移较大的轴,斜滑归一到最近主轴(防斜滑误判)。 - 触摸 + 鼠标双支持(桌面可用鼠标拖拽/点击调试);多指触摸忽略。 - 元素上的 touchstart/touchmove/touchend 全部 `preventDefault` (防滚动/双指缩放/长按菜单),元素需有 `touch-action: none` (`style.css` 的 `#game-root` 已带)。 - 手势绑定是全屏捕获层级的:绑在 `#game-root` 上即可覆盖整个屏幕。 - **内置基础震动反馈**(haptics.js):滑动识别成功→`swipe`、tap→`confirm`、 doubletap→`confirm`;不支持震动的平台自动走视觉降级(边框闪光)。 游戏语义反馈(error/success/warning 等)由游戏层调用 `vibrate()`。 - **长按**:按住不动 ≥450ms 触发 `'longpress'`,松开触发 `'longpressend'`; 长按期间不触发 tap;位移超 tapSlop 取消(已激活则先补发 `longpressend`, 滑动照常判定)。长按同时派发 window 事件 `soundgame:longpress(end)`—— audio.js 内置监听实现"长按=旁白临时 1.7 倍速(不变调)"。 **游戏若把 longpress 挪作他用,注意语音快进行为会同时发生。** **单击有约 300ms 延迟**:引擎要等双击窗口关闭才能确定是单击,`tap` 回调因此延迟 ~300ms 触发(`doubletap` 和滑动是即时的)。这是引擎级行为, 游戏设计必须绕开——即时反馈(格挡、击打)绑滑动或双击,单击只用于 "继续剧情""检查房间"这类非即时操作(Android 端 reflex/storyline/escape 均如此设计)。 **例外(opt-in):`attachGestures(root, cb, { immediateTap: true })`**—— 按下(touchstart/mousedown)立即回调 `tap`,不等双击窗口;若 300ms 内 第二下也成立为点按,松开时补发 `doubletap` 改判(第一次 `tap` 已发出, 是否撤回其效果由游戏层决定,如 rhythm 多打一下 kick 无害)。代价:每次 按下都先发 `tap`——若该次按下最终判成滑动/长按,这声 `tap` 不可撤回, 所以只有"按下即出声"的演奏类游戏(rhythm)应开启,默认 false 行为不变。 **`tapcancel`(仅 immediateTap 模式补发,live round2 新增)**:序曲 `tap` 的声音收不回,但**判定可以撤回**——本次按下一旦确定不再是点按(移动超 `tapSlop` 转滑动、`longpress` 成立、触摸被 cancel;无 move 事件的快滑在 滑动事件之前补发),引擎补发 `tapcancel`,游戏层应撤销该次 tap 的判定/ 统计(rhythm 据此把爆音移出判定链)。双击序曲(`tap`+`tap`+`doubletap` 序列)**不发** `tapcancel`——归属撤销由游戏层按双击窗口自行处理。 `onGesture` 签名因此扩为:`'up'|'down'|'left'|'right'|'tap'|'doubletap'| 'longpress'|'longpressend'|'tapcancel'`。 ## engine/game.js — `Game` / `StateMachineGame` ```js import { Game, StateMachineGame } from '../engine/game.js'; class Game { constructor(audio, root) // audio: WebAudioEngine;root: #game-root 元素 onGesture(g) {} // 手势入口(子类实现) start() {} // 音频解锁后调用一次(子类实现) stop() {} // 可选;默认实现 audio.stopAll() } ``` `StateMachineGame` 是推荐起点(对齐 Android `GameEngine`,线性剧情类适用): ```js class MyGame extends StateMachineGame { initialState() { return 'INTRO'; } // 初始状态 transition(state, g) { ...; return state; } // 纯函数,返回原状态 = 无转移 onEnterState(state, prev) { ... } // 副作用:播音频;prev 初始为 null // setState(next) 可强制切换(计时器、onComplete 驱动) } ``` `onGesture` 是 final 逻辑:算 `transition`,状态变了才更新并回调 `onEnterState`。`start()` 以 `prev = null` 进一次 `initialState()`。 状态复杂(多维状态、计时器驱动)的游戏直接继承 `Game` 即可——Android 端 三个游戏实际都没用状态机基类。 ## 与 Android 引擎的行为差异 | 项 | Android | Web | |---|---|---| | sfx 预热 | SoundPool 异步加载,必须音量 0 预播否则首播丢失 | 按需解码 + 缓存,不需要假播;`prewarm()` 只是提前解码 | | 音频格式 | ogg 全覆盖 | **iOS Safari 可能无法解码 ogg**,引擎自动回退 m4a/mp3/wav;建议双格式 | | 音频解锁 | 无限制 | 必须先经用户手势(启动遮罩已内置) | | 滑动阈值 | 64dp(物理密度无关) | 64 CSS px(≈64dp@160dpi,高分屏实际手感略小,可接受) | | BGM/voice 播放 | ExoPlayer 流式 | 整段 fetch + 解码进内存,**大文件有解码延迟和内存占用**——BGM 建议压到几分钟内、voice 单句切分 | | 资源缺失 | Log.e 静默跳过 | console.error 静默跳过(行为一致) | ## 移动浏览器的坑(务必读) 1. **自动播放限制**:iOS Safari / Chrome 要求 AudioContext 在用户手势中 resume。引擎的启动遮罩已解决,但不要绕过它——解锁前 `play()` 是无声 no-op。 2. **iOS 不支持 ogg**:Safari 对 Ogg Vorbis 的解码支持很差(随版本变化, 老版本完全不行)。引擎已做扩展名回退,但资产侧要保证有 `.m4a`(AAC) 或 `.mp3` 副本,否则 iOS 上全程无声。生成:`ffmpeg -i in.ogg -c:a aac out.m4a`。 3. **iOS 静音开关**:iPhone 静音模式下 Web Audio 默认**仍然出声** (AudioContext 不受静音开关控制,这点比 `