peih's picture
backup native-eiffel
5dd27d0 verified
Raw History Blame Contribute Delete
13.8 kB
// Run a standalone Eden game in Node, without a browser, and stream its scene.
//
// usage: node host.mjs <gameDir> <threeModule> [width height [configJSON]]
// configJSON mirrors window.gameStandalone: {"restartKeys": [...], "eventPrompts": {...}}.
//
// stdin: one JSON command per line
// {"cmd":"advance","events":[{"action":"w","phase":"down"}],"targetTick":3,"capture":true}
// {"cmd":"chunk","frames":[{"targetTick":3,"events":[...],"capture":true}, ...]}
// one message per captured frame, then one with all states
// {"cmd":"probe","events":[...]} input and a zero-length frame, no simulation step
// {"cmd":"state"}
// stdout: one message per command, framed as
// u32 headerBytes | header JSON | u32 binaryBytes | binary
// The header lists named arrays as {dtype, offset, shape} inside the binary.
// The first message, sent unprompted, describes the static scene: geometry
// buffers, the material table and the draw list. Each captured frame sends the
// per-draw world matrices, per-material colors, dynamic line vertices and the
// camera — everything a renderer needs, and nothing else.
import { register } from 'node:module';
import { pathToFileURL, fileURLToPath } from 'node:url';
import path from 'node:path';
import { createInterface } from 'node:readline';
import { installDom } from './dom.mjs';
const [gameDir, threeModule, width = '960', height = '544', configJson = '{}'] = process.argv.slice(2);
const config = JSON.parse(configJson);
const restartKeys = Array.isArray(config.restartKeys) && config.restartKeys.length ? config.restartKeys : ['r'];
const eventPrompts = config.eventPrompts || {};
const here = path.dirname(fileURLToPath(import.meta.url));
register(pathToFileURL(path.join(here, 'hooks.mjs')), {
data: { gameDir: path.resolve(gameDir), threeModule: path.resolve(threeModule), shellStub: path.join(here, 'shell-stub.mjs') },
});
// Game logs must not corrupt the framed stdout stream.
for (const level of ['log', 'info', 'debug', 'warn']) console[level] = (...args) => process.stderr.write(args.join(' ') + '\n');
const W = Number(width), H = Number(height);
const dom = installDom({ width: W, height: H });
const SIM_HZ = 60;
const FRAME_MS = 1000 / SIM_HZ;
const out = process.stdout;
function send(header, arrays = {}) {
let offset = 0;
const parts = [];
header.arrays = {};
for (const [name, array] of Object.entries(arrays)) {
const pad = (4 - (offset % 4)) % 4;
if (pad) { parts.push(new Uint8Array(pad)); offset += pad; }
header.arrays[name] = { dtype: array.constructor.name, offset, length: array.length };
parts.push(new Uint8Array(array.buffer, array.byteOffset, array.byteLength));
offset += array.byteLength;
}
const json = Buffer.from(JSON.stringify(header));
const head = Buffer.alloc(4); head.writeUInt32LE(json.length);
const size = Buffer.alloc(4); size.writeUInt32LE(offset);
out.write(Buffer.concat([head, json, size, ...parts.map((p) => Buffer.from(p.buffer, p.byteOffset, p.byteLength))]));
}
const NAMED = {
' ': [' ', 'Space'], space: [' ', 'Space'], shift: ['Shift', 'ShiftLeft'],
escape: ['Escape', 'Escape'], enter: ['Enter', 'Enter'], tab: ['Tab', 'Tab'],
backspace: ['Backspace', 'Backspace'], control: ['Control', 'ControlLeft'], ctrl: ['Control', 'ControlLeft'],
};
function keyAndCode(action) {
const lower = String(action).toLowerCase();
if (NAMED[lower]) return NAMED[lower];
if (lower.length === 1 && /[a-z]/.test(lower)) return [lower, `Key${lower.toUpperCase()}`];
if (lower.length === 1 && /[0-9]/.test(lower)) return [lower, `Digit${lower}`];
if (lower.startsWith('arrow')) { const name = `Arrow${lower.slice(5, 6).toUpperCase()}${lower.slice(6)}`; return [name, name]; }
return [action, action];
}
function key(action, phase) {
const [k, code] = keyAndCode(action);
dom.dispatch(phase === 'down' ? 'keydown' : 'keyup', {
type: phase === 'down' ? 'keydown' : 'keyup', key: k, code, repeat: false,
target: document.body, preventDefault() {}, stopPropagation() {},
});
}
// Pointer input arrives as the capture browser receives it from Playwright:
// a move to the normalized position, then a press or release on 'button'.
const BUTTONS = { 0: 0, 1: 1, 2: 2 };
let pointer = null;
function mouse(type, x, y, button = 0) {
const movementX = pointer ? x - pointer[0] : 0, movementY = pointer ? y - pointer[1] : 0;
pointer = [x, y];
dom.dispatch(type, { type, clientX: x, clientY: y, movementX, movementY, button,
target: document.body, preventDefault() {}, stopPropagation() {} });
}
function pointerEvent({ phase, x, y, button, pressed }) {
if (typeof x !== 'number' || typeof y !== 'number' || !(x >= 0 && x <= 1 && y >= 0 && y <= 1)) return;
// Chromium reports mouse coordinates as whole CSS pixels, truncated; matching
// that keeps mouse-look identical to the capture browser (checked frame by frame).
const px = Math.floor(Math.min(x * W, W - 1)), py = Math.floor(Math.min(y * H, H - 1));
mouse('mousemove', px, py);
if (phase === 'button' && button in BUTTONS) mouse(pressed ? 'mousedown' : 'mouseup', px, py, BUTTONS[button]);
}
function dispatchEvents(events) {
for (const event of events || []) {
const { action, phase } = event;
if (action === 'pointer') { pointerEvent(event); continue; }
if (action === 'restart' || action === 'reset') {
if (phase === 'down') for (const restartKey of restartKeys) { key(restartKey, 'down'); key(restartKey, 'up'); }
continue;
}
if (phase === 'down' || phase === 'up') key(action, phase);
}
}
const main = await import(pathToFileURL(path.join(path.resolve(gameDir), 'main.js')).href);
await main.start();
const renderer = globalThis.__headlessRenderer;
const { scene, camera } = globalThis.__scene;
dom.runFrame(); // first frame paints the initial scene, as standalone.js does
// ---- static scene description -------------------------------------------
const geometries = new Map(); // uuid -> index
const materials = new Map(); // uuid -> index
const geometryList = [], materialList = [], draws = [];
function geometryIndex(geometry) {
if (geometries.has(geometry.uuid)) return geometries.get(geometry.uuid);
const index = geometryList.length;
geometries.set(geometry.uuid, index);
geometryList.push(geometry);
return index;
}
function materialIndex(material) {
if (materials.has(material.uuid)) return materials.get(material.uuid);
const index = materialList.length;
materials.set(material.uuid, index);
materialList.push(material);
return index;
}
const objects = [];
// Named ancestors, nearest first: 'torso/guard-2/...' tells which guard a part belongs to.
const namePath = (object) => {
const names = [];
for (let o = object; o; o = o.parent) if (o.name) names.push(o.name);
return names.join('/');
};
scene.traverse((object) => {
if (!(object.isMesh || object.isLine)) return;
const geometry = geometryIndex(object.geometry);
const list = Array.isArray(object.material) ? object.material : [object.material];
const groups = Array.isArray(object.material) && object.geometry.groups.length
? object.geometry.groups : [{ start: 0, count: Infinity, materialIndex: 0 }];
for (const group of groups) {
draws.push({ object: objects.length, geometry, material: materialIndex(list[group.materialIndex]),
start: group.start, count: Number.isFinite(group.count) ? group.count : -1,
kind: object.isLine ? 'line' : 'mesh', name: object.name || object.parent?.name || '', path: namePath(object) });
}
objects.push(object);
});
function materialStatic(m) {
return { type: m.type, transparent: m.transparent, depthWrite: m.depthWrite, depthTest: m.depthTest,
toneMapped: m.toneMapped, side: m.side, flatShading: !!m.flatShading, vertexColors: !!m.vertexColors,
roughness: m.roughness ?? null, metalness: m.metalness ?? null };
}
{
const arrays = {};
const meta = geometryList.map((g, i) => {
const position = g.attributes.position;
const normal = g.attributes.normal;
const color = g.attributes.color;
arrays[`g${i}_position`] = Float32Array.from(position.array);
if (normal) arrays[`g${i}_normal`] = Float32Array.from(normal.array);
if (color) arrays[`g${i}_color`] = Float32Array.from(color.array);
const index = g.index ? Uint32Array.from(g.index.array) : Uint32Array.from({ length: position.count }, (_, k) => k);
arrays[`g${i}_index`] = index;
return { vertices: position.count, itemSize: position.itemSize, indexed: !!g.index, indices: index.length,
normal: !!normal, color: !!color, dynamic: objects.some((o) => o.isLine && o.geometry === g) };
});
send({ type: 'scene', width: W, height: H, draws, geometries: meta, materials: materialList.map(materialStatic),
clearColor: renderer.clearColor, toneMapping: renderer.toneMapping, exposure: renderer.toneMappingExposure,
lights: describeLights() }, arrays);
}
function describeLights() {
const lights = [];
scene.traverse((o) => {
if (o.isHemisphereLight) lights.push({ type: 'hemisphere', sky: o.color.toArray(), ground: o.groundColor.toArray(), intensity: o.intensity });
else if (o.isDirectionalLight) lights.push({ type: 'directional', color: o.color.toArray(), intensity: o.intensity });
else if (o.isAmbientLight) lights.push({ type: 'ambient', color: o.color.toArray(), intensity: o.intensity });
else if (o.isLight) lights.push({ type: o.type, unsupported: true });
});
return lights;
}
// ---- per-frame state ----------------------------------------------------
const objectData = new Float32Array(objects.length * 17); // visible + matrixWorld
const materialData = new Float32Array(materialList.length * 8); // color rgb, opacity, emissive rgb, emissiveIntensity
const dynamicLines = geometryList.map((g, i) => (objects.some((o) => o.isLine && o.geometry === g) ? i : -1)).filter((i) => i >= 0);
function effectiveVisible(object) {
for (let o = object; o; o = o.parent) if (!o.visible) return false;
return true;
}
function captureFrame() {
objects.forEach((o, i) => {
objectData[i * 17] = effectiveVisible(o) ? 1 : 0;
objectData.set(o.matrixWorld.elements, i * 17 + 1);
});
materialList.forEach((m, i) => {
const c = m.color, e = m.emissive;
materialData.set([c.r, c.g, c.b, m.opacity, e ? e.r : 0, e ? e.g : 0, e ? e.b : 0, m.emissiveIntensity ?? 1], i * 8);
});
// Lights follow their own transforms; directional light direction is position - target.
const lightData = [];
scene.traverse((o) => {
if (o.isDirectionalLight) {
o.target.updateMatrixWorld();
const p = o.matrixWorld.elements, t = o.target.matrixWorld.elements;
lightData.push(p[12] - t[12], p[13] - t[13], p[14] - t[14]);
}
});
const arrays = {
objects: objectData,
materials: materialData,
camera: Float32Array.from([...camera.projectionMatrix.elements, ...camera.matrixWorldInverse.elements,
...camera.matrixWorld.elements.slice(12, 15)]),
lights: Float32Array.from(lightData),
};
for (const g of dynamicLines) arrays[`g${g}_position`] = Float32Array.from(geometryList[g].attributes.position.array);
return arrays;
}
function readState() {
try {
const snapshot = globalThis.__gameTest?.read?.();
if (!snapshot || typeof snapshot !== 'object') return null;
const { frame, clock, ...state } = snapshot;
if (Array.isArray(state.events)) {
state.events = state.events.map((event) => {
const prompt = event && typeof event.prompt === 'string' ? event.prompt : eventPrompts[event?.type];
return { ...event, prompt: typeof prompt === 'string' ? prompt : null };
});
}
return state;
} catch { return null; }
}
let tick = 0;
function simulateTo(targetTick) {
if (!Number.isSafeInteger(targetTick) || targetTick < tick) throw Error('Invalid target tick');
while (tick < targetTick) {
tick++;
dom.setNow(tick * FRAME_MS);
dom.runFrame();
}
}
const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
for await (const line of lines) {
if (!line.trim()) continue;
const command = JSON.parse(line);
const started = dom.realNow();
if (command.cmd === 'advance') {
dispatchEvents(command.events);
simulateTo(command.targetTick);
const simulated = dom.realNow();
const arrays = command.capture ? captureFrame() : {};
const state = command.state === false ? undefined : readState();
send({ type: 'frame', tick, renders: renderer.renders, state,
timing: { simulate_ms: simulated - started, export_ms: dom.realNow() - simulated } }, arrays);
} else if (command.cmd === 'chunk') {
// A whole chunk in one command: every frame's input and tick up front.
// Each captured frame's scene goes out the moment it is simulated, so the
// renderer works on it while the following frames are still stepping;
// the HUD states of all frames follow in one closing message.
const states = [];
command.frames.forEach((frame, index) => {
dispatchEvents(frame.events);
simulateTo(frame.targetTick);
states.push(readState());
if (frame.capture) send({ type: 'scene', index, tick }, captureFrame());
});
send({ type: 'chunk', tick, states, timing: { chunk_ms: dom.realNow() - started } });
} else if (command.cmd === 'probe') {
// A zero-length frame, as host/standalone.js probe() does: the game's
// clock sees no elapsed time, but input handlers and redraws run.
dispatchEvents(command.events);
dom.runFrame();
send({ type: 'probe', tick, state: readState() });
} else if (command.cmd === 'state') {
send({ type: 'state', tick, state: readState() });
} else if (command.cmd === 'quit') {
break;
}
}
process.exit(0);