Add procedural background music engine wired to music toggle
This commit is contained in:
parent
800880ed30
commit
ef70c6324d
11
README.md
11
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.
|
||||
|
||||
106
js/game.js
106
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() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user