// Record the RAMMING CHARGE (Vanguard) as a video. // // This headless box renders ~1–2 fps, so real-time capture (screencast or a // screenshot loop) can't hold a 350 ms dash. Instead we drive the // simulation DETERMINISTICALLY: // // 1. boot the ?combat&skill=max game, ack the tutorial comms, // 2. spawn one hostile 520 px ahead on the ship's heading, // 3. FREEZE the world (comms pause — GameScene.update then skips // ship.update / combat.update but keeps RENDERING), // 4. cast RAMMING CHARGE, // 5. hand-step the exact unfrozen block (ship.update + combat.update + // combatHud) in 16.67 ms slices, one screenshot per slice, // 6. ffmpeg → mp4 (25 fps playback ≈ 4 s of slow-mo). // // Usage: node dev/ram-video.mjs (dev server :8080, headless browser :9333) import fs from 'node:fs'; import { execFileSync } from 'node:child_process'; const CDP = '127.0.0.1:9333'; const BASE = 'http://127.0.0.1:8080'; const SHOTS = new URL('./shots/', import.meta.url).pathname; const FRAMES = SHOTS + 'ram-charge.frames'; const MP4 = SHOTS + 'ram-charge.mp4'; const DT = 40; // ms per simulated slice (25 Hz — coarse enough to keep the headless GPU light) const STEPS = 60; // 2.4 s of game time: the full 1 s dash + impact + ember tail fs.rmSync(FRAMES, { recursive: true, force: true }); fs.mkdirSync(FRAMES, { recursive: true }); const target = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(`${BASE}/index.html?combat&skill=max`)}`, { method: 'PUT' })).json(); const ws = new WebSocket(target.webSocketDebuggerUrl); let id = 0; const pending = new Map(); const send = (method, params = {}) => new Promise((res, rej) => { const mid = ++id; const to = setTimeout(() => { pending.delete(mid); rej(new Error(`TIMEOUT ${method}`)); }, 25000); pending.set(mid, { res: (v) => { clearTimeout(to); res(v); }, rej: (e) => { clearTimeout(to); rej(e); } }); ws.send(JSON.stringify({ id: mid, method, params })); }); ws.onmessage = (m) => { const x = JSON.parse(m.data); if (x.id && pending.has(x.id)) { const { res, rej } = pending.get(x.id); pending.delete(x.id); x.error ? rej(new Error(x.error.message)) : res(x.result); } }; await new Promise((res, rej) => { ws.onopen = res; ws.onerror = () => rej(new Error('ws error')); }); await send('Page.enable'); await send('Runtime.enable'); const ev = async (expression) => { const r = await send('Runtime.evaluate', { expression, returnByValue: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? 'eval error'); return r.result?.value; }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const shot = async (name) => { const r = await send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(`${FRAMES}/${name}.png`, Buffer.from(r.data, 'base64')); }; // ---- 1. boot --------------------------------------------------------------- let ready = false; for (let i = 0; i < 90 && !ready; i++) { ready = await ev('Boolean(window.__ORBIT_COMBAT__)').catch(() => false); if (!ready) await ev(`(() => { const m = window.game?.scene?.getScene('MenuScene'); if (m?.startNewGame) m.startNewGame(); return true; })()`) .catch(() => {}); await sleep(500); } if (!ready) throw new Error('combat never came up'); await sleep(2500); // intro settle // ---- 2. stage --------------------------------------------------------------- const stage = await ev(`(() => { const gs = window.game.scene.getScene('GameScene'); const sys = gs.combat; if (gs.comms?.paused) { try { gs.comms._ack(); } catch {} } // clear the tutorial hold gs.comms.queue.length = 0; // keep the clip clean — no comms mid-shot const s = sys.ship; const target = sys.spawnEnemy(s.x + 520, s.y, 991); return { ship: [Math.round(s.x), Math.round(s.y)], target: target ? [Math.round(target.x), Math.round(target.y)] : null, paused: gs.comms?.paused, }; })()`); console.log('stage:', JSON.stringify(stage)); await sleep(700); // ---- 3. pre-cast beat, then freeze + cast ----------------------------------- await shot('0000'); await sleep(400); await shot('0001'); const cast = await ev(`(() => { const gs = window.game.scene.getScene('GameScene'); gs.comms.queue.length = 0; gs.comms._setPaused(true); // freeze: RAF now skips ship/combat stepping const oc = window.__ORBIT_COMBAT__; oc.cast(0); const d = oc.system.dash; return { frozen: gs.comms?.paused === true, dash: d ? { toX: Math.round(d.toX), toY: Math.round(d.toY), durationMs: d.durationMs } : null, }; })()`); console.log('cast:', JSON.stringify(cast)); // ---- 4. hand-step + capture ------------------------------------------------- let impactStep = -1; for (let i = 0; i < STEPS; i++) { const st = await ev(`(() => { const gs = window.game.scene.getScene('GameScene'); const dt = ${DT}; // our own deterministic clock (RAF's is real-time and jumps ~1 s here) window.__ramT = (window.__ramT ?? gs.time.now) + dt; const t0 = window.__ramT; gs.ship.update(t0, dt); gs.combat.update(t0, dt); gs.combatBar?.setStats(gs.combat); gs.combatHud?.update(t0); const d = gs.combat.dash; return { dashAlive: !!d, shipX: Math.round(gs.ship.x), nFx: gs.combat.fx.items.length, }; })()`); if (i > 0 && st.dashAlive === false && impactStep === -1) impactStep = i; await shot(String(i + 2).padStart(4, '0')); if (i % 10 === 0) console.log(`step ${i}: dash=${st.dashAlive} fx=${st.nFx} x=${st.shipX}`); } console.log('impact at step ~', impactStep); // ---- 5. encode ---------------------------------------------------------------- execFileSync('ffmpeg', [ '-y', '-framerate', '25', '-i', `${FRAMES}/%04d.png`, '-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2', '-c:v', 'libx264', '-preset', 'slow', '-crf', '18', '-pix_fmt', 'yuv420p', MP4, ], { stdio: 'inherit' }); console.log('video:', MP4); // ---- 6. key stills ------------------------------------------------------------- const still = (stepIdx, name) => { const src = `${FRAMES}/${String(stepIdx).padStart(4, '0')}.png`; if (fs.existsSync(src)) { fs.copyFileSync(src, SHOTS + name); console.log('still:', SHOTS + name); } }; still(Math.min(STEPS + 1, Math.round(0.20 * 60)), 'ram-dash.png'); // mid-charge still(Math.max(2, impactStep + 1), 'ram-impact.png'); // the burst still(Math.min(STEPS + 1, Math.round(1.2 * 60) + 2), 'ram-trail.png'); // ember tail fs.rmSync(FRAMES, { recursive: true, force: true }); ws.close(); process.exit(0);