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">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet.markercluster';
|
import 'leaflet.markercluster';
|
||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
import { useGameStore } from '../stores/game';
|
import { useGameStore } from '../stores/game';
|
||||||
import type { Place } from '../stores/game';
|
import type { Place } from '../stores/game';
|
||||||
import type { Team } from '../stores/auth';
|
import type { Team } from '../stores/auth';
|
||||||
|
import * as audio from '../lib/audio';
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const game = useGameStore();
|
const game = useGameStore();
|
||||||
|
|
||||||
|
const soundMuted = ref(audio.isMuted());
|
||||||
|
function toggleSound() {
|
||||||
|
soundMuted.value = !soundMuted.value;
|
||||||
|
audio.setMuted(soundMuted.value);
|
||||||
|
}
|
||||||
|
|
||||||
const mapEl = ref<HTMLDivElement | null>(null);
|
const mapEl = ref<HTMLDivElement | null>(null);
|
||||||
let map: L.Map | null = null;
|
let map: L.Map | null = null;
|
||||||
// Clustered — with places synced worldwide (tens of thousands, not the
|
// Clustered — with places synced worldwide (tens of thousands, not the
|
||||||
@@ -120,6 +127,7 @@ function redraw() {
|
|||||||
function onMarkerClick(place: Place) {
|
function onMarkerClick(place: Place) {
|
||||||
selectedPlaceId.value = place.id;
|
selectedPlaceId.value = place.id;
|
||||||
actionError.value = null;
|
actionError.value = null;
|
||||||
|
audio.playClick();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doClaim(place: Place) {
|
async function doClaim(place: Place) {
|
||||||
@@ -127,8 +135,10 @@ async function doClaim(place: Place) {
|
|||||||
actionBusy.value = true;
|
actionBusy.value = true;
|
||||||
try {
|
try {
|
||||||
await game.claim(place.id);
|
await game.claim(place.id);
|
||||||
|
audio.playClaim();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
actionError.value = err instanceof Error ? err.message : String(err);
|
actionError.value = err instanceof Error ? err.message : String(err);
|
||||||
|
audio.playError();
|
||||||
} finally {
|
} finally {
|
||||||
actionBusy.value = false;
|
actionBusy.value = false;
|
||||||
}
|
}
|
||||||
@@ -137,6 +147,7 @@ async function doClaim(place: Place) {
|
|||||||
function startLink(place: Place) {
|
function startLink(place: Place) {
|
||||||
linkSourceId.value = place.id;
|
linkSourceId.value = place.id;
|
||||||
actionError.value = null;
|
actionError.value = null;
|
||||||
|
audio.playClick();
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelLink() {
|
function cancelLink() {
|
||||||
@@ -147,11 +158,17 @@ async function completeLink(place: Place) {
|
|||||||
if (!linkSourceId.value) return;
|
if (!linkSourceId.value) return;
|
||||||
actionError.value = null;
|
actionError.value = null;
|
||||||
actionBusy.value = true;
|
actionBusy.value = true;
|
||||||
|
const fieldsBefore = game.fields.length;
|
||||||
try {
|
try {
|
||||||
await game.link(linkSourceId.value, place.id);
|
await game.link(linkSourceId.value, place.id);
|
||||||
linkSourceId.value = null;
|
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) {
|
} catch (err) {
|
||||||
actionError.value = err instanceof Error ? err.message : String(err);
|
actionError.value = err instanceof Error ? err.message : String(err);
|
||||||
|
audio.playError();
|
||||||
} finally {
|
} finally {
|
||||||
actionBusy.value = false;
|
actionBusy.value = false;
|
||||||
}
|
}
|
||||||
@@ -160,10 +177,12 @@ async function completeLink(place: Place) {
|
|||||||
async function doSync() {
|
async function doSync() {
|
||||||
actionError.value = null;
|
actionError.value = null;
|
||||||
actionBusy.value = true;
|
actionBusy.value = true;
|
||||||
|
audio.playClick();
|
||||||
try {
|
try {
|
||||||
await game.syncPlaces();
|
await game.syncPlaces();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
actionError.value = err instanceof Error ? err.message : String(err);
|
actionError.value = err instanceof Error ? err.message : String(err);
|
||||||
|
audio.playError();
|
||||||
} finally {
|
} finally {
|
||||||
actionBusy.value = false;
|
actionBusy.value = false;
|
||||||
}
|
}
|
||||||
@@ -218,8 +237,11 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
await game.refreshAll();
|
await game.refreshAll();
|
||||||
redraw();
|
redraw();
|
||||||
|
if (!soundMuted.value) audio.startMusic();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => audio.stopMusic());
|
||||||
|
|
||||||
watch(() => [game.places, game.links, game.fields], redraw, { deep: true });
|
watch(() => [game.places, game.links, game.fields], redraw, { deep: true });
|
||||||
watch(linkSourceId, redraw);
|
watch(linkSourceId, redraw);
|
||||||
</script>
|
</script>
|
||||||
@@ -257,6 +279,13 @@ watch(linkSourceId, redraw);
|
|||||||
>
|
>
|
||||||
↻ Sync BTC Map
|
↻ Sync BTC Map
|
||||||
</button>
|
</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
|
<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"
|
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()"
|
@click="auth.logout()"
|
||||||
|
|||||||
Reference in New Issue
Block a user