[render] isometric world, instanced belts/cargo, GLB hot-swap registry

Replaces the render stub with the full M1 surface:

- camera: ortho iso rig, wheel zoom, middle/right-drag + WASD pan, resize
- ground: shader tile grid w/ world-edge dissolve + screen-space vignette
- registry: procedural placeholders (two-material rule) + MODELBEAST GLB
  hot-swap, probed on init and every 10s in dev. GLBs auto-normalise to
  the machine footprint, so any export scale lands correctly.
- belts: InstancedMesh per belt def, chevron scroll injected into a
  MeshStandardMaterial so belts keep lighting/fog/brownout
- cargo: InstancedMesh per item type, interpolated across ticks via alpha
- ghost + pickTile with footprint occupancy (cyan valid / red blocked)
- feedback: jam klaxon blink, brownout dims the scene via tone-mapping
  exposure so emissives dim too

Machine accents are derived from data (recipes -> outputs -> ItemDef.color),
with a legibility floor: near-black products like MDAT ORE would otherwise
render the extractor's CRT head as a black panel and its ore as invisible.

Verified in the dev build: 500 entities + 2,000 belt items at 3.2ms/frame
(5 instanced draw calls); a GLB dropped in mid-session swaps without reload.
Dev scaffold is DEV-gated and tree-shaken from the prod bundle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-17 16:31:10 +10:00
parent 3a42ab2d9e
commit 883dd4b02b
11 changed files with 1549 additions and 23 deletions

View File

@ -0,0 +1,175 @@
/**
* LANE-RENDER belt cargo. One InstancedMesh per item type; sim owns the truth,
* we own the smoothness.
*
* Interpolation: main.ts hands us the post-tick snapshot plus `alpha` (fraction into
* the next tick), so we render lerp(prevTick, thisTick, alpha) the classic
* fixed-timestep interpolation. One tick of latency, and no extrapolation jitter when
* an item is blocked by back-pressure (prev == cur -> it simply sits still).
*
* BeltItem has no stable id (see contracts), so items are matched across ticks by
* (belt entity, item type, order along the belt). Items can't overtake each other, so
* that ordering is stable EXCEPT on the tick where the lead item leaves a belt and
* the queue behind it shifts index. Those items miss their match for exactly one tick
* and render at the sim's `t` instead of interpolated: a bounded error of one tick of
* belt travel (beltSpeed/30 0.07 tiles), which is imperceptible. A stable
* `BeltItem.id` would make this exact filed as a CONTRACT REQUEST in NOTES.
*
* Hot path: two allocation-free passes over beltItems (count, then write matrices).
*/
import * as THREE from 'three';
import type { GameData, ItemDef, MachineDef, SimSnapshot } from '../contracts';
import { DIR_VEC } from './coords';
import { HEIGHT_BY_KIND, readable } from './palette';
const ITEM_SIZE = 0.3;
const RIDE_Y = HEIGHT_BY_KIND.belt + ITEM_SIZE * 0.5;
interface ItemMesh {
mesh: THREE.InstancedMesh;
capacity: number;
}
/**
* Products glow; raw ore doesn't saturation belongs to media (style guide §8).
* The base colour goes through `readable()` because MDAT ORE's authored #1a1a22 is
* invisible against the belt; ore still reads as the darkest thing on the line, just
* not as a hole in the world.
*/
function itemMaterial(def: ItemDef): THREE.MeshStandardMaterial {
return new THREE.MeshStandardMaterial({
color: readable(def.color),
emissive: new THREE.Color(def.color),
emissiveIntensity: def.tier >= 2 ? 1.4 : def.tier * 0.35,
roughness: 0.5,
metalness: 0.2,
});
}
function itemGeometry(def: ItemDef): THREE.BufferGeometry {
// Products read as faceted crystals; raw/refined as crate-ish boxes.
return def.tier >= 2
? new THREE.OctahedronGeometry(ITEM_SIZE * 0.7)
: new THREE.BoxGeometry(ITEM_SIZE, ITEM_SIZE * 0.8, ITEM_SIZE);
}
export class BeltItemLayer {
readonly group = new THREE.Group();
private byItem = new Map<string, ItemMesh>();
private items = new Map<string, ItemDef>();
private prev = new Map<string, number>();
private cur = new Map<string, number>();
private lastTick = -1;
private dummy = new THREE.Object3D();
private belts = new Map<number, { x: number; y: number; dir: 0 | 1 | 2 | 3 }>();
private counts = new Map<string, number>();
private seq = new Map<string, number>();
private sorted: SimSnapshot['beltItems'][number][] = [];
constructor(data: GameData, private defs: Map<string, MachineDef>) {
this.group.name = 'beltItems';
for (const i of data.items) this.items.set(i.id, i);
}
private ensure(def: ItemDef, needed: number): ItemMesh {
const existing = this.byItem.get(def.id);
if (existing && needed <= existing.capacity) return existing;
const capacity = Math.max(128, 1 << Math.ceil(Math.log2(Math.max(1, needed))));
// Re-use geo/mat across growth; only the InstancedMesh itself is replaced.
const geometry = existing?.mesh.geometry ?? itemGeometry(def);
const material = (existing?.mesh.material as THREE.Material) ?? itemMaterial(def);
if (existing) {
this.group.remove(existing.mesh);
existing.mesh.dispose();
}
const mesh = new THREE.InstancedMesh(geometry, material, capacity);
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
mesh.frustumCulled = false;
mesh.castShadow = true;
mesh.count = 0;
this.group.add(mesh);
const im: ItemMesh = { mesh, capacity };
this.byItem.set(def.id, im);
return im;
}
sync(snap: SimSnapshot, alpha: number, timeSec: number): void {
this.belts.clear();
for (const e of snap.entities) {
const def = this.defs.get(e.def);
if (def?.kind === 'belt') this.belts.set(e.id, { x: e.pos.x, y: e.pos.y, dir: e.dir });
}
// Order cargo front-to-back per belt so the ordering keys are stable.
this.sorted.length = 0;
for (const bi of snap.beltItems) if (this.belts.has(bi.entity)) this.sorted.push(bi);
this.sorted.sort((a, b) => (a.entity !== b.entity ? a.entity - b.entity : b.t - a.t));
// Roll the tick window: cur becomes prev the moment the sim advances.
const advanced = snap.tick !== this.lastTick;
if (advanced) {
const swap = this.prev;
this.prev = this.cur;
this.cur = swap;
this.cur.clear();
this.lastTick = snap.tick;
}
// Pass 1 — count per item type, and record this tick's positions for next frame.
this.counts.clear();
this.seq.clear();
for (const bi of this.sorted) {
this.counts.set(bi.item, (this.counts.get(bi.item) ?? 0) + 1);
if (advanced) {
const sk = `${bi.entity}:${bi.item}`;
const idx = this.seq.get(sk) ?? 0;
this.seq.set(sk, idx + 1);
this.cur.set(`${bi.entity}:${bi.item}:${idx}`, bi.t);
}
}
for (const [itemId, count] of this.counts) {
const def = this.items.get(itemId);
if (def) this.ensure(def, count);
}
// Pass 2 — write instance matrices.
this.seq.clear();
const written = new Map<string, number>();
for (const bi of this.sorted) {
const belt = this.belts.get(bi.entity)!;
const def = this.items.get(bi.item);
const im = this.byItem.get(bi.item);
if (!def || !im) continue;
const sk = `${bi.entity}:${bi.item}`;
const idx = this.seq.get(sk) ?? 0;
this.seq.set(sk, idx + 1);
const from = this.prev.get(`${bi.entity}:${bi.item}:${idx}`);
const t = from === undefined ? bi.t : from + (bi.t - from) * alpha;
const dir = DIR_VEC[belt.dir];
this.dummy.position.set(
belt.x + 0.5 + dir.x * (t - 0.5),
RIDE_Y,
belt.y + 0.5 + dir.y * (t - 0.5),
);
this.dummy.rotation.set(0, 0, 0);
if (def.tier >= 2) {
this.dummy.rotation.y = timeSec * 1.6; // products tumble; ore just rides
this.dummy.position.y += Math.sin(timeSec * 3 + bi.entity) * 0.02;
}
this.dummy.updateMatrix();
const n = written.get(bi.item) ?? 0;
if (n < im.capacity) {
im.mesh.setMatrixAt(n, this.dummy.matrix);
written.set(bi.item, n + 1);
}
}
for (const [id, im] of this.byItem) {
im.mesh.count = written.get(id) ?? 0;
im.mesh.instanceMatrix.needsUpdate = true;
}
}
}

147
fktry/src/render/belts.ts Normal file
View File

@ -0,0 +1,147 @@
/**
* LANE-RENDER belts, instanced.
*
* One InstancedMesh per belt def (so a future fast-belt def just works). The scrolling
* chevron is injected into a MeshStandardMaterial rather than written as a raw
* ShaderMaterial, so belts keep real lighting, fog and brownout dimming for free.
*
* Chevrons scroll along local -z; the instance yaw carries them to world direction.
*/
import * as THREE from 'three';
import type { MachineDef, SimSnapshot } from '../contracts';
import { AssetRegistry } from './registry';
import { yawFor } from './coords';
import { ACCENT_BY_KIND, HEIGHT_BY_KIND } from './palette';
const CHEVRONS_PER_TILE = 3.0;
interface BeltMesh {
mesh: THREE.InstancedMesh;
capacity: number;
assetVersion: number;
live: Map<number, { x: number; y: number; dir: number }>;
}
export class BeltLayer {
readonly group = new THREE.Group();
private byDef = new Map<string, BeltMesh>();
private uTime = { value: 0 };
private dummy = new THREE.Object3D();
constructor(private registry: AssetRegistry, private defs: Map<string, MachineDef>) {
this.group.name = 'belts';
}
private material(def: MachineDef): THREE.Material {
const mat = new THREE.MeshStandardMaterial({
color: 0x2a2833,
roughness: 0.9,
metalness: 0.1,
});
const uSpeed = { value: def.beltSpeed ?? 2 };
const uAccent = { value: new THREE.Color(ACCENT_BY_KIND.belt) };
const topY = (HEIGHT_BY_KIND.belt * 0.92).toFixed(4);
mat.onBeforeCompile = (shader) => {
shader.uniforms.uTime = this.uTime;
shader.uniforms.uSpeed = uSpeed;
shader.uniforms.uAccent = uAccent;
shader.vertexShader =
'varying vec3 vLocal;\n' +
shader.vertexShader.replace('#include <begin_vertex>', '#include <begin_vertex>\n vLocal = position;');
shader.fragmentShader =
'uniform float uTime;\nuniform float uSpeed;\nuniform vec3 uAccent;\nvarying vec3 vLocal;\n' +
shader.fragmentShader.replace(
'#include <emissivemap_fragment>',
`#include <emissivemap_fragment>
if (vLocal.y > ${topY}) {
float band = fract((vLocal.z + abs(vLocal.x) * 0.55) * ${CHEVRONS_PER_TILE.toFixed(1)}
+ uTime * uSpeed * ${CHEVRONS_PER_TILE.toFixed(1)});
float arrow = smoothstep(0.40, 0.50, band) - smoothstep(0.50, 0.60, band);
totalEmissiveRadiance += uAccent * arrow * 0.85;
}`,
);
};
return mat;
}
private ensure(def: MachineDef, needed: number): BeltMesh {
const entry = this.registry.get(def.asset);
const version = entry?.version ?? 0;
let bm = this.byDef.get(def.id);
if (bm && bm.assetVersion !== version) {
this.group.remove(bm.mesh);
bm.mesh.dispose();
bm = undefined; // GLB landed for belts: rebuild the instanced mesh from it
}
if (bm && needed <= bm.capacity) return bm;
const capacity = Math.max(64, 1 << Math.ceil(Math.log2(Math.max(1, needed))));
const inst = entry?.instanceable() ?? null;
const geometry = inst?.geometry ?? new THREE.BoxGeometry(0.94, HEIGHT_BY_KIND.belt, 0.94);
if (!inst) geometry.translate(0, HEIGHT_BY_KIND.belt / 2, 0);
// A real GLB brings its own look; the placeholder plate gets the chevron scroll.
// (Taking inst.material unconditionally here silently dropped the chevrons — the
// registry hands back a plain plate material for the placeholder case.)
const material = entry?.isGLB && inst ? inst.material : this.material(def);
if (bm) {
this.group.remove(bm.mesh);
bm.mesh.dispose(); // grow: drop the old instanced mesh, keep geo/mat
}
const mesh = new THREE.InstancedMesh(geometry, material, capacity);
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
mesh.castShadow = mesh.receiveShadow = true;
mesh.frustumCulled = false;
mesh.count = 0;
this.group.add(mesh);
const next: BeltMesh = { mesh, capacity, assetVersion: version, live: new Map() };
this.byDef.set(def.id, next);
return next;
}
sync(snap: SimSnapshot, timeSec: number): void {
this.uTime.value = timeSec;
// Bucket belt entities by def.
const buckets = new Map<string, Array<{ id: number; x: number; y: number; dir: number }>>();
for (const e of snap.entities) {
const def = this.defs.get(e.def);
if (!def || def.kind !== 'belt') continue;
let b = buckets.get(def.id);
if (!b) buckets.set(def.id, (b = []));
b.push({ id: e.id, x: e.pos.x, y: e.pos.y, dir: e.dir });
}
for (const [defId, def] of this.defs) {
if (def.kind !== 'belt') continue;
const items = buckets.get(defId) ?? [];
const bm = this.ensure(def, items.length);
// Belts are static once placed: only rewrite matrices when the set changes.
let dirty = items.length !== bm.live.size;
if (!dirty) {
for (const it of items) {
const prev = bm.live.get(it.id);
if (!prev || prev.x !== it.x || prev.y !== it.y || prev.dir !== it.dir) {
dirty = true;
break;
}
}
}
if (!dirty) continue;
bm.live.clear();
items.forEach((it, i) => {
this.dummy.position.set(it.x + 0.5, 0, it.y + 0.5); // 1x1 footprint: tile centre
this.dummy.rotation.set(0, yawFor(it.dir as 0 | 1 | 2 | 3), 0);
this.dummy.updateMatrix();
bm.mesh.setMatrixAt(i, this.dummy.matrix);
bm.live.set(it.id, { x: it.x, y: it.y, dir: it.dir });
});
bm.mesh.count = items.length;
bm.mesh.instanceMatrix.needsUpdate = true;
bm.mesh.computeBoundingSphere();
}
}
}

157
fktry/src/render/camera.ts Normal file
View File

@ -0,0 +1,157 @@
/**
* LANE-RENDER orthographic isometric camera rig.
* Pan: middle-drag or WASD. Zoom: wheel (clamped). Resize handled.
*/
import * as THREE from 'three';
const ISO_DIR = new THREE.Vector3(1, 1.15, 1).normalize();
/**
* Ortho: this affects near/far clipping, not apparent size. It DOES set how far every
* object sits from the camera, so scene fog must be banded around it see index.ts.
*/
export const CAM_DIST = 90;
const ZOOM_MIN = 8;
const ZOOM_MAX = 40;
const PAN_KEY_SPEED = 14; // world units/sec at default zoom
export const WORLD_HALF = 32; // 64x64 world, origin at centre (CONTRACTS.md)
export class CameraRig {
readonly camera: THREE.OrthographicCamera;
private target = new THREE.Vector3(0, 0, 0);
/** Half-height of the ortho frustum in world units — our zoom level. */
private zoomD = 16;
private keys = new Set<string>();
private dragging = false;
private lastX = 0;
private lastY = 0;
private disposers: Array<() => void> = [];
constructor(private container: HTMLElement, private dom: HTMLCanvasElement) {
this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 400);
this.applyFrustum();
this.place();
this.bind();
}
private bind(): void {
const onWheel = (e: WheelEvent) => {
e.preventDefault();
this.zoomD = THREE.MathUtils.clamp(this.zoomD * Math.exp(e.deltaY * 0.0012), ZOOM_MIN, ZOOM_MAX);
this.applyFrustum();
};
const onDown = (e: PointerEvent) => {
if (e.button === 0) return; // left stays free for LANE-UI's click-to-place
// main.ts places on ANY pointerdown over #game; keep our pan from also building.
e.stopPropagation();
e.preventDefault();
this.dragging = true;
this.lastX = e.clientX;
this.lastY = e.clientY;
this.dom.setPointerCapture(e.pointerId);
};
const onMove = (e: PointerEvent) => {
if (!this.dragging) return;
const wpp = (this.zoomD * 2) / this.container.clientHeight; // world units per pixel
this.panScreen((e.clientX - this.lastX) * wpp, (e.clientY - this.lastY) * wpp);
this.lastX = e.clientX;
this.lastY = e.clientY;
};
const onUp = (e: PointerEvent) => {
this.dragging = false;
if (this.dom.hasPointerCapture(e.pointerId)) this.dom.releasePointerCapture(e.pointerId);
};
const onCtx = (e: Event) => e.preventDefault(); // right-drag pan without a menu
const isTyping = (t: EventTarget | null) => {
const el = t as HTMLElement | null;
return !!el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable);
};
const onKeyDown = (e: KeyboardEvent) => {
if (isTyping(e.target)) return; // don't eat LANE-UI's typing
this.keys.add(e.key.toLowerCase());
};
const onKeyUp = (e: KeyboardEvent) => this.keys.delete(e.key.toLowerCase());
const onBlur = () => this.keys.clear();
this.dom.addEventListener('wheel', onWheel, { passive: false });
this.dom.addEventListener('pointerdown', onDown);
this.dom.addEventListener('pointermove', onMove);
this.dom.addEventListener('pointerup', onUp);
this.dom.addEventListener('pointercancel', onUp);
this.dom.addEventListener('contextmenu', onCtx);
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('blur', onBlur);
this.disposers.push(() => {
this.dom.removeEventListener('wheel', onWheel);
this.dom.removeEventListener('pointerdown', onDown);
this.dom.removeEventListener('pointermove', onMove);
this.dom.removeEventListener('pointerup', onUp);
this.dom.removeEventListener('pointercancel', onUp);
this.dom.removeEventListener('contextmenu', onCtx);
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('blur', onBlur);
});
}
/** Screen-space drag delta (world units) -> target motion on the ground plane. */
private panScreen(dxWorld: number, dyWorld: number): void {
const right = new THREE.Vector3().setFromMatrixColumn(this.camera.matrix, 0).setY(0).normalize();
const up = new THREE.Vector3().setFromMatrixColumn(this.camera.matrix, 1).setY(0).normalize();
this.target.addScaledVector(right, -dxWorld);
this.target.addScaledVector(up, dyWorld);
this.clampTarget();
this.place();
}
update(dtSec: number): void {
let kx = 0;
let ky = 0;
if (this.keys.has('a') || this.keys.has('arrowleft')) kx -= 1;
if (this.keys.has('d') || this.keys.has('arrowright')) kx += 1;
if (this.keys.has('w') || this.keys.has('arrowup')) ky += 1;
if (this.keys.has('s') || this.keys.has('arrowdown')) ky -= 1;
if (!kx && !ky) return;
// Scale with zoom so keyboard panning feels the same when zoomed in.
const step = PAN_KEY_SPEED * dtSec * (this.zoomD / 16);
const right = new THREE.Vector3().setFromMatrixColumn(this.camera.matrix, 0).setY(0).normalize();
const up = new THREE.Vector3().setFromMatrixColumn(this.camera.matrix, 1).setY(0).normalize();
this.target.addScaledVector(right, kx * step);
this.target.addScaledVector(up, ky * step);
this.clampTarget();
this.place();
}
private clampTarget(): void {
this.target.x = THREE.MathUtils.clamp(this.target.x, -WORLD_HALF, WORLD_HALF);
this.target.z = THREE.MathUtils.clamp(this.target.z, -WORLD_HALF, WORLD_HALF);
this.target.y = 0;
}
private place(): void {
this.camera.position.copy(this.target).addScaledVector(ISO_DIR, CAM_DIST);
this.camera.lookAt(this.target);
this.camera.updateMatrixWorld();
}
private applyFrustum(): void {
const aspect = Math.max(0.0001, this.container.clientWidth / Math.max(1, this.container.clientHeight));
const d = this.zoomD;
this.camera.left = -d * aspect;
this.camera.right = d * aspect;
this.camera.top = d;
this.camera.bottom = -d;
this.camera.updateProjectionMatrix();
}
resize(): void {
this.applyFrustum();
}
dispose(): void {
for (const d of this.disposers) d();
this.disposers = [];
}
}

View File

@ -0,0 +1,58 @@
/**
* LANE-RENDER tile/world coordinate math. The single place the CONTRACTS.md
* conventions are encoded: tile grid, origin at world centre, +x east, +y south,
* Dir 0..3 = N,E,S,W clockwise, machine `pos` = origin (min) corner.
*
* Mapping: tile (x, y) -> three.js world (x, 0, y). Tile (x,y) spans [x,x+1]x[y,y+1].
*/
import * as THREE from 'three';
import type { Dir, MachineDef, Vec2 } from '../contracts';
export const DIR_VEC: Record<Dir, Vec2> = {
0: { x: 0, y: -1 }, // N
1: { x: 1, y: 0 }, // E
2: { x: 0, y: 1 }, // S
3: { x: -1, y: 0 }, // W
};
/** Placeholders are authored facing N (-z). Yaw -90° per dir step: N->E->S->W. */
export function yawFor(dir: Dir): number {
return (-dir * Math.PI) / 2;
}
/** E/W rotation swaps the footprint's width and depth. */
export function rotatedFootprint(def: MachineDef, dir: Dir): Vec2 {
return dir === 1 || dir === 3
? { x: def.footprint.y, y: def.footprint.x }
: { x: def.footprint.x, y: def.footprint.y };
}
/** Centre of a machine's rotated footprint, in world units, on the ground plane. */
export function centerOf(pos: Vec2, def: MachineDef, dir: Dir, out = new THREE.Vector3()): THREE.Vector3 {
const f = rotatedFootprint(def, dir);
return out.set(pos.x + f.x / 2, 0, pos.y + f.y / 2);
}
/** Every tile a machine occupies. Used for ghost occupancy checks. */
export function tilesOf(pos: Vec2, def: MachineDef, dir: Dir): Vec2[] {
const f = rotatedFootprint(def, dir);
const out: Vec2[] = [];
for (let dx = 0; dx < f.x; dx++) {
for (let dy = 0; dy < f.y; dy++) out.push({ x: pos.x + dx, y: pos.y + dy });
}
return out;
}
export const tileKey = (x: number, y: number): number => (x + 512) * 4096 + (y + 512);
/** Free all geometry/material owned by a subtree before dropping it. */
export function disposeObject(root: THREE.Object3D): void {
root.traverse((o) => {
const m = o as THREE.Mesh;
if (!m.isMesh) return;
m.geometry?.dispose();
const mat = m.material;
if (Array.isArray(mat)) mat.forEach((x) => x.dispose());
else mat?.dispose();
});
}

View File

@ -0,0 +1,139 @@
/**
* LANE-RENDER dev-only mock snapshot. NOT part of the game.
*
* LANE-SIM is being built in parallel and its stub reports zero entities, so there is
* nothing on screen to verify this lane against. This fabricates the M1 line
* extractor -> belts -> demuxer -> quantizer -> mosh reactor -> shipper, with cargo
* riding the belts purely so the renderer can be checked with real eyes.
*
* Gated behind `?devscene` AND only used while the real sim has no entities: the
* moment LANE-SIM produces anything, this yields automatically. It never fabricates
* over live sim data.
*
* Items are advanced along the belt CHAIN (handing off between belt entities) rather
* than by wrapping `t` on one belt, because that's what the real sim does — and it's
* what exercises the renderer's cross-belt interpolation path honestly.
*/
import { TICKS_PER_SECOND, type Dir, type EntityState, type GameData, type SimSnapshot } from '../contracts';
/**
* Dev builds only. The `import.meta.env.DEV` guard is load-bearing, not decoration:
* without it `?devscene` would fabricate a fake factory in a shipped build and expose
* the renderer internals to anyone with a query string. It also lets Vite drop this
* whole module from the production bundle.
*/
export const devSceneEnabled = (): boolean =>
import.meta.env.DEV && new URLSearchParams(location.search).has('devscene');
/** `?devscene=stress` builds the standing-rule load: 500 entities + 2,000 belt items. */
export const devSceneStress = (): boolean =>
import.meta.env.DEV && new URLSearchParams(location.search).get('devscene') === 'stress';
interface Segment {
ids: number[]; // belt entity ids, in travel order
item: string;
count: number;
}
export class DevScene {
private entities: EntityState[] = [];
private segments: Segment[] = [];
private speed = 2;
private ok = false;
constructor(private data: GameData, private stress = false) {
this.build();
}
get usable(): boolean {
return this.ok;
}
private has(id: string): boolean {
return this.data.machines.some((m) => m.id === id);
}
private build(): void {
const need = ['seam-extractor', 'belt', 'demuxer', 'quantizer', 'mosh-reactor', 'shipper'];
if (!need.every((n) => this.has(n))) return; // data drifted; stay out of the way
this.speed = this.data.machines.find((m) => m.id === 'belt')?.beltSpeed ?? 2;
let id = 1;
const machine = (def: string, x: number, y: number, dir: Dir = 1, recipe: string | null = null) => {
this.entities.push({
id: id++, def, pos: { x, y }, dir, recipe,
progress: 0, inputBuf: {}, outputBuf: {}, jammed: null, heat: 0,
});
};
const beltRun = (x0: number, x1: number, item: string, count: number, y = 0) => {
const ids: number[] = [];
for (let x = x0; x <= x1; x++) {
ids.push(id);
machine('belt', x, y, 1);
}
this.segments.push({ ids, item, count });
};
if (this.stress) {
// Standing-rule load: ~500 entities and ~2,000 belt items, to prove the
// instancing holds up before there's any real factory to slow down.
const items = ['mdat-ore', 'luma-bars', 'coefficient-pack', 'melt'];
const machines = ['quantizer', 'demuxer', 'mosh-reactor', 'decode-asic'];
for (let r = 0; r < 20; r++) {
const y = -28 + r * 3;
beltRun(-30, -7, items[r % items.length], 100, y); // 24 belts x 20 = 480
machine(machines[r % machines.length], -5, y, 1); // +20 machines = 500
}
this.segments = this.segments.filter((s) => this.data.items.some((i) => i.id === s.item));
this.ok = true;
return;
}
// A single line along the belt row y=0, running east.
machine('seam-extractor', -14, 0, 1, 'extract-mdat');
beltRun(-12, -9, 'mdat-ore', 5);
machine('demuxer', -8, 0, 1, 'demux-ore');
beltRun(-6, -3, 'luma-bars', 4);
machine('quantizer', -2, 0, 1, 'quantize');
beltRun(0, 3, 'coefficient-pack', 4);
machine('mosh-reactor', 4, -1, 1, 'mosh'); // 3x3: centre lands on the belt row
beltRun(7, 10, 'melt', 3);
machine('shipper', 11, 0, 1);
machine('decode-asic', -2, 4, 1);
// Only claim items the data actually defines.
this.segments = this.segments.filter((s) => this.data.items.some((i) => i.id === s.item));
this.ok = true;
}
snapshot(tick: number): SimSnapshot {
// Exercise the feedback dressing on a slow cycle: jams, then brownout.
const jamPhase = Math.floor(tick / 450) % 3 === 2;
const brownout = Math.floor(tick / 300) % 4 === 3;
for (const e of this.entities) {
if (e.def === 'demuxer') e.jammed = jamPhase ? 'output full: LUMA BARS' : null;
if (e.recipe) e.progress = ((tick % 90) / 90);
}
const beltItems: SimSnapshot['beltItems'] = this.segments.flatMap((seg) => {
const perTick = this.speed / TICKS_PER_SECOND;
const span = seg.ids.length;
return Array.from({ length: seg.count }, (_, k) => {
const p = (tick * perTick + (k * span) / seg.count) % span;
const idx = Math.min(span - 1, Math.floor(p));
return { item: seg.item, entity: seg.ids[idx], t: p - idx };
});
});
return {
tick,
paused: false,
entities: this.entities,
beltItems,
bandwidth: { gen: 20, draw: brownout ? 26 : 14, stored: brownout ? 0 : 40, brownout },
shippedTotal: { melt: Math.floor(tick / 90) },
activeCommission: null,
commissionProgress: {},
};
}
}

View File

@ -0,0 +1,113 @@
/**
* LANE-RENDER machine entities (everything except belts, which are instanced).
*
* Syncs the scene to `snap.entities`: add, remove, re-transform, and rebuild when a
* MODELBEAST GLB hot-swaps under an asset key. Sim is truth; we only ever read.
*/
import * as THREE from 'three';
import type { Dir, MachineDef, SimSnapshot } from '../contracts';
import { AssetRegistry, idleOf, type IdleFn } from './registry';
import { centerOf, disposeObject, yawFor } from './coords';
import { HEIGHT_BY_KIND, JAM_AMBER } from './palette';
interface Rec {
obj: THREE.Object3D;
def: string;
dir: Dir;
x: number;
y: number;
assetVersion: number;
/** Placeholders own their geo/mat and must be disposed. GLB clones share the
* template's disposing one would break every other instance. */
owns: boolean;
idle: IdleFn | null;
jam: THREE.Mesh;
phase: number;
}
const JAM_GEO = new THREE.SphereGeometry(0.09, 8, 6);
export class EntityLayer {
readonly group = new THREE.Group();
private live = new Map<number, Rec>();
private seen = new Set<number>();
/** One shared material: every alarm blinks together, like a real klaxon. */
private jamMat = new THREE.MeshStandardMaterial({
color: JAM_AMBER,
emissive: JAM_AMBER,
emissiveIntensity: 1.5,
});
constructor(private registry: AssetRegistry, private defs: Map<string, MachineDef>) {
this.group.name = 'entities';
}
sync(snap: SimSnapshot, timeSec: number): void {
this.seen.clear();
let anyJammed = false;
for (const e of snap.entities) {
const def = this.defs.get(e.def);
if (!def || def.kind === 'belt') continue; // belts: see belts.ts
const entry = this.registry.get(def.asset);
if (!entry) continue;
this.seen.add(e.id);
let rec = this.live.get(e.id);
if (rec && (rec.def !== e.def || rec.assetVersion !== entry.version)) {
this.drop(e.id, rec); // asset hot-swapped (or def changed): rebuild
rec = undefined;
}
if (!rec) {
const obj = entry.create();
const jam = new THREE.Mesh(JAM_GEO, this.jamMat);
jam.position.y = (HEIGHT_BY_KIND[def.kind] ?? 0.8) + 0.28;
jam.visible = false;
obj.add(jam);
this.group.add(obj);
rec = {
obj,
def: e.def,
dir: e.dir,
x: NaN,
y: NaN,
assetVersion: entry.version,
owns: !entry.isGLB,
idle: idleOf(obj),
jam,
phase: (e.id % 17) * 0.41, // desync idle motion between machines
};
this.live.set(e.id, rec);
}
if (rec.x !== e.pos.x || rec.y !== e.pos.y || rec.dir !== e.dir) {
centerOf(e.pos, def, e.dir, rec.obj.position);
rec.obj.rotation.y = yawFor(e.dir);
rec.x = e.pos.x;
rec.y = e.pos.y;
rec.dir = e.dir;
}
rec.jam.visible = !!e.jammed;
if (e.jammed) anyJammed = true;
rec.idle?.(timeSec + rec.phase);
}
for (const [id, rec] of this.live) {
if (!this.seen.has(id)) this.drop(id, rec);
}
if (anyJammed) {
this.jamMat.emissiveIntensity = Math.sin(timeSec * 7) > 0 ? 2.2 : 0.15;
}
}
private drop(id: number, rec: Rec): void {
this.group.remove(rec.obj);
// The jam light's geo/mat are shared across all machines — detach before any
// disposal walks the subtree and frees them out from under everyone else.
rec.obj.remove(rec.jam);
if (rec.owns) disposeObject(rec.obj);
this.live.delete(id);
}
}

131
fktry/src/render/ghost.ts Normal file
View File

@ -0,0 +1,131 @@
/**
* LANE-RENDER build ghost. Shows the selected machine at the hovered tile:
* cyan when it fits, red when its footprint collides with something already placed.
*
* Occupancy is read straight off the snapshot's entity footprints the sim still gets
* the final say when the place command lands; this is only the player's preview.
*/
import * as THREE from 'three';
import type { Dir, MachineDef, SimSnapshot, Vec2 } from '../contracts';
import { AssetRegistry } from './registry';
import { centerOf, tileKey, tilesOf, yawFor } from './coords';
import { idleOf, type IdleFn } from './registry';
import { WORLD_HALF } from './camera';
const VALID = 0x3fd4ff;
const INVALID = 0xff3b30;
function ghostMaterial(color: number): THREE.MeshStandardMaterial {
return new THREE.MeshStandardMaterial({
color,
emissive: color,
emissiveIntensity: 0.85,
transparent: true,
opacity: 0.42,
depthWrite: false,
roughness: 0.4,
});
}
export class GhostLayer {
readonly group = new THREE.Group();
private obj: THREE.Object3D | null = null;
private objDef: string | null = null;
private objVersion = -1;
/** GLB clones share the template's geometry — only dispose what we built ourselves. */
private owns = false;
private idle: IdleFn | null = null;
private def: MachineDef | null = null;
private pos: Vec2 | null = null;
private dir: Dir = 0;
private occupied = new Set<number>();
private matValid = ghostMaterial(VALID);
private matInvalid = ghostMaterial(INVALID);
constructor(private registry: AssetRegistry, private defs: Map<string, MachineDef>) {
this.group.name = 'ghost';
this.group.renderOrder = 10;
}
set(defId: string | null, pos: Vec2 | null, dir: Dir): void {
const def = defId ? this.defs.get(defId) ?? null : null;
this.def = def;
this.pos = def && pos ? pos : null;
this.dir = dir;
if (!def || !this.pos) {
this.group.visible = false;
return;
}
const entry = this.registry.get(def.asset);
if (!entry) {
this.group.visible = false;
return;
}
if (!this.obj || this.objDef !== def.id || this.objVersion !== entry.version) {
this.rebuild(def);
this.objDef = def.id;
this.objVersion = entry.version;
}
this.group.visible = true;
}
private rebuild(def: MachineDef): void {
this.drop();
const entry = this.registry.get(def.asset)!;
const obj = entry.create();
this.owns = !entry.isGLB;
this.idle = idleOf(obj);
// Replace (never mutate) materials: a hot-swapped GLB's materials are shared
// with every placed copy of that machine.
obj.traverse((o) => {
const m = o as THREE.Mesh;
if (!m.isMesh) return;
m.material = this.matValid;
m.castShadow = m.receiveShadow = false;
});
this.group.add(obj);
this.obj = obj;
}
private drop(): void {
if (!this.obj) return;
this.group.remove(this.obj);
if (this.owns) {
this.obj.traverse((o) => {
const m = o as THREE.Mesh;
if (m.isMesh) m.geometry?.dispose(); // materials here are the shared ghost mats
});
}
this.obj = null;
}
update(snap: SimSnapshot, timeSec: number): void {
if (!this.group.visible || !this.def || !this.pos || !this.obj) return;
this.occupied.clear();
for (const e of snap.entities) {
const d = this.defs.get(e.def);
if (!d) continue;
for (const t of tilesOf(e.pos, d, e.dir)) this.occupied.add(tileKey(t.x, t.y));
}
const want = tilesOf(this.pos, this.def, this.dir);
const ok = want.every(
(t) =>
!this.occupied.has(tileKey(t.x, t.y)) &&
t.x >= -WORLD_HALF && t.x < WORLD_HALF &&
t.y >= -WORLD_HALF && t.y < WORLD_HALF,
);
const mat = ok ? this.matValid : this.matInvalid;
this.obj.traverse((o) => {
const m = o as THREE.Mesh;
if (m.isMesh) m.material = mat;
});
centerOf(this.pos, this.def, this.dir, this.obj.position);
this.obj.position.y += 0.02;
this.obj.rotation.y = yawFor(this.dir);
this.idle?.(timeSec);
}
}

View File

@ -0,0 +1,78 @@
/**
* LANE-RENDER Stream-Crust ground plane.
*
* A shader grid rather than GridHelper: resolution-independent antialiased lines,
* major lines every 8 tiles, faint scanline grain, and a dissolve at the world edge
* so the Bitstream fades into the void instead of ending on a hard rectangle.
*/
import * as THREE from 'three';
import { WORLD } from './palette';
import { WORLD_HALF } from './camera';
const PLANE = WORLD_HALF * 5; // extends past the world; grid only drawn inside ±WORLD_HALF
const VERT = /* glsl */ `
varying vec2 vWorld;
void main() {
vec4 wp = modelMatrix * vec4(position, 1.0);
vWorld = wp.xz;
gl_Position = projectionMatrix * viewMatrix * wp;
}
`;
const FRAG = /* glsl */ `
precision highp float;
uniform vec3 uBase;
uniform vec3 uLine;
uniform vec3 uMajor;
uniform float uHalf;
varying vec2 vWorld;
// Antialiased grid: distance to the nearest cell border, in pixels.
float gridMask(vec2 coord, float width) {
vec2 g = abs(fract(coord - 0.5) - 0.5) / fwidth(coord);
return 1.0 - min(min(g.x, g.y) / width, 1.0);
}
void main() {
vec3 col = uBase;
col = mix(col, uLine, gridMask(vWorld, 1.0) * 0.85);
col = mix(col, uMajor, gridMask(vWorld / 8.0, 1.0) * 0.9);
col += (sin(vWorld.y * 26.0) * 0.5 + 0.5) * 0.006; // crust grain
vec2 d = abs(vWorld) / uHalf;
float inside = 1.0 - smoothstep(0.82, 1.0, max(d.x, d.y));
gl_FragColor = vec4(mix(uBase, col, inside), 1.0);
}
`;
export function createGround(): THREE.Mesh {
const mat = new THREE.ShaderMaterial({
uniforms: {
uBase: { value: new THREE.Color(WORLD.crust) },
uLine: { value: new THREE.Color(WORLD.gridLine) },
uMajor: { value: new THREE.Color(WORLD.gridMajor) },
uHalf: { value: WORLD_HALF },
},
vertexShader: VERT,
fragmentShader: FRAG,
});
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(PLANE, PLANE), mat);
mesh.rotation.x = -Math.PI / 2; // +y up; tile (x,y) -> world (x, 0, y)
mesh.receiveShadow = true;
mesh.renderOrder = -1;
mesh.name = 'ground';
return mesh;
}
/** Screen-space vignette. Cheap, and keeps post-processing out of LANE-SCREEN's way. */
export function createVignette(): HTMLDivElement {
const el = document.createElement('div');
el.style.cssText = [
'position:absolute',
'inset:0',
'pointer-events:none',
'background:radial-gradient(ellipse at center, rgba(0,0,0,0) 45%, rgba(4,3,9,0.78) 100%)',
].join(';');
return el;
}

View File

@ -1,34 +1,171 @@
/** LANE-RENDER territory. Stub — replace per lanes/LANE-RENDER.md. */
/**
* LANE-RENDER the Bitstream, made visible.
*
* Isometric factory view. Sim is truth: we render snapshots and never simulate.
* Composition: registry (assets + MODELBEAST hot-swap) -> ground / belts / entities /
* belt items / ghost layers -> ortho iso camera.
*/
import * as THREE from 'three';
import type { Dir, GameData, Renderer, SimSnapshot, Vec2 } from '../contracts';
import type { Dir, GameData, MachineDef, Renderer, SimSnapshot, Vec2 } from '../contracts';
import { AssetRegistry } from './registry';
import { CAM_DIST, CameraRig, WORLD_HALF } from './camera';
import { createGround, createVignette } from './ground';
import { EntityLayer } from './entities';
import { BeltLayer } from './belts';
import { BeltItemLayer } from './beltitems';
import { GhostLayer } from './ghost';
import { DevScene, devSceneEnabled, devSceneStress } from './devscene';
import { WORLD } from './palette';
const KEY_INTENSITY = 1.9;
const FILL_INTENSITY = 0.7;
const AMBIENT_INTENSITY = 1.1;
/** ACES rolls mid-tones down hard; this lifts the factory back off a near-black ground. */
const BASE_EXPOSURE = 1.35;
export function createRenderer(): Renderer {
let renderer: THREE.WebGLRenderer;
let gl: THREE.WebGLRenderer;
let scene: THREE.Scene;
let camera: THREE.OrthographicCamera;
let rig: CameraRig;
let registry: AssetRegistry;
let entities: EntityLayer;
let belts: BeltLayer;
let cargo: BeltItemLayer;
let ghost: GhostLayer;
let dev: DevScene | null = null;
let defs: Map<string, MachineDef>;
const raycaster = new THREE.Raycaster();
const groundPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
const ndc = new THREE.Vector2();
const hit = new THREE.Vector3();
const clock = new THREE.Clock();
let dim = 1; // smoothed brownout level
/** Dev-only frame-cost sampler. Measures CPU ms per frame, which unlike frame
* RATE stays honest even when the tab is backgrounded and rAF is throttled. */
const stats = { ms: 0, avg: 0, frames: 0 };
return {
async init(container: HTMLElement, _data: GameData) {
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(container.clientWidth, container.clientHeight);
container.appendChild(renderer.domElement);
async init(container: HTMLElement, data: GameData) {
defs = new Map(data.machines.map((m) => [m.id, m]));
gl = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
gl.setPixelRatio(Math.min(devicePixelRatio, 2));
gl.setSize(container.clientWidth, container.clientHeight);
gl.shadowMap.enabled = true;
gl.shadowMap.type = THREE.PCFSoftShadowMap;
gl.toneMapping = THREE.ACESFilmicToneMapping;
container.appendChild(gl.domElement);
container.appendChild(createVignette());
scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0812);
const aspect = container.clientWidth / container.clientHeight;
const d = 12;
camera = new THREE.OrthographicCamera(-d * aspect, d * aspect, d, -d, 0.1, 200);
camera.position.set(20, 24, 20);
camera.lookAt(0, 0, 0);
scene.add(new THREE.GridHelper(64, 64, 0x2a2440, 0x181425));
scene.add(new THREE.AmbientLight(0xffffff, 0.8));
addEventListener('resize', () => {
renderer.setSize(container.clientWidth, container.clientHeight);
});
scene.background = new THREE.Color(WORLD.bg);
// Fog distances are measured from the CAMERA, which an ortho iso rig parks
// CAM_DIST away from everything — so the band starts past the focal point and
// only bites at the far reaches. Absolute values here would fog the whole world
// into the background.
scene.fog = new THREE.Fog(WORLD.bg, CAM_DIST + 12, CAM_DIST + 78);
scene.add(createGround());
const key = new THREE.DirectionalLight(0xbfc8ff, KEY_INTENSITY);
key.position.set(18, 30, 12);
key.castShadow = true;
key.shadow.mapSize.set(2048, 2048);
key.shadow.camera.near = 1;
key.shadow.camera.far = 90;
const s = 26;
key.shadow.camera.left = -s;
key.shadow.camera.right = s;
key.shadow.camera.top = s;
key.shadow.camera.bottom = -s;
key.shadow.bias = -0.0012;
scene.add(key);
// Cool bounce from below-ish so machine flanks don't go pure black.
const fill = new THREE.DirectionalLight(0x4a3f7a, FILL_INTENSITY);
fill.position.set(-14, 8, -16);
scene.add(fill);
scene.add(new THREE.AmbientLight(0x8f8fbf, AMBIENT_INTENSITY));
rig = new CameraRig(container, gl.domElement);
registry = new AssetRegistry();
await registry.init(data);
belts = new BeltLayer(registry, defs);
entities = new EntityLayer(registry, defs);
cargo = new BeltItemLayer(data, defs);
ghost = new GhostLayer(registry, defs);
scene.add(belts.group, entities.group, cargo.group, ghost.group);
if (devSceneEnabled()) {
const d = new DevScene(data, devSceneStress());
if (d.usable) {
dev = d;
console.info('[render] devscene active — mock line until LANE-SIM lands entities');
// Dev-only introspection hook (devscene builds only). Never touched in prod.
(window as unknown as { __render: unknown }).__render = {
scene, camera: rig.camera, stats, gl, dev,
};
}
}
const onResize = () => {
gl.setSize(container.clientWidth, container.clientHeight);
rig.resize();
};
addEventListener('resize', onResize);
new ResizeObserver(onResize).observe(container);
},
render(_snap: SimSnapshot, _alpha: number) {
if (renderer) renderer.render(scene, camera);
render(snap: SimSnapshot, alpha: number) {
if (!gl) return;
const t0 = dev ? performance.now() : 0;
const dt = clock.getDelta();
const t = clock.elapsedTime;
// Dev scaffold yields the instant the real sim has anything to show.
const view = dev && snap.entities.length === 0 ? dev.snapshot(snap.tick) : snap;
rig.update(dt);
belts.sync(view, t);
entities.sync(view, t);
cargo.sync(view, alpha, t);
ghost.update(view, t);
// Brownout: drop the whole scene ~30% and flicker. Driven through tone-mapping
// exposure so emissives dim too — a brownout that left the glow at full would
// read as a lighting bug rather than a power failure.
const target = view.bandwidth.brownout ? 0.7 : 1;
dim += (target - dim) * Math.min(1, dt * 6);
const flicker = view.bandwidth.brownout
? 0.88 + Math.sin(t * 37) * 0.08 + (Math.sin(t * 13.3) > 0.72 ? -0.22 : 0)
: 1;
gl.toneMappingExposure = BASE_EXPOSURE * dim * flicker;
gl.render(scene, rig.camera);
if (dev) {
stats.ms = performance.now() - t0;
stats.frames++;
stats.avg += (stats.ms - stats.avg) * 0.05; // rolling
}
},
pickTile(clientX: number, clientY: number): Vec2 | null {
if (!gl) return null;
const rect = gl.domElement.getBoundingClientRect();
ndc.x = ((clientX - rect.left) / rect.width) * 2 - 1;
ndc.y = -((clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(ndc, rig.camera);
if (!raycaster.ray.intersectPlane(groundPlane, hit)) return null;
const x = Math.floor(hit.x);
const y = Math.floor(hit.z);
if (x < -WORLD_HALF || x >= WORLD_HALF || y < -WORLD_HALF || y >= WORLD_HALF) return null;
return { x, y };
},
setGhost(def: string | null, pos: Vec2 | null, dir: Dir) {
ghost?.set(def, pos, dir);
},
pickTile(_x: number, _y: number): Vec2 | null { return null; },
setGhost(_def: string | null, _pos: Vec2 | null, _dir: Dir) {},
};
}

View File

@ -0,0 +1,78 @@
/**
* LANE-RENDER world palette, per the style guide (docs/FKTRY_LORE.md §8).
*
* Rule the guide gives us: the base world is desaturated industrial neutrals; ALL
* saturation belongs to media/artifacts. So machine BODIES live here as grimy
* neutrals, and machine ACCENTS are derived from data (the colour of what the
* machine makes) in registry.ts colour = value = literally the resource.
*/
import * as THREE from 'three';
import type { MachineKind } from '../contracts';
const hsl = { h: 0, s: 0, l: 0 };
/**
* Perceptual (sRGB) lightness of a data colour. Reasoned in sRGB rather than the
* linear working space so the thresholds below mean what they look like.
*/
export function lightnessOf(hex: string): number {
new THREE.Color(hex).getHSL(hsl, THREE.SRGBColorSpace);
return hsl.l;
}
/**
* Data colours are authored for the codex, not for legibility on a near-black ground:
* MDAT ORE is #1a1a22, which renders as an invisible smudge. Lift very dark colours to
* a floor, keeping hue and saturation, so every item still reads as itself on a belt.
*/
export function readable(hex: string, minL = 0.34): THREE.Color {
const c = new THREE.Color(hex);
c.getHSL(hsl, THREE.SRGBColorSpace);
if (hsl.l < minL) c.setHSL(hsl.h, hsl.s, minL, THREE.SRGBColorSpace);
return c;
}
/** Below this a derived accent is too dark to be "the impossible element" (§8). */
export const ACCENT_MIN_L = 0.3;
export const WORLD = {
bg: 0x0a0812,
crust: 0x0c0a15,
gridLine: 0x181425,
gridMajor: 0x241d38,
} as const;
/** Grimy industrial bodies. Desaturated on purpose — the accent does the talking. */
export const BODY_BY_KIND: Record<MachineKind, number> = {
extractor: 0x6e6134, // grimy yellow chassis (codex §4 seam extractor)
crafter: 0x3b3944,
belt: 0x2a2833,
splitter: 0x35333f,
power: 0x45434f,
buffer: 0x3a3f48,
shipper: 0x4a4652,
};
/** Fallback accent when a machine runs no recipes (belts, power, shippers). */
export const ACCENT_BY_KIND: Record<MachineKind, number> = {
extractor: 0x9fe8ff, // CRT monitor head
crafter: 0x3fffe0,
belt: 0x4a5568,
splitter: 0x7fb0ff,
power: 0xffb03f, // serene LED face
buffer: 0x7fffd4,
shipper: 0x3fd4ff, // uplink to THE SCREEN
};
/** Body height in world units. Silhouette differentiation on a flat iso grid. */
export const HEIGHT_BY_KIND: Record<MachineKind, number> = {
extractor: 1.15,
crafter: 0.8,
belt: 0.14,
splitter: 0.45,
power: 0.95,
buffer: 0.7,
shipper: 1.3,
};
export const JAM_AMBER = 0xffa726;

View File

@ -0,0 +1,313 @@
/**
* LANE-RENDER asset registry + MODELBEAST hot-swap.
*
* Every entity mesh keys off `MachineDef.asset` through here. No hard-coded ids.
*
* Default for every key is a procedural placeholder built to the style guide's
* two-material rule: a grimy neutral body + exactly ONE emissive accent, where the
* accent colour is DERIVED FROM DATA the colour of the item the machine makes
* (recipes -> outputs -> ItemDef.color). A quantizer glows coefficient-cyan, a mosh
* reactor glows melt-orange, and a new machine in data/ lights up with zero code.
*
* If `public/assets/models/<key>.glb` exists, it replaces the placeholder factory.
* We probe on init and re-probe every 10s in dev, so an asset dropped in mid-session
* appears without a reload. GLBs are auto-normalised (scaled to footprint, sat on the
* ground) so MODELBEAST can export at any scale and it just lands correctly.
*/
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { RoundedBoxGeometry } from 'three/examples/jsm/geometries/RoundedBoxGeometry.js';
import type { GameData, MachineDef } from '../contracts';
import { ACCENT_BY_KIND, ACCENT_MIN_L, BODY_BY_KIND, HEIGHT_BY_KIND, lightnessOf } from './palette';
const MODEL_DIR = 'assets/models';
const POLL_MS = 10_000;
/** Per-frame idle animation attached to a placeholder. `t` is seconds (phase-shifted). */
export type IdleFn = (t: number) => void;
export interface AssetEntry {
key: string;
/** Bumps when a GLB hot-swaps in; layers rebuild meshes whose version moved. */
version: number;
isGLB: boolean;
/** A fresh instance, normalised to the machine footprint, origin at ground centre. */
create(): THREE.Object3D;
/** Single geo+mat for InstancedMesh use (belts). Null if the asset can't be instanced. */
instanceable(): { geometry: THREE.BufferGeometry; material: THREE.Material } | null;
}
export function idleOf(obj: THREE.Object3D): IdleFn | null {
return (obj.userData.fktryIdle as IdleFn | undefined) ?? null;
}
// ---------------------------------------------------------------- placeholders
function bodyMat(color: number): THREE.MeshStandardMaterial {
return new THREE.MeshStandardMaterial({ color, roughness: 0.85, metalness: 0.15 });
}
function accentMat(color: number): THREE.MeshStandardMaterial {
return new THREE.MeshStandardMaterial({
color,
emissive: color,
emissiveIntensity: 1.35,
roughness: 0.35,
metalness: 0.1,
});
}
/**
* Procedural placeholder: footprint-sized bevelled box, one emissive accent, one tiny
* idle motion. Accent geometry + idle differ per kind so silhouettes read apart on the
* iso grid even before real assets land.
*/
function makePlaceholder(def: MachineDef, accent: number): THREE.Object3D {
const g = new THREE.Group();
const fw = Math.max(0.4, def.footprint.x * 0.92);
const fd = Math.max(0.4, def.footprint.y * 0.92);
const h = HEIGHT_BY_KIND[def.kind] ?? 0.8;
const body = new THREE.Mesh(
new RoundedBoxGeometry(fw, h, fd, 2, Math.min(0.08, h * 0.25)),
bodyMat(BODY_BY_KIND[def.kind] ?? 0x3b3944),
);
body.position.y = h / 2;
body.castShadow = body.receiveShadow = true;
g.add(body);
const am = accentMat(accent);
switch (def.kind) {
case 'extractor': {
// CRT monitor for a head + a drill bit that spins (codex §4).
const crt = new THREE.Mesh(new THREE.BoxGeometry(fw * 0.5, h * 0.3, 0.06), am);
crt.position.set(0, h * 0.72, -fd / 2 - 0.01);
g.add(crt);
const drill = new THREE.Mesh(new THREE.ConeGeometry(fw * 0.16, h * 0.5, 6), am.clone());
drill.position.set(0, h * 0.22, fd * 0.22);
drill.rotation.x = Math.PI;
g.add(drill);
g.userData.fktryIdle = ((t: number) => {
drill.rotation.y = t * 4;
(crt.material as THREE.MeshStandardMaterial).emissiveIntensity =
1.2 + Math.sin(t * 9) * 0.25; // CRT flicker
}) satisfies IdleFn;
break;
}
case 'crafter': {
// The jaw: an emissive slab that bobs. Quantizer energy, applied to all crafters.
const jaw = new THREE.Mesh(new THREE.BoxGeometry(fw * 0.62, h * 0.14, fd * 0.62), am);
jaw.position.y = h + 0.02;
g.add(jaw);
g.userData.fktryIdle = ((t: number) => {
jaw.position.y = h + 0.02 + Math.abs(Math.sin(t * 1.6)) * 0.09;
}) satisfies IdleFn;
break;
}
case 'power': {
const led = new THREE.Mesh(new THREE.BoxGeometry(fw * 0.7, 0.05, fd * 0.12), am);
led.position.y = h + 0.02;
g.add(led);
g.userData.fktryIdle = ((t: number) => {
am.emissiveIntensity = 1.0 + Math.sin(t * 2.2) * 0.5; // serene LED breath
}) satisfies IdleFn;
break;
}
case 'shipper': {
// Uplink dish, pointed at THE SCREEN.
const dish = new THREE.Mesh(new THREE.ConeGeometry(fw * 0.3, h * 0.42, 12, 1, true), am);
dish.position.y = h + h * 0.2;
g.add(dish);
g.userData.fktryIdle = ((t: number) => {
dish.rotation.y = t * 1.1;
am.emissiveIntensity = 1.2 + Math.sin(t * 6) * 0.35;
}) satisfies IdleFn;
break;
}
case 'buffer': {
const ring = new THREE.Mesh(new THREE.TorusGeometry(fw * 0.3, 0.035, 8, 20), am);
ring.position.y = h + 0.04;
ring.rotation.x = Math.PI / 2;
g.add(ring);
g.userData.fktryIdle = ((t: number) => {
ring.rotation.z = t * 0.8;
}) satisfies IdleFn;
break;
}
default: {
// splitter / belt / anything new in data/: a simple emissive brow.
const brow = new THREE.Mesh(new THREE.BoxGeometry(fw * 0.55, 0.05, fd * 0.2), am);
brow.position.y = h + 0.02;
g.add(brow);
g.userData.fktryIdle = ((t: number) => {
am.emissiveIntensity = 1.1 + Math.sin(t * 3) * 0.25;
}) satisfies IdleFn;
}
}
return g;
}
/** The flat belt plate. Instanced by belts.ts; chevron scroll lives in its material. */
export function beltPlateGeometry(): THREE.BufferGeometry {
const geo = new THREE.BoxGeometry(0.94, HEIGHT_BY_KIND.belt, 0.94);
geo.translate(0, HEIGHT_BY_KIND.belt / 2, 0);
return geo;
}
// ---------------------------------------------------------------- GLB handling
/** Fit an arbitrary GLB into the machine footprint, bottom sat on the ground plane. */
function normaliseGLB(src: THREE.Object3D, def: MachineDef): THREE.Object3D {
const obj = src.clone(true);
const box = new THREE.Box3().setFromObject(obj);
const size = box.getSize(new THREE.Vector3());
const targetW = Math.max(0.4, def.footprint.x * 0.92);
const targetD = Math.max(0.4, def.footprint.y * 0.92);
const s = Math.min(
size.x > 1e-6 ? targetW / size.x : 1,
size.z > 1e-6 ? targetD / size.z : 1,
);
if (Number.isFinite(s) && s > 0) obj.scale.setScalar(s);
// Re-measure after scaling, then centre on X/Z and sit the base at y=0.
const box2 = new THREE.Box3().setFromObject(obj);
const c = box2.getCenter(new THREE.Vector3());
obj.position.x -= c.x;
obj.position.z -= c.z;
obj.position.y -= box2.min.y;
obj.traverse((o) => {
if ((o as THREE.Mesh).isMesh) o.castShadow = o.receiveShadow = true;
});
return obj;
}
function firstInstanceable(
root: THREE.Object3D,
): { geometry: THREE.BufferGeometry; material: THREE.Material } | null {
let found: THREE.Mesh | null = null;
root.updateWorldMatrix(true, true);
root.traverse((o) => {
const m = o as THREE.Mesh;
if (!found && m.isMesh && m.geometry) found = m;
});
if (!found) return null;
const mesh = found as THREE.Mesh;
const geometry = mesh.geometry.clone().applyMatrix4(mesh.matrixWorld);
const material = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
return { geometry, material };
}
/**
* True only if the URL is really a GLB. A dev server's SPA fallback happily answers
* 200 + text/html for a missing file, so check the content-type before we trust it.
*/
async function glbExists(url: string): Promise<boolean> {
try {
const res = await fetch(url, { method: 'HEAD', cache: 'no-store' });
if (!res.ok) return false;
const ct = res.headers.get('content-type') ?? '';
return !ct.includes('text/html');
} catch {
return false;
}
}
// ---------------------------------------------------------------- registry
export class AssetRegistry {
/** Bumps on any hot-swap. Layers compare against it to know a rebuild is due. */
version = 0;
private entries = new Map<string, AssetEntry>();
private defs = new Map<string, MachineDef>();
private loader = new GLTFLoader();
private timer: number | null = null;
async init(data: GameData): Promise<void> {
for (const def of data.machines) {
this.defs.set(def.asset, def);
const accent = accentFor(def, data);
this.entries.set(def.asset, this.placeholderEntry(def, accent));
}
await this.probeAll();
if (import.meta.env.DEV) {
this.timer = setInterval(() => void this.probeAll(), POLL_MS) as unknown as number;
}
}
dispose(): void {
if (this.timer !== null) clearInterval(this.timer);
}
get(key: string): AssetEntry | null {
return this.entries.get(key) ?? null;
}
private placeholderEntry(def: MachineDef, accent: number): AssetEntry {
const prev = this.entries.get(def.asset);
return {
key: def.asset,
version: prev ? prev.version : 0,
isGLB: false,
create: () => makePlaceholder(def, accent),
// Belts are instanced by belts.ts, which supplies the chevron material itself —
// this material is only a fallback and is normally discarded.
instanceable: () =>
def.kind === 'belt'
? { geometry: beltPlateGeometry(), material: bodyMat(BODY_BY_KIND.belt) }
: null,
};
}
/** Probe every key that hasn't got a GLB yet. Cheap: one HEAD each, 10s apart. */
private async probeAll(): Promise<void> {
const pending = [...this.defs.values()].filter((d) => !this.entries.get(d.asset)?.isGLB);
await Promise.all(pending.map((d) => this.tryLoad(d)));
}
private async tryLoad(def: MachineDef): Promise<void> {
const url = `${import.meta.env.BASE_URL}${MODEL_DIR}/${def.asset}.glb`;
if (!(await glbExists(url))) return;
try {
const gltf = await this.loader.loadAsync(url);
const prev = this.entries.get(def.asset);
const version = (prev?.version ?? 0) + 1;
const template = gltf.scene;
this.entries.set(def.asset, {
key: def.asset,
version,
isGLB: true,
create: () => normaliseGLB(template, def),
instanceable: () => firstInstanceable(normaliseGLB(template, def)),
});
this.version++;
console.info(`[render] hot-swapped GLB for "${def.asset}"`);
} catch (err) {
// Corrupt/half-written file (MODELBEAST may still be copying). Keep the
// placeholder and let the next poll retry.
console.warn(`[render] GLB for "${def.asset}" failed to parse, keeping placeholder`, err);
}
}
}
/**
* A machine's accent = the colour of what it makes.
*
* With one guard: raw products are near-black by design (MDAT ORE is #1a1a22), and an
* accent that dark isn't an accent — the seam extractor's CRT head would be a black
* panel on a black chassis. Any derived colour below the legibility floor is rejected
* in favour of the per-kind accent (which for the extractor is CRT blue, as the codex
* describes it). Machines that run no recipes fall back the same way.
*/
function accentFor(def: MachineDef, data: GameData): number {
for (const rid of def.recipes) {
const recipe = data.recipes.find((r) => r.id === rid);
const outId = recipe && Object.keys(recipe.outputs)[0];
const item = outId ? data.items.find((i) => i.id === outId) : undefined;
if (item && lightnessOf(item.color) >= ACCENT_MIN_L) {
return new THREE.Color(item.color).getHex();
}
}
return ACCENT_BY_KIND[def.kind] ?? 0x3fffe0;
}