diff --git a/deploy-assets/beyondmorpmovie.jpg b/deploy-assets/beyondmorpmovie.jpg new file mode 100644 index 0000000..2c5d34f Binary files /dev/null and b/deploy-assets/beyondmorpmovie.jpg differ diff --git a/deploy-assets/beyondmorpmovie_riso.jpg b/deploy-assets/beyondmorpmovie_riso.jpg new file mode 100644 index 0000000..152600e Binary files /dev/null and b/deploy-assets/beyondmorpmovie_riso.jpg differ diff --git a/deploy-assets/deepgroove.jpg b/deploy-assets/deepgroove.jpg new file mode 100644 index 0000000..b8c5cfb Binary files /dev/null and b/deploy-assets/deepgroove.jpg differ diff --git a/deploy-assets/deepgroove_riso.jpg b/deploy-assets/deepgroove_riso.jpg new file mode 100644 index 0000000..3be8e77 Binary files /dev/null and b/deploy-assets/deepgroove_riso.jpg differ diff --git a/deploy-assets/morpinghorror.jpg b/deploy-assets/morpinghorror.jpg new file mode 100644 index 0000000..ce759c0 Binary files /dev/null and b/deploy-assets/morpinghorror.jpg differ diff --git a/deploy-assets/morpinghorror_riso.jpg b/deploy-assets/morpinghorror_riso.jpg new file mode 100644 index 0000000..002bdff Binary files /dev/null and b/deploy-assets/morpinghorror_riso.jpg differ diff --git a/deploy-games.sh b/deploy-games.sh new file mode 100755 index 0000000..da8368d --- /dev/null +++ b/deploy-games.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Deploy the FMV games to monsterrobot.games/games// +# Game dirs are READ-ONLY bind mounts into forum-nginx: rsync to the HOST path. +set -euo pipefail +HOST="humanjing@100.71.119.27"; BASE="/home/humanjing/monsterrobot.games" +HERE="$(cd "$(dirname "$0")" && pwd)" +for g in "$@"; do + case "$g" in + beyondmorpmovie|deepgroove|morpinghorror) SRC="$HERE/games/$g/engine/";; + recordstoreguy) SRC="$HERE/engine/";; + *) echo "unknown game $g"; exit 1;; + esac + echo "== $g" + rsync -az --delete --exclude='.DS_Store' "$SRC" "$HOST:$BASE/games/$g/" + [ -f "$HERE/deploy-assets/$g.jpg" ] && scp -q "$HERE/deploy-assets/$g.jpg" "$HERE/deploy-assets/${g}_riso.jpg" "$HOST:$BASE/covers/" + code=$(curl -s -o /dev/null -w '%{http_code}' "https://monsterrobot.games/games/$g/?cb=$RANDOM") + echo " live: HTTP $code" +done diff --git a/games/deepgroove/engine/index.html b/games/deepgroove/engine/index.html new file mode 100644 index 0000000..0ad3ae7 --- /dev/null +++ b/games/deepgroove/engine/index.html @@ -0,0 +1,40 @@ + + + + + +DEEP GROOVE + + + +
+ + +
+
+
◉♪
+
+
+
+
0
+
+
+
+

DEEP GROOVE

+

one night. one envelope. eleven temptations.

+ +

+
+ + +
+ + + diff --git a/games/deepgroove/engine/player.js b/games/deepgroove/engine/player.js new file mode 100644 index 0000000..8ca9161 --- /dev/null +++ b/games/deepgroove/engine/player.js @@ -0,0 +1,405 @@ +/* DEEP GROOVE — 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.dg_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("DEEP GROOVE 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 = "

" + title + "

" + sub + "

"; + m.classList.remove("hidden"); + G.state = "END"; +} +function toAttract() { + $("msg").classList.add("hidden"); + $("hiscore").textContent = "HI-SCORE " + (localStorage.dg_hiscore || 0); + $("attract").classList.remove("hidden"); + attractPlay(); + G.state = "ATTRACT"; +} +function saveHi() { + if (G.score > (+localStorage.dg_hiscore || 0)) localStorage.dg_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(); diff --git a/games/deepgroove/engine/scenes.json b/games/deepgroove/engine/scenes.json new file mode 100644 index 0000000..14ac346 --- /dev/null +++ b/games/deepgroove/engine/scenes.json @@ -0,0 +1,233 @@ +{ + "meta": { + "title": "DEEP GROOVE", + "lives": 5, + "version": 2 + }, + "start": "intro", + "scenes": { + "intro": { + "clip": "clips/dg_intro.mp4", + "death": "clips/dg1_death.mp4", + "windows": [], + "onSuccess": "dg1" + }, + "dg1": { + "clip": "clips/dg1_action.mp4", + "death": "clips/dg1_death.mp4", + "windows": [ + { + "t": [ + 1.4, + 2.2 + ], + "input": "right", + "cue": true + } + ], + "onSuccess": "dg2", + "checkpoint": true + }, + "dg2": { + "clip": "clips/dg2_action.mp4", + "death": "clips/dg2_death.mp4", + "windows": [ + { + "t": [ + 1.4, + 2.2 + ], + "input": "up", + "cue": true + } + ], + "onSuccess": "dg3" + }, + "dg3": { + "clip": "clips/dg3_action.mp4", + "death": "clips/dg3_death.mp4", + "windows": [ + { + "t": [ + 1.4, + 2.2 + ], + "input": "right", + "cue": true + }, + { + "t": [ + 2.2, + 3.0 + ], + "input": "right", + "cue": true + } + ], + "onSuccess": "dg4" + }, + "dg4": { + "clip": "clips/dg4_action.mp4", + "death": "clips/dg4_death.mp4", + "windows": [ + { + "t": [ + 1.4, + 2.2 + ], + "input": "up", + "cue": true + } + ], + "onSuccess": "dg5", + "checkpoint": true + }, + "dg5": { + "clip": "clips/dg5_action.mp4", + "death": "clips/dg5_death.mp4", + "windows": [ + { + "t": [ + 1.4, + 2.2 + ], + "input": "action", + "cue": true + } + ], + "onSuccess": "dg6" + }, + "dg6": { + "clip": "clips/dg6_action.mp4", + "death": "clips/dg6_death.mp4", + "windows": [ + { + "t": [ + 1.4, + 2.2 + ], + "input": "action", + "cue": true + } + ], + "onSuccess": "dg6b" + }, + "dg6b": { + "clip": "clips/dg6b_beat.mp4", + "death": "clips/dg6_death.mp4", + "windows": [], + "onSuccess": "dg7" + }, + "dg7": { + "clip": "clips/dg7_action.mp4", + "death": "clips/dg7_death.mp4", + "windows": [ + { + "t": [ + 1.4, + 2.2 + ], + "input": "left", + "cue": true + } + ], + "onSuccess": "dg8", + "checkpoint": true + }, + "dg8": { + "clip": "clips/dg8_beat.mp4", + "death": "clips/dg7_death.mp4", + "windows": [], + "onSuccess": "dg9" + }, + "dg9": { + "clip": "clips/dg9_action.mp4", + "death": "clips/dg9_death.mp4", + "windows": [ + { + "t": [ + 1.4, + 2.2 + ], + "input": "right", + "cue": true + } + ], + "onSuccess": "dg10" + }, + "dg10": { + "clip": "clips/dg10_action.mp4", + "death": "clips/dg10_death.mp4", + "windows": [ + { + "t": [ + 1.4, + 2.2 + ], + "input": "down", + "cue": true + } + ], + "onSuccess": "dg11", + "checkpoint": true + }, + "dg11": { + "clip": "clips/dg11_action.mp4", + "death": "clips/dg10_death.mp4", + "windows": [ + { + "t": [ + 1.4, + 2.9 + ], + "cue": true, + "choices": { + "up": { + "goto": "dgx_shop" + }, + "down": { + "goto": "dg_win_s" + } + } + } + ], + "onSuccess": "dg_win_s" + }, + "dgx_shop": { + "clip": "clips/dgx1.mp4", + "death": "clips/dg10_death.mp4", + "windows": [], + "onSuccess": "dgx_listen" + }, + "dgx_listen": { + "clip": "clips/dgx2.mp4", + "death": "clips/dg10_death.mp4", + "windows": [], + "onSuccess": "dgx_dance" + }, + "dgx_dance": { + "clip": "clips/dgx9.mp4", + "death": "clips/dg10_death.mp4", + "windows": [], + "onSuccess": "dg_docking_s" + }, + "dg_docking_s": { + "clip": "clips/dg_docking.mp4", + "death": "clips/dg10_death.mp4", + "windows": [], + "onSuccess": "dg_morning" + }, + "dg_morning": { + "clip": "clips/dg_morningafter.mp4", + "death": "clips/dg10_death.mp4", + "windows": [], + "onSuccess": "dg_win_s" + }, + "dg_win_s": { + "clip": "clips/dg_win.mp4", + "death": "clips/dg10_death.mp4", + "windows": [], + "onSuccess": "WIN" + } + } +} \ No newline at end of file diff --git a/games/deepgroove/engine/styles.css b/games/deepgroove/engine/styles.css new file mode 100644 index 0000000..f32ea0b --- /dev/null +++ b/games/deepgroove/engine/styles.css @@ -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; } diff --git a/games/morpinghorror/engine/index.html b/games/morpinghorror/engine/index.html new file mode 100644 index 0000000..31ed069 --- /dev/null +++ b/games/morpinghorror/engine/index.html @@ -0,0 +1,40 @@ + + + + + +THE MORPING HORROR + + + +
+ + +
+
+
◉♪
+
+
+
+
0
+
+
+
+

THE MORPING HORROR

+

never let the side run out

+ +

+
+ + +
+ + + diff --git a/games/morpinghorror/engine/player.js b/games/morpinghorror/engine/player.js new file mode 100644 index 0000000..71971f7 --- /dev/null +++ b/games/morpinghorror/engine/player.js @@ -0,0 +1,405 @@ +/* THE MORPING HORROR — 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.mh_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("THE MORPING HORROR 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 = "

" + title + "

" + sub + "

"; + m.classList.remove("hidden"); + G.state = "END"; +} +function toAttract() { + $("msg").classList.add("hidden"); + $("hiscore").textContent = "HI-SCORE " + (localStorage.mh_hiscore || 0); + $("attract").classList.remove("hidden"); + attractPlay(); + G.state = "ATTRACT"; +} +function saveHi() { + if (G.score > (+localStorage.mh_hiscore || 0)) localStorage.mh_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(); diff --git a/games/morpinghorror/engine/scenes.json b/games/morpinghorror/engine/scenes.json new file mode 100644 index 0000000..7be4b6d --- /dev/null +++ b/games/morpinghorror/engine/scenes.json @@ -0,0 +1,204 @@ +{ + "meta": { + "title": "THE MORPING HORROR", + "lives": 5, + "version": 2 + }, + "start": "intro", + "scenes": { + "intro": { + "clip": "clips/mh_intro.mp4", + "death": "clips/mh1_death.mp4", + "windows": [], + "onSuccess": "mh1" + }, + "mh1": { + "clip": "clips/mh1_action.mp4", + "death": "clips/mh1_death.mp4", + "windows": [ + { + "t": [ + 1.4, + 2.2 + ], + "input": "up", + "cue": true + } + ], + "onSuccess": "mhx_loop", + "checkpoint": true + }, + "mhx_loop": { + "clip": "clips/mhx1.mp4", + "death": "clips/mh1_death.mp4", + "windows": [], + "onSuccess": "mh2" + }, + "mh2": { + "clip": "clips/mh2_action.mp4", + "death": "clips/mh2_death.mp4", + "windows": [ + { + "t": [ + 1.4, + 2.2 + ], + "input": "down", + "cue": true + } + ], + "onSuccess": "mh3" + }, + "mh3": { + "clip": "clips/mh3_action.mp4", + "death": "clips/mh3_death.mp4", + "windows": [ + { + "t": [ + 1.4, + 2.2 + ], + "input": "action", + "cue": true + } + ], + "onSuccess": "mhx_cats" + }, + "mhx_cats": { + "clip": "clips/mhx2.mp4", + "death": "clips/mh3_death.mp4", + "windows": [], + "onSuccess": "mh4" + }, + "mh4": { + "clip": "clips/mh4_action.mp4", + "death": "clips/mh4_death.mp4", + "windows": [ + { + "t": [ + 1.4, + 2.2 + ], + "input": "left", + "cue": true + } + ], + "onSuccess": "mhx_wal", + "checkpoint": true + }, + "mhx_wal": { + "clip": "clips/mhx3.mp4", + "death": "clips/mh4_death.mp4", + "windows": [], + "onSuccess": "mh5" + }, + "mh5": { + "clip": "clips/mh5_action.mp4", + "death": "clips/mh5_death.mp4", + "windows": [ + { + "t": [ + 1.4, + 2.2 + ], + "input": "action", + "cue": true + } + ], + "onSuccess": "mh6" + }, + "mh6": { + "clip": "clips/mh6_action.mp4", + "death": "clips/mh6_death.mp4", + "windows": [ + { + "t": [ + 1.4, + 2.2 + ], + "input": "down", + "cue": true + } + ], + "onSuccess": "mhx_note", + "checkpoint": true + }, + "mhx_note": { + "clip": "clips/mhx4.mp4", + "death": "clips/mh6_death.mp4", + "windows": [], + "onSuccess": "mhx_smile" + }, + "mhx_smile": { + "clip": "clips/mhx5.mp4", + "death": "clips/mh7_death.mp4", + "windows": [], + "onSuccess": "mh7" + }, + "mh7": { + "clip": "clips/mh7_action.mp4", + "death": "clips/mh7_death.mp4", + "windows": [ + { + "t": [ + 1.4, + 2.2 + ], + "input": "action", + "cue": true + } + ], + "onSuccess": "mh8" + }, + "mh8": { + "clip": "clips/mh8_action.mp4", + "death": "clips/mh8_death.mp4", + "windows": [ + { + "t": [ + 1.4, + 2.2 + ], + "input": "action", + "cue": true + } + ], + "onSuccess": "mh_choice", + "checkpoint": true + }, + "mh_choice": { + "clip": "clips/mhx8.mp4", + "death": "clips/mh8_death.mp4", + "windows": [ + { + "t": [ + 1.4, + 2.9 + ], + "cue": true, + "choices": { + "up": { + "goto": "mh9" + }, + "down": { + "goto": "mhx_alt" + } + } + } + ], + "onSuccess": "mh9" + }, + "mh9": { + "clip": "clips/mh9_win.mp4", + "death": "clips/mh8_death.mp4", + "windows": [], + "onSuccess": "WIN" + }, + "mhx_alt": { + "clip": "clips/mhx6.mp4", + "death": "clips/mh8_death.mp4", + "windows": [], + "onSuccess": "WIN" + } + } +} \ No newline at end of file diff --git a/games/morpinghorror/engine/styles.css b/games/morpinghorror/engine/styles.css new file mode 100644 index 0000000..f32ea0b --- /dev/null +++ b/games/morpinghorror/engine/styles.css @@ -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; } diff --git a/production/STUDIO_STATUS.md b/production/STUDIO_STATUS.md new file mode 100644 index 0000000..4f41e7b --- /dev/null +++ b/production/STUDIO_STATUS.md @@ -0,0 +1,24 @@ +# FMV STUDIO — FINAL STATUS 2026-08-01 +Four AI-generated FMV QTE games, all live on the arcade. + +| game | url | scenes | clips | state | +|---|---|---|---|---| +| Record Store Guy | /games/recordstoreguy/ | 45 | 91 | live (Side B expansion pending) | +| Beyond Morp: The Movie: The Game | /games/beyondmorpmovie/ | 22 | 42 | live | +| Deep Groove | /games/deepgroove/ | 19 | 31 | live | +| The Morping Horror | /games/morpinghorror/ | 17 | 28 | live | + +Source library: ~/Documents/gameflow (545 clips) + ~/Documents/flow-vids. +Best-take manifests: production/final_manifests.json (every slot, every game). +Deploy: ./deploy-games.sh (rsync to games VPS host path; read-only +bind mount into forum-nginx so never docker cp). Landing backup on server: +index.html.bak-pre3games-*. + +## Outstanding +- mhx7 (Morping Horror: surrender-to-the-loop) — one clip, prompt in + production/flow/MISSING_PROMPTS.md. Wire + redeploy when shot. +- RSG Side B (15 prompts, 5 customers + bargain bin + trailer) — partially + started; prompts in MISSING_PROMPTS.md. Patch into RSG when shot. +- Window timings on all three new games are auto-fitted from clip durations — + playtest by hand and tune in ?debug=1 for feel. +- Games VPS disk at 96% (3.2GB free) — needs a cleanup pass soon.