Add procedural background music engine wired to music toggle

This commit is contained in:
OpenClaw Engineer 2026-03-14 20:15:23 -05:00
parent 800880ed30
commit ef70c6324d
2 changed files with 115 additions and 2 deletions

View File

@ -39,6 +39,13 @@ LAN test URL (from another device on your network):
- Backend endpoint: `api/scores.php`
- Data file: `data/scores.json`
## Audio / music toggle behavior
- Music defaults to **OFF** when no preference exists in localStorage.
- Toggle preference key: `dinoLand.musicEnabled` (`'1'` on, `'0'` off).
- First user click on the music toggle resumes/unlocks Web Audio and starts procedural background music when turned ON.
- Turning music OFF performs a short fade to mute.
## Quick verification checklist
1. Start server and open game.
@ -46,3 +53,7 @@ LAN test URL (from another device on your network):
3. Confirm hearts drop on ant / pterodactyl / meteor collisions.
4. Confirm water causes immediate game over.
5. Confirm leaderboard loads and score save works.
6. With a fresh browser profile, confirm music button starts as `🔇 Music: Off`.
7. Click toggle to ON and confirm audible looping background music starts.
8. Click toggle to OFF and confirm music fades/mutes.
9. Reload page and confirm toggle state persists.

View File

@ -92,11 +92,105 @@ const BIOMES = [
let audioCtx = null;
let gameConfig = { accessMode: 'standard', difficulty: 'medium' };
const musicState = {
bus: null,
schedulerId: null,
nextNoteTime: 0,
step: 0,
started: false
};
const MUSIC_STEP_SECONDS = 0.24;
const MUSIC_SCHEDULE_AHEAD_SECONDS = 0.6;
const MUSIC_LOOKAHEAD_MS = 80;
const MUSIC_ON_GAIN = 0.06;
const MUSIC_SEQUENCE = [
[220, 277.18, 329.63],
[246.94, 311.13, 369.99],
[196, 246.94, 293.66],
[174.61, 220, 261.63]
];
function ensureAudioCtx() {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
return audioCtx;
}
function ensureMusicBus() {
const ctx = ensureAudioCtx();
if (musicState.bus) return musicState.bus;
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.0001, ctx.currentTime);
gain.connect(ctx.destination);
musicState.bus = gain;
return gain;
}
function scheduleMusicNote(frequency, time, duration = MUSIC_STEP_SECONDS * 0.95) {
if (!musicState.bus) return;
const ctx = ensureAudioCtx();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(frequency, time);
gain.gain.setValueAtTime(0.0001, time);
gain.gain.exponentialRampToValueAtTime(0.16, time + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, time + duration);
osc.connect(gain);
gain.connect(musicState.bus);
osc.start(time);
osc.stop(time + duration + 0.03);
}
function runMusicScheduler() {
if (!musicState.started || !audioCtx) return;
const ctx = audioCtx;
while (musicState.nextNoteTime < ctx.currentTime + MUSIC_SCHEDULE_AHEAD_SECONDS) {
const chord = MUSIC_SEQUENCE[musicState.step % MUSIC_SEQUENCE.length];
const root = chord[0];
const third = chord[1];
const fifth = chord[2];
const bass = root / 2;
scheduleMusicNote(bass, musicState.nextNoteTime, MUSIC_STEP_SECONDS * 1.9);
scheduleMusicNote(root, musicState.nextNoteTime, MUSIC_STEP_SECONDS * 0.95);
scheduleMusicNote(third, musicState.nextNoteTime + MUSIC_STEP_SECONDS * 0.5, MUSIC_STEP_SECONDS * 0.9);
scheduleMusicNote(fifth, musicState.nextNoteTime + MUSIC_STEP_SECONDS, MUSIC_STEP_SECONDS * 0.9);
musicState.nextNoteTime += MUSIC_STEP_SECONDS * 2;
musicState.step += 1;
}
}
function startMusicIfNeeded() {
const ctx = ensureAudioCtx();
ensureMusicBus();
if (musicState.started) return;
musicState.started = true;
musicState.nextNoteTime = ctx.currentTime + 0.05;
musicState.step = 0;
runMusicScheduler();
musicState.schedulerId = window.setInterval(runMusicScheduler, MUSIC_LOOKAHEAD_MS);
}
function setMusicEnabledAudio(enabled) {
const ctx = ensureAudioCtx();
const bus = ensureMusicBus();
if (enabled) startMusicIfNeeded();
const now = ctx.currentTime;
bus.gain.cancelScheduledValues(now);
bus.gain.setValueAtTime(Math.max(0.0001, bus.gain.value), now);
bus.gain.exponentialRampToValueAtTime(enabled ? MUSIC_ON_GAIN : 0.0001, now + 0.35);
}
async function resumeAudioContext() {
const ctx = ensureAudioCtx();
if (ctx.state !== 'suspended') return true;
@ -286,13 +380,15 @@ if (musicToggleEl) {
ev.preventDefault();
ev.stopPropagation();
if (!musicEnabled) {
const nextEnabled = !musicEnabled;
if (nextEnabled) {
await resumeAudioContext();
}
musicEnabled = !musicEnabled;
musicEnabled = nextEnabled;
persistMusicEnabled(musicEnabled);
refreshMusicToggleUi();
setMusicEnabledAudio(musicEnabled);
});
}
@ -318,6 +414,12 @@ startFormEl.addEventListener('submit', (e) => {
deathScreenEl.classList.add('hidden');
state.running = true;
startErrorEl.textContent = '';
if (musicEnabled) {
resumeAudioContext().then((resumed) => {
if (resumed) setMusicEnabledAudio(true);
});
}
});
function restartToStartScreen() {