182 lines
8.7 KiB
JavaScript
182 lines
8.7 KiB
JavaScript
// Visual test for the engine flare (js/visuals/EngineFlare.js).
|
|
// Loads the real game in headless Chromium, starts GameScene, drives the
|
|
// ship through idle -> ignition -> full burn -> coast -> stopped, capturing
|
|
// a screenshot + fx state at each beat.
|
|
//
|
|
// node dev/engine-flare-test.mjs <outDir>
|
|
import { spawn } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
const outDir = process.argv[2] || '/tmp/efx';
|
|
fs.mkdirSync(outDir, { recursive: true });
|
|
const PORT = 9450 + Math.floor(Math.random() * 90);
|
|
const HOME = process.env.HOME || os.homedir();
|
|
const CHROME = [
|
|
path.join(HOME, '.cache/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-linux64/chrome-headless-shell'),
|
|
path.join(HOME, '.cache/ms-playwright/chromium-1234/chrome-linux64/chrome'),
|
|
].find((p) => fs.existsSync(p));
|
|
if (!CHROME) throw new Error('no chromium');
|
|
const udd = fs.mkdtempSync(path.join(os.tmpdir(), 'efx-'));
|
|
const chrome = spawn(CHROME, [
|
|
'--headless', `--remote-debugging-port=${PORT}`, `--user-data-dir=${udd}`,
|
|
'--no-first-run', '--disable-gpu', '--enable-unsafe-swiftshader', '--no-sandbox',
|
|
'--window-size=960,600', 'about:blank',
|
|
], { stdio: 'ignore' });
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
try {
|
|
let v;
|
|
for (let i = 0; i < 100; i++) {
|
|
try { v = await (await fetch(`http://127.0.0.1:${PORT}/json/version`)).json(); break; } catch { await sleep(200); }
|
|
}
|
|
if (!v) throw new Error('CDP never came up');
|
|
const t = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent('http://127.0.0.1:8123/index.html')}`, { method: 'PUT' })).json();
|
|
const ws = new WebSocket(t.webSocketDebuggerUrl);
|
|
let id = 0; const p = new Map();
|
|
const send = (m, pa = {}) => new Promise((res, rej) => { const i = ++id; p.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method: m, params: pa })); });
|
|
ws.onmessage = (m) => { const x = JSON.parse(m.data); if (x.id && p.has(x.id)) { const { res, rej } = p.get(x.id); p.delete(x.id); x.error ? rej(new Error(x.error.message)) : res(x.result); } };
|
|
await new Promise((r) => (ws.onopen = r));
|
|
await send('Page.enable');
|
|
await send('Runtime.enable');
|
|
await send('Network.enable');
|
|
await send('Network.setCacheDisabled', { cacheDisabled: true });
|
|
const evaluate = async (expr) => {
|
|
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
|
|
if (r.exceptionDetails) throw new Error('page: ' + JSON.stringify(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text).slice(0, 500));
|
|
return r.result?.value;
|
|
};
|
|
const shot = async (name) => {
|
|
const s = await send('Page.captureScreenshot', { format: 'png' });
|
|
const f = path.join(outDir, name + '.png');
|
|
fs.writeFileSync(f, Buffer.from(s.data, 'base64'));
|
|
return f;
|
|
};
|
|
|
|
// Wait for game boot + GameScene ready.
|
|
const ready = async (expr, ms = 30000) => {
|
|
const t0 = Date.now();
|
|
while (Date.now() - t0 < ms) {
|
|
const r = await evaluate(expr).catch(() => null);
|
|
if (r) return r;
|
|
await sleep(250);
|
|
}
|
|
throw new Error('timeout waiting: ' + expr);
|
|
};
|
|
|
|
await ready('window.game && window.game.isRunning');
|
|
// Start the game scene directly (skip the menu).
|
|
await evaluate('(() => { window.game.scene.start("GameScene"); return "started"; })()');
|
|
await ready('(() => { const s = window.game.scene.getScene("GameScene"); return s && s.ship && s.engineFx ? { burn: s.engineFx.burn, nozzles: s.engineFx.nozzles?.length } : null; })()', 30000);
|
|
|
|
// Keep the test deterministic: an auto-playing 'ack' comm pauses the
|
|
// action contract (ship.stop()) mid-sequence. Quiet the comms hub —
|
|
// the effect under test (engine flare) does not depend on it.
|
|
await evaluate('(() => { const s = window.game.scene.getScene("GameScene"); if (s.comms) { s.comms._down = true; s.comms._setPaused(false); } return "ok"; })()');
|
|
|
|
const fx = () => `(() => {
|
|
const s = window.game.scene.getScene("GameScene");
|
|
const f = s.engineFx; const sh = s.ship;
|
|
const alive = f.emitters.reduce((n, e) => n + (e.alive ? e.alive.length : 0), 0);
|
|
return {
|
|
burn: +f.burn.toFixed(3), flare: +f.flare.toFixed(3), burst: +f.burst.toFixed(3),
|
|
alive, speed: +Math.hypot(sh.body.velocity.x, sh.body.velocity.y).toFixed(1),
|
|
state: sh.state,
|
|
accel: +Math.hypot(sh.body.acceleration.x, sh.body.acceleration.y).toFixed(1),
|
|
};
|
|
})()`;
|
|
|
|
const base = await evaluate('(() => { const sh = window.game.scene.getScene("GameScene").ship; return { x: Math.round(sh.x), y: Math.round(sh.y) }; })()');
|
|
console.log('ship at', base);
|
|
|
|
// Some seeds spawn the ship touching the tether/a solid (it is then braked
|
|
// on contact — real game behavior). Clear that first so the thrust test
|
|
// can run: if a constraint touches, step the ship out of it.
|
|
const clear = await evaluate(`(() => {
|
|
const s = window.game.scene.getScene("GameScene");
|
|
const sh = s.ship;
|
|
const touches = () => !!(s.tetherField.constrainShip(sh) || s.solids.some((o) => o.constrainShip(sh, sh.radius)));
|
|
if (!touches()) return { ok: true, wasTouching: false };
|
|
// Step the ship out of whatever touches it: away from the nearest
|
|
// tether anchor / solid center.
|
|
let best = null;
|
|
for (const t of s.tetherField.tethers) {
|
|
const d = Math.hypot(sh.x - t.x, sh.y - t.y);
|
|
if (d < t.radius + 40) best = { x: t.x, y: t.y };
|
|
}
|
|
if (!best) {
|
|
let min = Infinity;
|
|
for (const o of s.solids) {
|
|
const d = Math.hypot(sh.x - o.x, sh.y - o.y);
|
|
if (d < min) { min = d; best = { x: o.x, y: o.y }; }
|
|
}
|
|
}
|
|
for (let i = 0; i < 20 && touches(); i++) {
|
|
const d = Math.hypot(sh.x - best.x, sh.y - best.y) || 1;
|
|
sh.setPosition(sh.x + ((sh.x - best.x) / d) * 80, sh.y + ((sh.y - best.y) / d) * 80);
|
|
sh.body.velocity.set(0, 0);
|
|
sh.body.acceleration.set(0, 0);
|
|
}
|
|
return { ok: !touches(), wasTouching: true, pos: [Math.round(sh.x), Math.round(sh.y)] };
|
|
})()`);
|
|
if (!clear.ok) throw new Error('could not clear obstacle: ' + JSON.stringify(clear));
|
|
if (clear.wasTouching) console.log('cleared obstacle at', clear.pos);
|
|
const pos = await evaluate('(() => { const sh = window.game.scene.getScene("GameScene").ship; return { x: Math.round(sh.x), y: Math.round(sh.y) }; })()');
|
|
Object.assign(base, pos);
|
|
|
|
// 0) idle
|
|
await sleep(500);
|
|
console.log('idle ', JSON.stringify(await evaluate(fx())));
|
|
await shot('0-idle');
|
|
|
|
// 1) ignition: full throttle lock. The spawn seed decides where the
|
|
// tether rim and solids are, so RAYCAST candidate headings against
|
|
// the real geometry (the ship must stay inside the tether union and
|
|
// clear of solid worlds) and use the first clear corridor.
|
|
const corridor = await evaluate(`(() => {
|
|
const s = window.game.scene.getScene("GameScene");
|
|
const sh = s.ship;
|
|
const tethers = s.tetherField.tethers;
|
|
const insideUnion = (x, y) => tethers.some((t) => { const dx = x - t.x, dy = y - t.y; return dx * dx + dy * dy <= (t.radius + 1) * (t.radius + 1); });
|
|
const clearOfSolids = (x, y) => s.solids.every((o) => Math.hypot(o.x - x, o.y - y) > (o.radius ?? 30) + sh.radius + 80);
|
|
for (let a = 0; a < 360; a += 10) {
|
|
const rad = a * Math.PI / 180;
|
|
let good = true;
|
|
for (let d = 150; d <= 750; d += 150) {
|
|
const x = sh.x + Math.cos(rad) * d, y = sh.y + Math.sin(rad) * d;
|
|
if (!insideUnion(x, y) || !clearOfSolids(x, y)) { good = false; break; }
|
|
}
|
|
if (good) return { ang: a, tx: Math.round(sh.x + Math.cos(rad) * 900), ty: Math.round(sh.y + Math.sin(rad) * 900) };
|
|
}
|
|
return null;
|
|
})()`);
|
|
if (!corridor) throw new Error('no clear flight corridor found');
|
|
console.log('corridor ang=' + corridor.ang + ' target', corridor.tx, corridor.ty);
|
|
await evaluate(`(() => { const s = window.game.scene.getScene("GameScene"); s.ship.thrustToward(${corridor.tx}, ${corridor.ty}); return "ok"; })()`);
|
|
await sleep(130);
|
|
console.log('ignite ', JSON.stringify(await evaluate(fx())));
|
|
await shot('1-ignite');
|
|
|
|
// 2) full burn (throttle lock holds)
|
|
await sleep(700);
|
|
console.log('burn ', JSON.stringify(await evaluate(fx())));
|
|
await shot('2-burn');
|
|
|
|
// 3) coast: cut throttle -> burn decays, trail fades
|
|
await evaluate('(() => { const s = window.game.scene.getScene("GameScene"); s.ship.stop(); return s.ship.state; })()');
|
|
await sleep(160);
|
|
console.log('coast ', JSON.stringify(await evaluate(fx())));
|
|
await shot('3-coast');
|
|
|
|
// 4) settled: nothing lit, no live particles
|
|
await sleep(900);
|
|
console.log('done ', JSON.stringify(await evaluate(fx())));
|
|
await shot('4-settled');
|
|
|
|
ws.close();
|
|
} finally {
|
|
chrome.kill();
|
|
setTimeout(() => fs.rmSync(udd, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }), 1500);
|
|
}
|