From 2f07d74c780f9b554c7ba2c805033494e53359e2 Mon Sep 17 00:00:00 2001 From: OpenClaw Engineer Date: Sat, 14 Mar 2026 20:42:00 -0500 Subject: [PATCH] Implement state-aware music transitions for title, biome gameplay, death, and restart --- README.md | 9 +++- js/game.js | 121 +++++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 120 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 3b007b7..e32573d 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,13 @@ LAN test URL (from another device on your network): - 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. +- First user click on the music toggle resumes/unlocks Web Audio and starts procedural music when turned ON. +- Music state is context-aware: + - **Title/start screen** uses title music profile. + - **Gameplay** switches profile by biome (Plains, Desert, Jungle, Snow, and Lava mapped to the Volcano profile). + - **Death screen** switches to death music profile. + - **Restart/respawn to start screen** returns to title profile. +- Turning music OFF performs a short fade to mute; turning back ON fades in the current state profile. ## Quick verification checklist diff --git a/js/game.js b/js/game.js index e177b16..59d96db 100644 --- a/js/game.js +++ b/js/game.js @@ -97,7 +97,9 @@ const musicState = { schedulerId: null, nextNoteTime: 0, step: 0, - started: false + started: false, + mode: 'title', + profile: 'title' }; const MUSIC_STEP_SECONDS = 0.24; @@ -105,12 +107,64 @@ 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] -]; +const MUSIC_PROFILES = { + title: { + sequence: [ + [196, 246.94, 293.66], + [174.61, 220, 261.63], + [164.81, 207.65, 246.94], + [174.61, 220, 261.63] + ] + }, + gameplay_plains: { + sequence: [ + [220, 277.18, 329.63], + [246.94, 311.13, 369.99], + [196, 246.94, 293.66], + [174.61, 220, 261.63] + ] + }, + gameplay_desert: { + sequence: [ + [164.81, 196, 246.94], + [174.61, 220, 261.63], + [196, 233.08, 293.66], + [174.61, 220, 261.63] + ] + }, + gameplay_jungle: { + sequence: [ + [246.94, 311.13, 369.99], + [261.63, 329.63, 392.0], + [220, 277.18, 329.63], + [233.08, 293.66, 349.23] + ] + }, + gameplay_snow: { + sequence: [ + [196, 246.94, 293.66], + [220, 277.18, 329.63], + [174.61, 220, 261.63], + [196, 246.94, 293.66] + ] + }, + gameplay_volcano: { + sequence: [ + [138.59, 174.61, 207.65], + [146.83, 185, 220], + [130.81, 164.81, 196], + [123.47, 155.56, 185] + ] + }, + death: { + sequence: [ + [130.81, 155.56, 185], + [123.47, 146.83, 174.61], + [110, 138.59, 164.81], + [98, 123.47, 146.83] + ] + } +}; function ensureAudioCtx() { if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); @@ -147,12 +201,55 @@ function scheduleMusicNote(frequency, time, duration = MUSIC_STEP_SECONDS * 0.95 osc.stop(time + duration + 0.03); } +function resolveMusicProfile() { + return MUSIC_PROFILES[musicState.profile] || MUSIC_PROFILES.title; +} + +function normalizeBiomeMusicKey(name) { + const raw = String(name || '').toLowerCase(); + if (raw === 'lava') return 'volcano'; + if (raw === 'volcano') return 'volcano'; + if (raw === 'plains' || raw === 'desert' || raw === 'jungle' || raw === 'snow') return raw; + return 'plains'; +} + +function setMusicMode(mode, profile) { + if (!profile || !MUSIC_PROFILES[profile]) return; + const profileChanged = musicState.profile !== profile; + musicState.mode = mode; + musicState.profile = profile; + if (profileChanged) { + musicState.step = 0; + } +} + +function updateMusicForGameState(force = false) { + let nextMode = 'title'; + let nextProfile = 'title'; + + if (state.dead) { + nextMode = 'death'; + nextProfile = 'death'; + } else if (state.running) { + nextMode = 'gameplay'; + const focusX = state.player.x + state.player.width * 0.5; + const biome = getBiomeAt(focusX); + const biomeKey = normalizeBiomeMusicKey(biome.name); + nextProfile = `gameplay_${biomeKey}`; + } + + if (force || musicState.mode !== nextMode || musicState.profile !== nextProfile) { + setMusicMode(nextMode, nextProfile); + } +} + 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 profile = resolveMusicProfile(); + const chord = profile.sequence[musicState.step % profile.sequence.length]; const root = chord[0]; const third = chord[1]; const fifth = chord[2]; @@ -176,6 +273,7 @@ function startMusicIfNeeded() { musicState.started = true; musicState.nextNoteTime = ctx.currentTime + 0.05; musicState.step = 0; + updateMusicForGameState(true); runMusicScheduler(); musicState.schedulerId = window.setInterval(runMusicScheduler, MUSIC_LOOKAHEAD_MS); } @@ -388,6 +486,7 @@ if (musicToggleEl) { musicEnabled = nextEnabled; persistMusicEnabled(musicEnabled); refreshMusicToggleUi(); + updateMusicForGameState(true); setMusicEnabledAudio(musicEnabled); }); } @@ -414,6 +513,7 @@ startFormEl.addEventListener('submit', (e) => { deathScreenEl.classList.add('hidden'); state.running = true; startErrorEl.textContent = ''; + updateMusicForGameState(true); if (musicEnabled) { resumeAudioContext().then((resumed) => { @@ -437,6 +537,7 @@ function restartToStartScreen() { passwordEl.value = ''; startErrorEl.textContent = ''; startScreenEl.classList.remove('hidden'); + updateMusicForGameState(true); } restartBtnEl.addEventListener('click', restartToStartScreen); @@ -765,6 +866,7 @@ async function kill(reason) { state.running = false; state.dead = true; state.deadReason = reason; + updateMusicForGameState(true); finalScoreEl.textContent = `Final Score: ${state.score} • ${reason}`; await populateLeaderboard(); deathScreenEl.classList.remove('hidden'); @@ -972,6 +1074,8 @@ function tick(ts) { const dt = Math.min((ts - state.lastTs) / 1000, 0.05); state.lastTs = ts; + updateMusicForGameState(); + if (state.running) { state.hitCooldown = Math.max(0, state.hitCooldown - dt); updatePlayer(dt); @@ -1007,5 +1111,6 @@ function tick(ts) { requestAnimationFrame((ts) => { state.lastTs = ts; updateHeartsUI(); + updateMusicForGameState(true); requestAnimationFrame(tick); });