Files
SoundGame/docs/web-engine-api.md
T
SoundGame Dev cc0f8c008d 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 文档
2026-08-29 21:55:44 +08:00

16 KiB
Raw Blame History

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 控制台发现——调试时务必开着控制台。

快速开始

复制模板开工:

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 必须在一次真实用户手势中创建/恢复。 引擎用启动遮罩解决:

  1. 页面加载后 showStartOverlay() 显示全屏封面 + "轻触开始"(呼吸动画);
  2. 用户第一次点按遮罩 → 引擎在手势回调里 new AudioContext() + resume();
  3. 遮罩移除,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 恢复。
  • 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 事件同步。

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]);   // 自定义震型
  • 特性检测 '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

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

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 静默跳过(行为一致)

移动浏览器的坑(务必读)

  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 压缩体积内。

本地验证

# 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 版一样,模拟器/桌面听不出手感。