// Run a standalone Eden game in Node, without a browser, and stream its scene. // // usage: node host.mjs [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);