# 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,localStorage 持久化) │ ├── haptics.js # navigator.vibrate 震动封装(预定义震型) │ ├── game.js # Game / StateMachineGame 基类 │ └── style.css # 全屏黑底 + 封面铺满 + 屏蔽系统手势 ├── 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()` 显示全屏封面 + "轻触开始"(呼吸动画); 2. 用户第一次点按遮罩 → 引擎在手势回调里 `new AudioContext()` + `resume()`; 3. 遮罩移除,Promise resolve → 你调 `game.start()` 接管。 **任何 `play()` 在解锁前调用只会 console.warn 并跳过。** 解锁后再 `attachGestures`,这样开始那次点按不会被游戏误收。 ## 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 恢复。 - `stopAll()` — stopBgm(200) + stopVoice()(sfx 很短,不主动停)。 三层行为(对齐 Android DefaultAudioEngine): | 层 | 播放器 | 特性 | |---|---|---| | BGM | AudioBufferSourceNode(双实例轮换) | 循环;切换时新源 0→满、旧源 →0 交叉淡入,淡出后 stop | | voice | 单通道 | duck BGM 到 20%(300ms 渐降),播完 300ms 恢复;onComplete 驱动链播 | | sfx | AudioBufferSourceNode 即发即弃 | 预解码 AudioBuffer 缓存,低延迟,可叠加 | **用户设置自动生效(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`,默认 `1.0`):voice 层 `source.playbackRate`,在创建 source 时读取,**从下一句生效** (进行中的句子不变速)。 - `settings.bgm`(`1.0|0.3|0`,默认 `1.0`):BGM 层独立增益总线。 改动对**进行中的 BGM 立即生效**(150ms 渐变);调到 `0` 立即淡出停止 且此后 `play('bgm/...')` 只记意图不加载不播放;从 `0` 调回非零时, 若此前有想播的 BGM 会自动补播。 ## 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, bgm: 1|0.3|0 } 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]); // 自定义震型 ``` - 特性检测 `'vibrate' in navigator`;不支持的平台(iOS Safari)**静默 no-op**。 - 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') => void // detach(): 解除绑定 ``` 判定参数(`DEFAULT_GESTURE_CONFIG`,与 Android `GestureConfig` 对齐): | 参数 | 默认 | 含义 | |---|---|---| | `swipeMinDistance` | 64 px | 累积位移阈值(≈ Android 64dp,按 160dpi 等效) | | `swipeMinVelocity` | 400 px/s | 平均速度阈值(位移 / 拖动时长) | | `doubleTapWindow` | 300 ms | 双击判定窗口 | | `tapSlop` | 24 px | 位移小于此才算点按 | - 方向取位移较大的轴,斜滑归一到最近主轴(防斜滑误判)。 - 触摸 + 鼠标双支持(桌面可用鼠标拖拽/点击调试);多指触摸忽略。 - 元素上的 touchstart/touchmove/touchend 全部 `preventDefault` (防滚动/双指缩放/长按菜单),元素需有 `touch-action: none` (`style.css` 的 `#game-root` 已带)。 - 手势绑定是全屏捕获层级的:绑在 `#game-root` 上即可覆盖整个屏幕。 - **内置基础震动反馈**(haptics.js):滑动识别成功→`swipe`、tap→`confirm`、 doubletap→`confirm`;不支持震动的平台自动静默。游戏语义反馈 (error/success/warning 等)由游戏层调用 `vibrate()`。 **单击有约 300ms 延迟**:引擎要等双击窗口关闭才能确定是单击,`tap` 回调因此延迟 ~300ms 触发(`doubletap` 和滑动是即时的)。这是引擎级行为, 游戏设计必须绕开——即时反馈(格挡、击打)绑滑动或双击,单击只用于 "继续剧情""检查房间"这类非即时操作(Android 端 reflex/storyline/escape 均如此设计)。 ## 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 不受静音开关控制,这点比 `