type AudioTrackMap = Record; 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();