// 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; // 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; 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 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; 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; const rnd = (a, b) => a + Math.random() * (b - a); const log = m => fetch('/log?' + encodeURIComponent(m)); // 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.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.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); 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 }); }; const pilot = t => (T().G.mode === 'tennis' ? pilotTennis : pilotGore)(t); // ---- 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); 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' }); } 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(S); const cv = document.getElementById('c'); 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; 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 < pilotUntil) pilot(t); legs(t, S.movement); crowd(t, S); guns(t, S); } catch (e) { console.error(e); } if (t < S.seconds + 1) requestAnimationFrame(loop); }; requestAnimationFrame(loop); 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) 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); })();