diff --git a/PROJECT.md b/PROJECT.md index c848a90..1224cb9 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -3553,27 +3553,37 @@ 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. - **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 CAPTURE RIG IS BUILT (2026-08-18), AND THE SHOT LIST IS DATA.** `python3 tools/capture.py` + fills `promo/clips/`; `python3 tools/capture.py horde boss` shoots two; `python3 tools/capture.py + my-shots.json` shoots another list entirely; `--seed 77` re-rolls every take. What to shoot lives + in `tools/scenes.json` — nine scenes today — and its `"_"` block documents every knob: + `seconds`, `setting` (gore / zombie), `beat` (transition / horde / boss / death / mopboy), + `court`, `boss`, `weapons`, `movement`, `density`, `diversity`, `gore`, `games`, `screen`. + `tools/clips.js` is the engine that knows how to shoot any of it: `tools/capture.py` serves the + repo with it injected, opens a throwaway-profile Chrome, and the page stages the scene, records + canvas + audio, POSTs the webm back; ffmpeg writes h264/aac. Nothing is faked for the camera — + every clip enters through the doors a player uses (`T.start`, `T.intoSplatter`, `startHorde`, + `startBossFight`, `startMatch`), the crowd walks in where the game's own spawner puts it, and the + player walks on held arrow keys through the real `axisX`. The two pilots are deliberately + mediocre (the tennis one IS the reachability soak's from `test.js`) and are chosen by what the + court is doing, not by config: `G.mode === 'tennis'` taps to swing, splatter draws the slingshot. + Six 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. + phone-sized CSS box, which `render.js` measures (`cv.clientWidth`) through its ResizeObserver. + - **THE SCREEN IS A PHONE, not 9:16** (Oliver, 2026-08-18): 393x852 like a 15-class iPhone, which + records at 1080x2340. The game genuinely lays out differently for it — the portrait branch + derives its logical height from the box — so a 9:16 take is not the framing a player sees. + `screen: "9x16"` is still there for a platform that insists. + - **`TUNE.lowPower` defaults ON and caps `RES` at 3**, so the first recordings came out 810 wide + and needed a blurry 1.33x upscale. The rig sets it to 0; with `--force-device-scale-factor=3` + the backing store IS the output and no filter ever resamples 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. + - **The court is remembered, not passed**: `main.js` reads `localStorage.court` once at module + init, so a scene's court rides the URL and is stored before the module boots. - **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 @@ -3581,6 +3591,7 @@ never an interstitial. An interstitial after death would land exactly in the "on 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/tools/capture.py b/tools/capture.py index 7f457cb..33bde92 100644 --- a/tools/capture.py +++ b/tools/capture.py @@ -1,51 +1,106 @@ #!/usr/bin/env python3 -"""Promo clip factory — one command, a folder of postable vertical mp4s. +"""Promo clip factory — one command, a folder of postable phone-shaped 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 + python3 tools/capture.py # every scene in tools/scenes.json + python3 tools/capture.py horde boss # only these + python3 tools/capture.py my-shots.json # a different shot list + python3 tools/capture.py --seed 77 --keep # re-roll the randomness / keep the raw webm -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/. +THE SHOT LIST IS DATA (tools/scenes.json — its "_" block documents every knob). This file serves +the repo with tools/clips.js injected, opens Chrome on a throwaway profile, and lets the page stage +each scene through window.T, record canvas + audio, and POST the webm back; ffmpeg writes h264/aac +into promo/clips/. The game's own files are untouched, and tools/ is in neither the image nor the +Dockerfile COPY, so none of this ships. 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. +and headless 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 +import http.server, json, 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))) +COURTS = ('grass', 'clay', 'hard', 'gloom') +WEAPONS = ('bomb', 'triple', 'beacon', 'flamer', 'rocket') +BEATS = ('transition', 'horde', 'boss', 'death', 'mopboy') +SCREENS = ('phone', 'tall', '9x16') +DIALS = ('movement', 'density', 'diversity', 'gore') -done = threading.Event() -made = [] + +def check(sc, i): + """Validate one scene. The config is hand-written, so a typo must name itself here rather than + come back as fifteen seconds of an empty court.""" + def bad(msg): + sys.exit('scenes[%d] (%s): %s' % (i, sc.get('name', '?'), msg)) + name = sc.get('name') + if not name or not all(c.isalnum() or c in '-_' for c in name): + bad('needs a name of letters, digits, - and _') + if not 3 <= sc.get('seconds', 0) <= 60: + bad('seconds must be 3..60') + if sc.get('setting') not in ('gore', 'zombie'): + bad('setting must be gore or zombie') + if sc.get('beat') and sc['beat'] not in BEATS: + bad('beat must be one of ' + ', '.join(BEATS)) + if sc.get('court') and sc['court'] not in COURTS: + bad('court must be one of ' + ', '.join(COURTS)) + if sc.get('screen') and sc['screen'] not in SCREENS: + bad('screen must be one of ' + ', '.join(SCREENS)) + for w in sc.get('weapons', []): + if w not in WEAPONS: + bad('unknown weapon %r — have %s' % (w, ', '.join(WEAPONS))) + for d in DIALS: + if d in sc and not 0 <= sc[d] <= 1: + bad('%s must be 0..1' % d) + if sc.get('boss') is not None: + if not 1 <= sc['boss'] <= 10: + bad('boss must be chapter 1..10') + # ONE MODE OWNS THE COURT (beats.js): a boss fight refuses to start inside a match, so the + # config cannot ask for both and quietly get neither. + if sc.get('setting') != 'gore': + bad('a boss fight is a gore-run beat — set setting to gore') + return sc def to_mp4(webm, mp4): + # No scaling: at RES 4 the canvas backing store IS the output (a phone box records 1080x2340), + # and pixel art must never be resampled. The filter only guarantees even dimensions for h264. # -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 + '-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2', '-r', '60', '-c:v', 'libx264', '-preset', 'slow', '-crf', '18', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '160k', '-movflags', '+faststart', mp4], check=True) +# ---- the command line: a .json is the shot list, a --flag is a flag, anything else is a scene name +argv = sys.argv[1:] +keep = '--keep' in argv +seed = '' +if '--seed' in argv: + seed = argv[argv.index('--seed') + 1] + argv = [a for a in argv if a != seed] +cfg_path = next((a for a in argv if a.endswith('.json')), os.path.join(HERE, 'scenes.json')) +wanted = [a for a in argv if not a.startswith('-') and not a.endswith('.json')] + +cfg = json.load(open(cfg_path)) +scenes = {} +for i, sc in enumerate(cfg['scenes']): + check(sc, i) + scenes[sc['name']] = sc +order = wanted or list(scenes) +bad = [s for s in order if s not in scenes] +if bad: + sys.exit('unknown scene(s): %s\nin %s: %s' % (', '.join(bad), os.path.relpath(cfg_path, ROOT), ', '.join(scenes))) + +done = threading.Event() +made = [] + + class Handler(http.server.SimpleHTTPRequestHandler): def __init__(self, *a, **kw): super().__init__(*a, directory=ROOT, **kw) @@ -53,19 +108,23 @@ class Handler(http.server.SimpleHTTPRequestHandler): def log_message(self, *a): pass + def send_bytes(self, body, ctype): + self.send_response(200) + self.send_header('Content-Type', ctype) + self.send_header('Content-Length', str(len(body))) + self.send_header('Cache-Control', 'no-store') + self.end_headers() + self.wfile.write(body) + 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 == '/scenes': + return self.send_bytes(json.dumps(scenes).encode(), 'application/json') 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 self.send_bytes(html, 'text/html') return super().do_GET() def do_POST(self): @@ -93,19 +152,26 @@ class Handler(http.server.SimpleHTTPRequestHandler): 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) +print('shooting %d scene(s) from %s: %s' % (len(order), os.path.relpath(cfg_path, ROOT), ', '.join(order)), flush=True) + +url = 'http://127.0.0.1:%d/?clips=%s' % (PORT, ','.join(order)) +if seed: + url += '&seed=' + seed +if scenes[order[0]].get('court'): # the court must be stored before the game module boots + url += '&court=' + scenes[order[0]]['court'] 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 + '--force-device-scale-factor=3', # dpr 3 -> render.js picks RES 4, the sharp tier '--window-size=700,1000', '--window-position=0,0', - '--app=http://127.0.0.1:%d/?clips=%s' % (PORT, ','.join(scenes)), + '--app=' + url, ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) -ok = done.wait(timeout=60 + 30 * len(scenes)) +budget = 60 + sum(scenes[s]['seconds'] + 14 for s in order) +ok = done.wait(timeout=budget) chrome.terminate() srv.shutdown() print('%d clip(s) in promo/clips/' % len(made) if ok else 'TIMED OUT after %d clip(s)' % len(made)) diff --git a/tools/clips.js b/tools/clips.js index 6cb770c..0f0ab6a 100644 --- a/tools/clips.js +++ b/tools/clips.js @@ -1,14 +1,23 @@ -// 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. +// PROMO CLIP RIG — injected into index.html by tools/capture.py, never shipped (tools/ is in +// .dockerignore and not in the Dockerfile COPY). The SCENES ARE DATA: tools/scenes.json (or any +// config passed to capture.py) says what to shoot, this file knows how to shoot it. Everything is +// staged through window.T — the real doors, the real update loop — recorded off the canvas with +// MediaRecorder, POSTed back, and the page reloads itself into the next scene. +// +// ONE PAGE LOAD PER SCENE: a fresh boot is the only teardown guaranteed correct, and it costs a +// second. Anything that must happen BEFORE the game module boots (the seed, the audio tap, the +// remembered court) happens at the top of this file, which runs first as a classic script. (() => { 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 ---------- + // THE COURT IS REMEMBERED, NOT PASSED: main.js reads localStorage once at module init, so the + // pick has to be in place before it boots — which is why it rides the URL rather than the config + // fetch. Same door the setup screen writes through. + if (q.get('court')) try { localStorage.setItem('court', q.get('court')); } catch {} + + // ---- 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; @@ -19,11 +28,11 @@ // ---- 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. + // MediaStreamDestination. Patching the prototype here means the game needs no master-bus refactor + // for a tool that ships to nobody. NOTE: `destination` lives on BaseAudioContext, not on + // AudioContext — walk the chain for it rather than assuming a level. 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; @@ -39,14 +48,22 @@ const sleep = ms => new Promise(r => setTimeout(r, ms)); const T = () => window.T; + const rnd = (a, b) => a + Math.random() * (b - a); + const log = m => fetch('/log?' + encodeURIComponent(m)); - // ---- 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. + // THE SCREEN IS A PHONE. Not 9:16 — a real handset is taller (393x852 on a 15/16-class iPhone, + // aspect 0.46), and the game LAYS OUT differently for it: render.js derives its logical height + // from the canvas box, so the apron between court and rack grows and the framing is the one a + // player actually sees. `9x16` stays available for a platform that insists on it. + const SCREENS = { phone: [393, 852], tall: [412, 915], '9x16': [270, 480] }; + + // ---- the two autopilots, picked by what the court is doing rather than by the config: tennis + // rules (the match, and the ordinary tennis a transition opens on) tap to swing; splatter draws + // the slingshot at the nearest body. Both are deliberately mediocre — the tennis one IS the + // reachability soak's from test.js — because a clip of a perfect player is a clip of nothing. 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') { @@ -56,8 +73,7 @@ }; 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; + if (G.horde || G.mgT > 0 || t - lastTap < 0.45) return; // a burst and a horde fire themselves let best = null, bd = 1e9; for (const z of zombies) { const d = Math.hypot(z.x - P.x, z.z - P.z); @@ -68,53 +84,101 @@ 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 }); }; + const pilot = t => (T().G.mode === 'tennis' ? pilotTennis : pilotGore)(t); - // ---- 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(); }], - ] }, + // ---- LEGS. The player walks on the real input path: held arrow keys, the same axisX/axisZ every + // desktop player uses. `movement` 0 is a statue, 1 sweeps the full width — and it only bites in + // 'rally' or under a burst, because the game's own rule is no walking between points. + const held = {}; + const hold = (code, on) => { + if (!!held[code] === !!on) return; + held[code] = on; + dispatchEvent(new KeyboardEvent(on ? 'keydown' : 'keyup', { code })); }; + const legs = (t, m) => { + if (!m) return; + const { P } = T(); + const tx = Math.sin(t * (0.45 + 0.75 * m)) * m * 5.5; // a patrol, not a jitter + hold('ArrowLeft', P.x - tx > 0.3); + hold('ArrowRight', tx - P.x > 0.3); + }; + + // ---- the crowd. `density` is how many bodies are kept on court, `diversity` how deep into the + // roster the mix goes (0 = shamblers only, 1 = the whole club). They enter where the game's own + // spawner puts them, behind the far baseline, so they walk in rather than appear. + const VARIANTS = ['a', 'b', 'd', 'f', 'e', 's', 'g', 'h', 'k']; + let lastSpawn = -9; + const crowd = (t, S) => { + const g = T(); + const want = Math.round((S.density || 0) * 14); + if (!want || g.zombies.length >= want || t - lastSpawn < 0.3 || g.G.horde || g.G.bossFight) return; // a boss blocks every other spawn anyway + lastSpawn = t; + const pool = 1 + Math.round((S.diversity || 0) * (VARIANTS.length - 1)); + g.spawnZombie(rnd(-4.8, 4.8), -1.5 - Math.random(), VARIANTS[(Math.random() * pool) | 0]); + }; + + // ---- the mess. Blood normally accumulates over a run; a 15-second clip has no run behind it, so + // `gore` paints the history in. Same {x,z,r,a} stain the game pushes itself. + const paint = amount => { + const { gore } = T(); + for (let i = 0; i < Math.round((amount || 0) * 30); i++) + gore.push({ x: rnd(-6.5, 6.5), z: rnd(2, 22), r: rnd(0.5, 1.8), a: rnd(0.35, 0.9) }); + }; + + // ---- the rack. Everything listed is granted at full stock and the selection walks the list, so a + // clip can show three tins in fifteen seconds. Serve tins spend per shot; burst tins arm on the + // pilot's next pull and then fire themselves. + let lastGun = -9, gunI = 0; + const guns = (t, S) => { + const g = T(); + if (!S.weapons || !S.weapons.length || g.G.mgT > 0 || g.G.horde) return; + if (t - lastGun < (S.weaponEvery || 4)) return; + lastGun = t; + const slot = 1 + g.G.rack.findIndex(s => s.id === S.weapons[gunI++ % S.weapons.length]); + if (slot > 0) g.selectWeapon(slot); + }; + + // ---- THE STAGED BEATS. Everything above is a dial; these are the moments, and each is the real + // one reached through the real door. `add(at, fn)` books a one-shot on the scene clock. + const BEATS = { + transition: (S, add) => add(S.turnAt || 4.5, g => g.forceZombies()), // G.transT runs ~9 s from here + horde: (S, add) => add(2.5, g => g.startHorde()), + boss: (S, add) => add(1.2, g => g.startBossFight(S.boss || 3)), + death: (S, add) => add(Math.max(3, S.seconds - 6), g => g.killPlayer()), + // he only walks out for a stain on his OWN side of the net (state.mopWorthIt), which is why the + // pilot is switched off first: a mopped court and a shooting player are two different shots. + mopboy: (S, add) => add(Math.max(3, S.seconds - 8), g => { g.G.boy = null; g.sendBoy(0, 18, true); }), + }; + + function enter(S) { + const g = T(); + g.start(null); // menus down, court/kit/tour selected as remembered + if (S.beat === 'transition') return; // the turn is only reachable from ordinary tennis + g.intoSplatter(); + g.G.runGames = S.games != null ? S.games : (S.boss ? S.boss * g.TUNE.bossEvery : 8); + if (S.boss) g.G.bossDue = 0; // ...or the schedule queues a SECOND fight behind it + if (S.setting === 'zombie' && !S.boss) g.startMatch(); + for (const id of (S.weapons || [])) g.grantWeapon(id, 9); + paint(S.gore); + } + + // ---- the frame. The clip is the canvas BACKING STORE, not the window: Chrome opens maximised and + // ignores --window-size, and resizeTo does nothing to it, so the format is pinned by giving the + // canvas a phone-sized CSS box, which render.js measures (cv.clientWidth) through its + // ResizeObserver. At RES 4 (see lowPower below) a 393x852 box records at 1080x2340. + function frame(S) { + // FULL FAT FOR THE CAMERA. lowPower defaults ON and caps RES at 3 — invisible on a phone, worth + // 44% of the fill rate — but the clip is the one place sharpness is judged on a big screen. + T().TUNE.lowPower = 0; + T().TUNE.pullHintAfter = 1e9; // and no ghost hand: SWIPE TO DODGE is for a stalled player, on + // film it is a hint drawn over the art + const [w, h] = SCREENS[S.screen] || SCREENS.phone; + const st = document.createElement('style'); + st.textContent = `canvas#c { width: ${w}px !important; height: ${h}px !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 record(cv, secs) { const s = cv.captureStream(60); @@ -130,58 +194,48 @@ 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() { + const cfg = await (await fetch('/scenes')).json(); + const S = cfg[list[0]]; 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(); } + await frame(S); 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; + log(`${S.name}: ${cv.width}x${cv.height}, ${S.setting}${S.beat ? ' / ' + S.beat : ''}, court ${S.court || 'grass'}`); + + const booked = []; + const add = (at, fn) => booked.push([at, fn]); + if (BEATS[S.beat]) BEATS[S.beat](S, add); const fired = new Set(); + const pilotUntil = S.beat === 'mopboy' ? Math.max(3, S.seconds - 8.5) : Infinity; + + enter(S); 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); } } + const g = T(); + if (g.G.modal) { g.G.modal = null; g.G.modalQ.length = 0; } // no cards over the footage + // THE OPPONENT BURSTS WHEN HIS GAME IS TAKEN, and that burst is zombie tennis's whole payoff — + // but a mediocre autopilot needs a minute to earn one and a clip is fifteen seconds. Every + // serve is game point: the rally is real, the wait is not. + if (S.setting === 'zombie' && S.gamePoint !== false && g.G.phase === 'pretoss') g.score.pts[0] = 3; + for (const [at, fn] of booked) if (t >= at && !fired.has(at)) { fired.add(at); try { fn(g); } catch (e) { console.error(e); } } if (t > 0.2) try { - if (pilot && !(sc.pilotUntil && t > sc.pilotUntil)) pilot(t); - if (sc.each) sc.each(t); + if (t < pilotUntil) pilot(t); + legs(t, S.movement); + crowd(t, S); + guns(t, S); } catch (e) { console.error(e); } - if (t < sc.secs + 1) requestAnimationFrame(loop); + if (t < S.seconds + 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() { + await sleep(250); // one beat of staged court before the recorder rolls + const blob = await record(cv, S.seconds); + await fetch('/clip/' + S.name + '.webm', { method: 'POST', body: blob }); 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') : '')); + if (!rest.length) return void fetch('/done', { method: 'POST' }); + const nx = cfg[rest[0]]; + location.replace('?clips=' + rest.join(',') + (q.get('seed') ? '&seed=' + q.get('seed') : '') + + (nx && nx.court ? '&court=' + nx.court : '')); } addEventListener('load', run); diff --git a/tools/scenes.json b/tools/scenes.json new file mode 100644 index 0000000..e4de3b0 --- /dev/null +++ b/tools/scenes.json @@ -0,0 +1,70 @@ +{ + "_": [ + "THE SHOT LIST. `python3 tools/capture.py` shoots every scene below into promo/clips/;", + "`python3 tools/capture.py horde boss` shoots two; `python3 tools/capture.py my.json` shoots", + "another list entirely. Keys starting with _ are ignored, which is where the comments go.", + "", + "name file name, and the name you pass on the command line", + "seconds length of the take (3-60)", + "setting gore | zombie — the splatter run, or the tennis match against the roster", + "beat transition | horde | boss | death | mopboy — the staged moment, optional", + "court grass | clay | hard | gloom", + "boss 1-10, the chapter whose boss walks on (gore only; it owns the court, so no match)", + "weapons rack tins granted and cycled through: bomb, triple, beacon, flamer, rocket", + "movement 0 statue .. 1 sweeps the full width (only bites in a rally or under a burst)", + "density 0 .. 1 how many bodies are kept on court (1 is about fourteen)", + "diversity 0 shamblers only .. 1 the whole roster (steward, runner, ball boy, brute, bouncer)", + "gore 0 .. 1 blood already on the court when the clip opens", + "games run progress the HUD shows and the speed ramp reads (default 8, or the boss chapter)", + "screen phone (default, 393x852 -> 1080x2340) | tall | 9x16", + "seed whole-shoot seed lives on the command line: --seed 1234 re-rolls every take" + ], + + "scenes": [ + { + "_": "THE FLAGSHIP. Four seconds of an ordinary SNES tennis game, then the turn. Nothing else in the clip needs to happen — do not add weapons or a crowd to it.", + "name": "transition", "seconds": 17, "setting": "gore", "beat": "transition", + "court": "grass", "movement": 0.35, "gore": 0 + }, + { + "name": "horde", "seconds": 15, "setting": "gore", "beat": "horde", + "court": "clay", "movement": 0.85, "density": 0.8, "diversity": 0.9, "gore": 0.45, "games": 14 + }, + { + "_": "The epidemic: a wave lined up across the far court and a flamethrower opened on it.", + "name": "flamer", "seconds": 14, "setting": "gore", + "court": "grass", "weapons": ["flamer", "rocket"], "weaponEvery": 6, + "movement": 0.7, "density": 0.85, "diversity": 0.6, "gore": 0.3, "games": 9 + }, + { + "_": "Artillery night: three tins in fourteen seconds on the dark court.", + "name": "arsenal", "seconds": 14, "setting": "gore", + "court": "gloom", "weapons": ["bomb", "beacon", "triple"], "weaponEvery": 4, + "movement": 0.6, "density": 0.7, "diversity": 1, "gore": 0.55, "games": 16 + }, + { + "_": "The ball boy calmly mopping a massacre. The pilot stops shooting so he can work.", + "name": "mopboy", "seconds": 15, "setting": "gore", "beat": "mopboy", + "court": "hard", "movement": 0.4, "density": 0.55, "diversity": 0.7, "gore": 0.5, "games": 10 + }, + { + "name": "death", "seconds": 13, "setting": "gore", "beat": "death", + "court": "clay", "movement": 0.5, "density": 0.9, "diversity": 0.8, "gore": 0.7, "games": 11 + }, + { + "_": "Chapter three's boss walks on with the whole rack already gathered.", + "name": "boss", "seconds": 16, "setting": "gore", "beat": "boss", "boss": 3, + "court": "grass", "weapons": ["rocket", "bomb"], "movement": 0.75, "gore": 0.4 + }, + { + "_": "ZOMBIE TENNIS: a rally on real tennis rules, the opponent bursts, the next one is announced.", + "name": "match", "seconds": 18, "setting": "zombie", + "court": "grass", "movement": 0, "gore": 0.25 + }, + { + "_": "...and the same mode on clay, deeper into the roster, with the court already bloody.", + "name": "match-clay", "seconds": 16, "setting": "zombie", + "court": "clay", "movement": 0, "gore": 0.8, "games": 20 + } + ] +}