358 lines
9.9 KiB
TypeScript
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();
|