371 lines
13 KiB
JavaScript
371 lines
13 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",
|
|
};
|
|
|
|
// Tracks played softly under the world map. They are shuffled into a playlist
|
|
// and played one after another, so a long session hears both.
|
|
const MUSIC_SOURCES = [
|
|
// Crashed Ship, copyright Ted Kerr.
|
|
"assets/sounds/crashed_ship.ogg",
|
|
// Dark City, by Muncheybobo, used under CC BY 3.0.
|
|
"assets/sounds/dark_city.ogg",
|
|
// Constellation, by Trevor Lentz, used under CC BY-SA 3.0.
|
|
"assets/sounds/constellation.ogg",
|
|
// Patrol, by Alexandr Zhelanov, used under CC BY 3.0.
|
|
"assets/sounds/patrol.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));
|
|
}
|
|
|
|
// Fisher-Yates over the track indexes, so each pass over the playlist visits
|
|
// every track once in a different order.
|
|
function shuffledIndexes(count, random) {
|
|
const order = Array.from({ length: count }, (_, index) => index);
|
|
for (let i = order.length - 1; i > 0; i -= 1) {
|
|
const j = Math.floor(random() * (i + 1));
|
|
[order[i], order[j]] = [order[j], order[i]];
|
|
}
|
|
return order;
|
|
}
|
|
|
|
// 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.musicSources = [...MUSIC_SOURCES];
|
|
// 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;
|
|
// The shuffled order of track indexes and how far into it playback is.
|
|
this._musicOrder = [];
|
|
this._musicCursor = 0;
|
|
this._random = options.random || Math.random;
|
|
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;
|
|
const source = this._nextMusicSource();
|
|
if (!source) return;
|
|
try {
|
|
const music = new Audio(source);
|
|
music.volume = MUSIC_VOLUME;
|
|
if (this.musicSources.length <= 1) {
|
|
// A lone track has nothing to hand over to, so it loops.
|
|
music.loop = true;
|
|
} else {
|
|
// Otherwise the end of a track starts the next one in the shuffle.
|
|
music.onended = () => {
|
|
if (this._music !== music) return;
|
|
this._music = null;
|
|
this._startMusic();
|
|
};
|
|
}
|
|
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();
|
|
}
|
|
}
|
|
|
|
// The next track of the shuffled playlist, reshuffling once a full pass ends.
|
|
_nextMusicSource() {
|
|
if (this.musicSources.length === 0) return null;
|
|
if (this._musicCursor >= this._musicOrder.length) {
|
|
this._musicOrder = shuffledIndexes(this.musicSources.length, this._random);
|
|
this._musicCursor = 0;
|
|
}
|
|
const index = this._musicOrder[this._musicCursor];
|
|
this._musicCursor += 1;
|
|
return this.musicSources[index];
|
|
}
|
|
|
|
_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();
|