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:
parent
7de1612998
commit
4f7d21df2b
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
34
ENGINE.md
34
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).
|
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.
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
210
engine/player.js
210
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 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() {
|
||||||
|
|||||||
@ -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" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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.
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|
||||||
|
|||||||
@ -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__":
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user