- scenes.json: every DESIGN.md scene wired — T1-T7, SH1-6, Misfile, 4 needle-drop alts, 7 white labels, boss volleys, REPLAY queue sentinel, win+stinger gate - player.js: choice windows (branch/death-goto), random variants (sh2 tape), endurance restarts w/ tightening (sh4), hold windows (t4s5/t5s5), needledrop power-up branches, unprompted collects, mirror-on-replay, soft fail (t5s4), needle_skip transitions, stale-death-timeout guard - clips renamed to DESIGN ids (s01->t1s1 etc); placeholder gen covers all new window types and never overwrites real footage (86 clips: 8 real + 78 gen) - sheets: added missing sh4_action + t5s4_action prompts - verified in browser: choice pass/timeout, collect, needledrop, endurance tighten+death, hold pass/release-fail, soft fail, replay queue w/ mirror, win->stinger with 7 labels, live-timing run of t1s1 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
406 lines
14 KiB
JavaScript
406 lines
14 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 HOLD_SLACK = 0.35; // s after a hold window opens before "not holding" = fail
|
|
const RESTART_TIGHTEN = 0.15; // s shaved off window closes per endurance restart
|
|
const REPLAY_CAP = 8; // max died-in scenes replayed before T7
|
|
|
|
const GLYPH = { left: "◀", right: "▶", up: "▲", down: "▼", action: "●" };
|
|
const KEYMAP = { ArrowLeft: "left", ArrowRight: "right", ArrowUp: "up", ArrowDown: "down",
|
|
" ": "action", Enter: "action" };
|
|
const MIRROR = { left: "right", right: "left" };
|
|
|
|
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, retryId: null,
|
|
wi: 0, windows: [], lives: 0, score: 0, checkpoint: null,
|
|
labels: new Set(), deathLog: [], replay: null, // null = normal play, [] = replay mode
|
|
restarts: 0, pendingGoto: null, mirror: false,
|
|
held: new Set(), // inputs currently held down (for hold windows)
|
|
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 }; }
|
|
const first = resolve(G.data.start);
|
|
preload(first.clip);
|
|
preload(first.death);
|
|
attractPlay();
|
|
addEventListener("keydown", onKey);
|
|
addEventListener("keyup", (e) => { const d = KEYMAP[e.key]; if (d) G.held.delete(d); });
|
|
initTouch();
|
|
requestAnimationFrame(tick);
|
|
}
|
|
|
|
const vurl = (url) => url + "?v=" + (G.data?.meta?.version || 0); // cache-bust per data version
|
|
const resolve = (id) => { // follow random -> concrete variant scene
|
|
let s = G.data.scenes[id];
|
|
if (s.random) s = G.data.scenes[s.random[Math.floor(Math.random() * s.random.length)]];
|
|
return s;
|
|
};
|
|
|
|
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");
|
|
next.classList.toggle("mirror", G.mirror);
|
|
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
|
|
G.mirror = false;
|
|
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.labels.clear();
|
|
G.deathLog = [];
|
|
G.replay = null;
|
|
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, restarts = 0) {
|
|
const parent = G.data.scenes[id];
|
|
const s = resolve(id);
|
|
G.state = "PLAYING"; G.sceneId = id; G.retryId = id; G.scene = s;
|
|
G.wi = 0; G.restarts = restarts; G.pendingGoto = null;
|
|
G.mirror = !!(G.replay && s.mirrorOk); // replayed scenes come back flipped — the Dead Wax remembers
|
|
if (parent.checkpoint) G.checkpoint = id;
|
|
const shave = restarts * RESTART_TIGHTEN;
|
|
G.windows = s.windows.map((w) => ({
|
|
t: w.t[1] != null ? [w.t[0], Math.max(w.t[0] + 0.25, w.t[1] - shave)]
|
|
: [w.t[0], w.t[0] + WINDOW_DEFAULT],
|
|
input: w.input, choices: w.choices, hold: w.hold,
|
|
cue: w.cue !== false, cued: false, done: false,
|
|
}));
|
|
// needledrop / collect are one-shots, disabled during the replay gauntlet
|
|
G.nd = (!G.replay && s.needledrop) ? { ...s.needledrop, done: false, cued: false } : null;
|
|
G.col = (!G.replay && s.collect && !G.labels.has(s.collect.label))
|
|
? { ...s.collect, done: false } : null;
|
|
hud();
|
|
cueHide(); cueNd(false);
|
|
playClip(s.clip, onSceneClipEnd);
|
|
// preload likely-next clips: this death, then success path
|
|
preload(s.death).then(() => {
|
|
if (G.nd) preload(G.nd.clip);
|
|
const n = s.onSuccess;
|
|
if (n && n !== "WIN" && n !== "REPLAY") {
|
|
const ns = resolve(n);
|
|
preload(ns.clip); preload(ns.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
|
|
sceneCleared();
|
|
}
|
|
|
|
function sceneCleared() {
|
|
cueHide();
|
|
G.score += 1000 + (G.scene.score || 0);
|
|
if (G.scene.label) grabLabel(G.scene.label, 0);
|
|
hud();
|
|
advance(G.pendingGoto || G.scene.onSuccess);
|
|
}
|
|
|
|
function advance(next) {
|
|
if (G.replay) return replayNext();
|
|
if (next === "WIN") return win();
|
|
if (next === "REPLAY") return startReplay();
|
|
playScene(next);
|
|
}
|
|
|
|
// ---------- replay queue ("the Dead Wax remembers") ----------
|
|
function startReplay() {
|
|
const elig = (id) => {
|
|
const s = G.data.scenes[id];
|
|
return s && !s.random && !s.windows.some((w) => w.choices);
|
|
};
|
|
G.replay = G.deathLog.filter(elig).slice(0, REPLAY_CAP);
|
|
if (!G.replay.length) { G.replay = null; return playScene("t7s1"); }
|
|
replayNext();
|
|
}
|
|
function replayNext() {
|
|
G.state = "CUT";
|
|
G.mirror = false;
|
|
playClip("clips/needle_skip.mp4", () => {
|
|
const id = G.replay.shift();
|
|
if (id) return playScene(id);
|
|
G.replay = null;
|
|
playScene("t7s1");
|
|
});
|
|
}
|
|
|
|
function fail(why, deathClip, gotoAfter) {
|
|
if (G.state !== "PLAYING") return;
|
|
cueHide();
|
|
const s = G.scene;
|
|
// soft fail (t5s4 platter lock): no death, no life lost, just spat backwards
|
|
if (s.failGoto && !deathClip) {
|
|
G.state = "CUT";
|
|
if (s.death) return playClip(s.death, () => playScene(s.failGoto));
|
|
return playScene(s.failGoto);
|
|
}
|
|
// endurance (sh4): restart the clip with tighter windows until maxRestarts
|
|
if (s.onMiss === "restart" && G.restarts + 1 < (s.maxRestarts || 3)) {
|
|
G.state = "CUT";
|
|
return playScene(G.retryId, G.restarts + 1);
|
|
}
|
|
G.state = "DEATH";
|
|
if (!G.replay && !G.deathLog.includes(G.retryId)) G.deathLog.push(G.retryId);
|
|
if (G.debug) $("dbg-state").textContent = "DEATH (" + why + ")";
|
|
playClip(deathClip || s.death, () => {
|
|
setTimeout(() => {
|
|
if (G.state !== "DEATH") return; // scene was jumped/reset while death played
|
|
G.lives--; G.score = Math.max(0, G.score - 100); hud();
|
|
if (G.lives <= 0) return gameOver();
|
|
playScene(gotoAfter || G.retryId); // retry (or branch: misfile dimension)
|
|
}, DEATH_HOLD);
|
|
});
|
|
}
|
|
|
|
function win() {
|
|
G.state = "WIN";
|
|
G.mirror = false;
|
|
saveHi();
|
|
playClip("clips/win.mp4", () => {
|
|
if (G.labels.size >= 7)
|
|
return playClip("clips/stinger.mp4",
|
|
() => showEnd("RECORD STORE GUY WILL RETURN", "all 7 white labels · score " + G.score));
|
|
showEnd("YOU SURVIVED", "score " + G.score);
|
|
});
|
|
}
|
|
function gameOver() {
|
|
G.state = "GAMEOVER";
|
|
G.mirror = false;
|
|
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");
|
|
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.
|
|
const flip = (d) => (G.mirror && MIRROR[d]) || d;
|
|
|
|
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); }
|
|
if (w.hold) {
|
|
if (t > w.t[0] + HOLD_SLACK && t < w.t[1] && !G.held.has(flip(w.input)))
|
|
{ w.done = "failed"; return fail("let go"); }
|
|
if (t >= w.t[1]) { // held all the way through = pass
|
|
w.done = "passed"; G.wi++; G.score += 100; hud(); cueGood(flip(w.input));
|
|
if (G.debug) debugScene();
|
|
}
|
|
} else if (t >= w.t[1]) { w.done = "failed"; return fail("too late"); }
|
|
}
|
|
if (G.nd && !G.nd.done) { // needle-drop flash: optional, ignoring is not a fail
|
|
if (!G.nd.cued && t >= G.nd.t[0]) { G.nd.cued = true; cueNd(true); }
|
|
if (G.nd.cued && t >= G.nd.t[1]) { G.nd.done = true; cueNd(false); }
|
|
}
|
|
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;
|
|
G.held.add(dir);
|
|
const t = vid().currentTime;
|
|
// needle-drop: action during the flash branches to the power-up clip
|
|
if (G.nd && !G.nd.done && dir === "action" && t >= G.nd.t[0] && t <= G.nd.t[1]) {
|
|
G.nd.done = true; cueNd(false); cueHide();
|
|
G.score += G.nd.bonus || 500;
|
|
if (G.nd.label) grabLabel(G.nd.label);
|
|
hud();
|
|
G.state = "CUT";
|
|
const target = G.nd.skipTo || G.scene.onSuccess;
|
|
return playClip(G.nd.clip, () => { G.score += 1000; hud(); advance(target); });
|
|
}
|
|
// white label: unprompted input during a background flash — wrong guesses ignored
|
|
if (G.col && !G.col.done && dir === G.col.input && t >= G.col.t[0] && t <= G.col.t[1]) {
|
|
G.col.done = true;
|
|
return grabLabel(G.col.label);
|
|
}
|
|
const w = G.windows[G.wi];
|
|
if (!w) return; // windows all cleared, clip finishing
|
|
if (w.hold) { // the hold press itself; wrong direction still kills
|
|
if (dir !== flip(w.input) && t >= w.t[0]) { w.done = "failed"; fail("wrong input"); }
|
|
return;
|
|
}
|
|
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 (w.choices) {
|
|
const c = w.choices[dir];
|
|
if (!c) { w.done = "failed"; return fail("wrong choice"); }
|
|
w.done = "passed"; G.wi++;
|
|
if (c.death) return fail("bad choice", c.death, c.goto);
|
|
G.pendingGoto = c.goto; G.score += 100; hud(); cueGood(dir);
|
|
if (G.debug) debugScene();
|
|
return;
|
|
}
|
|
if (dir !== flip(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();
|
|
}
|
|
|
|
function grabLabel(n, points = 250) {
|
|
G.labels.add(n);
|
|
G.score += points;
|
|
hud();
|
|
toast("WHITE LABEL " + G.labels.size + "/7");
|
|
}
|
|
|
|
// ---------- 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() {
|
|
// ponytail: swipes+taps only; hold windows are keyboard-first for now,
|
|
// add touch-hold (touchstart..touchend spanning the window) if mobile matters
|
|
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;
|
|
let d;
|
|
if (Math.hypot(dx, dy) < 30 && Date.now() - st < 400) d = "action"; // tap
|
|
else d = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? "right" : "left") : (dy > 0 ? "down" : "up");
|
|
input(d);
|
|
G.held.delete(d); // touches don't hold
|
|
}, { passive: true });
|
|
}
|
|
|
|
// ---------- HUD + cue ----------
|
|
function hud() {
|
|
$("lives").textContent = "●".repeat(Math.max(0, G.lives));
|
|
$("score").textContent = G.score;
|
|
$("labels").textContent = "◉".repeat(G.labels.size) + "○".repeat(7 - G.labels.size);
|
|
}
|
|
function cueShow(w) {
|
|
const c = $("cue");
|
|
if (w.choices) c.textContent = Object.keys(w.choices).map((d) => GLYPH[d]).join(" ");
|
|
else c.textContent = GLYPH[flip(w.input)];
|
|
c.className = "show" + (w.hold ? " hold" : "");
|
|
}
|
|
function cueHide() { $("cue").className = ""; }
|
|
function cueGood(dir) { const c = $("cue"); c.textContent = GLYPH[dir]; c.className = "good"; }
|
|
function cueNd(on) { $("nd").className = on ? "show" : ""; }
|
|
function toast(text) {
|
|
const el = $("toast");
|
|
el.textContent = text;
|
|
el.className = "show";
|
|
setTimeout(() => (el.className = ""), 1500);
|
|
}
|
|
|
|
// ---------- 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();
|