orbit/dev/ram-video.mjs

157 lines
6.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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);