From 883dd4b02bc53d6286b320d74d4eec7643584a82 Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 17 Jul 2026 16:31:10 +1000 Subject: [PATCH] [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 --- fktry/src/render/beltitems.ts | 175 +++++++++++++++++++ fktry/src/render/belts.ts | 147 ++++++++++++++++ fktry/src/render/camera.ts | 157 +++++++++++++++++ fktry/src/render/coords.ts | 58 +++++++ fktry/src/render/devscene.ts | 139 +++++++++++++++ fktry/src/render/entities.ts | 113 ++++++++++++ fktry/src/render/ghost.ts | 131 ++++++++++++++ fktry/src/render/ground.ts | 78 +++++++++ fktry/src/render/index.ts | 183 +++++++++++++++++--- fktry/src/render/palette.ts | 78 +++++++++ fktry/src/render/registry.ts | 313 ++++++++++++++++++++++++++++++++++ 11 files changed, 1549 insertions(+), 23 deletions(-) create mode 100644 fktry/src/render/beltitems.ts create mode 100644 fktry/src/render/belts.ts create mode 100644 fktry/src/render/camera.ts create mode 100644 fktry/src/render/coords.ts create mode 100644 fktry/src/render/devscene.ts create mode 100644 fktry/src/render/entities.ts create mode 100644 fktry/src/render/ghost.ts create mode 100644 fktry/src/render/ground.ts create mode 100644 fktry/src/render/palette.ts create mode 100644 fktry/src/render/registry.ts diff --git a/fktry/src/render/beltitems.ts b/fktry/src/render/beltitems.ts new file mode 100644 index 0000000..8563561 --- /dev/null +++ b/fktry/src/render/beltitems.ts @@ -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(); + private items = new Map(); + private prev = new Map(); + private cur = new Map(); + private lastTick = -1; + private dummy = new THREE.Object3D(); + private belts = new Map(); + private counts = new Map(); + private seq = new Map(); + private sorted: SimSnapshot['beltItems'][number][] = []; + + constructor(data: GameData, private defs: Map) { + 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(); + 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; + } + } +} diff --git a/fktry/src/render/belts.ts b/fktry/src/render/belts.ts new file mode 100644 index 0000000..268f224 --- /dev/null +++ b/fktry/src/render/belts.ts @@ -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; +} + +export class BeltLayer { + readonly group = new THREE.Group(); + private byDef = new Map(); + private uTime = { value: 0 }; + private dummy = new THREE.Object3D(); + + constructor(private registry: AssetRegistry, private defs: Map) { + 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 ', '#include \n vLocal = position;'); + shader.fragmentShader = + 'uniform float uTime;\nuniform float uSpeed;\nuniform vec3 uAccent;\nvarying vec3 vLocal;\n' + + shader.fragmentShader.replace( + '#include ', + `#include + 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>(); + 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(); + } + } +} diff --git a/fktry/src/render/camera.ts b/fktry/src/render/camera.ts new file mode 100644 index 0000000..2921f92 --- /dev/null +++ b/fktry/src/render/camera.ts @@ -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(); + 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 = []; + } +} diff --git a/fktry/src/render/coords.ts b/fktry/src/render/coords.ts new file mode 100644 index 0000000..6a20b0a --- /dev/null +++ b/fktry/src/render/coords.ts @@ -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 = { + 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(); + }); +} diff --git a/fktry/src/render/devscene.ts b/fktry/src/render/devscene.ts new file mode 100644 index 0000000..afae8ed --- /dev/null +++ b/fktry/src/render/devscene.ts @@ -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: {}, + }; + } +} diff --git a/fktry/src/render/entities.ts b/fktry/src/render/entities.ts new file mode 100644 index 0000000..076b85b --- /dev/null +++ b/fktry/src/render/entities.ts @@ -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(); + private seen = new Set(); + /** 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) { + 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); + } +} diff --git a/fktry/src/render/ghost.ts b/fktry/src/render/ghost.ts new file mode 100644 index 0000000..1830683 --- /dev/null +++ b/fktry/src/render/ghost.ts @@ -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(); + private matValid = ghostMaterial(VALID); + private matInvalid = ghostMaterial(INVALID); + + constructor(private registry: AssetRegistry, private defs: Map) { + 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); + } +} diff --git a/fktry/src/render/ground.ts b/fktry/src/render/ground.ts new file mode 100644 index 0000000..36fd6a6 --- /dev/null +++ b/fktry/src/render/ground.ts @@ -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; +} diff --git a/fktry/src/render/index.ts b/fktry/src/render/index.ts index fc51f95..4540741 100644 --- a/fktry/src/render/index.ts +++ b/fktry/src/render/index.ts @@ -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; + + 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) {}, }; } diff --git a/fktry/src/render/palette.ts b/fktry/src/render/palette.ts new file mode 100644 index 0000000..d416c88 --- /dev/null +++ b/fktry/src/render/palette.ts @@ -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 = { + 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 = { + 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 = { + 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; diff --git a/fktry/src/render/registry.ts b/fktry/src/render/registry.ts new file mode 100644 index 0000000..7bbf182 --- /dev/null +++ b/fktry/src/render/registry.ts @@ -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/.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 { + 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(); + private defs = new Map(); + private loader = new GLTFLoader(); + private timer: number | null = null; + + async init(data: GameData): Promise { + 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 { + 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 { + 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; +}