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 文档
This commit is contained in:
SoundGame Dev
2026-08-29 21:55:44 +08:00
parent f0f0a9b50e
commit cc0f8c008d
1885 changed files with 11246 additions and 5 deletions
+375
View File
@@ -0,0 +1,375 @@
/**
* audio.js — Web Audio 三层音频引擎,对齐 Android DefaultAudioEngine 行为。
*
* 三层架构(id 前缀即层名):
* | 层 | id 前缀 | 特性 |
* |------|-----------|------------------------------------------------------------|
* | BGM | 'bgm/' | 循环播放,切换时双 BufferSource 交叉淡入淡出(默认 800ms) |
* | 对白 | 'voice/' | 单通道;播放期间 BGM duck 到 20%,播完恢复; |
* | | | onComplete 回调驱动链播;被打断(stopVoice/新 voice 顶掉) |
* | | | 时**不触发** onComplete——游戏层可依赖"打断即作废语音链" |
* | 音效 | 'sfx/' | 预解码缓存,低延迟即时播放,可并发叠加 |
*
* 统一入口 play(id, opts),id 形如 'bgm/theme' 'voice/intro' 'sfx/hit',
* 自动拼路径 `${basePath}${id}.ogg`;也可带扩展名('sfx/hit.mp3')。
* id 不带扩展名时依次尝试 .ogg → .m4a → .mp3 → .wav(对齐 Android 的
* resolveAssetPath 容错;**.ogg 在 iOS Safari 上无法解码,建议给 iOS 准备
* .m4a/.mp3 副本**,详见 docs/web-engine-api.md)。
* 资产缺失只 console.error 静默跳过,不抛异常(对齐 Android)。
*
* **自动播放限制**:iOS / Chrome 要求 AudioContext 必须在用户手势中 resume。
* 标准流程(必须遵守,否则无声):
* const audio = new WebAudioEngine('audio/');
* await audio.showStartOverlay({ cover: 'cover.png' }); // 遮罩:封面 + "轻触开始"
* // 此处 AudioContext 已解锁
* game.start();
*
* 与 Android 的一个差异:Web 端 sfx 是按需解码并缓存 AudioBuffer,解码完成后
* 即可即时播放,**不需要** Android SoundPool 那种"音量 0 预播预热";
* prewarm(ids) 的作用是提前解码(消除首次播放的网络+解码延迟)。
*
* **用户设置(engine/settings.js)自动生效**:
* - settings.voice:voice 层按性别解析路径——play('voice/intro') 实际依次尝试
* `audio/voice/<gender>/intro.{ogg,m4a,mp3,wav}`,找不到回退平铺的
* `audio/voice/intro.*`(过渡期资产未分性别也不会炸)。游戏代码不用改。
* - settings.speed:voice 层 source.playbackRate(语速),从下一句开始生效。
* - settings.bgm:BGM 层增益;改动立即作用于进行中的 BGM(150ms 渐变)。
* 为 0 时不加载不播放 BGM(play('bgm/...') 记为"想播"直接跳过;
* 调回非 0 时若此前有想播的 BGM 会自动补播)。
*/
import { settings } from './settings.js';
const EXT_FALLBACKS = ['.ogg', '.m4a', '.mp3', '.wav'];
export class WebAudioEngine {
/**
* @param {string} basePath 音频根目录,默认 'audio/'(相对游戏页面)
* @param {object} [opts]
* @param {number} [opts.bgmVolume=1] BGM 基础音量 0~1
* @param {number} [opts.voiceVolume=1] 对白音量
* @param {number} [opts.sfxVolume=1] 音效音量
* @param {number} [opts.duckVolume=0.2] 对白播放时 BGM duck 到的比例
* @param {number} [opts.fadeMs=800] BGM 交叉淡入时长 ms
* @param {number} [opts.duckMs=300] duck / 恢复时长 ms
*/
constructor(basePath = 'audio/', opts = {}) {
this.basePath = basePath.endsWith('/') ? basePath : basePath + '/';
this.bgmVolume = opts.bgmVolume ?? 1;
this.voiceVolume = opts.voiceVolume ?? 1;
this.sfxVolume = opts.sfxVolume ?? 1;
this.duckVolume = opts.duckVolume ?? 0.2;
this.fadeMs = opts.fadeMs ?? 800;
this.duckMs = opts.duckMs ?? 300;
/** @type {AudioContext|null} */
this.ctx = null;
this.unlocked = false;
this._master = null; // 总线
this._bgmBus = null; // BGM 总线(duck 作用于此)
this._bgmPrefBus = null; // BGM 偏好总线(settings.bgm 增益作用于此)
this._voiceBus = null;
this._sfxBus = null;
this._cache = new Map(); // key -> Promise<AudioBuffer|null>(voice 的 key 含性别)
this._bgm = null; // { id, source, gain } 当前 BGM
this._bgmWanted = null; // { id, opts } 最近一次 BGM 请求(bgm=0 时只记不播)
this._voice = null; // { source, gain, stopped } 当前对白 token
this._voiceQueue = []; // queue:true 时的等待队列
// 设置变更实时生效
settings.subscribe((_, changed) => this._onSettingsChange(changed));
}
/**
* 创建/恢复 AudioContext。必须在用户手势回调中调用(showStartOverlay 已处理)。
* @returns {Promise<void>}
*/
async unlock() {
if (!this.ctx) {
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) throw new Error('Web Audio API 不可用');
this.ctx = new AC();
this._master = this.ctx.createGain();
this._master.connect(this.ctx.destination);
this._bgmPrefBus = this.ctx.createGain();
this._bgmPrefBus.gain.value = settings.get().bgm;
this._bgmPrefBus.connect(this._master);
this._bgmBus = this.ctx.createGain();
this._bgmBus.connect(this._bgmPrefBus);
this._voiceBus = this.ctx.createGain();
this._voiceBus.connect(this._master);
this._sfxBus = this.ctx.createGain();
this._sfxBus.connect(this._master);
}
if (this.ctx.state !== 'running') await this.ctx.resume();
this.unlocked = true;
}
/**
* 启动遮罩层:全屏封面 + "轻触开始"。第一次点按时解锁音频并移除遮罩。
* @param {object} [opts]
* @param {string} [opts.cover] 封面图地址(如 'cover.png'),缺省纯黑背景
* @param {string} [opts.text='轻触开始']
* @returns {Promise<void>} resolve 时音频已解锁,可安全 start()
*/
showStartOverlay(opts = {}) {
const { cover, text = '轻触开始' } = opts;
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.id = 'start-overlay';
if (cover) {
const img = document.createElement('img');
img.className = 'cover';
img.src = cover;
img.alt = '';
overlay.appendChild(img);
}
const hint = document.createElement('div');
hint.className = 'hint';
hint.textContent = text;
overlay.appendChild(hint);
document.body.appendChild(overlay);
let done = false;
const onTap = () => {
if (done) return;
done = true;
this.unlock().then(() => {
overlay.remove();
resolve();
});
};
// 同时监听 touchend 与 click;unlock() 必须在手势回调里同步发起
overlay.addEventListener('touchend', onTap);
overlay.addEventListener('click', onTap);
});
}
/**
* 统一播放入口。按 id 前缀分发到三层。
* @param {string} id 'bgm/xxx' | 'voice/xxx' | 'sfx/xxx'(可带扩展名)
* @param {object} [opts]
* @param {number} [opts.volume] 本次播放音量 0~1(乘层音量)
* @param {number} [opts.fadeMs] 仅 BGM:本次交叉淡入时长
* @param {boolean} [opts.loop=true] 仅 BGM:是否循环
* @param {() => void} [opts.onComplete] 仅 voice:自然播完回调(被打断不触发)
* @param {boolean} [opts.queue=false] 仅 voice:true 时排队等待当前对白播完,
* false(默认)顶掉当前对白(对齐 Android)
*/
play(id, opts = {}) {
const slash = id.indexOf('/');
const layer = slash > 0 ? id.slice(0, slash) : '';
if (!this.unlocked) {
console.warn('[WebAudioEngine] 音频尚未解锁(需先 showStartOverlay/unlock),跳过:', id);
return;
}
if (layer === 'bgm') this._playBgm(id, opts);
else if (layer === 'voice') this._playVoice(id, opts);
else if (layer === 'sfx') this._playSfx(id, opts);
else console.error('[WebAudioEngine] 未知音频层(id 需以 bgm/ voice/ sfx/ 开头):', id);
}
/** 预解码一批音频(消除首次播放延迟),id 同 play()。 */
prewarm(ids) {
for (const id of ids) this._load(id);
}
/** 停止 BGM(淡出后释放)。 */
stopBgm(fadeOutMs = this.fadeMs) {
this._bgmWanted = null;
const cur = this._bgm;
if (!cur || !this.ctx) return;
this._bgm = null;
this._fadeGain(cur.gain, 0, fadeOutMs);
setTimeout(() => { try { cur.source.stop(); } catch (_) { /* 已停止 */ } }, fadeOutMs + 60);
}
/**
* 打断当前对白并清空等待队列。**不触发** onComplete(对齐 Android,
* 游戏层依赖此实现"打断即作废整条语音链")。
*/
stopVoice() {
this._voiceQueue.length = 0;
const cur = this._voice;
if (!cur) return;
cur.stopped = true;
this._voice = null;
try { cur.source.stop(); } catch (_) { /* 已停止 */ }
this._unduckBgm();
}
/** 停止 BGM + 对白(sfx 都很短,不主动停)。 */
stopAll() {
this.stopBgm(200);
this.stopVoice();
}
// ---------------- 内部实现 ----------------
/**
* id -> 候选路径列表(带扩展名直接用,否则按回退表依次尝试)。
* voice 层按 settings.voice 优先尝试 `voice/<gender>/` 子目录,
* 找不到再回退平铺路径(过渡期兼容)。
*/
_candidates(id) {
const name = id.slice(id.lastIndexOf('/') + 1);
const hasExt = name.includes('.');
if (id.startsWith('voice/')) {
const genderBase = this.basePath + 'voice/' + settings.get().voice + '/' + id.slice(6);
const flatBase = this.basePath + id;
if (hasExt) return [genderBase, flatBase];
return [
...EXT_FALLBACKS.map((ext) => genderBase + ext),
...EXT_FALLBACKS.map((ext) => flatBase + ext),
];
}
const base = this.basePath + id;
if (hasExt) return [base];
return EXT_FALLBACKS.map((ext) => base + ext);
}
/**
* 加载并解码,缓存 Promise(含失败结果 null,避免重复 404)。
* 资产缺失/无法解码时 console.error 并返回 null,不抛异常。
*/
_load(id) {
// voice 层缓存键带性别:切换 settings.voice 后不会复用旧性别的缓存结果
const key = id.startsWith('voice/') ? `${settings.get().voice}|${id}` : id;
if (this._cache.has(key)) return this._cache.get(key);
const p = (async () => {
if (!this.ctx) return null;
for (const url of this._candidates(id)) {
try {
const resp = await fetch(url);
if (!resp.ok) continue;
const raw = await resp.arrayBuffer();
return await this.ctx.decodeAudioData(raw);
} catch (_) {
// 解码失败(如 iOS Safari 遇到 .ogg)→ 尝试下一个扩展名
}
}
console.error('[WebAudioEngine] 资产缺失或无法解码:', id, this._candidates(id));
return null;
})();
this._cache.set(key, p);
return p;
}
_fadeGain(gainNode, target, ms) {
const t = this.ctx.currentTime;
const g = gainNode.gain;
g.cancelScheduledValues(t);
g.setValueAtTime(g.value, t);
g.linearRampToValueAtTime(target, t + ms / 1000);
}
_duckBgm() {
if (this._bgmBus) this._fadeGain(this._bgmBus, this.duckVolume, this.duckMs);
}
/**
* 设置变更实时生效(settings.subscribe 回调)。
* - bgm 改值:进行中的 BGM 立即渐变到新增益(150ms);
* 改到 0:立即淡出停止(保留"想播"意图);从 0 改回:补播最近想播的 BGM。
* - voice/speed:无需处理——voice 播放在创建 source 时实时读取设置,
* 自然从下一句生效。
*/
_onSettingsChange(changed) {
if (changed.bgm === undefined) return;
const v = changed.bgm;
if (v === 0) {
const wanted = this._bgmWanted;
this.stopBgm(200); // stopBgm 会清 _bgmWanted,先存后恢复
this._bgmWanted = wanted;
} else {
if (this.ctx && this._bgmPrefBus) this._fadeGain(this._bgmPrefBus, v, 150);
if (!this._bgm && this._bgmWanted && this.unlocked) {
const w = this._bgmWanted;
this._playBgm(w.id, w.opts);
}
}
}
_unduckBgm() {
if (this._bgmBus) this._fadeGain(this._bgmBus, 1, this.duckMs);
}
async _playBgm(id, { volume = 1, fadeMs = this.fadeMs, loop = true } = {}) {
this._bgmWanted = { id, opts: { volume, fadeMs, loop } };
if (settings.get().bgm === 0) return; // BGM 关闭:只记意图,不加载不播放
if (this._bgm && this._bgm.id === id) return; // 同一首不重启
const buf = await this._load(id);
if (!buf || (this._bgm && this._bgm.id === id)) return;
const source = this.ctx.createBufferSource();
source.buffer = buf;
source.loop = loop;
const gain = this.ctx.createGain();
gain.gain.value = 0;
source.connect(gain);
gain.connect(this._bgmBus);
const old = this._bgm;
this._bgm = { id, source, gain };
source.start();
// 交叉淡入:新 0→目标,旧 →0 后释放(对齐 Android fadeVolume 时序)
this._fadeGain(gain, this.bgmVolume * volume, fadeMs);
if (old) {
this._fadeGain(old.gain, 0, fadeMs);
setTimeout(() => { try { old.source.stop(); } catch (_) { /* 已停止 */ } }, fadeMs + 60);
}
}
async _playVoice(id, { volume = 1, onComplete, queue = false } = {}) {
if (queue && this._voice) {
this._voiceQueue.push({ id, volume, onComplete });
return;
}
// 默认顶掉当前对白(不打 stopVoice() 以免清掉 queue 语义)
if (this._voice) {
const cur = this._voice;
cur.stopped = true;
this._voice = null;
try { cur.source.stop(); } catch (_) { /* 已停止 */ }
}
const buf = await this._load(id);
if (!buf) { this._unduckBgm(); return; }
const source = this.ctx.createBufferSource();
source.buffer = buf;
source.playbackRate.value = settings.get().speed; // 语速(下一句生效)
const gain = this.ctx.createGain();
gain.gain.value = this.voiceVolume * volume;
source.connect(gain);
gain.connect(this._voiceBus);
const token = { source, gain, stopped: false };
this._voice = token;
this._duckBgm();
source.onended = () => {
if (this._voice !== token || token.stopped) return; // 被打断:不回调
this._voice = null;
if (this._voiceQueue.length === 0) this._unduckBgm();
if (onComplete) onComplete();
const next = this._voiceQueue.shift();
if (next) this._playVoice(next.id, next);
};
source.start();
}
async _playSfx(id, { volume = 1 } = {}) {
const buf = await this._load(id);
if (!buf) return;
const source = this.ctx.createBufferSource();
source.buffer = buf;
const gain = this.ctx.createGain();
gain.gain.value = this.sfxVolume * volume;
source.connect(gain);
gain.connect(this._sfxBus);
source.start(); // 即发即弃,天然支持并发叠加
}
}