247 lines
6.4 KiB
JavaScript
247 lines
6.4 KiB
JavaScript
// Sound Manager - Handles audio loading, caching, and playback for games
|
|
class SoundManager {
|
|
constructor(adapter) {
|
|
this.adapter = adapter;
|
|
this.sounds = new Map(); // soundId -> { audio, loaded, loading, error }
|
|
this.currentlyPlaying = new Set(); // Track currently playing sounds
|
|
this.globalVolume = 1.0;
|
|
}
|
|
|
|
// Preload a sound file
|
|
async preload(soundId, url) {
|
|
// If already loaded or loading, return existing promise
|
|
if (this.sounds.has(soundId)) {
|
|
const sound = this.sounds.get(soundId);
|
|
if (sound.loaded) {
|
|
return sound.audio;
|
|
}
|
|
if (sound.loading) {
|
|
return sound.loadingPromise;
|
|
}
|
|
if (sound.error) {
|
|
throw new Error(`Sound ${soundId} failed to load: ${sound.error}`);
|
|
}
|
|
}
|
|
|
|
// Create new audio element
|
|
const audio = new Audio();
|
|
const soundEntry = {
|
|
audio,
|
|
loaded: false,
|
|
loading: true,
|
|
error: null,
|
|
url,
|
|
};
|
|
|
|
// Create promise for loading
|
|
const loadingPromise = new Promise((resolve, reject) => {
|
|
const onLoad = () => {
|
|
soundEntry.loaded = true;
|
|
soundEntry.loading = false;
|
|
audio.removeEventListener("canplaythrough", onLoad);
|
|
audio.removeEventListener("error", onError);
|
|
resolve(audio);
|
|
};
|
|
|
|
const onError = (e) => {
|
|
soundEntry.loading = false;
|
|
soundEntry.error = e.message || "Failed to load audio";
|
|
audio.removeEventListener("canplaythrough", onLoad);
|
|
audio.removeEventListener("error", onError);
|
|
reject(new Error(`Failed to load sound ${soundId}: ${soundEntry.error}`));
|
|
};
|
|
|
|
audio.addEventListener("canplaythrough", onLoad, { once: true });
|
|
audio.addEventListener("error", onError, { once: true });
|
|
audio.preload = "auto";
|
|
audio.src = url;
|
|
audio.load();
|
|
});
|
|
|
|
soundEntry.loadingPromise = loadingPromise;
|
|
this.sounds.set(soundId, soundEntry);
|
|
|
|
return loadingPromise;
|
|
}
|
|
|
|
// Play a sound (will load if not already loaded)
|
|
async play(soundId, options = {}) {
|
|
const {
|
|
loop = false,
|
|
volume = 1.0,
|
|
onEnd = null,
|
|
fade = false,
|
|
fadeDuration = 1000,
|
|
} = options;
|
|
|
|
let soundEntry = this.sounds.get(soundId);
|
|
|
|
if (!soundEntry) {
|
|
throw new Error(`Sound ${soundId} not preloaded. Use preload() first.`);
|
|
}
|
|
|
|
// Wait for loading if still loading
|
|
if (soundEntry.loading) {
|
|
await soundEntry.loadingPromise;
|
|
}
|
|
|
|
if (soundEntry.error) {
|
|
throw new Error(`Sound ${soundId} failed to load: ${soundEntry.error}`);
|
|
}
|
|
|
|
const audio = soundEntry.audio;
|
|
|
|
// Clone the audio element for concurrent playback
|
|
const playInstance = audio.cloneNode();
|
|
playInstance.loop = loop;
|
|
playInstance.volume = fade ? 0 : volume * this.globalVolume;
|
|
|
|
// Track this instance
|
|
const trackingId = `${soundId}_${Date.now()}`;
|
|
this.currentlyPlaying.add(trackingId);
|
|
|
|
// Handle end event
|
|
const cleanup = () => {
|
|
this.currentlyPlaying.delete(trackingId);
|
|
playInstance.removeEventListener("ended", cleanup);
|
|
if (onEnd) onEnd();
|
|
};
|
|
|
|
playInstance.addEventListener("ended", cleanup);
|
|
|
|
// Play the sound
|
|
try {
|
|
await playInstance.play();
|
|
|
|
// Fade in if requested
|
|
if (fade) {
|
|
this._fadeIn(playInstance, volume * this.globalVolume, fadeDuration);
|
|
}
|
|
|
|
return {
|
|
instance: playInstance,
|
|
stop: () => {
|
|
playInstance.pause();
|
|
playInstance.currentTime = 0;
|
|
cleanup();
|
|
},
|
|
fadeOut: (duration = fadeDuration) => {
|
|
return this._fadeOut(playInstance, duration).then(() => {
|
|
playInstance.pause();
|
|
cleanup();
|
|
});
|
|
},
|
|
};
|
|
} catch (error) {
|
|
cleanup();
|
|
throw new Error(`Failed to play sound ${soundId}: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
// Fade in audio
|
|
_fadeIn(audio, targetVolume, duration) {
|
|
const steps = 20;
|
|
const stepDuration = duration / steps;
|
|
const volumeStep = targetVolume / steps;
|
|
let currentStep = 0;
|
|
|
|
const interval = setInterval(() => {
|
|
currentStep++;
|
|
audio.volume = Math.min(volumeStep * currentStep, targetVolume);
|
|
|
|
if (currentStep >= steps) {
|
|
clearInterval(interval);
|
|
audio.volume = targetVolume;
|
|
}
|
|
}, stepDuration);
|
|
}
|
|
|
|
// Fade out audio
|
|
_fadeOut(audio, duration) {
|
|
return new Promise((resolve) => {
|
|
const steps = 20;
|
|
const stepDuration = duration / steps;
|
|
const startVolume = audio.volume;
|
|
const volumeStep = startVolume / steps;
|
|
let currentStep = 0;
|
|
|
|
const interval = setInterval(() => {
|
|
currentStep++;
|
|
audio.volume = Math.max(startVolume - volumeStep * currentStep, 0);
|
|
|
|
if (currentStep >= steps) {
|
|
clearInterval(interval);
|
|
audio.volume = 0;
|
|
resolve();
|
|
}
|
|
}, stepDuration);
|
|
});
|
|
}
|
|
|
|
// Stop all currently playing sounds
|
|
stopAll() {
|
|
for (const soundId of Array.from(this.currentlyPlaying)) {
|
|
const [id] = soundId.split("_");
|
|
const soundEntry = this.sounds.get(id);
|
|
if (soundEntry && soundEntry.audio) {
|
|
soundEntry.audio.pause();
|
|
soundEntry.audio.currentTime = 0;
|
|
}
|
|
}
|
|
this.currentlyPlaying.clear();
|
|
}
|
|
|
|
// Set global volume (0.0 to 1.0)
|
|
setGlobalVolume(volume) {
|
|
this.globalVolume = Math.max(0, Math.min(1, volume));
|
|
}
|
|
|
|
// Check if a sound is loaded
|
|
isLoaded(soundId) {
|
|
const sound = this.sounds.get(soundId);
|
|
return sound && sound.loaded;
|
|
}
|
|
|
|
// Check if a sound is currently loading
|
|
isLoading(soundId) {
|
|
const sound = this.sounds.get(soundId);
|
|
return sound && sound.loading;
|
|
}
|
|
|
|
// Get loading progress for all sounds
|
|
getLoadingStatus() {
|
|
const status = {
|
|
total: this.sounds.size,
|
|
loaded: 0,
|
|
loading: 0,
|
|
failed: 0,
|
|
};
|
|
|
|
for (const [, sound] of this.sounds) {
|
|
if (sound.loaded) status.loaded++;
|
|
else if (sound.loading) status.loading++;
|
|
else if (sound.error) status.failed++;
|
|
}
|
|
|
|
return status;
|
|
}
|
|
|
|
// Clear all sounds (useful for cleanup)
|
|
clear() {
|
|
this.stopAll();
|
|
this.sounds.clear();
|
|
}
|
|
|
|
// Remove a specific sound from cache
|
|
unload(soundId) {
|
|
const sound = this.sounds.get(soundId);
|
|
if (sound && sound.audio) {
|
|
sound.audio.pause();
|
|
sound.audio.src = "";
|
|
}
|
|
this.sounds.delete(soundId);
|
|
}
|
|
}
|
|
|
|
// Make available globally
|
|
window.SoundManager = SoundManager;
|