Files
ark-stronghold-protocol/public/js/audio.js
T
sganggs 01a44cd288 卫戍协议:盟约 web remake — initial import
Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
2026-09-29 16:21:31 +08:00

623 lines
27 KiB
JavaScript

// Audio manager (Web Audio): BGM per phase, UI SFX, per-unit battle SFX. Never throws.
//
// Sources: data/assets.json → audio (docs/ASSETS.md):
// bgm { lobby, prep, combat, boss: { intro?, loop } }, bossBgm { [bossId]: { intro?, loop } },
// sfx.ui { click, buy, sell, refresh, freeze, levelup, merge, equip, ready, timer, yourTurn, … },
// sfx.battle { deploy, tokenDeploy, charDie, tokenDie?, enemyDie, enemyHit, heal, killCoin, … },
// sfx.units { [charId|tokenId|enemyId]: { attack?, hit?, skill?, die?, born? } }.
//
// - The AudioContext is created on the first user gesture (pointerdown/keydown/touchend), so browsers
// never block or warn; everything requested before that is remembered (BGM) or dropped (SFX).
// - Channels: master → { bgm, sfx } gains; volumes from settings (0..1) + mute. Tab hidden ⇒ suspend.
// - BGM: `intro` then `loop` (1 s crossfade); switching tracks fades out/in (0.8 s). The same loop URL
// keeps playing across phases (prep and combat share a track).
// - Battle SFX from `b.ev` tuples (`handleBattleEvents`): at most MAX_VOICES concurrent unit sounds,
// a per-unit cooldown and a per-URL minimum gap (SfxLimiter), so a 60-unit fight stays listenable.
// - Deaths/deployments follow the official per-class defaults (unitSoundClass): only operators play the
// operator-knocked-down sound; summons use the token sounds; a summon used up by its own effect (fx `consumed`,
// 香槟炸弹) plays its impact sound instead of a death sound.
// - Buffers are fetched once and cached (LRU); failed fetch/decode ⇒ silent (logged once as a warning).
//
// `bgmKeyFor(route, pub)` picks the track for the current screen/phase (main.js calls `audio.install()`,
// which follows the store).
import { PHASE } from '../../shared/constants.js';
const MAX_VOICES = 8;
const UNIT_COOLDOWN_MS = 160;
const URL_GAP_MS = 45;
const BUFFER_CACHE = 180;
const XFADE_S = 1;
const FADE_S = 0.8;
// ---- pure helpers (unit-tested) -----------------------------------------------------------------------
/**
* BGM key for a route + match phase.
* @param {'title'|'lobby'|'room'|'game'|string} route
* @param {any} pub m.public (may be null)
* @returns {string|null} 'lobby' | 'prep' | 'combat' | 'boss' | 'boss:<bossId>' | null
*/
export function bgmKeyFor(route, pub) {
if (route !== 'game') return route === 'title' || route === 'lobby' || route === 'room' ? 'lobby' : null;
const phase = pub?.phase;
if (!phase) return 'lobby';
switch (phase) {
case PHASE.INFO_CHECK: case PHASE.BAND_DRAFT: case PHASE.BATTLE_CHECK: case PHASE.RESULT: case PHASE.LOBBY:
return 'lobby';
case PHASE.COMBAT: case PHASE.UNITE:
return 'combat';
case PHASE.FINAL_ASSAULT:
return pub.bossId ? `boss:${pub.bossId}` : 'boss';
case PHASE.HIDDEN_CORE:
return pub.hiddenBossId ? `boss:${pub.hiddenBossId}` : pub.bossId ? `boss:${pub.bossId}` : 'boss';
default:
return 'prep';
}
}
/**
* Resolve a BGM key to { intro?, loop } URLs from the manifest (boss:<id> falls back to the generic boss track).
* @param {any} manifest
* @param {string|null} key
* @returns {{ intro: string|null, loop: string }|null}
*/
export function resolveBgm(manifest, key) {
const a = manifest?.audio;
if (!a || !key) return null;
let t = null;
if (key.startsWith('boss:')) t = a.bossBgm?.[key.slice(5)] || a.bgm?.boss;
else t = a.bgm?.[key];
if (!t || typeof t.loop !== 'string') return null;
return { intro: typeof t.intro === 'string' ? t.intro : null, loop: t.loop };
}
/**
* Official sound class of a battle unit (audio_data `battle.ON_UNIT_DEAD|BORN.<class>` defaults):
* 'enemy' | 'char' (operators: b_char_dead “干员被击倒” / b_char_set) | 'token' (summons: b_char_tokendead /
* b_char_tokenset) | 'device' (stage devices: the act crate trap_1105 dies with b_char_tokendead, no born sound).
* Band map characters (预备干员-医疗 / Touch, `char_*` ids) are characters although the sim runs them as tokens.
* @param {{ side?: string, kind?: string, defId?: string, def?: string }|null} info tracked unit (UnitInfo subset)
*/
export function unitSoundClass(info) {
if (!info) return 'char';
if (info.side === 'enemy') return 'enemy';
const id = String(info.defId ?? info.def ?? '');
if (info.kind === 'device') return 'device';
if (info.kind === 'token') return /^char_/.test(id) ? 'char' : 'token';
return 'char';
}
/** URL of the generic token death sound (b_char_tokendead): sfx.battle.tokenDie, else next to charDie. */
function tokenDieUrl(manifest) {
const b = manifest?.audio?.sfx?.battle;
if (typeof b?.tokenDie === 'string') return b.tokenDie;
return typeof b?.charDie === 'string' && /b_char_dead\.mp3$/.test(b.charDie) ? b.charDie.replace(/b_char_dead\.mp3$/, 'b_char_tokendead.mp3') : null;
}
/**
* Death sound of a battle unit ('die' event): the unit's own ON_UNIT_DEAD sound, else its class default — only
* operators play the operator-knocked-down sound (charDie). A summon that fired and was used up (香槟炸弹: its
* explosion is the sound) is silent, and so is an operator leaving without being knocked out, when the event says so
* (`reason` ≠ 'killed').
* @param {any} manifest data/assets.json
* @param {{ side?: string, kind?: string, defId?: string, def?: string, boss?: boolean }|null} info
* @param {{ consumed?: boolean, reason?: string|null }} [o]
* @returns {string|null} sound URL
*/
export function deathSfxUrl(manifest, info, { consumed = false, reason = null } = {}) {
if (!info || consumed) return null;
const cls = unitSoundClass(info);
if (cls === 'char' && reason && reason !== 'killed') return null;
const own = manifest?.audio?.sfx?.units?.[info.def]?.die;
if (typeof own === 'string') return own;
const b = manifest?.audio?.sfx?.battle ?? {};
if (cls === 'enemy') return (info.boss ? b.enemyDieHeavy : null) ?? b.enemyDie ?? null;
if (cls === 'char') return typeof b.charDie === 'string' ? b.charDie : null;
return tokenDieUrl(manifest);
}
/**
* Deployment sound of an allied unit ('deploy' event): its own ON_UNIT_BORN sound, else operators b_char_set
* (sfx.battle.deploy), summons b_char_tokenset (tokenDeploy); stage devices have none.
* @returns {string|null}
*/
export function deploySfxUrl(manifest, info) {
if (!info || info.side === 'enemy') return null;
const own = manifest?.audio?.sfx?.units?.[info.def]?.born;
if (typeof own === 'string') return own;
const b = manifest?.audio?.sfx?.battle ?? {};
const cls = unitSoundClass(info);
if (cls === 'device') return null;
const url = cls === 'token' ? (b.tokenDeploy ?? b.deploy) : b.deploy;
return typeof url === 'string' ? url : null;
}
/** Concurrency + cooldown gate for battle SFX. Pure (time is passed in). */
/** Gestures that may unlock audio: iOS Safari only accepts touchend / click / keydown; pointerdown covers the rest. */
const UNLOCK_EVENTS = ['pointerdown', 'touchend', 'click', 'keydown'];
export class SfxLimiter {
/** @param {{ maxVoices?: number, unitCooldownMs?: number, urlGapMs?: number }} [o] */
constructor(o = {}) {
this.maxVoices = o.maxVoices ?? MAX_VOICES;
this.unitCooldownMs = o.unitCooldownMs ?? UNIT_COOLDOWN_MS;
this.urlGapMs = o.urlGapMs ?? URL_GAP_MS;
this.active = 0;
this.lastByUnit = new Map();
this.lastByUrl = new Map();
}
/**
* Whether a sound may start now; records it when allowed (call `release()` when it ends).
* @param {number} now ms
* @param {string|number|null} unitKey e.g. `${unitId}:atk`
* @param {string} url
*/
tryAcquire(now, unitKey, url) {
if (this.active >= this.maxVoices) return false;
if (unitKey != null) {
const t = this.lastByUnit.get(unitKey);
if (t != null && now - t < this.unitCooldownMs) return false;
}
const u = this.lastByUrl.get(url);
if (u != null && now - u < this.urlGapMs) return false;
if (unitKey != null) this.lastByUnit.set(unitKey, now);
this.lastByUrl.set(url, now);
if (this.lastByUnit.size > 600) this.lastByUnit.clear();
if (this.lastByUrl.size > 400) this.lastByUrl.clear();
this.active += 1;
return true;
}
release() { this.active = Math.max(0, this.active - 1); }
}
// ---- manager -----------------------------------------------------------------------------------------------
export class AudioManager {
/**
* @param {{ getManifest?: () => any, win?: any }} [opts]
*/
constructor(opts = {}) {
this.getManifest = typeof opts.getManifest === 'function' ? opts.getManifest : () => null;
this.win = opts.win ?? (typeof window !== 'undefined' ? window : null);
this.ctx = null;
this.master = null;
this.bgmGain = null;
this.sfxGain = null;
this.volumes = { bgm: 0.6, sfx: 0.8, muted: false };
this.buffers = new Map(); // url → Promise<AudioBuffer|null> (insertion order = LRU)
this.warned = new Set();
this.limiter = new SfxLimiter();
this.uiVoices = 0;
this.wantBgm = null; // desired key (kept while locked)
this.bgm = null; // { key, loopUrl, nodes: [{src, gain}], gain }
this.bgmToken = 0;
this.units = new Map(); // battle unit id → defId
this.lastAttacker = new Map(); // target id → attacker defId
this.consumed = new Set(); // summons used up by their own effect (香槟炸弹 exploded): no death sound
this.installed = false;
this._unlock = this._unlock.bind(this);
this._onVis = this._onVis.bind(this);
}
/** Attach gesture unlock + visibility handling. Idempotent. */
install() {
if (this.installed || !this.win) return;
this.installed = true;
try {
for (const ev of UNLOCK_EVENTS) this.win.addEventListener(ev, this._unlock, { capture: true, passive: true });
this.win.document?.addEventListener?.('visibilitychange', this._onVis);
// iOS / iPadOS: a phone call, Siri or another app puts the context into 'interrupted'; coming back to the page
// (pageshow / focus) resumes it (plus the next gesture, below)
this.win.addEventListener?.('pageshow', this._onVis);
this.win.addEventListener?.('focus', this._onVis);
} catch { /* ignore */ }
}
get unlocked() { return !!this.ctx; }
/**
* First user gesture: create the context. The gesture listeners stay until the context actually runs — iOS Safari
* only counts touchend / click (not pointerdown / touchstart) as activation, so a context created on pointerdown can
* stay 'suspended' until the finger lifts. A 1-sample silent buffer is played inside the gesture (older WebKit only
* unlocks output after something was started in a gesture).
*/
_unlock() {
if (this.ctx) {
const st = this.ctx.state;
if (st === 'running') { this._dropUnlock(); return; }
if (!this.win?.document?.hidden) {
this._primeOutput();
const p = this.ctx.resume?.();
if (p && typeof p.then === 'function') p.then(() => { if (this.ctx?.state === 'running') this._dropUnlock(); }, () => {});
}
return;
}
try {
const AC = this.win?.AudioContext || this.win?.webkitAudioContext;
if (!AC) return;
this.ctx = new AC();
this.master = this.ctx.createGain();
this.bgmGain = this.ctx.createGain();
this.sfxGain = this.ctx.createGain();
this.bgmGain.connect(this.master);
this.sfxGain.connect(this.master);
this.master.connect(this.ctx.destination);
// iOS / iPadOS: a call, Siri or another app's audio moves a running context to 'interrupted' (or 'suspended');
// a resume without a gesture may then be refused — listen for the next gesture again (dropped once it runs)
try {
this.ctx.addEventListener?.('statechange', () => {
const s = this.ctx?.state;
if (s && s !== 'running' && s !== 'closed' && !this.win?.document?.hidden) this._armUnlock();
});
} catch { /* ignore */ }
this._applyVolumes();
this._primeOutput();
if (this.ctx.state === 'running') this._dropUnlock();
else {
const p = this.ctx.resume?.();
if (p && typeof p.then === 'function') p.then(() => { if (this.ctx?.state === 'running') this._dropUnlock(); }, () => {});
}
if (this.wantBgm) { const k = this.wantBgm; this.wantBgm = null; this.playBgm(k); }
} catch (err) {
this._warn('ctx', err);
this.ctx = null;
}
}
/** (Re-)attach the gesture listeners after the context stopped running while visible (see _unlock / _onVis). */
_armUnlock() {
if (!this._unlockDropped || !this.win) return;
this._unlockDropped = false;
try { for (const ev of UNLOCK_EVENTS) this.win.addEventListener(ev, this._unlock, { capture: true, passive: true }); } catch { /* ignore */ }
}
/** Remove the first-gesture listeners (the context runs). */
_dropUnlock() {
if (this._unlockDropped || !this.win) return;
this._unlockDropped = true;
try { for (const ev of UNLOCK_EVENTS) this.win.removeEventListener(ev, this._unlock, { capture: true }); } catch { /* ignore */ }
}
/** Start a silent 1-sample buffer (inside a user gesture: unlocks output on older WebKit). */
_primeOutput() {
try {
const c = this.ctx;
if (!c || typeof c.createBuffer !== 'function') return;
const src = c.createBufferSource();
src.buffer = c.createBuffer(1, 1, c.sampleRate || 44100);
src.connect(c.destination);
src.start ? src.start(0) : src.noteOn?.(0);
} catch { /* ignore */ }
}
_onVis() {
try {
if (!this.ctx) return;
if (this.win?.document?.hidden) this.ctx.suspend().catch(() => {});
else if (this.ctx.state !== 'running') {
// back on the page: resume, and keep a gesture ready in case the browser wants one first (iOS after a call)
this._armUnlock();
this.ctx.resume().then(() => { if (this.ctx?.state === 'running') this._dropUnlock(); }, () => {});
}
} catch { /* ignore */ }
}
_warn(key, err) {
if (this.warned.has(key)) return;
this.warned.add(key);
try { console.warn(`[audio] ${key} unavailable`, err?.message || err || ''); } catch { /* ignore */ }
}
/**
* Set channel volumes (0..1) and mute.
* @param {{ bgm?: number, sfx?: number, muted?: boolean }} v
*/
setVolumes(v) {
const n = (x, d) => (Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : d);
this.volumes = { bgm: n(v?.bgm, this.volumes.bgm), sfx: n(v?.sfx, this.volumes.sfx), muted: typeof v?.muted === 'boolean' ? v.muted : this.volumes.muted };
this._applyVolumes();
}
_applyVolumes() {
if (!this.ctx) return;
try {
const t = this.ctx.currentTime;
this.master.gain.setTargetAtTime(this.volumes.muted ? 0 : 1, t, 0.03);
// perceptual curve
this.bgmGain.gain.setTargetAtTime(this.volumes.bgm ** 2 * 0.55, t, 0.05);
this.sfxGain.gain.setTargetAtTime(this.volumes.sfx ** 2 * 0.9, t, 0.03);
} catch { /* ignore */ }
}
/** Fetch + decode (cached, LRU). Resolves null on failure. */
_buffer(url) {
if (!this.ctx || typeof url !== 'string' || !url) return Promise.resolve(null);
const hit = this.buffers.get(url);
if (hit) {
this.buffers.delete(url);
this.buffers.set(url, hit);
return hit;
}
const p = (async () => {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const ab = await res.arrayBuffer();
return await new Promise((resolve) => {
try {
const r = this.ctx.decodeAudioData(ab, resolve, () => resolve(null));
if (r && typeof r.then === 'function') r.then(resolve, () => resolve(null));
} catch { resolve(null); }
});
} catch (err) {
this._warn(url, err);
return null;
}
})();
this.buffers.set(url, p);
while (this.buffers.size > BUFFER_CACHE) {
const first = this.buffers.keys().next().value;
// never evict the playing BGM
if (this.bgm && first === this.bgm.loopUrl) { const v = this.buffers.get(first); this.buffers.delete(first); this.buffers.set(first, v); break; }
this.buffers.delete(first);
}
return p;
}
/** Preload a list of URLs (e.g. UI SFX) once unlocked. */
preload(urls) {
if (!this.ctx) return;
for (const u of Array.isArray(urls) ? urls : []) this._buffer(u);
}
// ---- BGM ------------------------------------------------------------------------------------------------
/**
* Switch BGM (null stops). Same loop URL ⇒ no restart.
* @param {string|null} key see bgmKeyFor
*/
playBgm(key) {
try {
if (!this.ctx) { this.wantBgm = key; return; }
const track = resolveBgm(this.getManifest(), key);
if (this.bgm && track && this.bgm.loopUrl === track.loop) { this.bgm.key = key; return; }
if (!track && !this.bgm) return;
const token = ++this.bgmToken;
this._fadeOutBgm();
if (!track) return;
this._startBgm(key, track, token);
} catch (err) { this._warn('bgm', err); }
}
async _startBgm(key, track, token) {
const [intro, loop] = await Promise.all([track.intro ? this._buffer(track.intro) : null, this._buffer(track.loop)]);
if (token !== this.bgmToken || !this.ctx || !loop) return;
try {
const ctx = this.ctx;
const gain = ctx.createGain();
gain.connect(this.bgmGain);
const t0 = ctx.currentTime + 0.05;
gain.gain.setValueAtTime(0, t0);
gain.gain.linearRampToValueAtTime(1, t0 + FADE_S);
const nodes = [];
let loopAt = t0;
if (intro) {
const s = ctx.createBufferSource();
s.buffer = intro;
const g = ctx.createGain();
s.connect(g); g.connect(gain);
s.start(t0);
const end = t0 + intro.duration;
const xf = Math.min(XFADE_S, intro.duration / 2);
g.gain.setValueAtTime(1, Math.max(t0, end - xf));
g.gain.linearRampToValueAtTime(0, end);
nodes.push({ src: s, gain: g });
loopAt = end - xf;
}
const s = ctx.createBufferSource();
s.buffer = loop;
s.loop = true;
const g = ctx.createGain();
s.connect(g); g.connect(gain);
if (intro) {
g.gain.setValueAtTime(0, loopAt);
g.gain.linearRampToValueAtTime(1, loopAt + Math.min(XFADE_S, intro.duration / 2));
}
s.start(loopAt);
nodes.push({ src: s, gain: g });
this.bgm = { key, loopUrl: track.loop, nodes, gain };
} catch (err) { this._warn('bgm-start', err); }
}
_fadeOutBgm() {
const cur = this.bgm;
this.bgm = null;
if (!cur || !this.ctx) return;
try {
const t = this.ctx.currentTime;
cur.gain.gain.cancelScheduledValues(t);
cur.gain.gain.setValueAtTime(cur.gain.gain.value, t);
cur.gain.gain.linearRampToValueAtTime(0, t + FADE_S);
for (const n of cur.nodes) { try { n.src.stop(t + FADE_S + 0.05); } catch { /* ignore */ } }
setTimeout(() => { try { cur.gain.disconnect(); } catch { /* ignore */ } }, (FADE_S + 0.3) * 1000);
} catch { /* ignore */ }
}
// ---- SFX ------------------------------------------------------------------------------------------------
_play(url, { volume = 1, rate = 1, limited = false, unitKey = null } = {}) {
if (!this.ctx || !url || this.volumes.muted || this.volumes.sfx <= 0) return;
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
if (limited) { if (!this.limiter.tryAcquire(now, unitKey, url)) return; }
else if (this.uiVoices >= 12) return;
else this.uiVoices += 1;
const release = () => { if (limited) this.limiter.release(); else this.uiVoices = Math.max(0, this.uiVoices - 1); };
this._buffer(url).then((buf) => {
if (!buf || !this.ctx) { release(); return; }
try {
const s = this.ctx.createBufferSource();
s.buffer = buf;
s.playbackRate.value = rate;
const g = this.ctx.createGain();
g.gain.value = Math.max(0, Math.min(1.5, volume));
s.connect(g); g.connect(this.sfxGain);
let done = false;
const end = () => { if (!done) { done = true; release(); try { g.disconnect(); } catch { /* ignore */ } } };
s.onended = end;
setTimeout(end, (buf.duration / rate) * 1000 + 250); // safety if onended never fires
s.start();
} catch { release(); }
}, release);
}
/**
* UI sound by name (sfx.ui keys). Unknown names are ignored.
* @param {string} name
* @param {{ volume?: number }} [o]
*/
sfx(name, o = {}) {
try {
const url = this.getManifest()?.audio?.sfx?.ui?.[name];
if (typeof url === 'string') this._play(url, { volume: o.volume ?? 0.9 });
} catch { /* ignore */ }
}
/** Battle sound by name (sfx.battle keys), limited like unit sounds. */
battle(name, o = {}) {
try {
const url = this.getManifest()?.audio?.sfx?.battle?.[name];
if (typeof url === 'string') this._play(url, { volume: o.volume ?? 0.7, limited: true, unitKey: o.unitKey ?? `b:${name}` });
} catch { /* ignore */ }
}
/**
* Per-unit sound (attack/hit/skill/die/born), throttled.
* @param {string} defId charId/tokenId/enemyId (or chess id — mapped via its spine/char id by the caller)
* @param {'attack'|'hit'|'skill'|'die'|'born'} kind
* @param {number|string} unitId battle unit id (cooldown key)
* @returns {boolean} whether a unit-specific sound exists
*/
unit(defId, kind, unitId, skillIndex) {
try {
const u = this.getManifest()?.audio?.sfx?.units?.[defId];
// DESIGN §16: the equipped skill's own ON_SKILL_START sound (`skills[index]`) when the manifest has it
const own = kind === 'skill' && Number.isInteger(skillIndex) && u?.skills ? u.skills[skillIndex] : null;
const url = typeof own === 'string' ? own : u?.[kind];
if (typeof url !== 'string') return false;
this._play(url, { volume: kind === 'attack' || kind === 'hit' ? 0.55 : 0.8, limited: true, unitKey: `${unitId}:${kind}` });
return true;
} catch { return false; }
}
// ---- battle events ------------------------------------------------------------------------------------------
/** Reset the unit map for a new field (m.field.units = UnitInfo[]). */
setFieldUnits(units) {
this.units.clear();
this.lastAttacker.clear();
this.consumed.clear();
for (const u of Array.isArray(units) ? units : []) this._track(u);
}
_track(u) {
if (!u || typeof u !== 'object' || u.id == null) return;
// UnitInfo.spine is the model id (charId / tokenId / enemyId) — the key of sfx.units; kind/defId pick the
// official class sounds (operator vs summon vs device)
this.units.set(u.id, { def: u.spine || u.defId, defId: u.defId ?? null, kind: u.kind ?? null, side: u.side, boss: !!u.boss,
skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : null });
}
/** Play a resolved battle sound for a unit event, limited like unit sounds. */
_playUnitUrl(url, unitKey, volume = 0.8) {
if (typeof url === 'string') this._play(url, { volume, limited: true, unitKey });
}
/**
* React to `b.ev` tuples (DESIGN §8.2).
* @param {any[]} ev
*/
handleBattleEvents(ev) {
if (!this.ctx || !Array.isArray(ev)) return;
try {
for (const e of ev) {
if (!Array.isArray(e)) continue;
const kind = e[0];
if (kind === 'spawn') { this._track(e[1]); continue; }
if (kind === 'atk') {
const src = this.units.get(e[1]);
if (!src) continue;
this.lastAttacker.set(e[2], src.def);
if (!this.unit(src.def, 'attack', e[1]) && src.side === 'enemy') this.battle('enemyHit', { unitKey: `${e[1]}:atk`, volume: 0.35 });
} else if (kind === 'dmg') {
const by = this.lastAttacker.get(e[1]);
if (by) this.unit(by, 'hit', `h${e[1]}`);
} else if (kind === 'heal') {
this.battle('heal', { unitKey: `heal:${e[1]}`, volume: 0.35 });
} else if (kind === 'skill' && e[2]) {
const u = this.units.get(e[1]);
if (u) this.unit(u.def, 'skill', e[1], u.skillIndex ?? undefined);
} else if (kind === 'die') {
const u = this.units.get(e[1]);
if (!u) continue;
const consumed = this.consumed.delete(e[1]);
const m = this.getManifest();
const url = deathSfxUrl(m, u, { consumed, reason: typeof e[2] === 'string' ? e[2] : null });
if (!url) continue;
const own = url === m?.audio?.sfx?.units?.[u.def]?.die;
this._playUnitUrl(url, own ? `${e[1]}:die` : `die:${e[1]}`, own ? 0.8 : 0.7);
} else if (kind === 'deploy') {
const u = this.units.get(e[1]);
if (!u || u.side === 'enemy') continue;
const m = this.getManifest();
const url = deploySfxUrl(m, u);
if (!url) continue;
const own = url === m?.audio?.sfx?.units?.[u.def]?.born;
this._playUnitUrl(url, own ? `${e[1]}:born` : 'deploy', own ? 0.8 : 0.5);
} else if (kind === 'fx') {
// a summon used up by its own effect (香槟炸弹 exploding: `consumed`): its impact sound now, no death sound
const ex = e[4];
if (!ex || typeof ex !== 'object' || !ex.consumed || ex.id == null) continue;
const u = this.units.get(ex.id);
if (!u || u.side === 'enemy') continue;
this.consumed.add(ex.id);
if (this.consumed.size > 200) this.consumed.delete(this.consumed.values().next().value);
this.unit(u.def, 'hit', `${ex.id}:boom`);
} else if (kind === 'bounty') {
this.battle('killCoin', { unitKey: 'coin' });
}
}
} catch (err) { this._warn('events', err); }
}
}
let manifestGetter = () => null;
/** App-wide audio manager. */
export const audio = new AudioManager({ getManifest: () => manifestGetter() });
/**
* Wire the singleton to the app (called once by main.js): manifest source, settings and store-driven BGM.
* @param {{ getManifest: () => any, subscribe: (fn: (s:any, prev:any) => void) => () => void, getState: () => any,
* selectRoute: (s:any) => string, settings?: { bgm:number, sfx:number, muted:boolean } }} deps
*/
export function installAudio(deps) {
try {
manifestGetter = typeof deps?.getManifest === 'function' ? deps.getManifest : manifestGetter;
audio.install();
if (deps?.settings) audio.setVolumes(deps.settings);
if (typeof deps?.subscribe === 'function' && typeof deps?.getState === 'function') {
const sync = (s) => {
try { audio.playBgm(bgmKeyFor(deps.selectRoute(s), s.match?.public)); } catch { /* ignore */ }
};
sync(deps.getState());
return deps.subscribe((s, prev) => {
if (s.match?.public?.phase !== prev?.match?.public?.phase || s.room !== prev?.room || s.session !== prev?.session
|| s.match?.public?.bossId !== prev?.match?.public?.bossId) sync(s);
});
}
} catch (err) { console.warn('[audio] install failed', err); }
return () => {};
}