diff --git a/.gitignore b/.gitignore index 33cdf3f..24e337e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ production/raw/ *.env flow-vids-RSG/ flow-chars-insurance/ +games/*/engine/clips/*.mp4 diff --git a/games/beyondmorpmovie/engine/index.html b/games/beyondmorpmovie/engine/index.html new file mode 100644 index 0000000..4f318f8 --- /dev/null +++ b/games/beyondmorpmovie/engine/index.html @@ -0,0 +1,40 @@ + + +
+ + +the game of the book of the movie of the game
+PRESS ANY KEY
+ +" + sub + "
PRESS ANY KEY
"; + 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(); diff --git a/games/beyondmorpmovie/engine/scenes.json b/games/beyondmorpmovie/engine/scenes.json new file mode 100644 index 0000000..b83b191 --- /dev/null +++ b/games/beyondmorpmovie/engine/scenes.json @@ -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" + } + } +} \ No newline at end of file diff --git a/games/beyondmorpmovie/engine/styles.css b/games/beyondmorpmovie/engine/styles.css new file mode 100644 index 0000000..f32ea0b --- /dev/null +++ b/games/beyondmorpmovie/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; }