File size: 5,024 Bytes
1ba2764 89dfb1c 1ba2764 89dfb1c 1ba2764 89dfb1c 1ba2764 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 | // 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; } };
})();
|