Engine v2 + full 45-scene graph: choices, random, endurance, hold, needledrop, white labels, replay queue, stinger

- 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>
This commit is contained in:
type-two 2026-07-20 04:40:41 +10:00
parent 7de1612998
commit 4f7d21df2b
9 changed files with 457 additions and 150 deletions

View File

@ -4,8 +4,8 @@
{ {
"name": "rsg-engine", "name": "rsg-engine",
"runtimeExecutable": "python3", "runtimeExecutable": "python3",
"runtimeArgs": ["-m", "http.server", "8123", "-d", "engine"], "runtimeArgs": ["-m", "http.server", "8321", "-d", "engine"],
"port": 8123 "port": 8321
} }
] ]
} }

View File

@ -61,18 +61,28 @@ default death; a `choices` window must be the clip's last window; `random: ["a",
on a scene picks one of two clip/outcome sets per attempt (for THE TAPE mimic gag). on a scene picks one of two clip/outcome sets per attempt (for THE TAPE mimic gag).
Choice windows default to longer durations (1.5s) — they're decisions, not reflexes. Choice windows default to longer durations (1.5s) — they're decisions, not reflexes.
## v2 mechanics (spec'd in DESIGN.md — build after Phase 3) ## v2 mechanics (BUILT 2026-07-20 — as implemented in player.js)
- `mirror: true` scene flag: CSS `transform: scaleX(-1)` on the video + swap - `mirrorOk: true` scene flag: scene is mirror-eligible. The engine mirrors it
left/right in input handling and cues. Free content doubling (Dragon's Lair trick). (CSS scaleX(-1) + left/right swap in inputs and cues) when it comes back in the
- Replay queue: scenes died-in (cap 8) replay before the final track. replay queue — free variety, "the Dead Wax remembers... backwards".
- Difficulty tiers: easy = skip scenes flagged `skippableOnEasy` + windows +0.2s; - Replay queue: `onSuccess: "REPLAY"` (on sh6) queues died-in scenes (unique,
hard = `cue:false` everywhere after first visit. cap 8, choice/random scenes excluded), needle_skip.mp4 transition between each,
- `needledrop: {t, clip, bonus}` on a scene: optional action input during a flash then t7s1. Needledrop/collect are disabled during replays.
window branches to an alternate clip; ignoring it is not a fail. - `needledrop: {t, clip, bonus, label?, skipTo?}` on a scene: optional action
- `collect: {t, input}` on a scene: unprompted input during a background flash sets input during the flash branches to the alt clip; ignoring it is not a fail.
a flag (white labels); wrong unprompted inputs outside windows stay ignored. `label` grants a white label, `skipTo` overrides onSuccess (t6s1 alt skips t6s2).
- Endurance windows: `onMiss: "restart"` (replay clip, tighter windows), `maxRestarts`. - `collect: {t, input, label}` on a scene: unprompted input during a background
- Hold windows: `hold: true` — input must be held from window open to close. flash grabs white label N (+250, toast); wrong unprompted inputs stay ignored.
All 7 labels → stinger.mp4 plays after win.mp4.
- Endurance: `onMiss: "restart"` + `maxRestarts` on a scene — misses replay the
clip with window closes tightened 0.15s per restart; the Nth miss is a real death.
- Hold windows: `hold: true` — input held from open (0.35s slack) to close.
- Soft fail: `failGoto: "id"` on a scene — fail costs no life, plays the death
clip if any, and respawns at the given scene (t5s4 platter lock → t5s1).
- Choice deaths can branch: `{death, goto}` respawns at goto after the death
(sh1 wrong file → Misfile Dimension). Scene-level `score`/`label` grant on clear
(sh5_bonus). Choice window timeout uses the scene's default death.
- NOT built (cut for now): difficulty tiers, gamepad, touch-hold windows.
## Player loop (player.js structure) ## Player loop (player.js structure)
1. **Boot**: fetch scenes.json → preload manifest. 1. **Boot**: fetch scenes.json → preload manifest.

View File

@ -12,9 +12,12 @@
<video id="vidB" playsinline preload="auto"></video> <video id="vidB" playsinline preload="auto"></video>
<div id="scanlines"></div> <div id="scanlines"></div>
<div id="cue"></div> <div id="cue"></div>
<div id="nd">◉♪</div>
<div id="toast"></div>
<div id="hud"> <div id="hud">
<div id="lives"></div> <div id="lives"></div>
<div id="score">0</div> <div id="score">0</div>
<div id="labels"></div>
</div> </div>
<div id="attract" class="screen"> <div id="attract" class="screen">
<h1>RECORD STORE GUY</h1> <h1>RECORD STORE GUY</h1>

View File

@ -6,18 +6,25 @@ const WINDOW_DEFAULT = 0.7; // s
const EARLY_GRACE = 0.25; // s before window open where input = fail const EARLY_GRACE = 0.25; // s before window open where input = fail
const DEATH_HOLD = 600; // ms on last death frame before retry const DEATH_HOLD = 600; // ms on last death frame before retry
const CUE_LEAD = 0.0; // s cue shows before window opens 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 GLYPH = { left: "◀", right: "▶", up: "▲", down: "▼", action: "●" };
const KEYMAP = { ArrowLeft: "left", ArrowRight: "right", ArrowUp: "up", ArrowDown: "down", const KEYMAP = { ArrowLeft: "left", ArrowRight: "right", ArrowUp: "up", ArrowDown: "down",
" ": "action", Enter: "action" }; " ": "action", Enter: "action" };
const MIRROR = { left: "right", right: "left" };
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const vids = [$("vidA"), $("vidB")]; const vids = [$("vidA"), $("vidB")];
let cur = 0; // index of active video let cur = 0; // index of active video
const G = { const G = {
data: null, state: "ATTRACT", scene: null, sceneId: null, data: null, state: "ATTRACT", scene: null, sceneId: null, retryId: null,
wi: 0, windows: [], lives: 0, score: 0, checkpoint: 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"), debug: new URLSearchParams(location.search).has("debug"),
blobs: new Map(), // url -> objectURL blobs: new Map(), // url -> objectURL
}; };
@ -27,15 +34,22 @@ async function boot() {
G.data = await (await fetch("scenes.json?v=" + Date.now())).json(); G.data = await (await fetch("scenes.json?v=" + Date.now())).json();
$("hiscore").textContent = "HI-SCORE " + (localStorage.rsg_hiscore || 0); $("hiscore").textContent = "HI-SCORE " + (localStorage.rsg_hiscore || 0);
if (G.debug) { initDebug(); window.RSG = { G, input, vid }; } if (G.debug) { initDebug(); window.RSG = { G, input, vid }; }
preload(G.data.scenes[G.data.start].clip); const first = resolve(G.data.start);
preload(G.data.scenes[G.data.start].death); preload(first.clip);
preload(first.death);
attractPlay(); attractPlay();
addEventListener("keydown", onKey); addEventListener("keydown", onKey);
addEventListener("keyup", (e) => { const d = KEYMAP[e.key]; if (d) G.held.delete(d); });
initTouch(); initTouch();
requestAnimationFrame(tick); requestAnimationFrame(tick);
} }
const vurl = (url) => url + "?v=" + (G.data?.meta?.version || 0); // cache-bust per data version 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) { async function preload(url) {
if (!url || G.blobs.has(url)) return G.blobs.get(url); if (!url || G.blobs.has(url)) return G.blobs.get(url);
@ -57,6 +71,7 @@ function playClip(url, onEnded) {
if (p) p.catch(() => {}); // pre-gesture autoplay rejection is fine if (p) p.catch(() => {}); // pre-gesture autoplay rejection is fine
next.onended = onEnded; next.onended = onEnded;
next.classList.add("active"); next.classList.add("active");
next.classList.toggle("mirror", G.mirror);
vids[cur].classList.remove("active"); vids[cur].classList.remove("active");
vids[cur].pause(); vids[cur].pause();
cur = 1 - cur; cur = 1 - cur;
@ -66,6 +81,7 @@ const vid = () => vids[cur];
// ---------- scene flow ---------- // ---------- scene flow ----------
function attractPlay() { // looping mood video behind the title screen function attractPlay() { // looping mood video behind the title screen
G.mirror = false;
const v = playClip("clips/attract.mp4", null); const v = playClip("clips/attract.mp4", null);
v.loop = true; v.loop = true;
v.muted = true; v.muted = true;
@ -74,27 +90,44 @@ function attractPlay() { // looping mood video behind the title screen
function startGame() { function startGame() {
G.lives = G.data.meta.lives; G.lives = G.data.meta.lives;
G.score = 0; G.score = 0;
G.labels.clear();
G.deathLog = [];
G.replay = null;
G.checkpoint = G.data.start; G.checkpoint = G.data.start;
vids.forEach((v) => { v.muted = false; v.loop = false; }); vids.forEach((v) => { v.muted = false; v.loop = false; });
$("attract").classList.add("hidden"); $("attract").classList.add("hidden");
playScene(G.data.start); playScene(G.data.start);
} }
function playScene(id) { function playScene(id, restarts = 0) {
const s = G.data.scenes[id]; const parent = G.data.scenes[id];
G.state = "PLAYING"; G.sceneId = id; G.scene = s; G.wi = 0; const s = resolve(id);
if (s.checkpoint) G.checkpoint = 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) => ({ G.windows = s.windows.map((w) => ({
t: w.t[1] != null ? w.t : [w.t[0], w.t[0] + WINDOW_DEFAULT], t: w.t[1] != null ? [w.t[0], Math.max(w.t[0] + 0.25, w.t[1] - shave)]
input: w.input, cue: w.cue !== false, cued: false, done: false, : [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(); hud();
cueHide(); cueNd(false);
playClip(s.clip, onSceneClipEnd); playClip(s.clip, onSceneClipEnd);
// preload likely-next clips: this death, then success path // preload likely-next clips: this death, then success path
preload(s.death).then(() => { preload(s.death).then(() => {
if (G.nd) preload(G.nd.clip);
const n = s.onSuccess; const n = s.onSuccess;
if (n && n !== "WIN") { preload(G.data.scenes[n].clip); preload(G.data.scenes[n].death); } if (n && n !== "WIN" && n !== "REPLAY") {
else preload("clips/win.mp4"); const ns = resolve(n);
preload(ns.clip); preload(ns.death);
} else preload("clips/win.mp4");
}); });
if (G.debug) debugScene(); if (G.debug) debugScene();
} }
@ -102,33 +135,87 @@ function playScene(id) {
function onSceneClipEnd() { function onSceneClipEnd() {
if (G.state !== "PLAYING") return; if (G.state !== "PLAYING") return;
if (G.wi < G.windows.length) return fail("clip ended mid-window"); // safety net if (G.wi < G.windows.length) return fail("clip ended mid-window"); // safety net
G.score += 1000; hud(); sceneCleared();
const n = G.scene.onSuccess;
if (n === "WIN") return win();
playScene(n);
} }
function fail(why) { function sceneCleared() {
if (G.state !== "PLAYING") return;
G.state = "DEATH";
cueHide(); 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 + ")"; if (G.debug) $("dbg-state").textContent = "DEATH (" + why + ")";
playClip(G.scene.death, () => { playClip(deathClip || s.death, () => {
setTimeout(() => { setTimeout(() => {
if (G.state !== "DEATH") return; // scene was jumped/reset while death played
G.lives--; G.score = Math.max(0, G.score - 100); hud(); G.lives--; G.score = Math.max(0, G.score - 100); hud();
if (G.lives <= 0) return gameOver(); if (G.lives <= 0) return gameOver();
playScene(G.sceneId); // retry the scene (checkpoints reserved for act restarts) playScene(gotoAfter || G.retryId); // retry (or branch: misfile dimension)
}, DEATH_HOLD); }, DEATH_HOLD);
}); });
} }
function win() { function win() {
G.state = "WIN"; G.state = "WIN";
G.mirror = false;
saveHi(); saveHi();
playClip("clips/win.mp4", () => showEnd("YOU SURVIVED", "score " + G.score)); 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() { function gameOver() {
G.state = "GAMEOVER"; G.state = "GAMEOVER";
G.mirror = false;
saveHi(); saveHi();
playClip("clips/gameover.mp4", () => showEnd("GAME OVER", "score " + G.score)); playClip("clips/gameover.mp4", () => showEnd("GAME OVER", "score " + G.score));
} }
@ -153,13 +240,26 @@ function saveHi() {
// qteCheck is driven from THREE sources so throttling can't starve it: // qteCheck is driven from THREE sources so throttling can't starve it:
// rAF (smooth when focused), video timeupdate (fires while playing even // rAF (smooth when focused), video timeupdate (fires while playing even
// unfocused), and a 100ms interval backup. // unfocused), and a 100ms interval backup.
const flip = (d) => (G.mirror && MIRROR[d]) || d;
function qteCheck() { function qteCheck() {
if (G.state !== "PLAYING") return; if (G.state !== "PLAYING") return;
const t = vid().currentTime; const t = vid().currentTime;
const w = G.windows[G.wi]; const w = G.windows[G.wi];
if (w) { if (w) {
if (!w.cued && w.cue && t >= w.t[0] - CUE_LEAD) { w.cued = true; cueShow(w.input); } if (!w.cued && w.cue && t >= w.t[0] - CUE_LEAD) { w.cued = true; cueShow(w); }
if (t >= w.t[1]) { w.done = "failed"; fail("too late"); } 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); if (G.debug) debugTick(t);
} }
@ -171,18 +271,54 @@ function input(dir) {
if (G.state === "ATTRACT") return startGame(); if (G.state === "ATTRACT") return startGame();
if (G.state === "END") return toAttract(); if (G.state === "END") return toAttract();
if (G.state !== "PLAYING") return; 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]; const w = G.windows[G.wi];
if (!w) return; // windows all cleared, clip finishing if (!w) return; // windows all cleared, clip finishing
const t = vid().currentTime; 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] - EARLY_GRACE) return; // idle fidget: ignore
if (t < w.t[0]) { w.done = "failed"; return fail("too early"); } // Dragon's Lair rule 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"); } 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 // pass
w.done = "passed"; G.wi++; G.score += 100; hud(); w.done = "passed"; G.wi++; G.score += 100; hud();
cueGood(dir); cueGood(dir);
if (G.debug) debugScene(); 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 ---------- // ---------- input sources ----------
function onKey(e) { function onKey(e) {
if (e.repeat) return; if (e.repeat) return;
@ -193,14 +329,19 @@ function onKey(e) {
} }
function initTouch() { 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; let sx, sy, st;
addEventListener("touchstart", (e) => { addEventListener("touchstart", (e) => {
sx = e.touches[0].clientX; sy = e.touches[0].clientY; st = Date.now(); sx = e.touches[0].clientX; sy = e.touches[0].clientY; st = Date.now();
}, { passive: true }); }, { passive: true });
addEventListener("touchend", (e) => { addEventListener("touchend", (e) => {
const dx = e.changedTouches[0].clientX - sx, dy = e.changedTouches[0].clientY - sy; 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 let d;
input(Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? "right" : "left") : (dy > 0 ? "down" : "up")); 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 }); }, { passive: true });
} }
@ -208,10 +349,23 @@ function initTouch() {
function hud() { function hud() {
$("lives").textContent = "●".repeat(Math.max(0, G.lives)); $("lives").textContent = "●".repeat(Math.max(0, G.lives));
$("score").textContent = G.score; $("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 cueShow(dir) { const c = $("cue"); c.textContent = GLYPH[dir]; c.className = "show"; }
function cueHide() { $("cue").className = ""; } function cueHide() { $("cue").className = ""; }
function cueGood(dir) { const c = $("cue"); c.textContent = GLYPH[dir]; c.className = "good"; } 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) ---------- // ---------- debug mode (?debug=1) ----------
function initDebug() { function initDebug() {

View File

@ -1,89 +1,167 @@
{ {
"meta": { "meta": { "title": "RECORD STORE GUY", "lives": 5, "version": 4 },
"title": "RECORD STORE GUY", "start": "intro",
"lives": 5,
"version": 3
},
"start": "s00",
"scenes": { "scenes": {
"s01": {
"clip": "clips/s01_action.mp4", "intro": { "clip": "clips/intro.mp4", "death": "clips/t1s1_death.mp4",
"death": "clips/s01_death.mp4", "windows": [], "onSuccess": "t1s1" },
"windows": [
{ "t1s1": { "clip": "clips/t1s1_action.mp4", "death": "clips/t1s1_death.mp4",
"t": [ "windows": [ { "t": [4.6, 5.4], "input": "left" } ],
4.6, "onSuccess": "t1s2", "checkpoint": true },
5.4 "t1s2": { "clip": "clips/t1s2_action.mp4", "death": "clips/t1s2_death.mp4",
], "collect": { "t": [2.0, 2.5], "input": "up", "label": 1 },
"input": "left", "windows": [ { "t": [4.2, 5.0], "input": "up" } ],
"cue": true "onSuccess": "t1s3" },
} "t1s3": { "clip": "clips/t1s3_action.mp4", "death": "clips/t1s3_death.mp4",
], "windows": [ { "t": [3.0, 3.7], "input": "right" } ],
"onSuccess": "s02", "onSuccess": "t1s4" },
"checkpoint": true "t1s4": { "clip": "clips/t1s4_action.mp4", "death": "clips/t1s4_death.mp4",
}, "windows": [ { "t": [3.0, 3.7], "input": "down" } ],
"s02": { "onSuccess": "sh1" },
"clip": "clips/s02_action.mp4",
"death": "clips/s02_death.mp4", "sh1": { "clip": "clips/sh1_action.mp4", "death": "clips/sh1_death_pit.mp4",
"windows": [ "windows": [ { "t": [6.0, 7.5], "choices": {
{ "left": { "goto": "t2s1" },
"t": [ "up": { "death": "clips/sh1_death_pit.mp4", "goto": "mf1" },
4.2, "right": { "death": "clips/sh1_death_pit.mp4", "goto": "mf1" }
5.0 } } ],
], "onSuccess": "t2s1" },
"input": "up", "mf1": { "clip": "clips/mf1_action.mp4", "death": "clips/mf1_death.mp4",
"cue": true "windows": [ { "t": [3.0, 3.7], "input": "down" } ],
} "onSuccess": "sh1" },
],
"onSuccess": "s03", "t2s1": { "clip": "clips/t2s1_action.mp4", "death": "clips/t2s1_death.mp4",
"checkpoint": false "windows": [ { "t": [3.0, 3.7], "input": "up" } ],
}, "onSuccess": "t2s2", "checkpoint": true },
"s03": { "t2s2": { "clip": "clips/t2s2_action.mp4", "death": "clips/t2s2_death.mp4", "mirrorOk": true,
"clip": "clips/s03_action.mp4", "windows": [ { "t": [2.2, 2.9], "input": "right" }, { "t": [3.6, 4.3], "input": "right" } ],
"death": "clips/s03_death.mp4", "onSuccess": "t2s3" },
"windows": [ "t2s3": { "clip": "clips/t2s3_action.mp4", "death": "clips/t2s3_death.mp4",
{ "collect": { "t": [1.2, 1.7], "input": "up", "label": 2 },
"t": [ "windows": [ { "t": [2.4, 3.1], "input": "left" }, { "t": [4.6, 5.3], "input": "up" } ],
2.4, "onSuccess": "t2s4" },
3.1 "t2s4": { "clip": "clips/t2s4_action.mp4", "death": "clips/t2s4_death.mp4",
], "needledrop": { "t": [1.5, 2.1], "clip": "clips/t2s4_alt.mp4", "bonus": 500 },
"input": "left", "windows": [ { "t": [3.2, 3.9], "input": "action" } ],
"cue": true "onSuccess": "t2s5" },
}, "t2s5": { "clip": "clips/t2s5_action.mp4", "death": "clips/t2s5_death.mp4",
{ "windows": [ { "t": [2.5, 3.2], "input": "right" }, { "t": [3.8, 4.5], "input": "action" } ],
"t": [ "onSuccess": "sh2" },
4.6,
5.3 "sh2": { "random": ["sh2_thin", "sh2_thick"] },
], "sh2_thin": { "clip": "clips/sh2_action_thin.mp4", "death": "clips/sh2_death.mp4",
"input": "up", "windows": [ { "t": [3.0, 4.5], "choices": {
"cue": true "left": { "goto": "t3s1" },
} "right": { "death": "clips/sh2_death.mp4" }
], } } ],
"onSuccess": "s04", "onSuccess": "t3s1" },
"checkpoint": false "sh2_thick": { "clip": "clips/sh2_action_thick.mp4", "death": "clips/sh2_death.mp4",
}, "windows": [ { "t": [3.0, 4.5], "choices": {
"s00": { "right": { "goto": "t3s1" },
"clip": "clips/intro.mp4", "left": { "death": "clips/sh2_death.mp4" }
"death": "clips/s01_death.mp4", } } ],
"windows": [], "onSuccess": "t3s1" },
"onSuccess": "s01",
"checkpoint": false "t3s1": { "clip": "clips/t3s1_action.mp4", "death": "clips/t3s1_death.mp4",
}, "windows": [ { "t": [1.8, 2.4], "input": "right" }, { "t": [2.8, 3.4], "input": "right" }, { "t": [3.8, 4.4], "input": "up" } ],
"s04": { "onSuccess": "t3s2", "checkpoint": true },
"clip": "clips/s04_action.mp4", "t3s2": { "clip": "clips/t3s2_action.mp4", "death": "clips/t3s2_death.mp4",
"death": "clips/s04_death.mp4", "windows": [ { "t": [3.0, 3.7], "input": "left" } ],
"windows": [ "onSuccess": "t3s3" },
{ "t3s3": { "clip": "clips/t3s3_action.mp4", "death": "clips/t3s3_death.mp4", "mirrorOk": true,
"t": [ "windows": [ { "t": [2.2, 2.9], "input": "down" }, { "t": [3.6, 4.3], "input": "action" } ],
6.0, "onSuccess": "t3s4" },
7.2 "t3s4": { "clip": "clips/t3s4_action.mp4", "death": "clips/t3s4_death.mp4",
], "needledrop": { "t": [0.8, 1.4], "clip": "clips/t3s4_alt.mp4", "bonus": 500, "label": 3 },
"input": "left", "windows": [ { "t": [1.8, 2.4], "input": "up" }, { "t": [2.8, 3.4], "input": "right" }, { "t": [3.8, 4.4], "input": "action" } ],
"cue": true "onSuccess": "sh3" },
}
], "sh3": { "clip": "clips/sh3_action.mp4", "death": "clips/sh3_death_post.mp4",
"onSuccess": "WIN", "windows": [ { "t": [3.0, 4.5], "choices": {
"checkpoint": false "left": { "death": "clips/sh3_death_acct.mp4" },
} "up": { "goto": "t4s1" },
"right": { "death": "clips/sh3_death_post.mp4" }
} } ],
"onSuccess": "t4s1" },
"t4s1": { "clip": "clips/t4s1_action.mp4", "death": "clips/t4s1_death.mp4",
"windows": [ { "t": [3.0, 3.7], "input": "action" } ],
"onSuccess": "t4s2", "checkpoint": true },
"t4s2": { "clip": "clips/t4s2_action.mp4", "death": "clips/t4s2_death.mp4", "mirrorOk": true,
"windows": [ { "t": [2.2, 2.9], "input": "right" }, { "t": [3.4, 4.1], "input": "up" } ],
"onSuccess": "t4s3" },
"t4s3": { "clip": "clips/t4s3_action.mp4", "death": "clips/t4s3_death.mp4", "mirrorOk": true,
"windows": [ { "t": [1.8, 2.4], "input": "left" }, { "t": [2.8, 3.4], "input": "right" }, { "t": [3.8, 4.4], "input": "left" } ],
"onSuccess": "t4s4" },
"t4s4": { "clip": "clips/t4s4_action.mp4", "death": "clips/t4s4_death.mp4",
"collect": { "t": [1.5, 2.0], "input": "up", "label": 4 },
"windows": [ { "t": [3.4, 3.9], "input": "action" } ],
"onSuccess": "t4s5" },
"t4s5": { "clip": "clips/t4s5_action.mp4", "death": "clips/t4s5_death.mp4",
"windows": [ { "t": [1.5, 3.5], "input": "down", "hold": true }, { "t": [4.0, 4.7], "input": "action" } ],
"onSuccess": "sh4" },
"sh4": { "clip": "clips/sh4_action.mp4", "death": "clips/sh4_death.mp4",
"onMiss": "restart", "maxRestarts": 3,
"windows": [ { "t": [1.2, 1.9], "input": "down" }, { "t": [2.2, 2.9], "input": "down" }, { "t": [3.2, 3.9], "input": "down" }, { "t": [4.2, 4.9], "input": "down" } ],
"onSuccess": "t5s1" },
"t5s1": { "clip": "clips/t5s1_action.mp4", "death": "clips/t5s1_death.mp4",
"windows": [ { "t": [2.0, 2.6], "input": "right" }, { "t": [3.4, 4.0], "input": "right" } ],
"onSuccess": "t5s2", "checkpoint": true },
"t5s2": { "clip": "clips/t5s2_action.mp4", "death": "clips/t5s2_death.mp4",
"needledrop": { "t": [0.8, 1.4], "clip": "clips/t5s2_alt.mp4", "bonus": 500 },
"windows": [ { "t": [1.8, 2.4], "input": "left" }, { "t": [2.8, 3.4], "input": "action" }, { "t": [3.8, 4.4], "input": "right" } ],
"onSuccess": "t5s3" },
"t5s3": { "clip": "clips/t5s3_action.mp4", "death": "clips/t5s3_death.mp4",
"collect": { "t": [1.0, 1.5], "input": "up", "label": 5 },
"windows": [ { "t": [2.4, 3.0], "input": "up" }, { "t": [3.6, 4.2], "input": "up" } ],
"onSuccess": "t5s4" },
"t5s4": { "clip": "clips/t5s4_action.mp4", "failGoto": "t5s1",
"windows": [ { "t": [3.2, 3.9], "input": "action" } ],
"onSuccess": "t5s5" },
"t5s5": { "clip": "clips/t5s5_action.mp4", "death": "clips/t5s5_death.mp4",
"windows": [ { "t": [1.5, 3.2], "input": "right", "hold": true }, { "t": [3.8, 4.5], "input": "up" } ],
"onSuccess": "sh5" },
"sh5": { "clip": "clips/sh5_action.mp4", "death": "clips/sh5_death.mp4",
"windows": [ { "t": [3.0, 4.5], "choices": {
"up": { "death": "clips/sh5_death.mp4" },
"down": { "goto": "t6s1" },
"right": { "goto": "sh5_bonus" }
} } ],
"onSuccess": "t6s1" },
"sh5_bonus": { "clip": "clips/sh5_bonus.mp4", "death": "clips/sh5_death.mp4",
"windows": [], "score": 1000, "label": 7, "onSuccess": "t6s1" },
"t6s1": { "clip": "clips/t6s1_action.mp4", "death": "clips/t6s1_death.mp4",
"needledrop": { "t": [0.8, 1.4], "clip": "clips/t6s1_alt.mp4", "bonus": 500, "skipTo": "t6s3" },
"windows": [ { "t": [1.8, 2.4], "input": "up" }, { "t": [2.8, 3.4], "input": "right" }, { "t": [3.8, 4.4], "input": "up" } ],
"onSuccess": "t6s2", "checkpoint": true },
"t6s2": { "clip": "clips/t6s2_action.mp4", "death": "clips/t6s2_death.mp4",
"collect": { "t": [1.2, 1.7], "input": "up", "label": 6 },
"windows": [ { "t": [3.0, 3.7], "input": "down" } ],
"onSuccess": "t6s3" },
"t6s3": { "clip": "clips/t6s3_action.mp4", "death": "clips/t6s3_death.mp4", "mirrorOk": true,
"windows": [ { "t": [3.2, 3.9], "input": "right" } ],
"onSuccess": "t6s4" },
"t6s4": { "clip": "clips/t6s4_action.mp4", "death": "clips/t6s4_death.mp4",
"windows": [ { "t": [3.4, 4.1], "input": "action" } ],
"onSuccess": "sh6" },
"sh6": { "clip": "clips/sh6_action.mp4", "death": "clips/sh6_death.mp4",
"windows": [ { "t": [1.4, 2.0], "input": "down" }, { "t": [2.4, 3.0], "input": "left" }, { "t": [3.4, 4.0], "input": "right" }, { "t": [4.4, 5.0], "input": "action" } ],
"onSuccess": "REPLAY" },
"t7s1": { "clip": "clips/t7s1_action.mp4", "death": "clips/t7s1_death.mp4",
"windows": [ { "t": [1.6, 2.2], "input": "left" }, { "t": [2.6, 3.2], "input": "down" }, { "t": [3.6, 4.2], "input": "right" } ],
"onSuccess": "t7s2", "checkpoint": true },
"t7s2": { "clip": "clips/t7s2_action.mp4", "death": "clips/t7s2_death.mp4",
"windows": [ { "t": [1.2, 1.7], "input": "right" }, { "t": [2.2, 2.7], "input": "up" }, { "t": [3.2, 3.7], "input": "left" }, { "t": [4.2, 4.7], "input": "action" } ],
"onSuccess": "t7s3" },
"t7s3": { "clip": "clips/t7s3_action.mp4", "death": "clips/t7s3_death.mp4",
"windows": [ { "t": [3.5, 3.9], "input": "action" } ],
"onSuccess": "WIN" }
} }
} }

View File

@ -6,6 +6,7 @@ video { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%
max-width: 100vw; max-height: 100vh; aspect-ratio: 16/9; width: 100%; max-width: 100vw; max-height: 100vh; aspect-ratio: 16/9; width: 100%;
opacity: 0; pointer-events: none; } opacity: 0; pointer-events: none; }
video.active { opacity: 1; } video.active { opacity: 1; }
video.mirror { transform: translate(-50%, -50%) scaleX(-1); }
#scanlines { position: absolute; inset: 0; pointer-events: none; z-index: 5; #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); background: repeating-linear-gradient(to bottom, rgba(0,0,0,0) 0 2px, rgba(0,0,0,.22) 2px 4px);
@ -17,6 +18,18 @@ video.active { opacity: 1; }
text-shadow: 0 0 2vmin #0ff, 0 0 6vmin #08f; pointer-events: none; } 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.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; } #cue.good { color: #0f4; text-shadow: 0 0 2vmin #0f4; animation: pop .25s ease-out; }
#cue.hold { color: #fa0; text-shadow: 0 0 2vmin #fa0, 0 0 6vmin #f60; }
/* needle-drop flash (optional power-up prompt) */
#nd { position: absolute; top: 14%; right: 8%; font-size: 8vmin; color: #f0f; z-index: 10;
opacity: 0; text-shadow: 0 0 2vmin #f0f, 0 0 6vmin #90f; pointer-events: none; }
#nd.show { opacity: 1; animation: pulse .18s ease-in-out infinite alternate; }
/* white-label toast */
#toast { position: absolute; top: 24%; left: 50%; transform: translateX(-50%);
font-size: 4vmin; color: #fff; z-index: 10; opacity: 0; letter-spacing: .5vmin;
text-shadow: 0 0 2vmin #fff; pointer-events: none; transition: opacity .3s; }
#toast.show { opacity: 1; }
@keyframes pulse { from { transform: translateX(-50%) scale(1); } to { transform: translateX(-50%) scale(1.18); } } @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; } } @keyframes pop { from { transform: translateX(-50%) scale(1.4); opacity: 1; } to { transform: translateX(-50%) scale(1); opacity: 0; } }
@ -25,6 +38,7 @@ video.active { opacity: 1; }
justify-content: space-between; padding: 0 3vmin; z-index: 10; pointer-events: none; } 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; } #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; } #score { color: #ff0; font-size: 4vmin; text-shadow: 0 0 1.5vmin #f80; }
#labels { color: #fff; font-size: 3vmin; letter-spacing: .5vmin; text-shadow: 0 0 1vmin #fff; }
/* full screens */ /* full screens */
.screen { position: absolute; inset: 0; z-index: 20; display: flex; flex-direction: column; .screen { position: absolute; inset: 0; z-index: 20; display: flex; flex-direction: column;

View File

@ -21,6 +21,9 @@ Inside the cluttered record store in the daytime, warm light, checkerboard floor
## sh3_death_acct ⭐QUALITY [attach: Dez, The Accountant] ## sh3_death_acct ⭐QUALITY [attach: Dez, The Accountant]
Inside the cluttered record store in the daytime, warm light, checkerboard floor. The Accountant rises vertically through the checkerboard floor beside the counter like a surfacing submarine, adjusts his glasses with one finger, opens his ledger, taps a single line, and Dez folds neatly in half like a closed book and files himself under the counter. The clip ends held on the Accountant sinking back down. Static wide shot. 1980s hand-drawn cel animation, thick ink outlines, painterly gouache backgrounds, film grain. No text, no captions, no watermark; all signs, posters and record sleeves are blank or abstract designs with no lettering. Inside the cluttered record store in the daytime, warm light, checkerboard floor. The Accountant rises vertically through the checkerboard floor beside the counter like a surfacing submarine, adjusts his glasses with one finger, opens his ledger, taps a single line, and Dez folds neatly in half like a closed book and files himself under the counter. The clip ends held on the Accountant sinking back down. Static wide shot. 1980s hand-drawn cel animation, thick ink outlines, painterly gouache backgrounds, film grain. No text, no captions, no watermark; all signs, posters and record sleeves are blank or abstract designs with no lettering.
## sh4_action [attach: Dez, The Boring Guy]
Inside the cluttered record store in the daytime, warm light, checkerboard floor. The Boring Guy stands at the counter in beige clothes and a lanyard, talking steadily at Dez with small emphatic hand gestures; four times he pauses and leans in expectantly, and Dez nods; the light through the window shifts slightly as if hours pass. Static two-shot across the counter. 1980s hand-drawn cel animation, thick ink outlines, painterly gouache backgrounds, film grain. No text, no captions, no watermark; all signs, posters and record sleeves are blank or abstract designs with no lettering.
## sh4_death [attach: Dez, The Boring Guy] ## sh4_death [attach: Dez, The Boring Guy]
Inside the cluttered record store in the daytime, warm light, checkerboard floor. Time-lapse: the Boring Guy talks on at the counter as light sweeps day to night to day; cobwebs grow over Dez behind the counter, seasons flicker past the window, and Dez is finally a cheerful skeleton in a red flannel shirt still nodding. The clip ends held on the nodding skeleton. Static two-shot. 1980s hand-drawn cel animation, thick ink outlines, painterly gouache backgrounds, film grain. No text, no captions, no watermark; all signs, posters and record sleeves are blank or abstract designs with no lettering. Inside the cluttered record store in the daytime, warm light, checkerboard floor. Time-lapse: the Boring Guy talks on at the counter as light sweeps day to night to day; cobwebs grow over Dez behind the counter, seasons flicker past the window, and Dez is finally a cheerful skeleton in a red flannel shirt still nodding. The clip ends held on the nodding skeleton. Static two-shot. 1980s hand-drawn cel animation, thick ink outlines, painterly gouache backgrounds, film grain. No text, no captions, no watermark; all signs, posters and record sleeves are blank or abstract designs with no lettering.

View File

@ -24,6 +24,10 @@ Inside a computerized hi-fi system rendered as a neon wireframe world, glowing c
## t5s3_death [attach: Dez] ## t5s3_death [attach: Dez]
Inside a computerized hi-fi system rendered as a neon wireframe world, glowing cyan and magenta grid floor like a giant equalizer, circuit-board skyscrapers, records as glowing light-discs, black sky with waveform aurora. The deletion wall catches Dez mid-stride and erases him from the feet upward, leaving only a glowing wireframe outline of his running pose that flickers and vanishes. The clip ends held on the empty corridor. Static wide shot. 1980s hand-drawn cel animation, thick ink outlines, painterly gouache backgrounds, film grain. No text, no captions, no watermark; all signs, posters and record sleeves are blank or abstract designs with no lettering. Inside a computerized hi-fi system rendered as a neon wireframe world, glowing cyan and magenta grid floor like a giant equalizer, circuit-board skyscrapers, records as glowing light-discs, black sky with waveform aurora. The deletion wall catches Dez mid-stride and erases him from the feet upward, leaving only a glowing wireframe outline of his running pose that flickers and vanishes. The clip ends held on the empty corridor. Static wide shot. 1980s hand-drawn cel animation, thick ink outlines, painterly gouache backgrounds, film grain. No text, no captions, no watermark; all signs, posters and record sleeves are blank or abstract designs with no lettering.
## t5s4_action [attach: Dez]
(No death clip — failing this gate spits Dez back to t5s1, no life lost.)
Inside a computerized hi-fi system rendered as a neon wireframe world, glowing cyan and magenta grid floor like a giant equalizer, circuit-board skyscrapers, records as glowing light-discs, black sky with waveform aurora. A colossal spinning record platter blocks the corridor like a vault door, a single gap in its groove sweeping around; Dez crouches, waits, and dives through as the gap aligns, the platter slamming shut behind his heels. Static wide shot facing the gate. 1980s hand-drawn cel animation, thick ink outlines, painterly gouache backgrounds, film grain. No text, no captions, no watermark; all signs, posters and record sleeves are blank or abstract designs with no lettering.
## t5s5_action [attach: Dez] ## t5s5_action [attach: Dez]
Inside a computerized hi-fi system rendered as a neon wireframe world, glowing cyan and magenta grid floor like a giant equalizer, circuit-board skyscrapers, records as glowing light-discs, black sky with waveform aurora. Dez escapes on a light-cycle that is unmistakably a turntable with wheels, trailing a ribbon of neon light, and launches off a glowing ramp. Fast tracking shot alongside. 1980s hand-drawn cel animation, thick ink outlines, painterly gouache backgrounds, film grain. No text, no captions, no watermark; all signs, posters and record sleeves are blank or abstract designs with no lettering. Inside a computerized hi-fi system rendered as a neon wireframe world, glowing cyan and magenta grid floor like a giant equalizer, circuit-board skyscrapers, records as glowing light-discs, black sky with waveform aurora. Dez escapes on a light-cycle that is unmistakably a turntable with wheels, trailing a ribbon of neon light, and launches off a glowing ramp. Fast tracking shot alongside. 1980s hand-drawn cel animation, thick ink outlines, painterly gouache backgrounds, film grain. No text, no captions, no watermark; all signs, posters and record sleeves are blank or abstract designs with no lettering.

View File

@ -1,12 +1,14 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Generate placeholder clips for every scene in engine/scenes.json (plus """Generate placeholder clips for every clip referenced by engine/scenes.json
attract/win/gameover) so the whole game is playable before any Flow credits are (actions, deaths, choice deaths, needle-drop alts, attract/win/gameover/
spent. Text is rendered to PNG cards with Pillow (this machine's ffmpeg lacks needle_skip/stinger) so the whole game is playable before any Flow credits are
drawtext); ffmpeg composites flash + glyph overlays + a 1kHz beep at exactly each spent. Existing files are SKIPPED real Flow footage is never overwritten.
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 Text is rendered to PNG cards with Pillow (this machine's ffmpeg lacks
venv python that has it.""" drawtext); ffmpeg composites flash + glyph overlays + a 1kHz beep at exactly
each QTE window so timing is verifiable by eye/ear. Choice windows show all
option glyphs; hold windows show HOLD; needle-drop flashes magenta top-right;
white-label collects flash white top-left. Deaths: red 'DEATH' cards."""
import glob import glob
import json import json
import os import os
@ -25,7 +27,6 @@ except ModuleNotFoundError:
ROOT = Path(__file__).resolve().parent.parent / "engine" ROOT = Path(__file__).resolve().parent.parent / "engine"
CLIPS = ROOT / "clips" CLIPS = ROOT / "clips"
DUR = 5.0
COLORS = ["#1a2a55", "#1f4d2a", "#4d3a1f", "#3a1f4d", "#1f4d4d"] COLORS = ["#1a2a55", "#1f4d2a", "#4d3a1f", "#3a1f4d", "#1f4d4d"]
GLYPH = {"left": "<<<", "right": ">>>", "up": "^^^", "down": "vvv", "action": "(GO)"} GLYPH = {"left": "<<<", "right": ">>>", "up": "^^^", "down": "vvv", "action": "(GO)"}
FONTS = ["/System/Library/Fonts/Supplemental/Arial Bold.ttf", FONTS = ["/System/Library/Fonts/Supplemental/Arial Bold.ttf",
@ -42,30 +43,43 @@ def png_card(path, color, text, size=110, fg="white"):
img.save(path) img.save(path)
def png_glyph(path, text): def png_glyph(path, text, size=200, fill="#ffee00"):
img = Image.new("RGBA", (W, H), (0, 0, 0, 0)) img = Image.new("RGBA", (W, H), (0, 0, 0, 0))
d = ImageDraw.Draw(img) d = ImageDraw.Draw(img)
f = ImageFont.truetype(FONT, 200) if FONT else ImageFont.load_default() f = ImageFont.truetype(FONT, size) if FONT else ImageFont.load_default()
d.text((W / 2, H - 220), text, font=f, fill="#ffee00", anchor="mm") d.text((W / 2, H - 220), text, font=f, fill=fill, anchor="mm")
img.save(path) img.save(path)
def clip(out, base_png, dur, windows=(), tmp=None): def win_glyph(w):
"""base card + per-window white flash, glyph overlay, and beep.""" if "choices" in w:
return " ".join(GLYPH[d] for d in w["choices"])
g = GLYPH[w["input"]]
return g + " HOLD" if w.get("hold") else g
def clip(out, base_png, dur, windows=(), flashes=(), tmp=None):
"""base card + per-window white flash, glyph overlay, beep; extra flashes
are (t0,t1,color) boxes (needle-drop magenta / white-label white)."""
args = ["ffmpeg", "-y", "-loglevel", "error", args = ["ffmpeg", "-y", "-loglevel", "error",
"-loop", "1", "-t", str(dur), "-framerate", "24", "-i", str(base_png), "-loop", "1", "-t", str(dur), "-framerate", "24", "-i", str(base_png),
"-f", "lavfi", "-i", f"sine=frequency=1000:sample_rate=44100:duration={dur}"] "-f", "lavfi", "-i", f"sine=frequency=1000:sample_rate=44100:duration={dur}"]
fc, last, vol = [], "0:v", "0" fc, last, vol, vi = [], "0:v", "0", 2
for i, w in enumerate(windows): for i, w in enumerate(windows):
t0, t1 = w["t"] t0, t1 = w["t"]
g = Path(tmp) / f"g{i}.png" g = Path(tmp) / f"{out.stem}_g{i}.png"
png_glyph(g, GLYPH[w["input"]]) png_glyph(g, win_glyph(w), size=140 if "choices" in w or w.get("hold") else 200)
args += ["-loop", "1", "-t", str(dur), "-framerate", "24", "-i", str(g)] args += ["-loop", "1", "-t", str(dur), "-framerate", "24", "-i", str(g)]
nxt = f"v{i}" nxt = f"v{i}"
fc.append(f"[{last}]drawbox=t=fill:color=white@0.35:enable='between(t,{t0},{t0 + 0.12})'" 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}]") f"[f{i}];[f{i}][{vi}:v]overlay=enable='between(t,{t0},{t1})'[{nxt}]")
last = nxt last, vi = nxt, vi + 1
vol = f"if(between(t,{t0},{t1}),1,{vol})" vol = f"if(between(t,{t0},{t1}),1,{vol})"
for j, (t0, t1, color, y) in enumerate(flashes):
nxt = f"x{j}"
fc.append(f"[{last}]drawbox=x={W - 260 if y == 'tr' else 40}:y=40:w=220:h=120:"
f"t=fill:color={color}@0.8:enable='between(t,{t0},{t1})'[{nxt}]")
last = nxt
fc.append(f"[{last}]format=yuv420p[vout]") fc.append(f"[{last}]format=yuv420p[vout]")
args += ["-filter_complex", ";".join(fc), "-map", "[vout]", "-map", "1:a", args += ["-filter_complex", ";".join(fc), "-map", "[vout]", "-map", "1:a",
"-af", f"volume='{vol}':eval=frame", "-af", f"volume='{vol}':eval=frame",
@ -78,18 +92,45 @@ def clip(out, base_png, dur, windows=(), tmp=None):
def main(): def main():
CLIPS.mkdir(exist_ok=True) CLIPS.mkdir(exist_ok=True)
data = json.loads((ROOT / "scenes.json").read_text()) data = json.loads((ROOT / "scenes.json").read_text())
deaths, alts = set(), set()
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
def make(name, color, text, dur, windows=()): def make(name, color, text, dur, windows=(), flashes=()):
out = CLIPS / name
if out.exists():
return print("skip (exists)", name)
base = Path(tmp) / (Path(name).stem + ".png") base = Path(tmp) / (Path(name).stem + ".png")
png_card(base, color, text) png_card(base, color, text)
clip(CLIPS / name, base, dur, windows, tmp) clip(out, base, dur, windows, flashes, tmp)
for i, (sid, s) in enumerate(data["scenes"].items()): for i, (sid, s) in enumerate(data["scenes"].items()):
make(Path(s["clip"]).name, COLORS[i % len(COLORS)], sid.upper(), DUR, s["windows"]) if "random" in s:
make(Path(s["death"]).name, "#550000", f"DEATH {sid.upper()}", 2.5) continue # variants carry their own clips
windows = s.get("windows", [])
dur = max([5.0] + [w["t"][1] + 0.8 for w in windows])
flashes = []
if "needledrop" in s:
nd = s["needledrop"]
flashes.append((nd["t"][0], nd["t"][1], "magenta", "tr"))
alts.add(nd["clip"])
if "collect" in s:
c = s["collect"]
flashes.append((c["t"][0], c["t"][1], "white", "tl"))
make(Path(s["clip"]).name, COLORS[i % len(COLORS)], sid.upper(), dur, windows, flashes)
if s.get("death"):
deaths.add(s["death"])
for w in windows:
for c in w.get("choices", {}).values():
if "death" in c:
deaths.add(c["death"])
for d in sorted(deaths):
make(Path(d).name, "#550000", f"DEATH {Path(d).stem.upper()}", 2.5)
for a in sorted(alts):
make(Path(a).name, "#0a3a5a", Path(a).stem.upper() + " !", 5.0)
make("attract.mp4", "#111111", "RECORD STORE GUY", 4.0) make("attract.mp4", "#111111", "RECORD STORE GUY", 4.0)
make("win.mp4", "#004400", "YOU SURVIVED", 4.0) make("win.mp4", "#004400", "YOU SURVIVED", 4.0)
make("gameover.mp4", "#220022", "GAME OVER", 3.0) make("gameover.mp4", "#220022", "GAME OVER", 3.0)
make("needle_skip.mp4", "#000000", "~ skip ~", 1.5)
make("stinger.mp4", "#2a1a00", "STINGER: MILDRED", 4.0)
if __name__ == "__main__": if __name__ == "__main__":