Files
healer-man/src/game/combatAudio.ts
T
2026-09-24 18:15:45 -04:00

457 lines
15 KiB
TypeScript

import {
combatEffectManifestEntry,
type CombatActorGroup,
type CombatEffectSoundAsset,
type CombatPresentationEvent,
type CombatPresentationSettings,
} from "./combatPresentation";
import {
combatMediaPackReady,
fetchOptionalMediaAsset,
} from "./combatMediaPack";
const MAX_COMBAT_VOICES = 32;
interface ActiveVoice {
readonly group: CombatActorGroup;
readonly castLoop?: ActiveCastLoop;
readonly stop: () => void;
}
interface ActiveCastLoop {
readonly group: CombatActorGroup;
readonly timeout: ReturnType<typeof setTimeout>;
cancelled: boolean;
stop?: () => void;
}
let context: AudioContext | null = null;
let masterGain: GainNode | null = null;
let groupGains: Record<CombatActorGroup, GainNode> | null = null;
let configuredSettings: CombatPresentationSettings | null = null;
let configuredMasterVolume = 0;
const activeVoices: ActiveVoice[] = [];
const activeCastLoops = new Map<string, ActiveCastLoop>();
const activeMasterVoices = new Set<() => void>();
const decodedBuffers = new Map<string, Promise<AudioBuffer | null>>();
export function masterAudioOutputEnabled(masterVolume: number): boolean {
return Number.isFinite(masterVolume) && masterVolume > 0;
}
export function combatAudioOutputEnabled(
settings: CombatPresentationSettings,
masterVolume: number,
): boolean {
return masterAudioOutputEnabled(masterVolume)
&& (["player", "party", "enemy"] as const).some((group) => (
settings.audioEnabled[group] && settings.audioVolume[group] > 0
));
}
export function authenticCombatAudioEnabled(settings: CombatPresentationSettings): boolean {
return settings.originalMediaEnabled && combatMediaPackReady();
}
export function combatMediaPreloadEnabled(
settings: CombatPresentationSettings,
masterVolume: number,
): boolean {
return authenticCombatAudioEnabled(settings) && (
settings.vfxQuality !== "off" || combatAudioOutputEnabled(settings, masterVolume)
);
}
function combatAudioGroupOutputEnabled(
settings: CombatPresentationSettings,
masterVolume: number,
group: CombatActorGroup,
): boolean {
return masterAudioOutputEnabled(masterVolume)
&& settings.audioEnabled[group]
&& settings.audioVolume[group] > 0;
}
function audioContextConstructor(): typeof AudioContext | null {
if (typeof window === "undefined") return null;
return window.AudioContext
?? (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
?? null;
}
function ensureContext(): AudioContext | null {
if (context) return context;
const Constructor = audioContextConstructor();
if (!Constructor) return null;
context = new Constructor();
masterGain = context.createGain();
masterGain.connect(context.destination);
groupGains = {
player: context.createGain(),
party: context.createGain(),
enemy: context.createGain(),
};
for (const gain of Object.values(groupGains)) gain.connect(masterGain);
applyCombatAudioSettings();
return context;
}
function applyCombatAudioSettings(): void {
if (!context || !masterGain || !groupGains || !configuredSettings) return;
masterGain.gain.setTargetAtTime(configuredMasterVolume, context.currentTime, 0.02);
for (const group of ["player", "party", "enemy"] as const) {
const volume = configuredSettings.audioEnabled[group]
? configuredSettings.audioVolume[group]
: 0;
groupGains[group].gain.setTargetAtTime(volume, context.currentTime, 0.02);
if (volume <= 0) stopCombatAudioGroup(group);
}
}
export function configureCombatAudio(
settings: CombatPresentationSettings,
masterVolume: number,
): void {
configuredSettings = settings;
configuredMasterVolume = Number.isFinite(masterVolume)
? Math.max(0, Math.min(1, masterVolume))
: 0;
if (!masterAudioOutputEnabled(configuredMasterVolume)) {
resetCombatAudio();
if (context?.state === "running") void context.suspend().catch(() => undefined);
}
applyCombatAudioSettings();
}
function positionPanner(
audioContext: AudioContext,
event: CombatPresentationEvent,
): PannerNode | StereoPannerNode {
if (typeof audioContext.createPanner === "function") {
const position = event.phase === "impact"
? event.targetPosition ?? event.origin
: event.origin;
const panner = audioContext.createPanner();
panner.panningModel = "HRTF";
panner.distanceModel = "inverse";
panner.refDistance = event.actorGroup === "player" ? 8 : 5;
panner.maxDistance = 55;
panner.rolloffFactor = event.actorGroup === "player" ? 0.15 : 0.8;
panner.positionX.value = position[0];
panner.positionY.value = position[1];
panner.positionZ.value = position[2];
return panner;
}
return audioContext.createStereoPanner();
}
function trimVoices(): void {
while (activeVoices.length >= MAX_COMBAT_VOICES) activeVoices.shift()?.stop();
}
function registerVoice(voice: ActiveVoice): () => void {
trimVoices();
activeVoices.push(voice);
return () => {
const index = activeVoices.indexOf(voice);
if (index >= 0) activeVoices.splice(index, 1);
};
}
function castLoopKey(event: CombatPresentationEvent): string {
return `${event.sessionId}:${event.sourceActorId}:${event.abilityId}`;
}
function finishCastLoop(key: string, loop: ActiveCastLoop): void {
if (activeCastLoops.get(key) !== loop) return;
loop.cancelled = true;
clearTimeout(loop.timeout);
activeCastLoops.delete(key);
}
function stopCastLoop(key: string): void {
const loop = activeCastLoops.get(key);
if (!loop) return;
finishCastLoop(key, loop);
loop.stop?.();
}
function beginCastLoop(event: CombatPresentationEvent): ActiveCastLoop {
const key = castLoopKey(event);
stopCastLoop(key);
const durationMs = Number.isFinite(event.durationMs)
? Math.max(0, event.durationMs ?? 0)
: 4_500;
const loop: ActiveCastLoop = {
group: event.actorGroup,
cancelled: false,
timeout: setTimeout(() => stopCastLoop(key), durationMs),
};
activeCastLoops.set(key, loop);
return loop;
}
export function selectCombatSoundAssets(
sounds: readonly CombatEffectSoundAsset[],
event: Pick<CombatPresentationEvent, "phase" | "instantCast">,
): readonly CombatEffectSoundAsset[] {
const exact = sounds.filter((sound) => sound.url && (!sound.phase || sound.phase === event.phase));
if (exact.length) return exact;
return event.phase === "release" && event.instantCast
? sounds.filter((sound) => sound.url && sound.phase === "cast-start")
: [];
}
function weightedSound(event: CombatPresentationEvent): CombatEffectSoundAsset | null {
if (!configuredSettings || !authenticCombatAudioEnabled(configuredSettings)) return null;
const sounds = combatEffectManifestEntry(event.sourceSpellId, event.source)?.sounds ?? [];
const playable = selectCombatSoundAssets(sounds, event);
if (!playable.length) return null;
const total = playable.reduce((sum, sound) => sum + Math.max(1, sound.weight ?? 1), 0);
let cursor = (event.id * 0.61803398875 % 1) * total;
for (const sound of playable) {
cursor -= Math.max(1, sound.weight ?? 1);
if (cursor <= 0) return sound;
}
return playable[playable.length - 1] ?? null;
}
async function decodeAudio(url: string): Promise<AudioBuffer | null> {
const audioContext = ensureContext();
if (!audioContext) return null;
if (!decodedBuffers.has(url)) {
decodedBuffers.set(url, fetchOptionalMediaAsset(url)
.then((response) => response.ok ? response.arrayBuffer() : Promise.reject(new Error(String(response.status))))
.then((data) => audioContext.decodeAudioData(data))
.catch(() => null));
}
return decodedBuffers.get(url)!;
}
export async function playMasterAudioCue(
url: string,
volume = 1,
): Promise<(() => void) | null> {
if (!configuredSettings
|| !authenticCombatAudioEnabled(configuredSettings)
|| !masterAudioOutputEnabled(configuredMasterVolume)) return null;
const audioContext = ensureContext();
const buffer = await decodeAudio(url);
if (
!masterAudioOutputEnabled(configuredMasterVolume)
|| !audioContext
|| !buffer
|| !masterGain
) return null;
void audioContext.resume().catch(() => undefined);
const source = audioContext.createBufferSource();
const envelope = audioContext.createGain();
source.buffer = buffer;
envelope.gain.value = Math.max(0, Math.min(1, volume));
source.connect(envelope);
envelope.connect(masterGain);
let stopped = false;
const stop = () => {
if (stopped) return;
stopped = true;
activeMasterVoices.delete(stop);
try { source.stop(); } catch { /* already stopped */ }
};
activeMasterVoices.add(stop);
source.onended = () => {
stopped = true;
activeMasterVoices.delete(stop);
source.disconnect();
envelope.disconnect();
};
source.start();
return stop;
}
async function playBufferedCombatCue(
event: CombatPresentationEvent,
sound: CombatEffectSoundAsset,
castLoop?: ActiveCastLoop,
): Promise<boolean> {
if (!sound.url) return false;
if (
!configuredSettings
|| !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup)
) return false;
const audioContext = ensureContext();
const buffer = await decodeAudio(sound.url);
if (
!configuredSettings
|| !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup)
|| !audioContext
|| !buffer
|| !groupGains
|| castLoop?.cancelled
) return false;
void audioContext.resume().catch(() => undefined);
const source = audioContext.createBufferSource();
const envelope = audioContext.createGain();
const panner = positionPanner(audioContext, event);
source.buffer = buffer;
if (castLoop) source.loop = true;
envelope.gain.value = Math.max(0, Math.min(1, sound.volume ?? 1));
source.connect(envelope);
envelope.connect(panner);
panner.connect(groupGains[event.actorGroup]);
let stopped = false;
const voice: ActiveVoice = {
group: event.actorGroup,
castLoop,
stop: () => {
if (stopped) return;
stopped = true;
try { source.stop(); } catch { /* already stopped */ }
},
};
const unregister = registerVoice(voice);
if (castLoop) castLoop.stop = voice.stop;
source.onended = () => {
unregister();
if (castLoop) finishCastLoop(castLoopKey(event), castLoop);
source.disconnect();
envelope.disconnect();
panner.disconnect();
};
source.start();
return true;
}
const GENERATED_SCHOOL_FREQUENCY: Readonly<Record<CombatPresentationEvent["school"], number>> = {
physical: 150,
holy: 520,
fire: 230,
nature: 330,
frost: 410,
shadow: 110,
arcane: 620,
};
function playGeneratedCombatCue(event: CombatPresentationEvent): boolean {
if (
!configuredSettings
|| !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup)
) return false;
const audioContext = ensureContext();
if (!audioContext || !groupGains) return false;
void audioContext.resume().catch(() => undefined);
const oscillator = audioContext.createOscillator();
const envelope = audioContext.createGain();
const panner = positionPanner(audioContext, event);
const duration = event.phase === "cast-start" ? 0.12 : event.phase === "aura-start" ? 0.2 : 0.09;
const phasePitch = event.phase === "release" ? 1.15 : event.phase === "impact" ? 0.82 : 1;
oscillator.type = event.school === "shadow" || event.school === "physical" ? "triangle" : "sine";
oscillator.frequency.setValueAtTime(
GENERATED_SCHOOL_FREQUENCY[event.school] * phasePitch,
audioContext.currentTime,
);
envelope.gain.setValueAtTime(0.0001, audioContext.currentTime);
envelope.gain.exponentialRampToValueAtTime(0.075, audioContext.currentTime + 0.01);
envelope.gain.exponentialRampToValueAtTime(0.0001, audioContext.currentTime + duration);
oscillator.connect(envelope);
envelope.connect(panner);
panner.connect(groupGains[event.actorGroup]);
let stopped = false;
const voice: ActiveVoice = {
group: event.actorGroup,
stop: () => {
if (stopped) return;
stopped = true;
try { oscillator.stop(); } catch { /* already stopped */ }
},
};
const unregister = registerVoice(voice);
oscillator.onended = () => {
stopped = true;
unregister();
oscillator.disconnect();
envelope.disconnect();
panner.disconnect();
};
oscillator.start();
oscillator.stop(audioContext.currentTime + duration);
return true;
}
export function playCombatAudio(event: CombatPresentationEvent): void {
if (event.phase === "release" || event.phase === "cast-cancel") {
stopCastLoop(castLoopKey(event));
}
if (event.phase === "cast-cancel") {
// A cancelled cast still cuts off legacy one-shot voices for this group.
// Other actors' active cast loops keep playing until their own terminal event.
for (const voice of [...activeVoices]) {
if (voice.group === event.actorGroup && !voice.castLoop) voice.stop();
}
return;
}
if (
!configuredSettings
|| !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup)
) return;
const sound = weightedSound(event);
if (sound) {
const castLoop = event.phase === "cast-start" && sound.loopDuringCast
? beginCastLoop(event)
: undefined;
void playBufferedCombatCue(event, sound, castLoop)
.then((played) => {
if (!played && !castLoop?.cancelled) {
if (castLoop) stopCastLoop(castLoopKey(event));
playGeneratedCombatCue(event);
}
})
.catch(() => {
if (!castLoop?.cancelled) {
if (castLoop) stopCastLoop(castLoopKey(event));
playGeneratedCombatCue(event);
}
});
} else {
playGeneratedCombatCue(event);
}
}
export function stopCombatAudioGroup(group: CombatActorGroup): void {
for (const [key, loop] of activeCastLoops) {
if (loop.group === group) stopCastLoop(key);
}
for (const voice of [...activeVoices]) {
if (voice.group === group) voice.stop();
}
}
export function resetCombatAudio(): void {
for (const key of activeCastLoops.keys()) stopCastLoop(key);
for (const voice of [...activeVoices]) voice.stop();
for (const stop of [...activeMasterVoices]) stop();
activeVoices.length = 0;
}
export function setCombatAudioListener(
position: readonly [number, number, number],
forward: readonly [number, number, number],
up: readonly [number, number, number] = [0, 1, 0],
): void {
if (!context) return;
const listener = context.listener;
const now = context.currentTime;
listener.positionX?.setTargetAtTime(position[0], now, 0.01);
listener.positionY?.setTargetAtTime(position[1], now, 0.01);
listener.positionZ?.setTargetAtTime(position[2], now, 0.01);
listener.forwardX?.setTargetAtTime(forward[0], now, 0.01);
listener.forwardY?.setTargetAtTime(forward[1], now, 0.01);
listener.forwardZ?.setTargetAtTime(forward[2], now, 0.01);
listener.upX?.setTargetAtTime(up[0], now, 0.01);
listener.upY?.setTargetAtTime(up[1], now, 0.01);
listener.upZ?.setTargetAtTime(up[2], now, 0.01);
}
export function activeCombatAudioVoiceCount(): number {
return activeVoices.length;
}