- 屏幕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
497 lines
29 KiB
Markdown
497 lines
29 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/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 版一样,模拟器/桌面听不出手感。
|