Files
heros_web/src/game/audio/SoundDirector.ts

358 lines
9.9 KiB
TypeScript

type AudioTrackMap = Record<string, string>;
type PlayEffectOptions = {
volume?: number;
rate?: number;
stopAfterMs?: number;
};
class SoundDirector {
private context?: AudioContext;
private master?: GainNode;
private started = false;
private muted = false;
private musicTracks: AudioTrackMap = {};
private effectTracks: AudioTrackMap = {};
private music?: HTMLAudioElement;
private currentMusicKey?: string;
private pendingMusicKey?: string;
private musicFadeTimer?: number;
private lastEffect?: { key: string; at: number; volume: number; rate: number };
private lastMovementStep?: { key: string; mounted: boolean; stepIndex: number; at: number; volume?: number; rate?: number };
private readonly musicVolume = 0.22;
private readonly effectVolume = 0.42;
registerMusicTracks(tracks: AudioTrackMap) {
this.musicTracks = tracks;
}
registerEffectTracks(tracks: AudioTrackMap) {
this.effectTracks = tracks;
}
start() {
if (this.started) {
this.resume();
if (this.pendingMusicKey) {
this.playMusic(this.pendingMusicKey);
}
return;
}
const AudioContextCtor = window.AudioContext ?? window.webkitAudioContext;
if (AudioContextCtor) {
this.context = new AudioContextCtor();
this.master = this.context.createGain();
this.master.gain.value = this.muted ? 0 : 0.3;
this.master.connect(this.context.destination);
}
this.started = true;
this.resume();
if (this.pendingMusicKey) {
this.playMusic(this.pendingMusicKey);
}
}
resume() {
void this.context?.resume();
if (this.started && this.music) {
void this.music.play().catch(() => undefined);
}
}
setMuted(muted: boolean) {
this.muted = muted;
if (this.music) {
this.music.muted = muted;
}
if (!this.context || !this.master) {
return;
}
this.master.gain.cancelScheduledValues(this.context.currentTime);
this.master.gain.linearRampToValueAtTime(muted ? 0 : 0.3, this.context.currentTime + 0.18);
}
isMuted() {
return this.muted;
}
playHover() {
this.playTone(540, 0.04, 0.018, 'sine');
}
playSelect() {
if (this.playEffect('ui-select', { volume: 0.24, stopAfterMs: 420 })) {
return;
}
this.playTone(300, 0.07, 0.04, 'sine');
window.setTimeout(() => this.playTone(450, 0.08, 0.028, 'sine'), 70);
}
playGrowthTick() {
if (this.playEffect('exp-gain', { volume: 0.18, rate: 1.12, stopAfterMs: 360 })) {
return;
}
this.playTone(620, 0.05, 0.018, 'triangle');
}
playLevelUp() {
if (this.playEffect('level-up', { volume: 0.36, stopAfterMs: 1200 })) {
return;
}
this.playEffect('exp-gain', { volume: 0.42, rate: 1.08, stopAfterMs: 820 });
this.playTone(520, 0.08, 0.035, 'triangle');
window.setTimeout(() => this.playTone(720, 0.09, 0.04, 'triangle'), 82);
window.setTimeout(() => this.playTone(980, 0.12, 0.034, 'sine'), 168);
}
playMeleeRush(mounted: boolean) {
if (mounted) {
this.playEffect('horse-gallop', { volume: 0.2, rate: 1.18, stopAfterMs: 360 });
return;
}
this.playEffect('footstep-walk', { volume: 0.2, rate: 1.35, stopAfterMs: 320 });
}
playMovementStep(mounted: boolean, stepIndex: number, options: PlayEffectOptions = {}) {
const key = mounted ? 'horse-gallop' : 'footstep-walk';
this.lastMovementStep = { key, mounted, stepIndex, at: performance.now(), volume: options.volume, rate: options.rate };
const played = this.playEffect(key, options);
this.playStepPulse(mounted, stepIndex, options.volume);
return played;
}
playMeleeSwing(hit: boolean, critical: boolean) {
this.playEffect('sword-slash', {
volume: critical ? 0.62 : hit ? 0.52 : 0.38,
rate: critical ? 0.94 : hit ? 1 : 1.1,
stopAfterMs: 640
});
this.playTone(critical ? 180 : 220, 0.04, critical ? 0.03 : 0.02, 'sawtooth');
}
playArrowShot() {
this.playEffect('sword-slash', { volume: 0.18, rate: 1.58, stopAfterMs: 280 });
this.playTone(760, 0.05, 0.012, 'triangle');
window.setTimeout(() => this.playTone(1180, 0.045, 0.01, 'sine'), 46);
}
playArrowImpact(hit: boolean, critical: boolean) {
if (hit) {
this.playEffect('combat-impact', { volume: critical ? 0.5 : 0.34, rate: critical ? 1.06 : 1.18, stopAfterMs: 420 });
this.playTone(critical ? 260 : 340, 0.05, critical ? 0.026 : 0.018, 'square');
return;
}
this.playTone(940, 0.06, 0.011, 'sine');
}
playStrategyPulse() {
this.playEffect('strategy-cast', { volume: 0.44, rate: 0.96, stopAfterMs: 960 });
this.playTone(460, 0.08, 0.012, 'sine');
window.setTimeout(() => this.playTone(690, 0.08, 0.012, 'triangle'), 78);
}
playItemLaunch() {
this.playEffect('cart-roll', { volume: 0.34, rate: 1.06, stopAfterMs: 740 });
this.playTone(180, 0.055, 0.02, 'sawtooth');
}
playEffect(key: string, options: PlayEffectOptions = {}) {
if (!this.started || this.muted) {
return false;
}
const src = this.effectTracks[key];
if (!src) {
return false;
}
const effect = new Audio(src);
effect.preload = 'auto';
effect.volume = options.volume ?? this.effectVolume;
effect.playbackRate = options.rate ?? 1;
effect.muted = this.muted;
this.lastEffect = { key, at: performance.now(), volume: effect.volume, rate: effect.playbackRate };
effect.addEventListener(
'ended',
() => {
effect.src = '';
},
{ once: true }
);
if (options.stopAfterMs) {
window.setTimeout(() => {
effect.pause();
effect.src = '';
}, options.stopAfterMs);
}
void effect.play().catch(() => undefined);
return true;
}
getDebugState() {
return {
started: this.started,
muted: this.muted,
contextState: this.context?.state ?? null,
currentMusicKey: this.currentMusicKey ?? null,
pendingMusicKey: this.pendingMusicKey ?? null,
lastEffect: this.lastEffect ?? null,
lastMovementStep: this.lastMovementStep ?? null
};
}
playMusic(key?: string) {
if (!key) {
return;
}
this.pendingMusicKey = key;
if (!this.started) {
return;
}
const src = this.musicTracks[key];
if (!src) {
return;
}
if (this.currentMusicKey === key && this.music) {
this.music.muted = this.muted;
void this.music.play().catch(() => undefined);
return;
}
const previous = this.music;
const next = new Audio(src);
next.loop = true;
next.preload = 'auto';
next.muted = this.muted;
next.volume = 0;
this.music = next;
this.currentMusicKey = key;
void next.play().catch(() => undefined);
this.fadeMusic(previous, next, this.musicVolume);
}
private playTone(
frequency: number,
duration: number,
gainValue: number,
type: OscillatorType
) {
if (!this.context || this.muted) {
return;
}
const oscillator = this.context.createOscillator();
const gain = this.context.createGain();
const now = this.context.currentTime;
oscillator.type = type;
oscillator.frequency.value = frequency;
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(gainValue, now + 0.012);
gain.gain.exponentialRampToValueAtTime(0.0001, now + duration);
oscillator.connect(gain);
gain.connect(this.master ?? this.context.destination);
oscillator.start(now);
oscillator.stop(now + duration + 0.04);
}
private playStepPulse(mounted: boolean, stepIndex: number, volume = 0.2) {
if (!this.context || this.muted) {
return;
}
const scale = Math.max(0.35, Math.min(1.2, volume / 0.24));
const start = this.context.currentTime;
const primaryFrequency = mounted ? 112 + (stepIndex % 2) * 26 : 148 + (stepIndex % 2) * 34;
const duration = mounted ? 0.09 : 0.075;
const gainValue = (mounted ? 0.022 : 0.016) * scale;
this.playPercussivePulse(primaryFrequency, start, duration, gainValue, mounted ? 'square' : 'triangle');
if (mounted) {
this.playPercussivePulse(82 + (stepIndex % 2) * 18, start + 0.055, 0.075, gainValue * 0.78, 'square');
}
}
private playPercussivePulse(
frequency: number,
start: number,
duration: number,
gainValue: number,
type: OscillatorType
) {
if (!this.context || this.muted) {
return;
}
const oscillator = this.context.createOscillator();
const gain = this.context.createGain();
oscillator.type = type;
oscillator.frequency.setValueAtTime(frequency, start);
oscillator.frequency.exponentialRampToValueAtTime(Math.max(40, frequency * 0.62), start + duration);
gain.gain.setValueAtTime(0.0001, start);
gain.gain.linearRampToValueAtTime(gainValue, start + 0.006);
gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
oscillator.connect(gain);
gain.connect(this.master ?? this.context.destination);
oscillator.start(start);
oscillator.stop(start + duration + 0.025);
}
private fadeMusic(previous: HTMLAudioElement | undefined, next: HTMLAudioElement, targetVolume: number) {
if (this.musicFadeTimer) {
window.clearInterval(this.musicFadeTimer);
}
const duration = 900;
const startedAt = performance.now();
const previousStartVolume = previous?.volume ?? 0;
this.musicFadeTimer = window.setInterval(() => {
const progress = Math.min(1, (performance.now() - startedAt) / duration);
next.volume = targetVolume * progress;
if (previous) {
previous.volume = previousStartVolume * (1 - progress);
}
if (progress >= 1) {
if (this.musicFadeTimer) {
window.clearInterval(this.musicFadeTimer);
this.musicFadeTimer = undefined;
}
if (previous) {
previous.pause();
previous.src = '';
}
}
}, 32);
}
}
declare global {
interface Window {
webkitAudioContext?: typeof AudioContext;
}
}
export const soundDirector = new SoundDirector();