- 设计:docs/design/23-hunt.md + voice-ids(84 id,166条)+ 13号断言登记,§15记录10+1条设计裁决 - 服务器:零依赖WebSocket(ws.js)+ 房间管理/服务端权威战斗/bot AI(hunt-room.js),断线30s宽限resume回槽、超时转bot;修复package.json模块类型误判的既有破坏 - 客户端:web/hunt/ 全套(教学T0-T4/听觉视野裁剪R1-R8/结算排行榜/guide接入),playtest 211断言 - 引擎:audio.js 后向兼容 opts.pan(StereoPannerNode),web-engine-api.md 同步 - 资产:166条TTS语音(QC无离群)+ 13件合成sfx(真峰值≤-3dBFS)+ 2首Mureka BGM,终检全绿 - 平衡:bot调参至挂机局71%失守/C、3bot局71% A/B(hunt-balance-scan.mjs 24种子扫描) - 接线:大厅卡片、profiles.js hunt桶、README/roadmap MMO路线条目 验证:hunt-sim 16/16 + api-smoke 12/12 + hunt playtest 211/211 + 既有10游戏与引擎回归零回退 + 真服务器WS端到端冒烟通过
28 KiB
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 控制台发现——调试时务必开着控制台。
快速开始
复制模板开工:
cp -r web/template web/<name>
然后改 web/<name>/index.html 的标题和 import 类名,替换 game.js 逻辑,
放入自己的 cover.png 和 audio/ 资产。入口页的标准接线(模板已写好):
<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 必须在一次真实用户手势中创建/恢复。 引擎用启动遮罩解决:
- 页面加载后
showStartOverlay()显示全屏封面 + "轻触开始"(呼吸动画, 提示文字位于屏幕 30% 高度处,避开封面图底部文字区); - 用户第一次点按遮罩 → 引擎在手势回调里
new AudioContext()+resume(); - 遮罩移除,Promise resolve → 你调
game.start()接管。
任何 play() 在解锁前调用只会 console.warn 并跳过。
解锁后再 attachGestures。开始那次点按不会被游戏误收,引擎双保险:
遮罩对触摸 preventDefault(不再合成鼠标事件),且 gesture.js 在
document 捕获级记录全页面最后一次触摸,700ms 内忽略一切合成鼠标回退
(早期版本只靠 root 级守卫,解锁 tap 的合成 click 会穿透成"单击跳旁白")。
engine/audio.js — WebAudioEngine
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.3pan = 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。
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 事件同步。
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] |
危险逼近 |
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
const detach = attachGestures(element, onGesture, config?)
// onGesture: (g: 'up'|'down'|'left'|'right'|'tap'|'doubletap'
// |'longpress'|'longpressend'|'tapcancel') => void
// ('tapcancel' 仅 immediateTap 模式补发,见下)
// detach(): 解除绑定
判定参数(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()):
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
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>,线性剧情类适用):
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 静默跳过(行为一致) |
移动浏览器的坑(务必读)
- 自动播放限制:iOS Safari / Chrome 要求 AudioContext 在用户手势中
resume。引擎的启动遮罩已解决,但不要绕过它——解锁前
play()是无声 no-op。 - iOS 不支持 ogg:Safari 对 Ogg Vorbis 的解码支持很差(随版本变化,
老版本完全不行)。引擎已做扩展名回退,但资产侧要保证有
.m4a(AAC) 或.mp3副本,否则 iOS 上全程无声。生成:ffmpeg -i in.ogg -c:a aac out.m4a。 - iOS 静音开关:iPhone 静音模式下 Web Audio 默认仍然出声
(AudioContext 不受静音开关控制,这点比
<audio>标签好), 但部分老版本 Safari 行为不一致,真机测试为准。 - 后台/锁屏:页面切后台 AudioContext 会被系统挂起,回到前台
需要
ctx.resume()(引擎在 unlock 后如被挂起,下次手势时不会自动恢复—— 如果游戏需要,可在visibilitychange里重新audio.unlock())。 - 页面滚动/缩放手势干扰:已用
touch-action: none+preventDefault+ viewport meta 锁住;iOS Safari 双指捏合缩放在少数版本仍可能触发, 属系统行为无法完全屏蔽。 - 蓝牙音频延迟:蓝牙耳机有 100~300ms 输出延迟,节奏类游戏(rhythm) 判定窗口要留余量,并优先建议用户用有线/扬声器。
- decodeAudioData 内存:所有音频解码成 PCM 常驻内存(约为原始压缩 文件的 10 倍体积)。单个游戏音频总量建议控制在 ~30MB 压缩体积内。
本地验证
# 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 版一样,模拟器/桌面听不出手感。