157 lines
6.4 KiB
JavaScript
157 lines
6.4 KiB
JavaScript
// 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);
|