// Lantern sound: dungeon ambience plus effects tied to game events. Silent until the // visitor turns it on. Assets are CC0 (see /static/assets/audio/CREDITS.txt). "use strict"; (() => { const DIR = "/static/assets/audio/"; const SFX = { step: ["footstep00", "footstep01", "footstep02", "footstep03", "footstep04"], pickup: ["metalClick"], unlock: ["metalLatch"], door: ["doorOpen_1"], write: ["bookFlip1"], win: ["handleCoins"], fail: ["creak1"], }; const STEP_MS = 222; // matches the 3D hero's walking speed (4.5 cells per second) const MUSIC_VOLUME = 0.55; let enabled = false; let ctx = null; const buffers = {}; const music = new Audio(DIR + "dungeon-ambience.mp3"); music.loop = true; music.preload = "none"; music.volume = 0; function storedPreference() { try { return localStorage.getItem("lantern-sound") === "on"; } catch { return false; } } function storePreference(on) { try { localStorage.setItem("lantern-sound", on ? "on" : "off"); } catch { /* private mode */ } } async function loadBuffers() { const names = [...new Set(Object.values(SFX).flat())]; await Promise.all(names.map(async (n) => { try { const res = await fetch(`${DIR}${n}.mp3`); buffers[n] = await ctx.decodeAudioData(await res.arrayBuffer()); } catch { /* a missing effect should never break the page */ } })); } function fadeMusic(target, ms = 1200) { const start = music.volume, t0 = performance.now(); const step = (now) => { const k = Math.min(1, (now - t0) / ms); music.volume = start + (target - start) * k; if (k < 1) requestAnimationFrame(step); else if (target === 0) music.pause(); }; requestAnimationFrame(step); } // Announce each sound so a screen recorder can rebuild the soundtrack in sync // (browser video capture has no audio). Harmless when nobody listens. function announce(kind, delayMs = 0, gain = 1) { window.dispatchEvent(new CustomEvent("lantern-sound", { detail: { kind, delayMs, gain } })); } function play(kind, delayMs = 0, gain = 0.6) { if (!enabled || !ctx) return; announce(kind, delayMs, gain); const names = SFX[kind]; const buf = buffers[names[Math.floor(Math.random() * names.length)]]; if (!buf) return; const src = ctx.createBufferSource(); const g = ctx.createGain(); g.gain.value = gain; src.buffer = buf; src.playbackRate.value = 0.94 + Math.random() * 0.12; // keep repeated steps from sounding mechanical src.connect(g).connect(ctx.destination); src.start(ctx.currentTime + delayMs / 1000); } async function setEnabled(on) { enabled = on; storePreference(on); const btn = document.getElementById("sound"); if (btn) { btn.setAttribute("aria-pressed", String(on)); btn.textContent = on ? "Sound on" : "Sound off"; } if (on) { if (!ctx) { ctx = new (window.AudioContext || window.webkitAudioContext)(); loadBuffers(); } if (ctx.state === "suspended") await ctx.resume(); music.play().then(() => { fadeMusic(MUSIC_VOLUME); announce("music", 0, MUSIC_VOLUME); }).catch(() => {}); } else { fadeMusic(0, 400); } } /** Sounds for one Beam call, played as the hero walks its route. */ function turn(turnEvent) { if (!enabled) return; let steps = 0; for (const e of turnEvent.executed) { if (e.tool === "write_notes") play("write", 0, 0.5); if (e.tool === "act") steps += (e.path || []).length; } for (let i = 0; i < steps; i++) play("step", i * STEP_MS, 0.35); const msg = (turnEvent.frame.message || "").toLowerCase(); const after = steps * STEP_MS; if (msg.includes("unlock")) { play("unlock", after, 0.7); play("door", after + 350, 0.6); } else if (/^[a-z] - /.test(msg)) play("pickup", after, 0.7); // inventory line: "g - a key ..." } function end(endEvent) { if (endEvent.outcome === "success") play("win", 300, 0.7); else if (endEvent.outcome !== "stopped") play("fail", 300, 0.6); } function lessons() { play("write", 0, 0.5); } document.addEventListener("DOMContentLoaded", () => { const btn = document.getElementById("sound"); if (!btn) return; btn.addEventListener("click", () => setEnabled(!enabled)); // Browsers only allow audio after a user gesture, so a remembered "on" waits for one. if (storedPreference()) { btn.textContent = "Sound on (click to start)"; const resume = (e) => { window.removeEventListener("pointerdown", resume); if (e.target.closest("#sound")) return; // the button's own click handler turns sound on setEnabled(true); }; window.addEventListener("pointerdown", resume); } }); window.LanternAudio = { turn, end, lessons, get enabled() { return enabled; } }; })();