- 新增 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 文档
320 lines
16 KiB
Markdown
320 lines
16 KiB
Markdown
# 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 版一样,模拟器/桌面听不出手感。
|