diff --git a/.dockerignore b/.dockerignore index 2d03d7c..dca3d75 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,3 +10,7 @@ assets/_* HANDOFF.md **/__pycache__ **/.DS_Store + +# promo footage is a local build product (tools/capture.py), never served +promo/clips +tools diff --git a/.gitignore b/.gitignore index 4c38cdd..a09e7ee 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ scores.json.tmp assets/_gen/ assets/_refcheck/ assets/__pycache__/ +promo/clips/ diff --git a/PROJECT.md b/PROJECT.md index 2592f50..c848a90 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -3553,8 +3553,34 @@ never an interstitial. An interstitial after death would land exactly in the "on Clips the game already contains: the transition (the flagship), a boss walking on, the ball boy calmly mopping blood, a flame ball starting an epidemic across a lined-up wave, parrying a boss's volley back into him, a horde, the death + run summary. - A capture helper on `window.T` to jump straight into a given beat (the transition timeline is on - `G.transT`) would make clean takes cheap — not yet built. + **THE CAPTURE RIG IS BUILT (2026-08-18): `python3 tools/capture.py` and the folder fills up.** + `tools/capture.py` serves the repo with `tools/clips.js` injected, opens a throwaway-profile + Chrome, and the page stages each highlight through `window.T`, records it, POSTs the webm back; + ffmpeg writes 1080x1920 h264/aac into `promo/clips/`. Named scenes shoot alone + (`python3 tools/capture.py horde boss`). Seven scenes exist and the list in `clips.js` is the + place to add one — the gore side is `transition` (the flagship: four seconds of ordinary tennis + before the turn), `horde`, `flamer`, `mopboy`, `death`, and the zombie-tennis side is `boss` and + `match`. Nothing is faked for the camera: every clip enters through the same doors a player does + (`T.start`, `T.intoSplatter`, `startHorde`, `startBossFight`, `startMatch`), and the two pilots + are deliberately mediocre — the tennis one IS the reachability soak's from `test.js`. + Four things it learned the hard way, none of them obvious: + - **The clip is the canvas BACKING STORE, not the window.** Chrome opened maximised and ignored + `--window-size`, and `resizeTo` did nothing; the frame is pinned instead by giving the canvas a + 9:16 CSS box, which `render.js` measures (`cv.clientWidth`) through its ResizeObserver. + - **`TUNE.lowPower` defaults ON and caps `RES` at 3**, so the recording came out 810x1440 and + needed a blurry 1.33x upscale. The rig sets it to 0; with `--force-device-scale-factor=3` the + backing store is exactly 1080x1920 and no filter ever touches a pixel. + - **Audio has no master bus** — every voice in `audio.js` connects straight to `ac.destination` — + so the rig makes `destination` itself a gain node fanning to the speakers AND to a + `MediaStreamDestination`, patched on `BaseAudioContext.prototype` (not `AudioContext`'s) before + the game module boots. No refactor of the game for a tool that ships to nobody. + - **One page load per scene.** A fresh boot is the only teardown guaranteed correct, and it costs + a second: the page reloads itself into the next scene and POSTs `/done` when the list is empty. + `tools/` is in neither the Dockerfile COPY nor the image, and `promo/clips/` is git- and + docker-ignored: the footage is a local build product, not a deployed asset. + Still hand-work after the rig: the name mark on screen (item 5 wants it in EVERY clip), any + cutting, and the caption. Skipped deliberately — a burned-in watermark is a taste decision and + ffmpeg `drawtext` needs a font file the repo does not have in a `drawtext`-friendly format. 5. **How a viewer actually reaches the game — the name does the work, not the link.** TikTok and Instagram have no clickable caption links, so the bio link costs two taps (profile, then link) and converts in the single-digit percents. YouTube Shorts descriptions and pinned comments DO click, diff --git a/index.html b/index.html index a51f555..fc3cbb9 100644 --- a/index.html +++ b/index.html @@ -659,6 +659,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { - + diff --git a/src/main.js b/src/main.js index f3f32d6..77e7d2b 100644 --- a/src/main.js +++ b/src/main.js @@ -4,9 +4,9 @@ // render (camera/drawing) · input (keys/touch) · audio (sfx/umpire) · panel (T tuning UI) import { TUNE, COURTS, TOURS, KITS, SKINS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt, CUP_ROUNDS, matchFoe } from './config.js'; -import { P, O, ball, score, G, setupPoint, selectCourt, selectTour, selectKit, selectSkin } from './state.js'; +import { P, O, ball, score, G, setupPoint, sendBoy, selectCourt, selectTour, selectKit, selectSkin } from './state.js'; import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, startGore, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, flickTarget, dragServe, toggleTestMode } from './gameplay.js'; -import { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench, FALL, benchFall, startNetVs, endNetVs } from './beats.js'; +import { startDuel, resolveInterlude, startHorde, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench, FALL, benchFall, startNetVs, endNetVs } from './beats.js'; import { netConnect, netHooks } from './net.js'; import { fitViewport, render, proj, RES, SPRV, LOAD, SPR, drawLook } from './render.js'; import { voice, ambient, menuMusic, menuDuck } from './audio.js'; @@ -788,4 +788,6 @@ requestAnimationFrame(frame); // exposed for in-browser sanity checks and simulated playtests window.T = { TUNE, ball, P, O, score, G, zombies, keyPressed, SPR, laserScene, laserFire, pause: v => { paused = v !== false; }, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, spawnBarrel, grantWeapon, toggleTestMode, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, showModal, dismissModal, dragServe, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, stats, step: dt => update(dt), render, press: keyPressed, FALL, benchFall, + start, intoSplatter, startHorde, sendBoy, // the two run doors + the wave: the promo rig (tools/) stages every clip through them + get phase() { return G.phase; }, set phase(v) { G.phase = v; } }; diff --git a/tools/capture.py b/tools/capture.py new file mode 100644 index 0000000..7f457cb --- /dev/null +++ b/tools/capture.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Promo clip factory — one command, a folder of postable vertical mp4s. + + python3 tools/capture.py # every scene + python3 tools/capture.py horde boss # only these + python3 tools/capture.py --keep # keep the raw webm next to the mp4 + +Serves the repo with tools/clips.js injected (the game's own files are untouched, and tools/ is not +in the Dockerfile COPY, so none of this ships), opens Chrome at phone-portrait size on a throwaway +profile, and lets the page stage each highlight through window.T, record canvas + audio, and POST +the webm back. ffmpeg then doubles it to 1080x1920 with NEAREST-NEIGHBOUR — pixel art must not be +blurred by an upscale — and writes h264/aac into promo/clips/. + +Why a real browser window and not headless: the recording is real-time off requestAnimationFrame, +and a backgrounded or headless tab is exactly where Chrome throttles rAF and swiftshader drops +frames. Two minutes of a window in the way is cheaper than debugging dropped frames. +""" +import http.server, os, shutil, subprocess, sys, threading, urllib.parse + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) +OUT = os.path.join(ROOT, 'promo', 'clips') +PORT = 8642 +CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' +# the scene names, in shooting order — must match SCENES in tools/clips.js +SCENES = ['transition', 'horde', 'flamer', 'mopboy', 'death', 'boss', 'match'] +INJECT = b'\n' + +args = [a for a in sys.argv[1:] if not a.startswith('-')] +keep = '--keep' in sys.argv +scenes = args or SCENES +bad = [s for s in scenes if s not in SCENES] +if bad: + sys.exit('unknown scene(s): %s\nknown: %s' % (', '.join(bad), ', '.join(SCENES))) + +done = threading.Event() +made = [] + + +def to_mp4(webm, mp4): + # -r 60 on the OUTPUT: MediaRecorder writes a variable-rate stream and the platforms want CFR. + subprocess.run(['ffmpeg', '-y', '-loglevel', 'error', '-i', webm, + '-vf', 'scale=1080:1920:flags=neighbor', # a no-op at RES 4; the guarantee is cheap + '-r', '60', '-c:v', 'libx264', '-preset', 'slow', '-crf', '18', + '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '160k', '-movflags', '+faststart', + mp4], check=True) + + +class Handler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *a, **kw): + super().__init__(*a, directory=ROOT, **kw) + + def log_message(self, *a): + pass + + def do_GET(self): + if self.path.startswith('/log?'): # the page's console, since the browser is throwaway + print(' .. ' + urllib.parse.unquote(self.path[5:]), flush=True) + self.send_response(204); self.end_headers(); return + if self.path.split('?')[0] in ('/', '/index.html'): + html = open(os.path.join(ROOT, 'index.html'), 'rb').read().replace(b'', INJECT, 1) + self.send_response(200) + self.send_header('Content-Type', 'text/html') + self.send_header('Content-Length', str(len(html))) + self.send_header('Cache-Control', 'no-store') + self.end_headers() + self.wfile.write(html) + return + return super().do_GET() + + def do_POST(self): + body = self.rfile.read(int(self.headers.get('Content-Length') or 0)) + if self.path == '/done': + done.set() + elif self.path.startswith('/clip/'): + name = os.path.basename(self.path) # basename: the path is never trusted + webm = os.path.join(OUT, name) + open(webm, 'wb').write(body) + mp4 = webm[:-5] + '.mp4' + try: + to_mp4(webm, mp4) + except subprocess.CalledProcessError: + print(' %-12s FAILED to convert (%d B of webm kept)' % (name[:-5], len(body)), flush=True) + else: + if not keep: + os.remove(webm) + made.append(mp4) + print(' %-12s %5.1f MB -> %s' % (name[:-5], os.path.getsize(mp4) / 1e6, os.path.relpath(mp4, ROOT)), flush=True) + self.send_response(204) + self.end_headers() + + +os.makedirs(OUT, exist_ok=True) +srv = http.server.ThreadingHTTPServer(('127.0.0.1', PORT), Handler) +threading.Thread(target=srv.serve_forever, daemon=True).start() +print('shooting %d scene(s): %s' % (len(scenes), ', '.join(scenes)), flush=True) + +profile = '/tmp/splat-capture-profile' +shutil.rmtree(profile, ignore_errors=True) +chrome = subprocess.Popen([ + CHROME, '--user-data-dir=' + profile, '--no-first-run', '--no-default-browser-check', + '--autoplay-policy=no-user-gesture-required', # the umpire is the hook; he must be in the file + '--force-device-scale-factor=3', # dpr 3 -> render.js picks RES 4 -> 1080x1920 + '--window-size=700,1000', '--window-position=0,0', + '--app=http://127.0.0.1:%d/?clips=%s' % (PORT, ','.join(scenes)), +], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + +ok = done.wait(timeout=60 + 30 * len(scenes)) +chrome.terminate() +srv.shutdown() +print('%d clip(s) in promo/clips/' % len(made) if ok else 'TIMED OUT after %d clip(s)' % len(made)) +sys.exit(0 if ok else 1) diff --git a/tools/clips.js b/tools/clips.js new file mode 100644 index 0000000..6cb770c --- /dev/null +++ b/tools/clips.js @@ -0,0 +1,188 @@ +// PROMO CLIP RIG — injected into index.html by tools/capture.py, never shipped (tools/ is not in +// the Dockerfile COPY). It stages a highlight with the real game through window.T, records the +// canvas + the WebAudio bus with MediaRecorder, POSTs the webm back to capture.py and reloads into +// the next scene. ONE PAGE LOAD PER SCENE — a fresh boot is the only teardown that is guaranteed +// correct, and it costs a second. +(() => { + const q = new URLSearchParams(location.search); + const list = (q.get('clips') || '').split(',').filter(Boolean); + if (!list.length) return; + + // ---- determinism: the same seed gives the same take, so a good clip can be re-shot ---------- + let seed = (Number(q.get('seed')) || 20260818) >>> 0; + Math.random = () => { + seed ^= seed << 13; seed >>>= 0; + seed ^= seed >> 17; + seed ^= seed << 5; seed >>>= 0; + return seed / 4294967296; + }; + + // ---- the audio tap. Every voice in audio.js connects straight to `ac.destination`, so the whole + // mix is grabbed by making `destination` a gain node that fans out to the real speakers AND to a + // MediaStreamDestination. Patching the prototype here (this script runs before the game module) + // means the game needs no master-bus refactor for a tool that ships to nobody. + let tap = null; + const AC = window.AudioContext || window.webkitAudioContext; + // `destination` is defined on BaseAudioContext, not on AudioContext — walk the chain for it + let proto = AC.prototype, desc = null; + while (proto && !(desc = Object.getOwnPropertyDescriptor(proto, 'destination'))) proto = Object.getPrototypeOf(proto); + const real = desc.get; + Object.defineProperty(proto, 'destination', { configurable: true, get() { + if (!this._promoBus) { + this._promoBus = this.createGain(); + this._promoBus.connect(real.call(this)); + tap = this.createMediaStreamDestination(); + this._promoBus.connect(tap); + } + return this._promoBus; + } }); + + const sleep = ms => new Promise(r => setTimeout(r, ms)); + const T = () => window.T; + + // ---- the two autopilots. The tennis one is the reachability soak's from test.js (auto-run plus a + // fixed tap window); the gore one draws the slingshot at the nearest body. Both are mediocre on + // purpose — a clip of a perfect player is a clip of nothing happening. + let lastTap = 0; + const pilotTennis = t => { + const { G, ball, P, keyPressed } = T(); + if (G.modal) { G.modal = null; G.modalQ.length = 0; } + if (G.phase === 'pretoss') { if (t - lastTap > 0.6) { lastTap = t; keyPressed('Space'); } } + else if (G.phase === 'toss') { if (ball.vy < 0.4 && ball.y > 2.4) keyPressed('Space'); } + else if (G.phase === 'rally' && ball.active && ball.lastHit === 'O') { + const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8); + if (d < 2.9 && d > 2.2) keyPressed('Space'); + } + }; + const pilotGore = t => { + const { G, P, zombies, TUNE, dragServe } = T(); + if (G.modal) { G.modal = null; G.modalQ.length = 0; } + if (G.horde || G.mgT > 0 || t - lastTap < 0.45) return; + let best = null, bd = 1e9; + for (const z of zombies) { + const d = Math.hypot(z.x - P.x, z.z - P.z); + if (d < bd) { bd = d; best = z; } + } + if (!best) return; + lastTap = t; + const dx = best.x - P.x, dz = best.z - P.z, len = Math.hypot(dx, dz) || 1; + dragServe({ mag: TUNE.pullFull + 6, fx: dx / len, fy: dz / len }); + }; + + // ---- THE SCENES. `secs` is the take; `at` fires staged events on the clock; `pilot` picks who + // plays. Everything here is the real game reached through the real doors — nothing is faked for + // the camera, which is the only way the footage stays honest when the game changes. + const SCENES = { + // SPLATTER GORE ----------------------------------------------------------------------------- + transition: { secs: 17, pilot: 'tennis', at: [ // the flagship: ordinary tennis, then the turn + [0, () => T().start(null)], + [4.5, () => { T().forceZombies(); }], + ] }, + horde: { secs: 14, pilot: 'gore', at: [ + [0, () => { const t = T(); t.start(null); t.intoSplatter(); t.G.runGames = 6; }], + [2.5, () => T().startHorde()], + ] }, + flamer: { secs: 12, pilot: null, at: [ + [0, () => { const t = T(); t.start(null); t.intoSplatter(); t.G.runGames = 8; t.grantWeapon('flamer', 3); }], + [1.0, () => { const t = T(); for (let i = 0; i < 9; i++) t.spawnZombie(-5 + i * 1.25, -9 + (i % 3) * 1.2, i % 3 ? 'a' : 'd'); }], + [3.0, () => { const t = T(); t.selectWeapon(1 + t.G.rack.findIndex(s => s.id === 'flamer')); t.dragServe({ mag: 140, fx: 0, fy: -1 }); }], + ] }, + // THE BALL BOY, calmly mopping a massacre — the joke lands only if the court is a mess first, + // so the pilot shoots for six seconds and is then switched off to let him work in peace. + // ...and they die on the NEAR court on purpose: mopWorthIt only sends him for a stain on his + // own side of the net, so a massacre at the far baseline is a massacre nobody mops. + mopboy: { secs: 15, pilot: 'gore', pilotUntil: 6.5, at: [ + [0, () => { const t = T(); t.start(null); t.intoSplatter(); t.G.runGames = 9; }], + [0.8, () => { const t = T(); for (let i = 0; i < 8; i++) t.spawnZombie(-4.5 + i * 1.3, 15 + (i % 3) * 1.6, i % 4 ? 'a' : 'b'); }], + [7.0, () => { const t = T(); t.G.boy = null; t.sendBoy(0, 18, true); }], + ] }, + death: { secs: 13, pilot: 'gore', at: [ + [0, () => { const t = T(); t.start(null); t.intoSplatter(); t.G.runGames = 11; }], + [1.0, () => { const t = T(); for (let i = 0; i < 7; i++) t.spawnZombie(-4 + i * 1.4, -3 - (i % 2), 'a'); }], + [7.0, () => T().killPlayer()], + ] }, + // ZOMBIE TENNIS ----------------------------------------------------------------------------- + boss: { secs: 16, pilot: 'gore', at: [ + [0, () => { const t = T(); t.start(null); t.intoSplatter(); + t.G.runGames = 3 * t.TUNE.bossEvery; t.G.bossDue = 0; + for (const id of ['bomb', 'triple', 'beacon', 'flamer', 'rocket']) t.grantWeapon(id, 9); } ], + [1.2, () => T().startBossFight(3)], + ] }, + // THE OPPONENT BURSTS WHEN HIS GAME IS TAKEN, and that burst is the mode's whole payoff — but a + // mediocre autopilot needs a minute to earn one and the clip is eighteen seconds. So every serve + // is game point: the rally is real, the wait is not. + match: { secs: 18, pilot: 'tennis', each: () => { const s = T().score; if (T().G.phase === 'pretoss') s.pts[0] = 3; }, at: [ + [0, () => { const t = T(); t.start(null); t.intoSplatter(); t.startMatch(); }], + ] }, + }; + + async function record(cv, secs) { + const s = cv.captureStream(60); + if (tap) for (const a of tap.stream.getAudioTracks()) s.addTrack(a); + const rec = new MediaRecorder(s, { mimeType: 'video/webm;codecs=vp9,opus', videoBitsPerSecond: 12e6 }); + const chunks = []; + rec.ondataavailable = e => e.data.size && chunks.push(e.data); + const done = new Promise(r => { rec.onstop = r; }); + rec.start(); + await sleep(secs * 1000); + rec.stop(); + await done; + return new Blob(chunks, { type: 'video/webm' }); + } + + // PORTRAIT IS THE FORMAT, AND THE WINDOW IS NOT ASKED. The clip is the canvas BACKING STORE, not + // the window, so the frame is pinned by giving the canvas a 9:16 CSS box (render.js measures the + // box, not the viewport) — no window manager, no title bar, no maximised-on-launch surprise. + // 270x480 logical at RES 4 (capture.py forces device-scale-factor 3, the dpr tier that picks 4) + // is exactly 1080x1920 native: the file needs no upscale, so no filter ever touches the pixels. + function frame() { + // FULL FAT FOR THE CAMERA. lowPower defaults ON (RES capped at 3) because it is invisible on a + // phone and buys 44% of the fill rate — but the clip is the one place the sharpness is judged + // on a big screen, and 0 is what makes the backing store exactly 1080x1920 at dpr 3. + T().TUNE.lowPower = 0; + T().TUNE.pullHintAfter = 1e9; // ...and no ghost hand: the SWIPE TO DODGE demo is for a player + // who has stalled, and on film it is just a hint over the art + const st = document.createElement('style'); + st.textContent = 'canvas#c { width: 270px !important; height: 480px !important; top: 0 !important; }'; + document.head.appendChild(st); + dispatchEvent(new Event('resize')); // ...and the ResizeObserver in render.js catches the box + return sleep(400); + } + + async function run() { + while (!window.T || !window.T.SPR) await sleep(100); + await frame(); + await sleep(1200); // let the boot settle: sprites decoded, theme running + const name = list[0], sc = SCENES[name]; + if (!sc) { console.warn('no such scene', name); return next(); } + const cv = document.getElementById('c'); + fetch('/log?' + encodeURIComponent(name + ': dpr ' + devicePixelRatio + ', viewport ' + innerWidth + 'x' + innerHeight + ', canvas ' + cv.width + 'x' + cv.height)); + const pilot = sc.pilot === 'tennis' ? pilotTennis : sc.pilot === 'gore' ? pilotGore : null; + const fired = new Set(); + const t0 = performance.now(); + const loop = () => { + const t = (performance.now() - t0) / 1000; + for (const [at, fn] of sc.at) if (t >= at && !fired.has(at)) { fired.add(at); try { fn(); } catch (e) { console.error(name, at, e); } } + if (t > 0.2) try { + if (pilot && !(sc.pilotUntil && t > sc.pilotUntil)) pilot(t); + if (sc.each) sc.each(t); + } catch (e) { console.error(e); } + if (t < sc.secs + 1) requestAnimationFrame(loop); + }; + requestAnimationFrame(loop); + sc.at[0][1](); // t=0 event before the recorder rolls: no black frames + fired.add(sc.at[0][0]); + await sleep(250); + const blob = await record(cv, sc.secs); + await fetch('/clip/' + name + '.webm', { method: 'POST', body: blob }); + next(); + } + + function next() { + const rest = list.slice(1); + if (!rest.length) { fetch('/done', { method: 'POST' }); return; } + location.replace('?clips=' + rest.join(',') + (q.get('seed') ? '&seed=' + q.get('seed') : '')); + } + + addEventListener('load', run); +})();