19 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,localStorage 持久化)
│ ├── haptics.js # navigator.vibrate 震动封装(预定义震型 + iOS 视觉降级)
│ ├── 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,这样开始那次点按不会被游戏误收。
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 时音频已解锁。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 恢复。skipVoice()— 跳过当前对白但让语音链继续:停掉当前 voice 并视为 这句自然播完——触发其 onComplete(游戏层的链播靠它推进)并继续等待队列。 与 stopVoice 的差异:stopVoice 作废整条链,skipVoice 只跳当前句。 游戏层自行绑定手势(如长按/特定滑动的"跳过这句")。stopAll()— stopBgm(200) + stopVoice()(sfx 很短,不主动停)。
三层行为(对齐 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 均不变调;自然结束由"源消耗位置 ÷ tempo"定时器判定,
onComplete/打断/排队语义与旧 BufferSource 实现完全一致。注意 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,默认1.0):voice 层 SoundTouch tempo(不变调),在创建播放实例时读取,从下一句生效 (进行中的句子不变速——除非长按快进,见上)。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 事件同步。
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] |
危险逼近 |
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') => void
// 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 |
- 方向取位移较大的轴,斜滑归一到最近主轴(防斜滑误判)。
- 触摸 + 鼠标双支持(桌面可用鼠标拖拽/点击调试);多指触摸忽略。
- 元素上的 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
均如此设计)。
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 版一样,模拟器/桌面听不出手感。