diff --git a/README.md b/README.md index bd0614c..3b007b7 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/js/game.js b/js/game.js index 604f36b..e177b16 100644 --- a/js/game.js +++ b/js/game.js @@ -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() {