Files
Battle-for-Tismo/client/js/audio.js
T

322 lines
11 KiB
JavaScript

// Sound effects and looping background music for the browser client.
//
// Everything is built lazily on the HTML5 Audio element, so callers never have
// to special-case a machine without audio (or the jsdom test harness): when the
// constructor is missing every call is a silent no-op. Browsers refuse to start
// playback before the first user gesture, so the music retries on the next
// interaction when its first attempt was blocked.
// One-shot effects. Only the handful of source files the game actually plays
// ship under client/assets/sounds/; the downloaded libraries stay untracked.
const EFFECT_SOURCES = {
// Click_Electronic_5.
click: "assets/sounds/button_click.mp3",
// Click_Electronic_14, played when a building, technology or unit finishes.
complete: "assets/sounds/build_complete.mp3",
// Mouth_08, the neutral notice (for now, a player joining).
notification: "assets/sounds/notification.mp3",
// DeathFlash, the distant impacts of a bombardment (see game_screen/bombing.js).
bomb: "assets/sounds/death_flash.flac",
};
// Crashed Ship (Ted Kerr), looped softly under the world map.
const MUSIC_SOURCE = "assets/sounds/crashed_ship.ogg";
const EFFECT_VOLUMES = { click: 0.35, complete: 0.6, notification: 0.6 };
const MUSIC_VOLUME = 0.3;
// Effects that are shaped with Web Audio rather than played dry. The artillery
// impacts get a low-pass to muffle them like a far-off blast, plus a short
// feedback delay for the echo rolling back off the ground.
export const EFFECT_PRESETS = {
bomb: {
lowpassHz: 780,
lowpassQ: 0.7,
echoDelay: 0.28,
echoFeedback: 0.32,
echoMix: 0.35,
// How long after the clip the echo tail is left to ring before the graph is
// torn down.
echoTail: 2.0,
},
};
function clamp01(value) {
return Math.min(1, Math.max(0, value));
}
// A real AudioContext, or null where Web Audio is unavailable (jsdom, an old
// browser). Kept in a function so merely importing this module never touches it.
function defaultContextFactory() {
const Ctor =
typeof AudioContext !== "undefined"
? AudioContext
: typeof window !== "undefined" && window.webkitAudioContext
? window.webkitAudioContext
: null;
return Ctor ? new Ctor() : null;
}
export class AudioManager {
constructor(options = {}) {
this.enabled = true;
this.effects = { ...EFFECT_SOURCES };
// Per-instance copies so a caller (or a test) can retune one preset.
this.presets = Object.fromEntries(
Object.entries(EFFECT_PRESETS).map(([name, preset]) => [name, { ...preset }])
);
this.musicSource = MUSIC_SOURCE;
// Whether the game has asked for music; kept apart from `enabled` so the
// sound toggle can mute and unmute without losing the request.
this._wanted = false;
this._music = null;
this._retryArmed = false;
// Web Audio context for the shaped effects, built on first use. `null`
// after a failure so the plain element path is used from then on.
this._context = null;
this._contextFailed = false;
this._contextFactory = options.contextFactory || defaultContextFactory;
this._resumeArmed = false;
// Called with the new `enabled` value so the UI can relabel its toggles.
this.onChange = null;
}
supported() {
return typeof Audio !== "undefined" && typeof Audio === "function";
}
// Plays a one-shot effect by name. Unknown names and missing audio support are
// ignored so the game logic stays blissfully unaware of the device. `volume`
// (0..1) overrides the effect's default, and `duration` (seconds) cuts a long
// clip short. An effect with a preset (the bombardment impacts) is routed
// through the Web Audio filter/echo chain when that is available.
play(name, options = {}) {
if (!this.enabled || !this.supported()) return;
const source = this.effects[name];
if (!source) return;
const fallback = EFFECT_VOLUMES[name];
const volume = clamp01(
options.volume !== undefined ? options.volume : fallback === undefined ? 0.5 : fallback
);
// Touch the context on every play, so the first click (a user gesture)
// unlocks it for the shaped effects later on.
const context = this._ensureContext();
const preset = options.effects || this.presets[name];
if (preset && context && this._playShaped(context, source, volume, options, preset)) return;
this._playPlain(source, volume, options.duration);
}
// The dry path: a bare Audio element, used for every effect without a preset
// and as the fallback when Web Audio is unavailable.
_playPlain(source, volume, duration) {
try {
const audio = new Audio(source);
audio.volume = volume;
const playback = audio.play();
if (playback && typeof playback.catch === "function") playback.catch(() => {});
if (duration > 0) this._stopAfter(audio, duration);
} catch {
// A device or browser that refuses the element: the game is unaffected.
}
}
// Routes the clip through a low-pass filter with a feedback echo. Returns
// false when the graph cannot be built, so the caller falls back to `_playPlain`.
_playShaped(context, source, volume, options, preset) {
try {
const audio = new Audio(source);
audio.volume = volume;
const node = context.createMediaElementSource(audio);
const filter = context.createBiquadFilter();
filter.type = "lowpass";
filter.frequency.value = preset.lowpassHz;
filter.Q.value = preset.lowpassQ;
const dry = context.createGain();
dry.gain.value = 1;
const wet = context.createGain();
wet.gain.value = preset.echoMix;
const delay = context.createDelay(1);
delay.delayTime.value = preset.echoDelay;
const feedback = context.createGain();
feedback.gain.value = preset.echoFeedback;
node.connect(filter);
filter.connect(dry);
dry.connect(context.destination);
filter.connect(delay);
delay.connect(feedback);
feedback.connect(delay);
delay.connect(wet);
wet.connect(context.destination);
const playback = audio.play();
if (playback && typeof playback.catch === "function") playback.catch(() => {});
if (options.duration > 0) this._stopAfter(audio, options.duration);
// Tear the graph down once the echo has faded, so the feedback loop does
// not keep the nodes alive for the rest of the game.
const life = (options.duration > 0 ? options.duration : 1) + preset.echoTail;
this._scheduleCleanup(() => {
for (const part of [node, filter, dry, wet, delay, feedback]) {
try {
part.disconnect();
} catch {
// already disconnected
}
}
}, life);
return true;
} catch {
return false;
}
}
// The shared AudioContext. Must be created (and resumed) around a user
// gesture; the first click does that, and a suspended context is retried on
// the next interaction.
_ensureContext() {
if (this._context || this._contextFailed) return this._context;
try {
const context = this._contextFactory();
if (!context) {
this._contextFailed = true;
return null;
}
this._context = context;
this._resumeContext();
return context;
} catch {
this._contextFailed = true;
return null;
}
}
_resumeContext() {
const context = this._context;
if (!context || context.state !== "suspended" || typeof context.resume !== "function") return;
const resumed = context.resume();
if (resumed && typeof resumed.catch === "function") resumed.catch(() => {});
this._armResume();
}
// Retries resuming the context on the next interaction, the way the music
// does, in case the context was created outside a gesture.
_armResume() {
if (this._resumeArmed || typeof document === "undefined" || !document.addEventListener) {
return;
}
this._resumeArmed = true;
const resume = () => {
document.removeEventListener("pointerdown", resume);
document.removeEventListener("keydown", resume);
this._resumeArmed = false;
if (this._context && this._context.state === "suspended") this._resumeContext();
};
document.addEventListener("pointerdown", resume);
document.addEventListener("keydown", resume);
}
// Pauses a one-shot effect after `seconds`, so a long clip can stand in for a
// short burst. The element is otherwise discarded, so nothing else is needed.
_stopAfter(audio, seconds) {
if (typeof setTimeout !== "function") return;
setTimeout(() => {
try {
audio.pause();
} catch {
// ignore
}
}, seconds * 1000);
}
_scheduleCleanup(cleanup, seconds) {
if (typeof setTimeout !== "function") return;
setTimeout(cleanup, seconds * 1000);
}
// Asks for the looping background music. GameScreen calls this when the world
// map opens and stopMusic when it closes.
requestMusic() {
this._wanted = true;
this._startMusic();
}
stopMusic() {
this._wanted = false;
this._stopMusic();
}
setEnabled(enabled) {
this.enabled = !!enabled;
if (this.enabled) {
if (this._wanted) this._startMusic();
} else {
this._stopMusic();
}
if (typeof this.onChange === "function") this.onChange(this.enabled);
}
toggle() {
this.setEnabled(!this.enabled);
}
_startMusic() {
if (!this.enabled || !this._wanted || !this.supported() || this._music) return;
try {
const music = new Audio(this.musicSource);
music.loop = true;
music.volume = MUSIC_VOLUME;
this._music = music;
const playback = music.play();
if (playback && typeof playback.catch === "function") {
// Autoplay was blocked: drop the element and retry on the next gesture.
playback.catch(() => {
if (this._music === music) this._releaseMusic();
});
}
} catch {
this._releaseMusic();
}
}
_releaseMusic() {
if (this._music) {
try {
this._music.pause();
} catch {
// ignore
}
this._music = null;
}
if (this._wanted) this._armRetry();
}
_stopMusic() {
if (!this._music) return;
const music = this._music;
this._music = null;
try {
music.pause();
music.currentTime = 0;
} catch {
// ignore
}
}
// Retries the music on the first interaction after a blocked autoplay. The
// listeners let go of themselves, so at most one retry is ever pending.
_armRetry() {
if (this._retryArmed || typeof document === "undefined" || !document.addEventListener) {
return;
}
this._retryArmed = true;
const retry = () => {
document.removeEventListener("pointerdown", retry);
document.removeEventListener("keydown", retry);
this._retryArmed = false;
if (this._wanted && this.enabled) this._startMusic();
};
document.addEventListener("pointerdown", retry);
document.addEventListener("keydown", retry);
}
}
export const audio = new AudioManager();