From 17a0bb07edb794057241bd2fc1864dba9d4d8eba Mon Sep 17 00:00:00 2001 From: m3ultra Date: Sun, 19 Jul 2026 16:59:59 +1000 Subject: [PATCH] =?UTF-8?q?Phase=201:=20playable=20engine=20shell=20?= =?UTF-8?q?=E2=80=94=20QTE=20player,=20debug=20mode,=20placeholder=20clip?= =?UTF-8?q?=20generator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified end-to-end in browser: attract, QTE pass/fail, death/retry, lives, win screen. QTE clock driven by rAF + timeupdate + interval so background-tab throttling can't starve the too-late check. Co-Authored-By: Claude Fable 5 --- .claude/launch.json | 11 ++ engine/index.html | 37 ++++++ engine/player.js | 241 +++++++++++++++++++++++++++++++++++++ engine/scenes.json | 30 +++++ engine/styles.css | 50 ++++++++ tools/make_placeholders.py | 96 +++++++++++++++ 6 files changed, 465 insertions(+) create mode 100644 .claude/launch.json create mode 100644 engine/index.html create mode 100644 engine/player.js create mode 100644 engine/scenes.json create mode 100644 engine/styles.css create mode 100644 tools/make_placeholders.py diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..d3a7312 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "rsg-engine", + "runtimeExecutable": "python3", + "runtimeArgs": ["-m", "http.server", "8123", "-d", "engine"], + "port": 8123 + } + ] +} diff --git a/engine/index.html b/engine/index.html new file mode 100644 index 0000000..ba185ce --- /dev/null +++ b/engine/index.html @@ -0,0 +1,37 @@ + + + + + +RECORD STORE GUY + + + +
+ + +
+
+
+
+
0
+
+
+

RECORD STORE GUY

+

wrong move — you're dead

+ +

+
+ + +
+ + + diff --git a/engine/player.js b/engine/player.js new file mode 100644 index 0000000..c245161 --- /dev/null +++ b/engine/player.js @@ -0,0 +1,241 @@ +/* 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); +} + +async function preload(url) { + if (!url || G.blobs.has(url)) return G.blobs.get(url); + try { + const b = await (await fetch(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) || 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 = "

" + 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"); + 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(); diff --git a/engine/scenes.json b/engine/scenes.json new file mode 100644 index 0000000..9ac142f --- /dev/null +++ b/engine/scenes.json @@ -0,0 +1,30 @@ +{ + "meta": { "title": "RECORD STORE GUY", "lives": 5, "version": 1 }, + "start": "s01", + "scenes": { + "s01": { + "clip": "clips/s01_action.mp4", + "death": "clips/s01_death.mp4", + "windows": [{ "t": [3.2, 3.9], "input": "right", "cue": true }], + "onSuccess": "s02", + "checkpoint": true + }, + "s02": { + "clip": "clips/s02_action.mp4", + "death": "clips/s02_death.mp4", + "windows": [ + { "t": [2.0, 2.7], "input": "down", "cue": true }, + { "t": [3.5, 4.2], "input": "up", "cue": true } + ], + "onSuccess": "s03", + "checkpoint": false + }, + "s03": { + "clip": "clips/s03_action.mp4", + "death": "clips/s03_death.mp4", + "windows": [{ "t": [3.0, 3.6], "input": "action", "cue": true }], + "onSuccess": "WIN", + "checkpoint": false + } + } +} diff --git a/engine/styles.css b/engine/styles.css new file mode 100644 index 0000000..d7cf479 --- /dev/null +++ b/engine/styles.css @@ -0,0 +1,50 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } +html, body { height: 100%; background: #000; overflow: hidden; font-family: "Courier New", monospace; } + +#stage { position: relative; width: 100vw; height: 100vh; display: flex; align-items: center; justify-content: center; } +video { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); + max-width: 100vw; max-height: 100vh; aspect-ratio: 16/9; width: 100%; + opacity: 0; pointer-events: none; } +video.active { opacity: 1; } + +#scanlines { position: absolute; inset: 0; pointer-events: none; z-index: 5; + background: repeating-linear-gradient(to bottom, rgba(0,0,0,0) 0 2px, rgba(0,0,0,.22) 2px 4px); + box-shadow: inset 0 0 18vmin rgba(0,0,0,.85); } + +/* QTE cue */ +#cue { position: absolute; bottom: 16%; left: 50%; transform: translateX(-50%); + font-size: 14vmin; font-weight: bold; color: #0ff; z-index: 10; opacity: 0; + text-shadow: 0 0 2vmin #0ff, 0 0 6vmin #08f; pointer-events: none; } +#cue.show { opacity: 1; animation: pulse .3s ease-in-out infinite alternate; } +#cue.good { color: #0f4; text-shadow: 0 0 2vmin #0f4; animation: pop .25s ease-out; } +@keyframes pulse { from { transform: translateX(-50%) scale(1); } to { transform: translateX(-50%) scale(1.18); } } +@keyframes pop { from { transform: translateX(-50%) scale(1.4); opacity: 1; } to { transform: translateX(-50%) scale(1); opacity: 0; } } + +/* HUD */ +#hud { position: absolute; top: 2vmin; left: 0; right: 0; display: flex; + justify-content: space-between; padding: 0 3vmin; z-index: 10; pointer-events: none; } +#lives { color: #f33; font-size: 4vmin; letter-spacing: .8vmin; text-shadow: 0 0 1.5vmin #f33; } +#score { color: #ff0; font-size: 4vmin; text-shadow: 0 0 1.5vmin #f80; } + +/* full screens */ +.screen { position: absolute; inset: 0; z-index: 20; display: flex; flex-direction: column; + align-items: center; justify-content: center; gap: 3vmin; text-align: center; + background: radial-gradient(ellipse at center, #180828 0%, #000 75%); } +.screen.hidden { display: none; } +.screen h1 { color: #f0f; font-size: 9vmin; text-shadow: 0 0 2vmin #f0f, 0 0 8vmin #90f; } +.screen h2 { color: #fff; font-size: 7vmin; text-shadow: 0 0 2vmin #0ff; } +.screen .sub { color: #0ff; font-size: 3vmin; } +.screen .blink { color: #fff; font-size: 4vmin; animation: blink 1s steps(2) infinite; } +#hiscore { color: #ff0; font-size: 3vmin; } +@keyframes blink { 50% { opacity: 0; } } + +/* debug */ +#debug { position: absolute; left: 0; right: 0; bottom: 0; z-index: 30; + background: rgba(0,0,0,.8); padding: 1vmin 2vmin; font-size: 1.8vmin; color: #0f0; } +#debug.hidden { display: none; } +#dbg-timeline { position: relative; height: 2.4vmin; background: #222; margin-bottom: .8vmin; } +.dbg-win { position: absolute; top: 0; bottom: 0; background: #08f; opacity: .8; } +.dbg-win.passed { background: #0f4; } .dbg-win.failed { background: #f33; } +#dbg-playhead { position: absolute; top: 0; bottom: 0; width: 2px; background: #fff; z-index: 2; } +#dbg-row { display: flex; gap: 2vmin; align-items: center; } +#dbg-row select { background: #111; color: #0f0; border: 1px solid #0f0; font: inherit; } diff --git a/tools/make_placeholders.py b/tools/make_placeholders.py new file mode 100644 index 0000000..b5ebe3a --- /dev/null +++ b/tools/make_placeholders.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Generate placeholder clips for every scene in engine/scenes.json (plus +attract/win/gameover) so the whole game is playable before any Flow credits are +spent. Text is rendered to PNG cards with Pillow (this machine's ffmpeg lacks +drawtext); ffmpeg composites flash + glyph overlays + a 1kHz beep at exactly each +QTE window so timing is verifiable by eye/ear. Deaths: red 'DEATH' cards. + +If Pillow is missing from the running interpreter, re-execs into a MODELBEAST +venv python that has it.""" +import glob +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +try: + from PIL import Image, ImageDraw, ImageFont +except ModuleNotFoundError: + for cand in glob.glob(str(Path.home() / "Documents/MODELBEAST/venvs/*/bin/python3")): + if subprocess.run([cand, "-c", "import PIL"], capture_output=True).returncode == 0: + os.execv(cand, [cand] + sys.argv) + sys.exit("Pillow not found in any interpreter; pip install pillow") + +ROOT = Path(__file__).resolve().parent.parent / "engine" +CLIPS = ROOT / "clips" +DUR = 5.0 +COLORS = ["#1a2a55", "#1f4d2a", "#4d3a1f", "#3a1f4d", "#1f4d4d"] +GLYPH = {"left": "<<<", "right": ">>>", "up": "^^^", "down": "vvv", "action": "(GO)"} +FONTS = ["/System/Library/Fonts/Supplemental/Arial Bold.ttf", + "/System/Library/Fonts/Supplemental/Arial.ttf"] +FONT = next((f for f in FONTS if Path(f).exists()), None) +W, H = 1280, 720 + + +def png_card(path, color, text, size=110, fg="white"): + img = Image.new("RGB", (W, H), color) + d = ImageDraw.Draw(img) + f = ImageFont.truetype(FONT, size) if FONT else ImageFont.load_default() + d.text((W / 2, H / 2), text, font=f, fill=fg, anchor="mm") + img.save(path) + + +def png_glyph(path, text): + img = Image.new("RGBA", (W, H), (0, 0, 0, 0)) + d = ImageDraw.Draw(img) + f = ImageFont.truetype(FONT, 200) if FONT else ImageFont.load_default() + d.text((W / 2, H - 220), text, font=f, fill="#ffee00", anchor="mm") + img.save(path) + + +def clip(out, base_png, dur, windows=(), tmp=None): + """base card + per-window white flash, glyph overlay, and beep.""" + args = ["ffmpeg", "-y", "-loglevel", "error", + "-loop", "1", "-t", str(dur), "-framerate", "24", "-i", str(base_png), + "-f", "lavfi", "-i", f"sine=frequency=1000:sample_rate=44100:duration={dur}"] + fc, last, vol = [], "0:v", "0" + for i, w in enumerate(windows): + t0, t1 = w["t"] + g = Path(tmp) / f"g{i}.png" + png_glyph(g, GLYPH[w["input"]]) + args += ["-loop", "1", "-t", str(dur), "-framerate", "24", "-i", str(g)] + nxt = f"v{i}" + fc.append(f"[{last}]drawbox=t=fill:color=white@0.35:enable='between(t,{t0},{t0 + 0.12})'" + f"[f{i}];[f{i}][{i + 2}:v]overlay=enable='between(t,{t0},{t1})'[{nxt}]") + last = nxt + vol = f"if(between(t,{t0},{t1}),1,{vol})" + fc.append(f"[{last}]format=yuv420p[vout]") + args += ["-filter_complex", ";".join(fc), "-map", "[vout]", "-map", "1:a", + "-af", f"volume='{vol}':eval=frame", + "-c:v", "libx264", "-crf", "23", "-c:a", "aac", "-shortest", + "-movflags", "+faststart", str(out)] + subprocess.run(args, check=True) + print("wrote", out.name) + + +def main(): + CLIPS.mkdir(exist_ok=True) + data = json.loads((ROOT / "scenes.json").read_text()) + with tempfile.TemporaryDirectory() as tmp: + def make(name, color, text, dur, windows=()): + base = Path(tmp) / (Path(name).stem + ".png") + png_card(base, color, text) + clip(CLIPS / name, base, dur, windows, tmp) + + for i, (sid, s) in enumerate(data["scenes"].items()): + make(Path(s["clip"]).name, COLORS[i % len(COLORS)], sid.upper(), DUR, s["windows"]) + make(Path(s["death"]).name, "#550000", f"DEATH {sid.upper()}", 2.5) + make("attract.mp4", "#111111", "RECORD STORE GUY", 4.0) + make("win.mp4", "#004400", "YOU SURVIVED", 4.0) + make("gameover.mp4", "#220022", "GAME OVER", 3.0) + + +if __name__ == "__main__": + main()