The shot list becomes a config file, and the camera holds a phone

tools/scenes.json says WHAT to shoot; tools/clips.js only knows how. Every
knob Oliver asked for is a key: seconds, setting (gore/zombie), beat, court,
boss, weapons, movement, density, diversity, gore, games, screen — documented
in the file's own "_" block, validated in capture.py so a typo names itself
instead of coming back as fifteen seconds of empty court. Nine scenes ship in
the default list, across all four courts.

The frame is a PHONE now (393x852 -> 1080x2340), not 9:16: the portrait branch
derives its logical height from the canvas box, so a 9:16 take was never the
framing a player sees. The player has legs — held arrow keys through the real
axisX, so horde and flamer are no longer a statue with a flamethrower. The
crowd tops itself up where the game's own spawner puts it, diversity walks the
roster, and `gore` paints in the run's history a fifteen-second clip has no
time to earn. The pilot is picked by what the court is doing rather than by
config: tennis mode taps to swing, splatter draws the slingshot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-18 13:25:59 +02:00
1 parent 491f3b97f1
commit 472c86b36e
4 files changed
+350 -149

No files matched your search

+26 -15
View File
@@ -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 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 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. 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.** **THE CAPTURE RIG IS BUILT (2026-08-18), AND THE SHOT LIST IS DATA.** `python3 tools/capture.py`
`tools/capture.py` serves the repo with `tools/clips.js` injected, opens a throwaway-profile fills `promo/clips/`; `python3 tools/capture.py horde boss` shoots two; `python3 tools/capture.py
Chrome, and the page stages each highlight through `window.T`, records it, POSTs the webm back; my-shots.json` shoots another list entirely; `--seed 77` re-rolls every take. What to shoot lives
ffmpeg writes 1080x1920 h264/aac into `promo/clips/`. Named scenes shoot alone in `tools/scenes.json` — nine scenes today — and its `"_"` block documents every knob:
(`python3 tools/capture.py horde boss`). Seven scenes exist and the list in `clips.js` is the `seconds`, `setting` (gore / zombie), `beat` (transition / horde / boss / death / mopboy),
place to add one — the gore side is `transition` (the flagship: four seconds of ordinary tennis `court`, `boss`, `weapons`, `movement`, `density`, `diversity`, `gore`, `games`, `screen`.
before the turn), `horde`, `flamer`, `mopboy`, `death`, and the zombie-tennis side is `boss` and `tools/clips.js` is the engine that knows how to shoot any of it: `tools/capture.py` serves the
`match`. Nothing is faked for the camera: every clip enters through the same doors a player does repo with it injected, opens a throwaway-profile Chrome, and the page stages the scene, records
(`T.start`, `T.intoSplatter`, `startHorde`, `startBossFight`, `startMatch`), and the two pilots canvas + audio, POSTs the webm back; ffmpeg writes h264/aac. Nothing is faked for the camera —
are deliberately mediocre — the tennis one IS the reachability soak's from `test.js`. every clip enters through the doors a player uses (`T.start`, `T.intoSplatter`, `startHorde`,
Four things it learned the hard way, none of them obvious: `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 - **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 `--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. phone-sized 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 - **THE SCREEN IS A PHONE, not 9:16** (Oliver, 2026-08-18): 393x852 like a 15-class iPhone, which
needed a blurry 1.33x upscale. The rig sets it to 0; with `--force-device-scale-factor=3` the records at 1080x2340. The game genuinely lays out differently for it — the portrait branch
backing store is exactly 1080x1920 and no filter ever touches a pixel. 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` — - **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 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 `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 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 - **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. 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 `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 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 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. 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 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 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, converts in the single-digit percents. YouTube Shorts descriptions and pinned comments DO click,
+100 -34
View File
@@ -1,51 +1,106 @@
#!/usr/bin/env python3 #!/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 # every scene in tools/scenes.json
python3 tools/capture.py horde boss # only these 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 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 THE SHOT LIST IS DATA (tools/scenes.json — its "_" block documents every knob). This file serves
in the Dockerfile COPY, so none of this ships), opens Chrome at phone-portrait size on a throwaway the repo with tools/clips.js injected, opens Chrome on a throwaway profile, and lets the page stage
profile, and lets the page stage each highlight through window.T, record canvas + audio, and POST each scene through window.T, record canvas + audio, and POST the webm back; ffmpeg writes h264/aac
the webm back. ffmpeg then doubles it to 1080x1920 with NEAREST-NEIGHBOUR — pixel art must not be into promo/clips/. The game's own files are untouched, and tools/ is in neither the image nor the
blurred by an upscale — and writes h264/aac into promo/clips/. Dockerfile COPY, so none of this ships.
Why a real browser window and not headless: the recording is real-time off requestAnimationFrame, 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 and headless is exactly where Chrome throttles rAF and swiftshader drops frames. Two minutes of a
frames. Two minutes of a window in the way is cheaper than debugging dropped frames. 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__)) HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE) ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, 'promo', 'clips') OUT = os.path.join(ROOT, 'promo', 'clips')
PORT = 8642 PORT = 8642
CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' 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'<script src="/tools/clips.js"></script>\n</head>' INJECT = b'<script src="/tools/clips.js"></script>\n</head>'
args = [a for a in sys.argv[1:] if not a.startswith('-')] COURTS = ('grass', 'clay', 'hard', 'gloom')
keep = '--keep' in sys.argv WEAPONS = ('bomb', 'triple', 'beacon', 'flamer', 'rocket')
scenes = args or SCENES BEATS = ('transition', 'horde', 'boss', 'death', 'mopboy')
bad = [s for s in scenes if s not in SCENES] SCREENS = ('phone', 'tall', '9x16')
if bad: DIALS = ('movement', 'density', 'diversity', 'gore')
sys.exit('unknown scene(s): %s\nknown: %s' % (', '.join(bad), ', '.join(SCENES)))
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): 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. # -r 60 on the OUTPUT: MediaRecorder writes a variable-rate stream and the platforms want CFR.
subprocess.run(['ffmpeg', '-y', '-loglevel', 'error', '-i', webm, 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', '-r', '60', '-c:v', 'libx264', '-preset', 'slow', '-crf', '18',
'-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '160k', '-movflags', '+faststart', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '160k', '-movflags', '+faststart',
mp4], check=True) 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): class Handler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *a, **kw): def __init__(self, *a, **kw):
super().__init__(*a, directory=ROOT, **kw) super().__init__(*a, directory=ROOT, **kw)
@@ -53,19 +108,23 @@ class Handler(http.server.SimpleHTTPRequestHandler):
def log_message(self, *a): def log_message(self, *a):
pass 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): def do_GET(self):
if self.path.startswith('/log?'): # the page's console, since the browser is throwaway if self.path.startswith('/log?'): # the page's console, since the browser is throwaway
print(' .. ' + urllib.parse.unquote(self.path[5:]), flush=True) print(' .. ' + urllib.parse.unquote(self.path[5:]), flush=True)
self.send_response(204); self.end_headers(); return 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'): if self.path.split('?')[0] in ('/', '/index.html'):
html = open(os.path.join(ROOT, 'index.html'), 'rb').read().replace(b'</head>', INJECT, 1) html = open(os.path.join(ROOT, 'index.html'), 'rb').read().replace(b'</head>', INJECT, 1)
self.send_response(200) return self.send_bytes(html, 'text/html')
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() return super().do_GET()
def do_POST(self): def do_POST(self):
@@ -93,19 +152,26 @@ class Handler(http.server.SimpleHTTPRequestHandler):
os.makedirs(OUT, exist_ok=True) os.makedirs(OUT, exist_ok=True)
srv = http.server.ThreadingHTTPServer(('127.0.0.1', PORT), Handler) srv = http.server.ThreadingHTTPServer(('127.0.0.1', PORT), Handler)
threading.Thread(target=srv.serve_forever, daemon=True).start() 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' profile = '/tmp/splat-capture-profile'
shutil.rmtree(profile, ignore_errors=True) shutil.rmtree(profile, ignore_errors=True)
chrome = subprocess.Popen([ chrome = subprocess.Popen([
CHROME, '--user-data-dir=' + profile, '--no-first-run', '--no-default-browser-check', 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 '--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', '--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) ], 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() chrome.terminate()
srv.shutdown() srv.shutdown()
print('%d clip(s) in promo/clips/' % len(made) if ok else 'TIMED OUT after %d clip(s)' % len(made)) print('%d clip(s) in promo/clips/' % len(made) if ok else 'TIMED OUT after %d clip(s)' % len(made))
+154 -100
View File
@@ -1,14 +1,23 @@
// PROMO CLIP RIG — injected into index.html by tools/capture.py, never shipped (tools/ is not in // PROMO CLIP RIG — injected into index.html by tools/capture.py, never shipped (tools/ is in
// the Dockerfile COPY). It stages a highlight with the real game through window.T, records the // .dockerignore and not in the Dockerfile COPY). The SCENES ARE DATA: tools/scenes.json (or any
// canvas + the WebAudio bus with MediaRecorder, POSTs the webm back to capture.py and reloads into // config passed to capture.py) says what to shoot, this file knows how to shoot it. Everything is
// the next scene. ONE PAGE LOAD PER SCENE — a fresh boot is the only teardown that is guaranteed // staged through window.T — the real doors, the real update loop — recorded off the canvas with
// correct, and it costs a second. // 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 q = new URLSearchParams(location.search);
const list = (q.get('clips') || '').split(',').filter(Boolean); const list = (q.get('clips') || '').split(',').filter(Boolean);
if (!list.length) return; 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; let seed = (Number(q.get('seed')) || 20260818) >>> 0;
Math.random = () => { Math.random = () => {
seed ^= seed << 13; seed >>>= 0; 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 // ---- 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 // 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) // MediaStreamDestination. Patching the prototype here means the game needs no master-bus refactor
// means the game needs no master-bus refactor for a tool that ships to nobody. // 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; let tap = null;
const AC = window.AudioContext || window.webkitAudioContext; 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; let proto = AC.prototype, desc = null;
while (proto && !(desc = Object.getOwnPropertyDescriptor(proto, 'destination'))) proto = Object.getPrototypeOf(proto); while (proto && !(desc = Object.getOwnPropertyDescriptor(proto, 'destination'))) proto = Object.getPrototypeOf(proto);
const real = desc.get; const real = desc.get;
@@ -39,14 +48,22 @@
const sleep = ms => new Promise(r => setTimeout(r, ms)); const sleep = ms => new Promise(r => setTimeout(r, ms));
const T = () => window.T; 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 // THE SCREEN IS A PHONE. Not 9:16 — a real handset is taller (393x852 on a 15/16-class iPhone,
// fixed tap window); the gore one draws the slingshot at the nearest body. Both are mediocre on // aspect 0.46), and the game LAYS OUT differently for it: render.js derives its logical height
// purpose — a clip of a perfect player is a clip of nothing happening. // 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; let lastTap = 0;
const pilotTennis = t => { const pilotTennis = t => {
const { G, ball, P, keyPressed } = 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'); } } 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 === 'toss') { if (ball.vy < 0.4 && ball.y > 2.4) keyPressed('Space'); }
else if (G.phase === 'rally' && ball.active && ball.lastHit === 'O') { else if (G.phase === 'rally' && ball.active && ball.lastHit === 'O') {
@@ -56,8 +73,7 @@
}; };
const pilotGore = t => { const pilotGore = t => {
const { G, P, zombies, TUNE, dragServe } = 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; // a burst and a horde fire themselves
if (G.horde || G.mgT > 0 || t - lastTap < 0.45) return;
let best = null, bd = 1e9; let best = null, bd = 1e9;
for (const z of zombies) { for (const z of zombies) {
const d = Math.hypot(z.x - P.x, z.z - P.z); 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; 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 }); 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 // ---- LEGS. The player walks on the real input path: held arrow keys, the same axisX/axisZ every
// plays. Everything here is the real game reached through the real doors — nothing is faked for // desktop player uses. `movement` 0 is a statue, 1 sweeps the full width — and it only bites in
// the camera, which is the only way the footage stays honest when the game changes. // 'rally' or under a burst, because the game's own rule is no walking between points.
const SCENES = { const held = {};
// SPLATTER GORE ----------------------------------------------------------------------------- const hold = (code, on) => {
transition: { secs: 17, pilot: 'tennis', at: [ // the flagship: ordinary tennis, then the turn if (!!held[code] === !!on) return;
[0, () => T().start(null)], held[code] = on;
[4.5, () => { T().forceZombies(); }], dispatchEvent(new KeyboardEvent(on ? 'keydown' : 'keyup', { code }));
] },
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(); }],
] },
}; };
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) { async function record(cv, secs) {
const s = cv.captureStream(60); const s = cv.captureStream(60);
@@ -130,58 +194,48 @@
return new Blob(chunks, { type: 'video/webm' }); 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() { async function run() {
const cfg = await (await fetch('/scenes')).json();
const S = cfg[list[0]];
while (!window.T || !window.T.SPR) await sleep(100); while (!window.T || !window.T.SPR) await sleep(100);
await frame(); await frame(S);
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'); const cv = document.getElementById('c');
fetch('/log?' + encodeURIComponent(name + ': dpr ' + devicePixelRatio + ', viewport ' + innerWidth + 'x' + innerHeight + ', canvas ' + cv.width + 'x' + cv.height)); log(`${S.name}: ${cv.width}x${cv.height}, ${S.setting}${S.beat ? ' / ' + S.beat : ''}, court ${S.court || 'grass'}`);
const pilot = sc.pilot === 'tennis' ? pilotTennis : sc.pilot === 'gore' ? pilotGore : null;
const booked = [];
const add = (at, fn) => booked.push([at, fn]);
if (BEATS[S.beat]) BEATS[S.beat](S, add);
const fired = new Set(); const fired = new Set();
const pilotUntil = S.beat === 'mopboy' ? Math.max(3, S.seconds - 8.5) : Infinity;
enter(S);
const t0 = performance.now(); const t0 = performance.now();
const loop = () => { const loop = () => {
const t = (performance.now() - t0) / 1000; 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 (t > 0.2) try {
if (pilot && !(sc.pilotUntil && t > sc.pilotUntil)) pilot(t); if (t < pilotUntil) pilot(t);
if (sc.each) sc.each(t); legs(t, S.movement);
crowd(t, S);
guns(t, S);
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
if (t < sc.secs + 1) requestAnimationFrame(loop); if (t < S.seconds + 1) requestAnimationFrame(loop);
}; };
requestAnimationFrame(loop); requestAnimationFrame(loop);
sc.at[0][1](); // t=0 event before the recorder rolls: no black frames await sleep(250); // one beat of staged court before the recorder rolls
fired.add(sc.at[0][0]); const blob = await record(cv, S.seconds);
await sleep(250); await fetch('/clip/' + S.name + '.webm', { method: 'POST', body: blob });
const blob = await record(cv, sc.secs);
await fetch('/clip/' + name + '.webm', { method: 'POST', body: blob });
next();
}
function next() {
const rest = list.slice(1); const rest = list.slice(1);
if (!rest.length) { fetch('/done', { method: 'POST' }); return; } if (!rest.length) return void fetch('/done', { method: 'POST' });
location.replace('?clips=' + rest.join(',') + (q.get('seed') ? '&seed=' + q.get('seed') : '')); 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); addEventListener('load', run);
+70
View File
@@ -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
}
]
}