Files
SoundGame/docs/web-engine-api.md
T
xsl ae850a4ce0 feat(rhythm): 鼓手模式重构——屏幕六区鼓组 + 无鼓伴奏合拍(用户裁定重构)
- 屏幕3列×2行六区鼓组(列=声像左中右/行=音高上亮下沉),六件程序合成打击乐,三重冗余蒙眼可辨
- 无鼓伴奏BGM上跟随领奏 call&response + 加花句;3曲90/105/120 BPM解锁制,BPM实测89.99/105.00/120.01
- 引擎后向兼容扩展:onGesture补{x,y}坐标(单参回调零感知,15游戏+3引擎测试全量回归零失败)
- 判定简化:全部音符=分区点按零延迟,旧双击补偿/下滑折中退役;键盘Digit1-6+空格=家区
- 教学摸鼓认区(摸哪报哪,8s无触摸自动进阶)+重教≤2+降速DDA+归因真话律
- 保留旧作好评:跟随领奏核心/S~C评级/解锁制/判定口径/guide工程遗产
- 语音49id双性别+8key;R1+R2连续2轮4/4愿意玩P0=0裁定可交付;live直证33 Perfect dt≈0.00s
- 03号文档仅文末加取代指针;新设计24号;档案research/rhythm-drummer/;Mureka累计$1.35
2026-09-14 20:17:10 +08:00

497 lines
29 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/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/<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()` 显示全屏封面 + "轻触开始"(呼吸动画,
提示文字位于屏幕 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<void>` — 创建/恢复 AudioContext。已在用户手势中时可用;
一般用 `showStartOverlay()` 代替。
- `showStartOverlay({ cover, text }): Promise<void>` — 启动遮罩,resolve 时
音频已解锁。解锁手势:触摸 / 鼠标 click / **键盘空格或回车 keydown**
(keydown 同属 user activation,回调内同步发起 `resume()` 即可解锁——
PC 键盘玩家无需伸手摸鼠标)。`text` 缺省按 `gesture.inputMode()` 自适应
(key →「按空格开始」,touch →「轻触开始」);显式传 `text` 完全覆盖。
- `play(id, opts?)` — 统一入口,按 id 前缀(`bgm/` `voice/` `sfx/`)分发:
- 公共:`opts.volume`(0~1,乘层音量)
- 公共(sfx/voice):`opts.pan`(可选声像 -1~1)——非 0 有限数时在 gain
与层总线之间插入 `StereoPannerNode`(clamp 到 [-1,1]);**缺省或 0 时
原路径逐字节不变**(不创建 panner 节点);老 Safari 无
`ctx.createStereoPanner` 时静默忽略 pan(不炸、不降级音质)。
方位→声像的游戏层约定见各游戏设计文档(如 hunt §8.3 `pan = sin(az)`)。
- BGM:`opts.fadeMs`、`opts.loop = true`;同一首重复 `play` 不重启
- voice:`opts.onComplete`(**自然播完**才触发;被 `stopVoice()` 或新 voice
顶掉时**不触发**,对齐 Android——用这一点实现"打断即作废整条语音链");
`opts.queue = true` 排队等当前对白播完(默认 false 直接顶掉,排队项同样
携带 pan)
- 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 均不变调。**自然结束(2026-09 三修,真机人耳反馈)**:
① 播放源尾部垫 0.25s 静音——SoundTouch Stretch 源结束时不冲刷内部最后
一个小节,句尾 0.1-0.25s 若不垫将永远停留在管线里不播出(任何平台);
② 完成信号 = 管线 onEnd(extract 返回 0 = 已无未交付帧),平台无关,
此时 disconnect 已交付帧仍会播完;③ onEnd 异常缺失时 1.5s 定时器兜底
(宁迟勿剪);④ 源未耗尽时定时器只做 80ms 短重排等待。onComplete/打断/
排队语义不变。回归:web/harness/engine-voice.test.mjs(PAD+I1-I3 四不变式)。
注意 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/<gender>/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, speed, bgm, guide, input }
settings.set({ speed: 1.35 }); // 部分更新;非法键/值被忽略(console.warn)
const off = settings.subscribe((snapshot, changed) => { ... }); // off() 退订
```
- 五项设置:`voice`(`'male'|'female'`)、`speed`(`1|1.35|1.7|2.2`)、
`bgm`(`1|0.3|0`)、`guide`(`'full'|'standard'|'off'`)、
`input`(`'auto'|'touch'|'key'`,P0-A 新增——提示文案输入模式,
`gesture.inputMode()` 读取;只影响提示,不关闭任何输入通道)。
- 非法值永远不会进入存储——读入时逐项校验,坏数据自动回退默认值。
- 游戏侧一般**不需要**直接用本模块: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',
// info?: { x: number, y: number }) => void
// ('tapcancel' 仅 immediateTap 模式补发,见下)
// detach(): 解除绑定
```
第二参 `info`(坐标透传,24 号鼓手模式新增,**缺省行为逐字节不变**):
`{x, y}` 为视口像素坐标——点按类事件(tap/序曲 tap/双击改判/tapcancel)与
长按 = **按下点**;四向滑动 = **滑动起点**(出手瞄准位置);键盘通道 =
虚拟指针坐标(元素中心,方向键滑动同样携带)。需要触点位置的游戏(rhythm
分区打击的「敲哪个区」判定)使用;既有单参回调零感知、零回归成本
(同 opts.pan / immediateTap / tapcancel 先例)。
判定参数(`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'`。
### 键盘通道(PC 平行输入,P0-A)
`attachGestures` 内置第三条输入通道(与 touch/mouse 并列,**喂同一状态机**,
游戏层收到的手势事件与震动反馈完全同构——游戏代码零改动)。设计依据与
业界惯例见 `research/platform/02-input-parity.md`。
| 手势(触屏语义) | 键盘主键 | 别名 | 行为 |
|---|---|---|---|
| 左/右滑 | ← / → | A / D | keydown 即发,零延迟(无位移/速度判定) |
| 上/下滑 | ↑ / ↓ | W / S | 同上 |
| 单击 | 空格 | — | 走完整 tap 状态机:抬起判 tap,**保留 ~300ms 双击窗**(与触屏同感) |
| 双击 | **回车** | 空格×2 | 回车**即时**触发(不等双击窗,确认类零延迟);空格连按两下走状态机同样判出 |
| 长按 | 按住空格 ≥450ms | — | `longpress`,松开 `longpressend`(旁白快进 window 事件照发) |
行为要点(`web/harness/engine-input.test.mjs` 全量断言,61 条):
- **空格 = 虚拟指针**:keydown 落元素中心进 `handleDown`,keyup 收尾——
tap/双击窗/长按定时器全部复用触摸路径;双击窗与触摸**共享**(混合设备
"触屏点一下 + 空格点一下"也判 doubletap,语义 = 同一虚拟指针)。
- **回车即时双击**:直接走 doubletap 分支(含 confirm 震动),同时作废
等待中的单击(与触屏双击的收尾语义一致)。
- **immediateTap 游戏(rhythm)零改动**:空格按下即发 tap(kick 演奏)、
长按补 `tapcancel`、双击改判——键盘路径逐条对齐触摸路径。
- **守卫**:`e.repeat` 抑制(按住 ↑ 只发一次 up;映射键 repeat 仍
preventDefault 防连滚);映射键 keydown 一律 `preventDefault`(防方向键/
空格滚页、回车激活焦点元素);焦点在 `INPUT/TEXTAREA/SELECT/
contenteditable` 时放行不吞键(大厅打字正常);未映射键不拦截(Tab 等
无障碍键放行);`tracking` 单飞行指针互斥(空格按住期间方向键/触摸互斥,
与多指忽略语义一致)。
- **无 Esc 映射**(裁定:下滑已覆盖菜单语义,Esc 留给浏览器/未来全屏)。
- 监听挂 `window`(游戏页焦点在 body,冒泡必达),`detach()` 时移除;
键盘监听在无 `window` 的 mock 环境(playtest/转写 harness)自动跳过,
对既有触摸路径零影响。
**提示随输入模式(`settings.input` + `inputMode()`)**:
```js
import { inputMode } from '../engine/gesture.js';
inputMode(); // 'touch' | 'key' —— 当前提示应使用的输入模式
```
- `settings.input`(`'auto'` 默认 / `'touch'` / `'key'`,settings.js VALID
枚举):手动覆盖优先;`auto` = 跟随最近使用的通道(映射键 keydown →
`key`,任意 touchstart → `touch`),会话内尚无输入时按能力检测
(`maxTouchPoints > 0 && (pointer: coarse)` → 触屏)。
- **只影响提示文案,不关闭任何输入通道**——touch/mouse/key 三通道永远
并行在线(混合设备友好)。当前接入点:启动遮罩默认文字
(`showStartOverlay` 不传 `text` 时按 `inputMode()` 选「轻触开始」/
「按空格开始」);游戏内提示文案由各游戏 HTML 后续接入。
## 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 版一样,模拟器/桌面听不出手感。