Files
SoundGame/docs/web-engine-api.md
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

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

  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

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。

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',
//             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()):

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

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

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