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:
1 parent
491f3b97f1
commit
472c86b36e
4 files changed
+350
-149
No files matched your search
+154
-100
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user