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 文档
This commit is contained in:
SoundGame Dev
2026-08-29 21:55:44 +08:00
parent f0f0a9b50e
commit cc0f8c008d
1885 changed files with 11246 additions and 5 deletions
+319
View File
@@ -0,0 +1,319 @@
# 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 版一样,模拟器/桌面听不出手感。