/* RECORD STORE GUY — FMV QTE player. Vanilla ES6, no deps. Spec: ENGINE.md */ "use strict"; // feel-tuning constants (see ENGINE.md) const WINDOW_DEFAULT = 0.7; // s const EARLY_GRACE = 0.25; // s before window open where input = fail const DEATH_HOLD = 600; // ms on last death frame before retry const CUE_LEAD = 0.0; // s cue shows before window opens const GLYPH = { left: "◀", right: "▶", up: "▲", down: "▼", action: "●" }; const KEYMAP = { ArrowLeft: "left", ArrowRight: "right", ArrowUp: "up", ArrowDown: "down", " ": "action", Enter: "action" }; const $ = (id) => document.getElementById(id); const vids = [$("vidA"), $("vidB")]; let cur = 0; // index of active video const G = { data: null, state: "ATTRACT", scene: null, sceneId: null, wi: 0, windows: [], lives: 0, score: 0, checkpoint: null, debug: new URLSearchParams(location.search).has("debug"), blobs: new Map(), // url -> objectURL }; // ---------- boot ---------- async function boot() { G.data = await (await fetch("scenes.json?v=" + Date.now())).json(); $("hiscore").textContent = "HI-SCORE " + (localStorage.rsg_hiscore || 0); if (G.debug) { initDebug(); window.RSG = { G, input, vid }; } preload(G.data.scenes[G.data.start].clip); preload(G.data.scenes[G.data.start].death); attractPlay(); addEventListener("keydown", onKey); initTouch(); requestAnimationFrame(tick); } const vurl = (url) => url + "?v=" + (G.data?.meta?.version || 0); // cache-bust per data version async function preload(url) { if (!url || G.blobs.has(url)) return G.blobs.get(url); try { const b = await (await fetch(vurl(url))).blob(); const o = URL.createObjectURL(b); G.blobs.set(url, o); return o; } catch { return url; } // fall back to streaming } // ---------- clip playback (A/B swap) ---------- function playClip(url, onEnded) { const next = vids[1 - cur]; next.src = G.blobs.get(url) || vurl(url); next.onended = null; next.currentTime = 0; const p = next.play(); if (p) p.catch(() => {}); // pre-gesture autoplay rejection is fine next.onended = onEnded; next.classList.add("active"); vids[cur].classList.remove("active"); vids[cur].pause(); cur = 1 - cur; return next; } const vid = () => vids[cur]; // ---------- scene flow ---------- function attractPlay() { // looping mood video behind the title screen const v = playClip("clips/attract.mp4", null); v.loop = true; v.muted = true; } function startGame() { G.lives = G.data.meta.lives; G.score = 0; G.checkpoint = G.data.start; vids.forEach((v) => { v.muted = false; v.loop = false; }); $("attract").classList.add("hidden"); playScene(G.data.start); } function playScene(id) { const s = G.data.scenes[id]; G.state = "PLAYING"; G.sceneId = id; G.scene = s; G.wi = 0; if (s.checkpoint) G.checkpoint = id; G.windows = s.windows.map((w) => ({ t: w.t[1] != null ? w.t : [w.t[0], w.t[0] + WINDOW_DEFAULT], input: w.input, cue: w.cue !== false, cued: false, done: false, })); hud(); playClip(s.clip, onSceneClipEnd); // preload likely-next clips: this death, then success path preload(s.death).then(() => { const n = s.onSuccess; if (n && n !== "WIN") { preload(G.data.scenes[n].clip); preload(G.data.scenes[n].death); } else preload("clips/win.mp4"); }); if (G.debug) debugScene(); } function onSceneClipEnd() { if (G.state !== "PLAYING") return; if (G.wi < G.windows.length) return fail("clip ended mid-window"); // safety net G.score += 1000; hud(); const n = G.scene.onSuccess; if (n === "WIN") return win(); playScene(n); } function fail(why) { if (G.state !== "PLAYING") return; G.state = "DEATH"; cueHide(); if (G.debug) $("dbg-state").textContent = "DEATH (" + why + ")"; playClip(G.scene.death, () => { setTimeout(() => { G.lives--; G.score = Math.max(0, G.score - 100); hud(); if (G.lives <= 0) return gameOver(); playScene(G.sceneId); // retry the scene (checkpoints reserved for act restarts) }, DEATH_HOLD); }); } function win() { G.state = "WIN"; saveHi(); playClip("clips/win.mp4", () => showEnd("YOU SURVIVED", "score " + G.score)); } function gameOver() { G.state = "GAMEOVER"; saveHi(); playClip("clips/gameover.mp4", () => showEnd("GAME OVER", "score " + G.score)); } function showEnd(title, sub) { const m = $("msg"); m.innerHTML = "

" + title + "

" + sub + "

"; m.classList.remove("hidden"); G.state = "END"; } function toAttract() { $("msg").classList.add("hidden"); $("hiscore").textContent = "HI-SCORE " + (localStorage.rsg_hiscore || 0); $("attract").classList.remove("hidden"); attractPlay(); G.state = "ATTRACT"; } function saveHi() { if (G.score > (+localStorage.rsg_hiscore || 0)) localStorage.rsg_hiscore = G.score; } // ---------- QTE core ---------- // qteCheck is driven from THREE sources so throttling can't starve it: // rAF (smooth when focused), video timeupdate (fires while playing even // unfocused), and a 100ms interval backup. function qteCheck() { if (G.state !== "PLAYING") return; const t = vid().currentTime; const w = G.windows[G.wi]; if (w) { if (!w.cued && w.cue && t >= w.t[0] - CUE_LEAD) { w.cued = true; cueShow(w.input); } if (t >= w.t[1]) { w.done = "failed"; fail("too late"); } } if (G.debug) debugTick(t); } function tick() { qteCheck(); requestAnimationFrame(tick); } setInterval(qteCheck, 100); vids.forEach((v) => v.addEventListener("timeupdate", qteCheck)); function input(dir) { if (G.state === "ATTRACT") return startGame(); if (G.state === "END") return toAttract(); if (G.state !== "PLAYING") return; const w = G.windows[G.wi]; if (!w) return; // windows all cleared, clip finishing const t = vid().currentTime; if (t < w.t[0] - EARLY_GRACE) return; // idle fidget: ignore if (t < w.t[0]) { w.done = "failed"; return fail("too early"); } // Dragon's Lair rule if (dir !== w.input) { w.done = "failed"; return fail("wrong input"); } // pass w.done = "passed"; G.wi++; G.score += 100; hud(); cueGood(dir); if (G.debug) debugScene(); } // ---------- input sources ---------- function onKey(e) { if (e.repeat) return; if (G.state === "ATTRACT" || G.state === "END") return input("action"); const d = KEYMAP[e.key]; if (d) { e.preventDefault(); input(d); } if (G.debug) debugKeys(e); } function initTouch() { let sx, sy, st; addEventListener("touchstart", (e) => { sx = e.touches[0].clientX; sy = e.touches[0].clientY; st = Date.now(); }, { passive: true }); addEventListener("touchend", (e) => { const dx = e.changedTouches[0].clientX - sx, dy = e.changedTouches[0].clientY - sy; if (Math.hypot(dx, dy) < 30 && Date.now() - st < 400) return input("action"); // tap input(Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? "right" : "left") : (dy > 0 ? "down" : "up")); }, { passive: true }); } // ---------- HUD + cue ---------- function hud() { $("lives").textContent = "●".repeat(Math.max(0, G.lives)); $("score").textContent = G.score; } function cueShow(dir) { const c = $("cue"); c.textContent = GLYPH[dir]; c.className = "show"; } function cueHide() { $("cue").className = ""; } function cueGood(dir) { const c = $("cue"); c.textContent = GLYPH[dir]; c.className = "good"; } // ---------- debug mode (?debug=1) ---------- function initDebug() { $("debug").classList.remove("hidden"); const sel = $("dbg-scene"); for (const id of Object.keys(G.data.scenes)) { const o = document.createElement("option"); o.value = o.textContent = id; sel.appendChild(o); } sel.onchange = () => { $("attract").classList.add("hidden"); $("msg").classList.add("hidden"); if (!G.lives) G.lives = G.data.meta.lives; playScene(sel.value); }; } function debugScene() { const tl = $("dbg-timeline"); tl.querySelectorAll(".dbg-win").forEach((n) => n.remove()); const d = vid().duration || 5; G.windows.forEach((w) => { const b = document.createElement("div"); b.className = "dbg-win" + (w.done ? " " + w.done : ""); b.style.left = (w.t[0] / d) * 100 + "%"; b.style.width = ((w.t[1] - w.t[0]) / d) * 100 + "%"; tl.appendChild(b); }); $("dbg-scene").value = G.sceneId; } function debugTick(t) { const d = vid().duration || 5; $("dbg-playhead").style.left = (t / d) * 100 + "%"; $("dbg-time").textContent = t.toFixed(2) + "s / " + d.toFixed(2) + "s [" + G.sceneId + " w" + G.wi + "]"; $("dbg-state").textContent = G.state; } function debugKeys(e) { // , . frame-step while paused if (e.key === ",") { vid().pause(); vid().currentTime -= 1 / 24; } if (e.key === ".") { vid().pause(); vid().currentTime += 1 / 24; } if (e.key === "p") vid().paused ? vid().play() : vid().pause(); } boot();