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` - Backend endpoint: `api/scores.php`
- Data file: `data/scores.json` - 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 ## Quick verification checklist
1. Start server and open game. 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. 3. Confirm hearts drop on ant / pterodactyl / meteor collisions.
4. Confirm water causes immediate game over. 4. Confirm water causes immediate game over.
5. Confirm leaderboard loads and score save works. 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 audioCtx = null;
let gameConfig = { accessMode: 'standard', difficulty: 'medium' }; 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() { function ensureAudioCtx() {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
return audioCtx; 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() { async function resumeAudioContext() {
const ctx = ensureAudioCtx(); const ctx = ensureAudioCtx();
if (ctx.state !== 'suspended') return true; if (ctx.state !== 'suspended') return true;
@ -286,13 +380,15 @@ if (musicToggleEl) {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
if (!musicEnabled) { const nextEnabled = !musicEnabled;
if (nextEnabled) {
await resumeAudioContext(); await resumeAudioContext();
} }
musicEnabled = !musicEnabled; musicEnabled = nextEnabled;
persistMusicEnabled(musicEnabled); persistMusicEnabled(musicEnabled);
refreshMusicToggleUi(); refreshMusicToggleUi();
setMusicEnabledAudio(musicEnabled);
}); });
} }
@ -318,6 +414,12 @@ startFormEl.addEventListener('submit', (e) => {
deathScreenEl.classList.add('hidden'); deathScreenEl.classList.add('hidden');
state.running = true; state.running = true;
startErrorEl.textContent = ''; startErrorEl.textContent = '';
if (musicEnabled) {
resumeAudioContext().then((resumed) => {
if (resumed) setMusicEnabledAudio(true);
});
}
}); });
function restartToStartScreen() { function restartToStartScreen() {