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:
@@ -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(); // 即发即弃,天然支持并发叠加
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user