Phase 1: playable engine shell — QTE player, debug mode, placeholder clip generator

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 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-19 16:59:59 +10:00
parent c4ec145455
commit 17a0bb07ed
6 changed files with 465 additions and 0 deletions

11
.claude/launch.json Normal file
View File

@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "rsg-engine",
"runtimeExecutable": "python3",
"runtimeArgs": ["-m", "http.server", "8123", "-d", "engine"],
"port": 8123
}
]
}

37
engine/index.html Normal file
View File

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>RECORD STORE GUY</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div id="stage">
<video id="vidA" playsinline preload="auto"></video>
<video id="vidB" playsinline preload="auto"></video>
<div id="scanlines"></div>
<div id="cue"></div>
<div id="hud">
<div id="lives"></div>
<div id="score">0</div>
</div>
<div id="attract" class="screen">
<h1>RECORD STORE GUY</h1>
<p class="sub">wrong move &mdash; you're dead</p>
<p class="blink">PRESS ANY KEY</p>
<p id="hiscore"></p>
</div>
<div id="msg" class="screen hidden"></div>
<div id="debug" class="hidden">
<div id="dbg-timeline"><div id="dbg-playhead"></div></div>
<div id="dbg-row">
<select id="dbg-scene"></select>
<span id="dbg-time"></span>
<span id="dbg-state"></span>
</div>
</div>
</div>
<script src="player.js"></script>
</body>
</html>

241
engine/player.js Normal file
View File

@ -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 = "<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();

30
engine/scenes.json Normal file
View File

@ -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
}
}
}

50
engine/styles.css Normal file
View File

@ -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; }

View File

@ -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()