Files
SoundGame/docs/web-engine-api.md
T
SoundGame Dev cc0f8c008d HTML5 重写:6 款游戏 + 用户系统 + 全局设置 + 5 轮迭代重构
- 新增 web/ 纯音频 HTML5 平台:共享引擎(手势/WebAudio 三层/设置/震动)、
  Node 零依赖服务器(静态 + 注册登录 + 进度存档 API)
- 游戏:剑与声(RPG 新作)、声命线、暗室(重设计)、音序回响(重设计)、
  节拍行者(跟随领奏重构)、盲航(同向闪避重构);声东击西删除
- 全局功能:男声/女声双套 TTS 资产、三档语速、BGM 音量三档、
  震动反馈语言(10 种震型)、服务器进度同步
- 资产:899 ogg + 899 m4a(Qwen3-TTS 双性别语音、Stable Audio 3 音效、
  Mureka BGM 4+3 首),终检零缺失
- 文档:docs/design/ 13 篇设计审查文档、docs/audio/ 资产管线、
  web/server/README.md API 文档
2026-08-29 21:55:44 +08:00

320 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/<name>/
├── 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/<id>.ogg`(相对游戏页面)。
- **iOS 兼容**:`.ogg` 在部分 iOS Safari 上无法解码(见下文"移动浏览器的坑")。
id 不带扩展名时引擎依次尝试 `.ogg → .m4a → .mp3 → .wav`,
所以只要给同名文件放一份 `.m4a` 或 `.mp3` 副本即可自动回退,代码不用改。
- 资产缺失只 `console.error` 静默跳过,**不会抛异常**(对齐 Android)。
拼错 id 不会崩,只能靠浏览器 DevTools 控制台发现——调试时务必开着控制台。
## 快速开始
复制模板开工:
```bash
cp -r web/template web/<name>
```
然后改 `web/<name>/index.html` 的标题和 import 类名,替换 `game.js` 逻辑,
放入自己的 `cover.png` 和 `audio/` 资产。入口页的标准接线(模板已写好):
```html
<link rel="stylesheet" href="../engine/style.css">
<meta name="viewport" content="width=device-width, initial-scale=1,
maximum-scale=1, user-scalable=no, viewport-fit=cover">
<div id="game-root"><img class="cover" src="cover.png" alt=""></div>
<script type="module">
import { WebAudioEngine } from '../engine/audio.js';
import { attachGestures } from '../engine/gesture.js';
import { MyGame } from './game.js';
async function main() {
const root = document.getElementById('game-root');
const audio = new WebAudioEngine('audio/');
const game = new MyGame(audio, root);
await audio.showStartOverlay({ cover: 'cover.png', text: '轻触开始' });
game.start(); // 此刻音频已解锁
attachGestures(root, (g) => game.onGesture(g)); // 解锁后才接管手势
}
main();
</script>
```
## 音频解锁流程(必须遵守,否则无声)
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<void>` — 创建/恢复 AudioContext。已在用户手势中时可用;
一般用 `showStartOverlay()` 代替。
- `showStartOverlay({ cover, text = '轻触开始' }): Promise<void>` —
启动遮罩,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/<gender>/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<S>`,线性剧情类适用):
```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 不受静音开关控制,这点比 `<audio>` 标签好),
但部分老版本 Safari 行为不一致,真机测试为准。
4. **后台/锁屏**:页面切后台 AudioContext 会被系统挂起,回到前台
需要 `ctx.resume()`(引擎在 unlock 后如被挂起,下次手势时不会自动恢复——
如果游戏需要,可在 `visibilitychange` 里重新 `audio.unlock()`)。
5. **页面滚动/缩放手势干扰**:已用 `touch-action: none` + `preventDefault` +
viewport meta 锁住;iOS Safari 双指捏合缩放在少数版本仍可能触发,
属系统行为无法完全屏蔽。
6. **蓝牙音频延迟**:蓝牙耳机有 100~300ms 输出延迟,节奏类游戏(rhythm)
判定窗口要留余量,并优先建议用户用有线/扬声器。
7. **decodeAudioData 内存**:所有音频解码成 PCM 常驻内存(约为原始压缩
文件的 10 倍体积)。单个游戏音频总量建议控制在 ~30MB 压缩体积内。
## 本地验证
```bash
# 1. JS 语法校验(node 装在 tools/node/,ES module 语法可被识别)
/home/ubuntu/SoundGame/tools/node/bin/node --check web/<name>/game.js
# 2. 起本地服务器(Node 静态 + 用户系统 API,见 web/server/README.md)
bash web/serve.sh
# 打印局域网地址,如 http://192.168.x.x:8000/
# 3. 桌面浏览器打开 http://localhost:8000/<name>/ 用鼠标调试
# (DevTools → Toggle device toolbar 模拟手机尺寸;控制台看引擎日志)
# 4. 手机与电脑连同一 WiFi,浏览器打开局域网地址
# WSL2 需要先在 Windows 做端口转发(serve.sh 注释里有命令)
# 5. curl 冒烟(资源 200 + Content-Type 正确)
curl -sI http://localhost:8000/<name>/index.html
curl -sI http://localhost:8000/<name>/audio/sfx/xxx.ogg
```
听感参数(反应窗口、响度平衡、立体声分离度)必须在真机上戴耳机验证——
这一点和 Android 版一样,模拟器/桌面听不出手感。