diff --git a/.claude/launch.json b/.claude/launch.json index d3a7312..2694c06 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -4,8 +4,8 @@ { "name": "rsg-engine", "runtimeExecutable": "python3", - "runtimeArgs": ["-m", "http.server", "8123", "-d", "engine"], - "port": 8123 + "runtimeArgs": ["-m", "http.server", "8321", "-d", "engine"], + "port": 8321 } ] } diff --git a/ENGINE.md b/ENGINE.md index 73dcd03..06c3357 100644 --- a/ENGINE.md +++ b/ENGINE.md @@ -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). Choice windows default to longer durations (1.5s) — they're decisions, not reflexes. -## v2 mechanics (spec'd in DESIGN.md — build after Phase 3) -- `mirror: true` scene flag: CSS `transform: scaleX(-1)` on the video + swap - left/right in input handling and cues. Free content doubling (Dragon's Lair trick). -- Replay queue: scenes died-in (cap 8) replay before the final track. -- Difficulty tiers: easy = skip scenes flagged `skippableOnEasy` + windows +0.2s; - hard = `cue:false` everywhere after first visit. -- `needledrop: {t, clip, bonus}` on a scene: optional action input during a flash - window branches to an alternate clip; ignoring it is not a fail. -- `collect: {t, input}` on a scene: unprompted input during a background flash sets - a flag (white labels); wrong unprompted inputs outside windows stay ignored. -- Endurance windows: `onMiss: "restart"` (replay clip, tighter windows), `maxRestarts`. -- Hold windows: `hold: true` — input must be held from window open to close. +## v2 mechanics (BUILT 2026-07-20 — as implemented in player.js) +- `mirrorOk: true` scene flag: scene is mirror-eligible. The engine mirrors it + (CSS scaleX(-1) + left/right swap in inputs and cues) when it comes back in the + replay queue — free variety, "the Dead Wax remembers... backwards". +- Replay queue: `onSuccess: "REPLAY"` (on sh6) queues died-in scenes (unique, + cap 8, choice/random scenes excluded), needle_skip.mp4 transition between each, + then t7s1. Needledrop/collect are disabled during replays. +- `needledrop: {t, clip, bonus, label?, skipTo?}` on a scene: optional action + input during the flash branches to the alt clip; ignoring it is not a fail. + `label` grants a white label, `skipTo` overrides onSuccess (t6s1 alt skips t6s2). +- `collect: {t, input, label}` on a scene: unprompted input during a background + 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) 1. **Boot**: fetch scenes.json → preload manifest. diff --git a/engine/index.html b/engine/index.html index ba185ce..ecca937 100644 --- a/engine/index.html +++ b/engine/index.html @@ -12,9 +12,12 @@
+
◉♪
+
0
+

RECORD STORE GUY

diff --git a/engine/player.js b/engine/player.js index 50c6f7d..e9d8db0 100644 --- a/engine/player.js +++ b/engine/player.js @@ -6,18 +6,25 @@ 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, + 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 }; @@ -27,15 +34,22 @@ 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); + 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); @@ -57,6 +71,7 @@ function playClip(url, onEnded) { 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; @@ -66,6 +81,7 @@ 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; @@ -74,27 +90,44 @@ function attractPlay() { // looping mood video behind the title screen 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) { - const s = G.data.scenes[id]; - G.state = "PLAYING"; G.sceneId = id; G.scene = s; G.wi = 0; - if (s.checkpoint) G.checkpoint = id; +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 : [w.t[0], w.t[0] + WINDOW_DEFAULT], - input: w.input, cue: w.cue !== false, cued: false, done: false, + 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") { preload(G.data.scenes[n].clip); preload(G.data.scenes[n].death); } - else preload("clips/win.mp4"); + 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(); } @@ -102,33 +135,87 @@ function playScene(id) { 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); + sceneCleared(); } -function fail(why) { - if (G.state !== "PLAYING") return; - G.state = "DEATH"; +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(G.scene.death, () => { + 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(G.sceneId); // retry the scene (checkpoints reserved for act restarts) + playScene(gotoAfter || G.retryId); // retry (or branch: misfile dimension) }, DEATH_HOLD); }); } function win() { G.state = "WIN"; + G.mirror = false; 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() { G.state = "GAMEOVER"; + G.mirror = false; saveHi(); 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: // 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.input); } - if (t >= w.t[1]) { w.done = "failed"; fail("too late"); } + 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); } @@ -171,18 +271,54 @@ 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 - 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]) { 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 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; @@ -193,14 +329,19 @@ function onKey(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; - 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")); + 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 }); } @@ -208,10 +349,23 @@ function initTouch() { 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 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"; } +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() { diff --git a/engine/scenes.json b/engine/scenes.json index e0d75fd..0a52ff4 100644 --- a/engine/scenes.json +++ b/engine/scenes.json @@ -1,89 +1,167 @@ { - "meta": { - "title": "RECORD STORE GUY", - "lives": 5, - "version": 3 - }, - "start": "s00", + "meta": { "title": "RECORD STORE GUY", "lives": 5, "version": 4 }, + "start": "intro", "scenes": { - "s01": { - "clip": "clips/s01_action.mp4", - "death": "clips/s01_death.mp4", - "windows": [ - { - "t": [ - 4.6, - 5.4 - ], - "input": "left", - "cue": true - } - ], - "onSuccess": "s02", - "checkpoint": true - }, - "s02": { - "clip": "clips/s02_action.mp4", - "death": "clips/s02_death.mp4", - "windows": [ - { - "t": [ - 4.2, - 5.0 - ], - "input": "up", - "cue": true - } - ], - "onSuccess": "s03", - "checkpoint": false - }, - "s03": { - "clip": "clips/s03_action.mp4", - "death": "clips/s03_death.mp4", - "windows": [ - { - "t": [ - 2.4, - 3.1 - ], - "input": "left", - "cue": true - }, - { - "t": [ - 4.6, - 5.3 - ], - "input": "up", - "cue": true - } - ], - "onSuccess": "s04", - "checkpoint": false - }, - "s00": { - "clip": "clips/intro.mp4", - "death": "clips/s01_death.mp4", - "windows": [], - "onSuccess": "s01", - "checkpoint": false - }, - "s04": { - "clip": "clips/s04_action.mp4", - "death": "clips/s04_death.mp4", - "windows": [ - { - "t": [ - 6.0, - 7.2 - ], - "input": "left", - "cue": true - } - ], - "onSuccess": "WIN", - "checkpoint": false - } + + "intro": { "clip": "clips/intro.mp4", "death": "clips/t1s1_death.mp4", + "windows": [], "onSuccess": "t1s1" }, + + "t1s1": { "clip": "clips/t1s1_action.mp4", "death": "clips/t1s1_death.mp4", + "windows": [ { "t": [4.6, 5.4], "input": "left" } ], + "onSuccess": "t1s2", "checkpoint": true }, + "t1s2": { "clip": "clips/t1s2_action.mp4", "death": "clips/t1s2_death.mp4", + "collect": { "t": [2.0, 2.5], "input": "up", "label": 1 }, + "windows": [ { "t": [4.2, 5.0], "input": "up" } ], + "onSuccess": "t1s3" }, + "t1s3": { "clip": "clips/t1s3_action.mp4", "death": "clips/t1s3_death.mp4", + "windows": [ { "t": [3.0, 3.7], "input": "right" } ], + "onSuccess": "t1s4" }, + "t1s4": { "clip": "clips/t1s4_action.mp4", "death": "clips/t1s4_death.mp4", + "windows": [ { "t": [3.0, 3.7], "input": "down" } ], + "onSuccess": "sh1" }, + + "sh1": { "clip": "clips/sh1_action.mp4", "death": "clips/sh1_death_pit.mp4", + "windows": [ { "t": [6.0, 7.5], "choices": { + "left": { "goto": "t2s1" }, + "up": { "death": "clips/sh1_death_pit.mp4", "goto": "mf1" }, + "right": { "death": "clips/sh1_death_pit.mp4", "goto": "mf1" } + } } ], + "onSuccess": "t2s1" }, + "mf1": { "clip": "clips/mf1_action.mp4", "death": "clips/mf1_death.mp4", + "windows": [ { "t": [3.0, 3.7], "input": "down" } ], + "onSuccess": "sh1" }, + + "t2s1": { "clip": "clips/t2s1_action.mp4", "death": "clips/t2s1_death.mp4", + "windows": [ { "t": [3.0, 3.7], "input": "up" } ], + "onSuccess": "t2s2", "checkpoint": true }, + "t2s2": { "clip": "clips/t2s2_action.mp4", "death": "clips/t2s2_death.mp4", "mirrorOk": true, + "windows": [ { "t": [2.2, 2.9], "input": "right" }, { "t": [3.6, 4.3], "input": "right" } ], + "onSuccess": "t2s3" }, + "t2s3": { "clip": "clips/t2s3_action.mp4", "death": "clips/t2s3_death.mp4", + "collect": { "t": [1.2, 1.7], "input": "up", "label": 2 }, + "windows": [ { "t": [2.4, 3.1], "input": "left" }, { "t": [4.6, 5.3], "input": "up" } ], + "onSuccess": "t2s4" }, + "t2s4": { "clip": "clips/t2s4_action.mp4", "death": "clips/t2s4_death.mp4", + "needledrop": { "t": [1.5, 2.1], "clip": "clips/t2s4_alt.mp4", "bonus": 500 }, + "windows": [ { "t": [3.2, 3.9], "input": "action" } ], + "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" } ], + "onSuccess": "sh2" }, + + "sh2": { "random": ["sh2_thin", "sh2_thick"] }, + "sh2_thin": { "clip": "clips/sh2_action_thin.mp4", "death": "clips/sh2_death.mp4", + "windows": [ { "t": [3.0, 4.5], "choices": { + "left": { "goto": "t3s1" }, + "right": { "death": "clips/sh2_death.mp4" } + } } ], + "onSuccess": "t3s1" }, + "sh2_thick": { "clip": "clips/sh2_action_thick.mp4", "death": "clips/sh2_death.mp4", + "windows": [ { "t": [3.0, 4.5], "choices": { + "right": { "goto": "t3s1" }, + "left": { "death": "clips/sh2_death.mp4" } + } } ], + "onSuccess": "t3s1" }, + + "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" } ], + "onSuccess": "t3s2", "checkpoint": true }, + "t3s2": { "clip": "clips/t3s2_action.mp4", "death": "clips/t3s2_death.mp4", + "windows": [ { "t": [3.0, 3.7], "input": "left" } ], + "onSuccess": "t3s3" }, + "t3s3": { "clip": "clips/t3s3_action.mp4", "death": "clips/t3s3_death.mp4", "mirrorOk": true, + "windows": [ { "t": [2.2, 2.9], "input": "down" }, { "t": [3.6, 4.3], "input": "action" } ], + "onSuccess": "t3s4" }, + "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 }, + "windows": [ { "t": [1.8, 2.4], "input": "up" }, { "t": [2.8, 3.4], "input": "right" }, { "t": [3.8, 4.4], "input": "action" } ], + "onSuccess": "sh3" }, + + "sh3": { "clip": "clips/sh3_action.mp4", "death": "clips/sh3_death_post.mp4", + "windows": [ { "t": [3.0, 4.5], "choices": { + "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" } } -} \ No newline at end of file +} diff --git a/engine/styles.css b/engine/styles.css index d7cf479..f32ea0b 100644 --- a/engine/styles.css +++ b/engine/styles.css @@ -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%; opacity: 0; pointer-events: none; } video.active { opacity: 1; } +video.mirror { transform: translate(-50%, -50%) scaleX(-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); @@ -17,6 +18,18 @@ video.active { opacity: 1; } 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; } +#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 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; } #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; } +#labels { color: #fff; font-size: 3vmin; letter-spacing: .5vmin; text-shadow: 0 0 1vmin #fff; } /* full screens */ .screen { position: absolute; inset: 0; z-index: 20; display: flex; flex-direction: column; diff --git a/production/flow/SHIFTS_SHOTS.md b/production/flow/SHIFTS_SHOTS.md index 4f77fd5..e1ad0a2 100644 --- a/production/flow/SHIFTS_SHOTS.md +++ b/production/flow/SHIFTS_SHOTS.md @@ -21,6 +21,9 @@ Inside the cluttered record store in the daytime, warm light, checkerboard floor ## 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. +## 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] 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. diff --git a/production/flow/TRACK5_SHOTS.md b/production/flow/TRACK5_SHOTS.md index 13d5f46..f20a65e 100644 --- a/production/flow/TRACK5_SHOTS.md +++ b/production/flow/TRACK5_SHOTS.md @@ -24,6 +24,10 @@ Inside a computerized hi-fi system rendered as a neon wireframe world, glowing c ## 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. +## 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] 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. diff --git a/tools/make_placeholders.py b/tools/make_placeholders.py index b5ebe3a..818bc72 100644 --- a/tools/make_placeholders.py +++ b/tools/make_placeholders.py @@ -1,12 +1,14 @@ #!/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. +"""Generate placeholder clips for every clip referenced by engine/scenes.json +(actions, deaths, choice deaths, needle-drop alts, attract/win/gameover/ +needle_skip/stinger) so the whole game is playable before any Flow credits are +spent. Existing files are SKIPPED — real Flow footage is never overwritten. -If Pillow is missing from the running interpreter, re-execs into a MODELBEAST -venv python that has it.""" +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. 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 json import os @@ -25,7 +27,6 @@ except ModuleNotFoundError: 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", @@ -42,30 +43,43 @@ def png_card(path, color, text, size=110, fg="white"): 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)) 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") + f = ImageFont.truetype(FONT, size) if FONT else ImageFont.load_default() + d.text((W / 2, H - 220), text, font=f, fill=fill, anchor="mm") img.save(path) -def clip(out, base_png, dur, windows=(), tmp=None): - """base card + per-window white flash, glyph overlay, and beep.""" +def win_glyph(w): + 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", "-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" + fc, last, vol, vi = [], "0:v", "0", 2 for i, w in enumerate(windows): t0, t1 = w["t"] - g = Path(tmp) / f"g{i}.png" - png_glyph(g, GLYPH[w["input"]]) + g = Path(tmp) / f"{out.stem}_g{i}.png" + 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)] 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 + f"[f{i}];[f{i}][{vi}:v]overlay=enable='between(t,{t0},{t1})'[{nxt}]") + last, vi = nxt, vi + 1 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]") args += ["-filter_complex", ";".join(fc), "-map", "[vout]", "-map", "1:a", "-af", f"volume='{vol}':eval=frame", @@ -78,18 +92,45 @@ def clip(out, base_png, dur, windows=(), tmp=None): def main(): CLIPS.mkdir(exist_ok=True) data = json.loads((ROOT / "scenes.json").read_text()) + deaths, alts = set(), set() 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") 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()): - 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) + if "random" in s: + 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("win.mp4", "#004400", "YOU SURVIVED", 4.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__":