Implement state-aware music transitions for title, biome gameplay, death, and restart
This commit is contained in:
parent
ef70c6324d
commit
2f07d74c78
@ -43,8 +43,13 @@ LAN test URL (from another device on your network):
|
|||||||
|
|
||||||
- Music defaults to **OFF** when no preference exists in localStorage.
|
- Music defaults to **OFF** when no preference exists in localStorage.
|
||||||
- Toggle preference key: `dinoLand.musicEnabled` (`'1'` on, `'0'` off).
|
- 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.
|
- First user click on the music toggle resumes/unlocks Web Audio and starts procedural music when turned ON.
|
||||||
- Turning music OFF performs a short fade to mute.
|
- 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
|
## Quick verification checklist
|
||||||
|
|
||||||
|
|||||||
121
js/game.js
121
js/game.js
@ -97,7 +97,9 @@ const musicState = {
|
|||||||
schedulerId: null,
|
schedulerId: null,
|
||||||
nextNoteTime: 0,
|
nextNoteTime: 0,
|
||||||
step: 0,
|
step: 0,
|
||||||
started: false
|
started: false,
|
||||||
|
mode: 'title',
|
||||||
|
profile: 'title'
|
||||||
};
|
};
|
||||||
|
|
||||||
const MUSIC_STEP_SECONDS = 0.24;
|
const MUSIC_STEP_SECONDS = 0.24;
|
||||||
@ -105,12 +107,64 @@ const MUSIC_SCHEDULE_AHEAD_SECONDS = 0.6;
|
|||||||
const MUSIC_LOOKAHEAD_MS = 80;
|
const MUSIC_LOOKAHEAD_MS = 80;
|
||||||
const MUSIC_ON_GAIN = 0.06;
|
const MUSIC_ON_GAIN = 0.06;
|
||||||
|
|
||||||
const MUSIC_SEQUENCE = [
|
const MUSIC_PROFILES = {
|
||||||
[220, 277.18, 329.63],
|
title: {
|
||||||
[246.94, 311.13, 369.99],
|
sequence: [
|
||||||
[196, 246.94, 293.66],
|
[196, 246.94, 293.66],
|
||||||
[174.61, 220, 261.63]
|
[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() {
|
function ensureAudioCtx() {
|
||||||
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
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);
|
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() {
|
function runMusicScheduler() {
|
||||||
if (!musicState.started || !audioCtx) return;
|
if (!musicState.started || !audioCtx) return;
|
||||||
const ctx = audioCtx;
|
const ctx = audioCtx;
|
||||||
|
|
||||||
while (musicState.nextNoteTime < ctx.currentTime + MUSIC_SCHEDULE_AHEAD_SECONDS) {
|
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 root = chord[0];
|
||||||
const third = chord[1];
|
const third = chord[1];
|
||||||
const fifth = chord[2];
|
const fifth = chord[2];
|
||||||
@ -176,6 +273,7 @@ function startMusicIfNeeded() {
|
|||||||
musicState.started = true;
|
musicState.started = true;
|
||||||
musicState.nextNoteTime = ctx.currentTime + 0.05;
|
musicState.nextNoteTime = ctx.currentTime + 0.05;
|
||||||
musicState.step = 0;
|
musicState.step = 0;
|
||||||
|
updateMusicForGameState(true);
|
||||||
runMusicScheduler();
|
runMusicScheduler();
|
||||||
musicState.schedulerId = window.setInterval(runMusicScheduler, MUSIC_LOOKAHEAD_MS);
|
musicState.schedulerId = window.setInterval(runMusicScheduler, MUSIC_LOOKAHEAD_MS);
|
||||||
}
|
}
|
||||||
@ -388,6 +486,7 @@ if (musicToggleEl) {
|
|||||||
musicEnabled = nextEnabled;
|
musicEnabled = nextEnabled;
|
||||||
persistMusicEnabled(musicEnabled);
|
persistMusicEnabled(musicEnabled);
|
||||||
refreshMusicToggleUi();
|
refreshMusicToggleUi();
|
||||||
|
updateMusicForGameState(true);
|
||||||
setMusicEnabledAudio(musicEnabled);
|
setMusicEnabledAudio(musicEnabled);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -414,6 +513,7 @@ startFormEl.addEventListener('submit', (e) => {
|
|||||||
deathScreenEl.classList.add('hidden');
|
deathScreenEl.classList.add('hidden');
|
||||||
state.running = true;
|
state.running = true;
|
||||||
startErrorEl.textContent = '';
|
startErrorEl.textContent = '';
|
||||||
|
updateMusicForGameState(true);
|
||||||
|
|
||||||
if (musicEnabled) {
|
if (musicEnabled) {
|
||||||
resumeAudioContext().then((resumed) => {
|
resumeAudioContext().then((resumed) => {
|
||||||
@ -437,6 +537,7 @@ function restartToStartScreen() {
|
|||||||
passwordEl.value = '';
|
passwordEl.value = '';
|
||||||
startErrorEl.textContent = '';
|
startErrorEl.textContent = '';
|
||||||
startScreenEl.classList.remove('hidden');
|
startScreenEl.classList.remove('hidden');
|
||||||
|
updateMusicForGameState(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
restartBtnEl.addEventListener('click', restartToStartScreen);
|
restartBtnEl.addEventListener('click', restartToStartScreen);
|
||||||
@ -765,6 +866,7 @@ async function kill(reason) {
|
|||||||
state.running = false;
|
state.running = false;
|
||||||
state.dead = true;
|
state.dead = true;
|
||||||
state.deadReason = reason;
|
state.deadReason = reason;
|
||||||
|
updateMusicForGameState(true);
|
||||||
finalScoreEl.textContent = `Final Score: ${state.score} • ${reason}`;
|
finalScoreEl.textContent = `Final Score: ${state.score} • ${reason}`;
|
||||||
await populateLeaderboard();
|
await populateLeaderboard();
|
||||||
deathScreenEl.classList.remove('hidden');
|
deathScreenEl.classList.remove('hidden');
|
||||||
@ -972,6 +1074,8 @@ function tick(ts) {
|
|||||||
const dt = Math.min((ts - state.lastTs) / 1000, 0.05);
|
const dt = Math.min((ts - state.lastTs) / 1000, 0.05);
|
||||||
state.lastTs = ts;
|
state.lastTs = ts;
|
||||||
|
|
||||||
|
updateMusicForGameState();
|
||||||
|
|
||||||
if (state.running) {
|
if (state.running) {
|
||||||
state.hitCooldown = Math.max(0, state.hitCooldown - dt);
|
state.hitCooldown = Math.max(0, state.hitCooldown - dt);
|
||||||
updatePlayer(dt);
|
updatePlayer(dt);
|
||||||
@ -1007,5 +1111,6 @@ function tick(ts) {
|
|||||||
requestAnimationFrame((ts) => {
|
requestAnimationFrame((ts) => {
|
||||||
state.lastTs = ts;
|
state.lastTs = ts;
|
||||||
updateHeartsUI();
|
updateHeartsUI();
|
||||||
|
updateMusicForGameState(true);
|
||||||
requestAnimationFrame(tick);
|
requestAnimationFrame(tick);
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user