Beyond Morp: The Movie: The Game — engine built, 42 clips wired, 22-scene graph, full playthrough verified (24300, no deaths)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-08-01 10:44:10 +10:00
parent 4b439b1b6e
commit d7b30047a0
5 changed files with 860 additions and 0 deletions

1
.gitignore vendored
View File

@ -4,3 +4,4 @@ production/raw/
*.env
flow-vids-RSG/
flow-chars-insurance/
games/*/engine/clips/*.mp4

View File

@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>BEYOND MORP: THE MOVIE: THE GAME</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div id="stage">
<video id="vidA" playsinline preload="auto"></video>
<video id="vidB" playsinline preload="auto"></video>
<div id="scanlines"></div>
<div id="cue"></div>
<div id="nd">◉♪</div>
<div id="toast"></div>
<div id="hud">
<div id="lives"></div>
<div id="score">0</div>
<div id="labels"></div>
</div>
<div id="attract" class="screen">
<h1>BEYOND MORP: THE MOVIE: THE GAME</h1>
<p class="sub">the game of the book of the movie of the game</p>
<p class="blink">PRESS ANY KEY</p>
<p id="hiscore"></p>
</div>
<div id="msg" class="screen hidden"></div>
<div id="debug" class="hidden">
<div id="dbg-timeline"><div id="dbg-playhead"></div></div>
<div id="dbg-row">
<select id="dbg-scene"></select>
<span id="dbg-time"></span>
<span id="dbg-state"></span>
</div>
</div>
</div>
<script src="player.js"></script>
</body>
</html>

View File

@ -0,0 +1,405 @@
/* BEYOND MORP: THE MOVIE: THE GAME — FMV QTE player. Vanilla ES6, no deps. Spec: ENGINE.md */
"use strict";
// feel-tuning constants (see ENGINE.md)
const WINDOW_DEFAULT = 0.7; // s
const EARLY_GRACE = 0.25; // s before window open where input = fail
const DEATH_HOLD = 600; // ms on last death frame before retry
const CUE_LEAD = 0.0; // s cue shows before window opens
const 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, 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
};
// ---------- boot ----------
async function boot() {
G.data = await (await fetch("scenes.json?v=" + Date.now())).json();
$("hiscore").textContent = "HI-SCORE " + (localStorage.bmm_hiscore || 0);
if (G.debug) { initDebug(); window.RSG = { G, input, vid }; }
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);
try {
const b = await (await fetch(vurl(url))).blob();
const o = URL.createObjectURL(b);
G.blobs.set(url, o);
return o;
} catch { return url; } // fall back to streaming
}
// ---------- clip playback (A/B swap) ----------
function playClip(url, onEnded) {
const next = vids[1 - cur];
next.src = G.blobs.get(url) || vurl(url);
next.onended = null;
next.currentTime = 0;
const p = next.play();
if (p) p.catch(() => {}); // pre-gesture autoplay rejection is fine
next.onended = onEnded;
next.classList.add("active");
next.classList.toggle("mirror", G.mirror);
vids[cur].classList.remove("active");
vids[cur].pause();
cur = 1 - cur;
return next;
}
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;
}
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, 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[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" && n !== "REPLAY") {
const ns = resolve(n);
preload(ns.clip); preload(ns.death);
} else preload("clips/win.mp4");
});
if (G.debug) debugScene();
}
function onSceneClipEnd() {
if (G.state !== "PLAYING") return;
if (G.wi < G.windows.length) return fail("clip ended mid-window"); // safety net
sceneCleared();
}
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(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(gotoAfter || G.retryId); // retry (or branch: misfile dimension)
}, DEATH_HOLD);
});
}
function win() {
G.state = "WIN";
G.mirror = false;
saveHi();
playClip("clips/win.mp4", () => {
if (G.labels.size >= 7)
return playClip("clips/stinger.mp4",
() => showEnd("BEYOND MORP: THE MOVIE: THE GAME 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));
}
function showEnd(title, sub) {
const m = $("msg");
m.innerHTML = "<h2>" + title + "</h2><p class='sub'>" + sub + "</p><p class='blink'>PRESS ANY KEY</p>";
m.classList.remove("hidden");
G.state = "END";
}
function toAttract() {
$("msg").classList.add("hidden");
$("hiscore").textContent = "HI-SCORE " + (localStorage.bmm_hiscore || 0);
$("attract").classList.remove("hidden");
attractPlay();
G.state = "ATTRACT";
}
function saveHi() {
if (G.score > (+localStorage.bmm_hiscore || 0)) localStorage.bmm_hiscore = G.score;
}
// ---------- QTE core ----------
// qteCheck is driven from THREE sources so throttling can't starve it:
// rAF (smooth when focused), video timeupdate (fires while playing even
// unfocused), and a 100ms interval backup.
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); }
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);
}
function tick() { qteCheck(); requestAnimationFrame(tick); }
setInterval(qteCheck, 100);
vids.forEach((v) => v.addEventListener("timeupdate", qteCheck));
function input(dir) {
if (G.state === "ATTRACT") return startGame();
if (G.state === "END") return toAttract();
if (G.state !== "PLAYING") return;
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
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 (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;
if (G.state === "ATTRACT" || G.state === "END") return input("action");
const d = KEYMAP[e.key];
if (d) { e.preventDefault(); input(d); }
if (G.debug) debugKeys(e);
}
function initTouch() {
// 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;
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 });
}
// ---------- HUD + cue ----------
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 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() {
$("debug").classList.remove("hidden");
const sel = $("dbg-scene");
for (const id of Object.keys(G.data.scenes)) {
const o = document.createElement("option"); o.value = o.textContent = id; sel.appendChild(o);
}
sel.onchange = () => { $("attract").classList.add("hidden"); $("msg").classList.add("hidden");
if (!G.lives) G.lives = G.data.meta.lives; playScene(sel.value); };
}
function debugScene() {
const tl = $("dbg-timeline");
tl.querySelectorAll(".dbg-win").forEach((n) => n.remove());
const d = vid().duration || 5;
G.windows.forEach((w) => {
const b = document.createElement("div");
b.className = "dbg-win" + (w.done ? " " + w.done : "");
b.style.left = (w.t[0] / d) * 100 + "%";
b.style.width = ((w.t[1] - w.t[0]) / d) * 100 + "%";
tl.appendChild(b);
});
$("dbg-scene").value = G.sceneId;
}
function debugTick(t) {
const d = vid().duration || 5;
$("dbg-playhead").style.left = (t / d) * 100 + "%";
$("dbg-time").textContent = t.toFixed(2) + "s / " + d.toFixed(2) + "s [" + G.sceneId + " w" + G.wi + "]";
$("dbg-state").textContent = G.state;
}
function debugKeys(e) { // , . frame-step while paused
if (e.key === ",") { vid().pause(); vid().currentTime -= 1 / 24; }
if (e.key === ".") { vid().pause(); vid().currentTime += 1 / 24; }
if (e.key === "p") vid().paused ? vid().play() : vid().pause();
}
boot();

View File

@ -0,0 +1,350 @@
{
"meta": {
"title": "BEYOND MORP: THE MOVIE: THE GAME",
"lives": 5,
"version": 2
},
"start": "intro",
"scenes": {
"intro": {
"clip": "clips/bm_title.mp4",
"death": "clips/a1s1_death.mp4",
"windows": [],
"onSuccess": "a1s1"
},
"a1s1": {
"clip": "clips/a1s1_action.mp4",
"death": "clips/a1s1_death.mp4",
"windows": [
{
"t": [
1.4,
2.2
],
"input": "action",
"cue": true
}
],
"onSuccess": "a1s2b",
"checkpoint": true
},
"a1s2b": {
"clip": "clips/a1s2b_action.mp4",
"death": "clips/a1s2b_death.mp4",
"windows": [
{
"t": [
1.4,
2.2
],
"input": "right",
"cue": true
}
],
"onSuccess": "a1s2"
},
"a1s2": {
"clip": "clips/a1s2_action.mp4",
"death": "clips/a1s2_death.mp4",
"windows": [
{
"t": [
1.4,
2.2
],
"input": "up",
"cue": true
},
{
"t": [
2.3,
3.1
],
"input": "left",
"cue": true
}
],
"onSuccess": "barry1"
},
"barry1": {
"clip": "clips/barry_rant_1.mp4",
"death": "clips/barry_deathread.mp4",
"windows": [
{
"t": [
1.4,
2.2
],
"input": "action",
"cue": true
}
],
"onSuccess": "a1s3"
},
"a1s3": {
"clip": "clips/a1s3_action.mp4",
"death": "clips/a1s3_death.mp4",
"windows": [
{
"t": [
1.4,
2.2
],
"input": "up",
"cue": true
}
],
"onSuccess": "a1s4"
},
"a1s4": {
"clip": "clips/a1s4_action.mp4",
"death": "clips/a1s1_death.mp4",
"windows": [
{
"t": [
1.4,
2.2
],
"input": "up",
"cue": true
}
],
"onSuccess": "a2s1",
"checkpoint": true
},
"a2s1": {
"clip": "clips/a2s1_action.mp4",
"death": "clips/a2s1_death.mp4",
"windows": [
{
"t": [
1.4,
2.2
],
"input": "left",
"cue": true
}
],
"onSuccess": "a2s2"
},
"a2s2": {
"clip": "clips/a2s2_action.mp4",
"death": "clips/a2s2_death.mp4",
"windows": [
{
"t": [
1.4,
2.2
],
"input": "down",
"cue": true
}
],
"onSuccess": "barry2"
},
"barry2": {
"clip": "clips/barry_rant_2.mp4",
"death": "clips/barry_deathread.mp4",
"windows": [
{
"t": [
1.4,
2.2
],
"input": "action",
"cue": true
}
],
"onSuccess": "a2s3"
},
"a2s3": {
"clip": "clips/a2s3_action.mp4",
"death": "clips/a2s3_death.mp4",
"windows": [
{
"t": [
1.4,
2.2
],
"input": "up",
"cue": true
},
{
"t": [
2.3,
3.1
],
"input": "up",
"cue": true
}
],
"onSuccess": "a2s4"
},
"a2s4": {
"clip": "clips/a2s4_action.mp4",
"death": "clips/a2s4_death.mp4",
"windows": [
{
"t": [
1.4,
2.2
],
"input": "action",
"cue": true
}
],
"onSuccess": "a2s5",
"checkpoint": true
},
"a2s5": {
"clip": "clips/a2s5_action.mp4",
"death": "clips/a2s5_death.mp4",
"windows": [
{
"t": [
1.4,
2.2
],
"input": "right",
"cue": true
}
],
"onSuccess": "a2s6"
},
"a2s6": {
"clip": "clips/a2s6_action.mp4",
"death": "clips/a2s6_death.mp4",
"windows": [
{
"t": [
1.4,
2.2
],
"input": "down",
"cue": true
}
],
"onSuccess": "a3s1"
},
"a3s1": {
"clip": "clips/a3s1_action.mp4",
"death": "clips/a3s1_death.mp4",
"windows": [
{
"t": [
1.4,
2.2
],
"input": "action",
"cue": true
}
],
"onSuccess": "a3s2",
"checkpoint": true
},
"a3s2": {
"clip": "clips/a3s2_action.mp4",
"death": "clips/a3s2_death.mp4",
"windows": [
{
"t": [
1.4,
2.2
],
"input": "left",
"cue": true
}
],
"onSuccess": "a3s3"
},
"a3s3": {
"clip": "clips/a3s3_action.mp4",
"death": "clips/a3s3_death.mp4",
"windows": [
{
"t": [
1.4,
2.2
],
"input": "action",
"cue": true
}
],
"onSuccess": "a3s4"
},
"a3s4": {
"clip": "clips/a3s4_action.mp4",
"death": "clips/a3s4_death.mp4",
"windows": [
{
"t": [
1.4,
2.2
],
"input": "up",
"cue": true
}
],
"onSuccess": "a3s5"
},
"a3s5": {
"clip": "clips/a3s5_action.mp4",
"death": "clips/a3s5_death.mp4",
"windows": [
{
"t": [
1.4,
2.2
],
"input": "action",
"cue": true
}
],
"onSuccess": "barry3"
},
"barry3": {
"clip": "clips/barry_approves.mp4",
"death": "clips/barry_deathread.mp4",
"windows": [],
"onSuccess": "a3s6"
},
"a3s6": {
"clip": "clips/a3s6_action.mp4",
"death": "clips/a3s6_death.mp4",
"windows": [
{
"t": [
1.4,
2.2
],
"input": "up",
"cue": true
},
{
"t": [
2.0,
2.8
],
"input": "down",
"cue": true
},
{
"t": [
2.6,
3.4
],
"input": "action",
"cue": true
}
],
"onSuccess": "a3s7",
"checkpoint": true
},
"a3s7": {
"clip": "clips/a3s7_win.mp4",
"death": "clips/a3s6_death.mp4",
"windows": [],
"onSuccess": "WIN"
}
}
}

View File

@ -0,0 +1,64 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; background: #000; overflow: hidden; font-family: "Courier New", monospace; }
#stage { position: relative; width: 100vw; height: 100vh; display: flex; align-items: center; justify-content: center; }
video { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
max-width: 100vw; max-height: 100vh; aspect-ratio: 16/9; width: 100%;
opacity: 0; pointer-events: none; }
video.active { opacity: 1; }
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);
box-shadow: inset 0 0 18vmin rgba(0,0,0,.85); }
/* QTE cue */
#cue { position: absolute; bottom: 16%; left: 50%; transform: translateX(-50%);
font-size: 14vmin; font-weight: bold; color: #0ff; z-index: 10; opacity: 0;
text-shadow: 0 0 2vmin #0ff, 0 0 6vmin #08f; pointer-events: none; }
#cue.show { opacity: 1; animation: pulse .3s ease-in-out infinite alternate; }
#cue.good { color: #0f4; text-shadow: 0 0 2vmin #0f4; animation: pop .25s ease-out; }
#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; } }
/* HUD */
#hud { position: absolute; top: 2vmin; left: 0; right: 0; display: flex;
justify-content: space-between; padding: 0 3vmin; z-index: 10; pointer-events: none; }
#lives { color: #f33; font-size: 4vmin; letter-spacing: .8vmin; text-shadow: 0 0 1.5vmin #f33; }
#score { color: #ff0; font-size: 4vmin; text-shadow: 0 0 1.5vmin #f80; }
#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;
align-items: center; justify-content: center; gap: 3vmin; text-align: center;
background: radial-gradient(ellipse at center, #180828 0%, #000 75%); }
.screen.hidden { display: none; }
.screen h1 { color: #f0f; font-size: 9vmin; text-shadow: 0 0 2vmin #f0f, 0 0 8vmin #90f; }
.screen h2 { color: #fff; font-size: 7vmin; text-shadow: 0 0 2vmin #0ff; }
.screen .sub { color: #0ff; font-size: 3vmin; }
.screen .blink { color: #fff; font-size: 4vmin; animation: blink 1s steps(2) infinite; }
#hiscore { color: #ff0; font-size: 3vmin; }
@keyframes blink { 50% { opacity: 0; } }
/* debug */
#debug { position: absolute; left: 0; right: 0; bottom: 0; z-index: 30;
background: rgba(0,0,0,.8); padding: 1vmin 2vmin; font-size: 1.8vmin; color: #0f0; }
#debug.hidden { display: none; }
#dbg-timeline { position: relative; height: 2.4vmin; background: #222; margin-bottom: .8vmin; }
.dbg-win { position: absolute; top: 0; bottom: 0; background: #08f; opacity: .8; }
.dbg-win.passed { background: #0f4; } .dbg-win.failed { background: #f33; }
#dbg-playhead { position: absolute; top: 0; bottom: 0; width: 2px; background: #fff; z-index: 2; }
#dbg-row { display: flex; gap: 2vmin; align-items: center; }
#dbg-row select { background: #111; color: #0f0; border: 1px solid #0f0; font: inherit; }