Download native-eiffel/engine/host.mjs from peih/matrix-shooter-train: direct link, hf CLI and curl.
- Browser
- Download file 13.8 kB
-
https://huggingface.co/peih/matrix-shooter-train/resolve/main/native-eiffel/engine/host.mjs
- Command line
-
hf download hf://peih/matrix-shooter-train/native-eiffel/engine/host.mjs
-
curl -L -o host.mjs https://huggingface.co/peih/matrix-shooter-train/resolve/main/native-eiffel/engine/host.mjs
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); | |