forked from phenom/healer-man
Release Healer Man 0.1.4
This commit is contained in:
1 parent
5bb170039a
commit
f4c9cf356c
63 files changed
+2694
-81
No files matched your search
@@ -0,0 +1,295 @@
|
||||
import {
|
||||
combatEffectManifestEntry,
|
||||
type CombatActorGroup,
|
||||
type CombatEffectSoundAsset,
|
||||
type CombatPresentationEvent,
|
||||
type CombatPresentationSettings,
|
||||
type CombatPresentationSchool,
|
||||
} from "./combatPresentation";
|
||||
|
||||
const MAX_COMBAT_VOICES = 32;
|
||||
|
||||
interface ActiveVoice {
|
||||
readonly group: CombatActorGroup;
|
||||
readonly 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.8;
|
||||
const activeVoices: ActiveVoice[] = [];
|
||||
const activeMasterVoices = new Set<() => void>();
|
||||
const decodedBuffers = new Map<string, Promise<AudioBuffer | null>>();
|
||||
|
||||
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.8;
|
||||
applyCombatAudioSettings();
|
||||
}
|
||||
|
||||
function positionPanner(
|
||||
audioContext: AudioContext,
|
||||
event: CombatPresentationEvent,
|
||||
): PannerNode | StereoPannerNode {
|
||||
if (typeof audioContext.createPanner === "function") {
|
||||
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 = event.origin[0];
|
||||
panner.positionY.value = event.origin[1];
|
||||
panner.positionZ.value = event.origin[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);
|
||||
};
|
||||
}
|
||||
|
||||
const SCHOOL_FREQUENCY: Readonly<Record<CombatPresentationSchool, number>> = {
|
||||
physical: 115,
|
||||
arcane: 520,
|
||||
fire: 190,
|
||||
frost: 720,
|
||||
nature: 360,
|
||||
shadow: 145,
|
||||
holy: 660,
|
||||
};
|
||||
|
||||
function playSynthesizedCombatCue(event: CombatPresentationEvent): void {
|
||||
const audioContext = ensureContext();
|
||||
if (!audioContext || !groupGains || !configuredSettings?.audioEnabled[event.actorGroup]) return;
|
||||
void audioContext.resume().catch(() => undefined);
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const envelope = audioContext.createGain();
|
||||
const panner = positionPanner(audioContext, event);
|
||||
const start = audioContext.currentTime;
|
||||
const isImpact = event.phase === "impact" || event.delivery === "melee";
|
||||
const duration = event.phase === "cast-start" ? 0.22 : isImpact ? 0.12 : event.phase === "tick" ? 0.1 : 0.18;
|
||||
const base = SCHOOL_FREQUENCY[event.school];
|
||||
oscillator.type = event.school === "physical" ? "sawtooth" : event.school === "holy" ? "sine" : "triangle";
|
||||
oscillator.frequency.setValueAtTime(base * (event.phase === "cast-start" ? 0.7 : 1), start);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(Math.max(35, base * (isImpact ? 0.42 : 1.55)), start + duration);
|
||||
envelope.gain.setValueAtTime(0.0001, start);
|
||||
envelope.gain.exponentialRampToValueAtTime(event.actorGroup === "player" ? 0.12 : 0.085, start + 0.015);
|
||||
envelope.gain.exponentialRampToValueAtTime(0.0001, start + 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 = () => {
|
||||
unregister();
|
||||
oscillator.disconnect();
|
||||
envelope.disconnect();
|
||||
panner.disconnect();
|
||||
};
|
||||
oscillator.start(start);
|
||||
oscillator.stop(start + duration + 0.02);
|
||||
}
|
||||
|
||||
function weightedSound(event: CombatPresentationEvent): CombatEffectSoundAsset | null {
|
||||
const sounds = combatEffectManifestEntry(event.sourceSpellId, event.source)?.sounds ?? [];
|
||||
const playable = sounds.filter((sound) => sound.url);
|
||||
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, fetch(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> {
|
||||
const audioContext = ensureContext();
|
||||
const buffer = await decodeAudio(url);
|
||||
if (!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): Promise<boolean> {
|
||||
if (!sound.url) return false;
|
||||
const audioContext = ensureContext();
|
||||
const buffer = await decodeAudio(sound.url);
|
||||
if (!audioContext || !buffer || !groupGains || !configuredSettings?.audioEnabled[event.actorGroup]) return false;
|
||||
void audioContext.resume().catch(() => undefined);
|
||||
const source = audioContext.createBufferSource();
|
||||
const envelope = audioContext.createGain();
|
||||
const panner = positionPanner(audioContext, event);
|
||||
source.buffer = buffer;
|
||||
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,
|
||||
stop: () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
try { source.stop(); } catch { /* already stopped */ }
|
||||
},
|
||||
};
|
||||
const unregister = registerVoice(voice);
|
||||
source.onended = () => {
|
||||
unregister();
|
||||
source.disconnect();
|
||||
envelope.disconnect();
|
||||
panner.disconnect();
|
||||
};
|
||||
source.start();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function playCombatAudio(event: CombatPresentationEvent): void {
|
||||
if (event.phase === "cast-cancel") {
|
||||
stopCombatAudioGroup(event.actorGroup);
|
||||
return;
|
||||
}
|
||||
if (!configuredSettings?.audioEnabled[event.actorGroup] || configuredSettings.audioVolume[event.actorGroup] <= 0) return;
|
||||
const sound = weightedSound(event);
|
||||
if (!sound) {
|
||||
playSynthesizedCombatCue(event);
|
||||
return;
|
||||
}
|
||||
void playBufferedCombatCue(event, sound).then((played) => {
|
||||
if (!played) playSynthesizedCombatCue(event);
|
||||
});
|
||||
}
|
||||
|
||||
export function stopCombatAudioGroup(group: CombatActorGroup): void {
|
||||
for (const voice of [...activeVoices]) {
|
||||
if (voice.group === group) voice.stop();
|
||||
}
|
||||
}
|
||||
|
||||
export function resetCombatAudio(): void {
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user