Add SFX and ambient music, with a mute toggle
Everything is synthesized via Web Audio (oscillators/filters) — no external audio files, since sourcing licensed music/SFX isn't something to do without the rights to it, and synthesis fits the HUD aesthetic anyway: - Claim: rising power-up blip - Link: descending zap - Field closes: ascending 4-note arpeggio (distinct from a plain link — checks fields.length before/after to tell them apart) - Rejected action: low buzz - Minor UI (marker select, sync): short neutral blip - Background: generative ambient loop (minor-pentatonic arpeggio through a lowpass filter + short feedback delay), starts on map mount, stops on unmount (was leaking into the login screen on logout otherwise) 🔊/🔇 toggle in the header, persisted to localStorage (regress_muted), takes effect immediately including stopping/resuming the ambient loop.
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
// Web Audio sound engine — synthesized SFX + a generative ambient music
|
||||
// loop, all generated on the fly (oscillators/filters), no audio files.
|
||||
// Real licensed music/SFX isn't something to source without the rights to
|
||||
// it, and synthesis fits the HUD aesthetic anyway.
|
||||
//
|
||||
// Browsers block audio before a user gesture, so the AudioContext is
|
||||
// created lazily on first interaction (see ensureContext()) rather than at
|
||||
// module load.
|
||||
|
||||
let ctx: AudioContext | null = null;
|
||||
let masterGain: GainNode | null = null;
|
||||
let musicGain: GainNode | null = null;
|
||||
let sfxGain: GainNode | null = null;
|
||||
let musicTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let musicPlaying = false;
|
||||
|
||||
const MUTE_KEY = 'regress_muted';
|
||||
|
||||
export function isMuted(): boolean {
|
||||
return localStorage.getItem(MUTE_KEY) === '1';
|
||||
}
|
||||
|
||||
function ensureContext(): AudioContext {
|
||||
if (!ctx) {
|
||||
ctx = new AudioContext();
|
||||
masterGain = ctx.createGain();
|
||||
masterGain.gain.value = isMuted() ? 0 : 1;
|
||||
masterGain.connect(ctx.destination);
|
||||
|
||||
musicGain = ctx.createGain();
|
||||
musicGain.gain.value = 0.18; // ambient bed sits well under SFX
|
||||
musicGain.connect(masterGain);
|
||||
|
||||
sfxGain = ctx.createGain();
|
||||
sfxGain.gain.value = 0.35;
|
||||
sfxGain.connect(masterGain);
|
||||
}
|
||||
if (ctx.state === 'suspended') void ctx.resume();
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function setMuted(muted: boolean): void {
|
||||
localStorage.setItem(MUTE_KEY, muted ? '1' : '0');
|
||||
if (masterGain) masterGain.gain.setTargetAtTime(muted ? 0 : 1, ensureContext().currentTime, 0.05);
|
||||
if (muted) stopMusic();
|
||||
else startMusic();
|
||||
}
|
||||
|
||||
function envelope(gain: GainNode, ac: AudioContext, attack: number, peak: number, release: number, delay = 0) {
|
||||
const t = ac.currentTime + delay;
|
||||
gain.gain.setValueAtTime(0, t);
|
||||
gain.gain.linearRampToValueAtTime(peak, t + attack);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, t + attack + release);
|
||||
}
|
||||
|
||||
function tone(
|
||||
freqStart: number,
|
||||
freqEnd: number,
|
||||
duration: number,
|
||||
type: OscillatorType,
|
||||
peak: number,
|
||||
delay = 0,
|
||||
) {
|
||||
if (isMuted()) return;
|
||||
const ac = ensureContext();
|
||||
const osc = ac.createOscillator();
|
||||
const gain = ac.createGain();
|
||||
osc.type = type;
|
||||
osc.frequency.setValueAtTime(freqStart, ac.currentTime + delay);
|
||||
osc.frequency.exponentialRampToValueAtTime(Math.max(freqEnd, 1), ac.currentTime + delay + duration);
|
||||
envelope(gain, ac, duration * 0.15, peak, duration * 0.85, delay);
|
||||
osc.connect(gain);
|
||||
gain.connect(sfxGain!);
|
||||
osc.start(ac.currentTime + delay);
|
||||
osc.stop(ac.currentTime + delay + duration + 0.05);
|
||||
}
|
||||
|
||||
/** Rising power-up blip — a place changes hands to you. */
|
||||
export function playClaim(): void {
|
||||
tone(280, 900, 0.18, 'square', 0.25);
|
||||
}
|
||||
|
||||
/** Quick descending zap — a link forms. */
|
||||
export function playLink(): void {
|
||||
tone(1200, 300, 0.12, 'sawtooth', 0.2);
|
||||
}
|
||||
|
||||
/** Ascending triumphant arpeggio — a field closes. */
|
||||
export function playField(): void {
|
||||
const notes = [440, 554.37, 659.25, 880]; // A major-ish arpeggio
|
||||
notes.forEach((f, i) => tone(f, f, 0.16, 'triangle', 0.3, i * 0.09));
|
||||
}
|
||||
|
||||
/** Low buzz — an action was rejected. */
|
||||
export function playError(): void {
|
||||
tone(160, 80, 0.22, 'sawtooth', 0.22);
|
||||
}
|
||||
|
||||
/** Short neutral blip for minor UI actions (button clicks, panel open). */
|
||||
export function playClick(): void {
|
||||
tone(600, 500, 0.06, 'sine', 0.12);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generative ambient loop: a slow minor-pentatonic arpeggio through a
|
||||
* lowpass filter with a short feedback delay for a bit of space/echo —
|
||||
* cheap way to get a synthwave-ish bed without a real composed track.
|
||||
*/
|
||||
function scheduleMusicStep(ac: AudioContext, scale: number[]) {
|
||||
if (!musicPlaying) return;
|
||||
|
||||
const root = 110; // A2
|
||||
const freq = root * scale[Math.floor(Math.random() * scale.length)];
|
||||
const osc = ac.createOscillator();
|
||||
const gain = ac.createGain();
|
||||
const filter = ac.createBiquadFilter();
|
||||
filter.type = 'lowpass';
|
||||
filter.frequency.value = 1200;
|
||||
filter.Q.value = 1;
|
||||
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.value = freq;
|
||||
|
||||
const delay = ac.createDelay();
|
||||
delay.delayTime.value = 0.375;
|
||||
const feedback = ac.createGain();
|
||||
feedback.gain.value = 0.25;
|
||||
delay.connect(feedback);
|
||||
feedback.connect(delay);
|
||||
|
||||
envelope(gain, ac, 0.4, 0.5, 1.8);
|
||||
osc.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(musicGain!);
|
||||
gain.connect(delay);
|
||||
delay.connect(musicGain!);
|
||||
|
||||
osc.start();
|
||||
osc.stop(ac.currentTime + 2.5);
|
||||
|
||||
const nextIn = 900 + Math.random() * 700; // ms — loose, not metronomic
|
||||
musicTimer = setTimeout(() => scheduleMusicStep(ac, scale), nextIn);
|
||||
}
|
||||
|
||||
export function startMusic(): void {
|
||||
if (musicPlaying || isMuted()) return;
|
||||
const ac = ensureContext();
|
||||
musicPlaying = true;
|
||||
// A minor pentatonic ratios over the root, for a moody but consonant loop.
|
||||
scheduleMusicStep(ac, [1, 1.2, 1.35, 1.5, 1.8]);
|
||||
}
|
||||
|
||||
export function stopMusic(): void {
|
||||
musicPlaying = false;
|
||||
if (musicTimer) {
|
||||
clearTimeout(musicTimer);
|
||||
musicTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet.markercluster';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useGameStore } from '../stores/game';
|
||||
import type { Place } from '../stores/game';
|
||||
import type { Team } from '../stores/auth';
|
||||
import * as audio from '../lib/audio';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const game = useGameStore();
|
||||
|
||||
const soundMuted = ref(audio.isMuted());
|
||||
function toggleSound() {
|
||||
soundMuted.value = !soundMuted.value;
|
||||
audio.setMuted(soundMuted.value);
|
||||
}
|
||||
|
||||
const mapEl = ref<HTMLDivElement | null>(null);
|
||||
let map: L.Map | null = null;
|
||||
// Clustered — with places synced worldwide (tens of thousands, not the
|
||||
@@ -120,6 +127,7 @@ function redraw() {
|
||||
function onMarkerClick(place: Place) {
|
||||
selectedPlaceId.value = place.id;
|
||||
actionError.value = null;
|
||||
audio.playClick();
|
||||
}
|
||||
|
||||
async function doClaim(place: Place) {
|
||||
@@ -127,8 +135,10 @@ async function doClaim(place: Place) {
|
||||
actionBusy.value = true;
|
||||
try {
|
||||
await game.claim(place.id);
|
||||
audio.playClaim();
|
||||
} catch (err) {
|
||||
actionError.value = err instanceof Error ? err.message : String(err);
|
||||
audio.playError();
|
||||
} finally {
|
||||
actionBusy.value = false;
|
||||
}
|
||||
@@ -137,6 +147,7 @@ async function doClaim(place: Place) {
|
||||
function startLink(place: Place) {
|
||||
linkSourceId.value = place.id;
|
||||
actionError.value = null;
|
||||
audio.playClick();
|
||||
}
|
||||
|
||||
function cancelLink() {
|
||||
@@ -147,11 +158,17 @@ async function completeLink(place: Place) {
|
||||
if (!linkSourceId.value) return;
|
||||
actionError.value = null;
|
||||
actionBusy.value = true;
|
||||
const fieldsBefore = game.fields.length;
|
||||
try {
|
||||
await game.link(linkSourceId.value, place.id);
|
||||
linkSourceId.value = null;
|
||||
// A bigger, more triumphant sound specifically when this link closed a
|
||||
// new triangle, not just for any successful link.
|
||||
if (game.fields.length > fieldsBefore) audio.playField();
|
||||
else audio.playLink();
|
||||
} catch (err) {
|
||||
actionError.value = err instanceof Error ? err.message : String(err);
|
||||
audio.playError();
|
||||
} finally {
|
||||
actionBusy.value = false;
|
||||
}
|
||||
@@ -160,10 +177,12 @@ async function completeLink(place: Place) {
|
||||
async function doSync() {
|
||||
actionError.value = null;
|
||||
actionBusy.value = true;
|
||||
audio.playClick();
|
||||
try {
|
||||
await game.syncPlaces();
|
||||
} catch (err) {
|
||||
actionError.value = err instanceof Error ? err.message : String(err);
|
||||
audio.playError();
|
||||
} finally {
|
||||
actionBusy.value = false;
|
||||
}
|
||||
@@ -218,8 +237,11 @@ onMounted(async () => {
|
||||
}
|
||||
await game.refreshAll();
|
||||
redraw();
|
||||
if (!soundMuted.value) audio.startMusic();
|
||||
});
|
||||
|
||||
onUnmounted(() => audio.stopMusic());
|
||||
|
||||
watch(() => [game.places, game.links, game.fields], redraw, { deep: true });
|
||||
watch(linkSourceId, redraw);
|
||||
</script>
|
||||
@@ -257,6 +279,13 @@ watch(linkSourceId, redraw);
|
||||
>
|
||||
↻ Sync BTC Map
|
||||
</button>
|
||||
<button
|
||||
class="rounded border border-cyan-neon/50 bg-black/40 px-3 py-1 text-cyan-neon transition hover:border-cyan-neon hover:shadow-glow-cyan"
|
||||
:title="soundMuted ? 'Unmute sound' : 'Mute sound'"
|
||||
@click="toggleSound"
|
||||
>
|
||||
{{ soundMuted ? '🔇' : '🔊' }}
|
||||
</button>
|
||||
<button
|
||||
class="rounded border border-magenta-neon/50 bg-black/40 px-3 py-1 text-magenta-neon transition hover:border-magenta-neon hover:shadow-glow-magenta"
|
||||
@click="auth.logout()"
|
||||
|
||||
Reference in New Issue
Block a user