recordstoreguy/engine/player.js
m3ultra 30dcf11401 Cache-bust clip URLs with meta.version; bump to v2 for real footage
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 23:49:17 +10:00

244 lines
8.4 KiB
JavaScript

/* 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);
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 startGame() {
G.lives = G.data.meta.lives;
G.score = 0;
G.checkpoint = G.data.start;
$("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 = "<h2>" + title + "</h2><p class='sub'>" + sub + "</p><p class='blink'>PRESS ANY KEY</p>";
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");
vid().pause();
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();