The highlights shoot themselves: one command, seven vertical clips
`python3 tools/capture.py` fills promo/clips/ with 1080x1920 h264/aac takes of the moments the campaign plan (item 4) already named — transition, horde, flamer, mopboy, death on the gore side, boss and match on the zombie-tennis side. capture.py serves the repo with tools/clips.js injected, opens a throwaway-profile Chrome and converts each POSTed webm with ffmpeg; clips.js stages every scene through the real doors on window.T and drives it with the reachability soak's own mediocre autopilot. Nothing is faked for the camera. The game gains four names on window.T (start, intoSplatter, startHorde, sendBoy) and nothing else: the frame is pinned by the canvas box rather than the window, RES 4 comes from lowPower 0 plus device-scale-factor 3, and the audio is tapped by making BaseAudioContext's `destination` a gain node — so audio.js needs no master bus for a tool that ships to nobody. tools/ and promo/clips/ are ignored by git and docker alike. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
296d34d8ca
commit
491f3b97f1
7 files changed
+338
-5
No files matched your search
@@ -10,3 +10,7 @@ assets/_*
|
|||||||
HANDOFF.md
|
HANDOFF.md
|
||||||
**/__pycache__
|
**/__pycache__
|
||||||
**/.DS_Store
|
**/.DS_Store
|
||||||
|
|
||||||
|
# promo footage is a local build product (tools/capture.py), never served
|
||||||
|
promo/clips
|
||||||
|
tools
|
||||||
@@ -4,3 +4,4 @@ scores.json.tmp
|
|||||||
assets/_gen/
|
assets/_gen/
|
||||||
assets/_refcheck/
|
assets/_refcheck/
|
||||||
assets/__pycache__/
|
assets/__pycache__/
|
||||||
|
promo/clips/
|
||||||
+28
-2
@@ -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
|
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.
|
||||||
A capture helper on `window.T` to jump straight into a given beat (the transition timeline is on
|
**THE CAPTURE RIG IS BUILT (2026-08-18): `python3 tools/capture.py` and the folder fills up.**
|
||||||
`G.transT`) would make clean takes cheap — not yet built.
|
`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
|
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,
|
||||||
|
|||||||
+1
-1
@@ -659,6 +659,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="src/main.js?v=288"></script>
|
<script type="module" src="src/main.js?v=289"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+4
-2
@@ -4,9 +4,9 @@
|
|||||||
// render (camera/drawing) · input (keys/touch) · audio (sfx/umpire) · panel (T tuning UI)
|
// 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 { 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 { 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 { netConnect, netHooks } from './net.js';
|
||||||
import { fitViewport, render, proj, RES, SPRV, LOAD, SPR, drawLook } from './render.js';
|
import { fitViewport, render, proj, RES, SPRV, LOAD, SPR, drawLook } from './render.js';
|
||||||
import { voice, ambient, menuMusic, menuDuck } from './audio.js';
|
import { voice, ambient, menuMusic, menuDuck } from './audio.js';
|
||||||
@@ -788,4 +788,6 @@ requestAnimationFrame(frame);
|
|||||||
// exposed for in-browser sanity checks and simulated playtests
|
// 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,
|
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,
|
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; } };
|
get phase() { return G.phase; }, set phase(v) { G.phase = v; } };
|
||||||
@@ -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'<script src="/tools/clips.js"></script>\n</head>'
|
||||||
|
|
||||||
|
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'</head>', 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)
|
||||||
+188
@@ -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);
|
||||||
|
})();
|
||||||
Reference in new issue
Block a user