diff --git a/fktry/src/render/beltitems.ts b/fktry/src/render/beltitems.ts index 8563561..4275056 100644 --- a/fktry/src/render/beltitems.ts +++ b/fktry/src/render/beltitems.ts @@ -7,20 +7,19 @@ * 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. + * Identity: contracts v2 added `BeltItem.id`. When present we key off it and the + * interpolation is EXACT. When absent we fall back to matching by (belt, type, order + * along the belt) — correct except for the one tick where the lead item leaves and the + * queue behind it shifts index. That fallback goes away when v3 makes `id` required. * - * Hot path: two allocation-free passes over beltItems (count, then write matrices). + * Pathing: cargo follows the belt's topology shape, so on a corner it enters at the + * feeding edge and sweeps the arc rather than teleporting to the back edge and cutting + * across. Still pure presentation — `t` remains the sim's progress along the tile. */ import * as THREE from 'three'; -import type { GameData, ItemDef, MachineDef, SimSnapshot } from '../contracts'; -import { DIR_VEC } from './coords'; +import type { GameData, ItemDef, SimSnapshot } from '../contracts'; import { HEIGHT_BY_KIND, readable } from './palette'; +import { BeltTopology, localPath, toWorld } from './topology'; const ITEM_SIZE = 0.3; const RIDE_Y = HEIGHT_BY_KIND.belt + ITEM_SIZE * 0.5; @@ -30,12 +29,7 @@ interface ItemMesh { 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. - */ +/** Products glow; raw ore doesn't — saturation belongs to media (style guide §8). */ function itemMaterial(def: ItemDef): THREE.MeshStandardMaterial { return new THREE.MeshStandardMaterial({ color: readable(def.color), @@ -47,7 +41,6 @@ function itemMaterial(def: ItemDef): THREE.MeshStandardMaterial { } 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); @@ -61,12 +54,15 @@ export class BeltItemLayer { 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][] = []; + private lp = { x: 0, z: 0 }; + private wp = { x: 0, z: 0 }; + /** Set once per frame so NOTES/telemetry can report which identity path ran. */ + exactIds = false; - constructor(data: GameData, private defs: Map) { + constructor(data: GameData, private topo: BeltTopology) { this.group.name = 'beltItems'; for (const i of data.items) this.items.set(i.id, i); } @@ -75,7 +71,6 @@ export class BeltItemLayer { 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) { @@ -94,18 +89,17 @@ export class BeltItemLayer { } 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 }); + const src = snap.beltItems; + // Sim is internally consistent: if it stamps ids, it stamps all of them. + this.exactIds = src.length > 0 && src[0].id !== undefined; + + this.sorted.length = 0; + for (const bi of src) if (this.topo.get(bi.entity)) this.sorted.push(bi); + if (!this.exactIds) { + // Order-match fallback needs cargo ordered front-to-back per belt. + this.sorted.sort((a, b) => (a.entity !== b.entity ? a.entity - b.entity : b.t - a.t)); } - // 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; @@ -115,17 +109,12 @@ export class BeltItemLayer { this.lastTick = snap.tick; } - // Pass 1 — count per item type, and record this tick's positions for next frame. + // Pass 1 — count per type, and record this tick's positions for the 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); - } + if (advanced) this.cur.set(this.keyOf(bi), bi.t); } for (const [itemId, count] of this.counts) { const def = this.items.get(itemId); @@ -136,23 +125,17 @@ export class BeltItemLayer { this.seq.clear(); const written = new Map(); for (const bi of this.sorted) { - const belt = this.belts.get(bi.entity)!; + const node = this.topo.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 from = this.prev.get(this.keyOf(bi)); 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), - ); + localPath(node.shape, t, this.lp); + toWorld(this.lp.x, this.lp.z, node.x, node.y, node.dir, this.wp); + this.dummy.position.set(this.wp.x, RIDE_Y, this.wp.z); this.dummy.rotation.set(0, 0, 0); if (def.tier >= 2) { this.dummy.rotation.y = timeSec * 1.6; // products tumble; ore just rides @@ -172,4 +155,13 @@ export class BeltItemLayer { im.mesh.instanceMatrix.needsUpdate = true; } } + + /** Exact when the sim stamps ids; order-matched otherwise. */ + private keyOf(bi: SimSnapshot['beltItems'][number]): string { + if (bi.id !== undefined) return `#${bi.id}`; + const sk = `${bi.entity}:${bi.item}`; + const idx = this.seq.get(sk) ?? 0; + this.seq.set(sk, idx + 1); + return `${bi.entity}:${bi.item}:${idx}`; + } } diff --git a/fktry/src/render/belts.ts b/fktry/src/render/belts.ts index 268f224..6ba21d6 100644 --- a/fktry/src/render/belts.ts +++ b/fktry/src/render/belts.ts @@ -1,40 +1,79 @@ /** - * LANE-RENDER — belts, instanced. + * LANE-RENDER — belts, instanced, one InstancedMesh per (belt def x topology shape). * - * 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. + * The chevron scroll is injected into a MeshStandardMaterial rather than written as a + * raw ShaderMaterial, so belts keep real lighting, fog and brownout dimming for free. + * The shape's flow field is baked into the shader source at material-build time — no + * runtime branch, and each shape is its own draw call anyway. * - * Chevrons scroll along local -z; the instance yaw carries them to world direction. + * Straight chevrons run along local -z; corners sweep the quarter-arc from the feeding + * edge to the output edge; merges add inlet arrows flowing in from the sides. The + * instance yaw carries all of it to world orientation. */ 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'; +import { ACCENT_BY_KIND, BODY_BY_KIND, HEIGHT_BY_KIND } from './palette'; +import { ARC_LEN, BELT_SHAPES, BeltTopology, type BeltShape } from './topology'; -const CHEVRONS_PER_TILE = 3.0; +const CHEVRONS_PER_TILE = 3; interface BeltMesh { mesh: THREE.InstancedMesh; capacity: number; assetVersion: number; - live: Map; +} + +/** The flow field for one shape, in local tile space. Baked, not branched. */ +function chevronGLSL(shape: BeltShape): string { + const N = CHEVRONS_PER_TILE.toFixed(1); + if (shape === 'cornerA' || shape === 'cornerB') { + const side = shape === 'cornerA' ? '1.0' : '-1.0'; + return ` + vec2 rel = vec2(vLocal.x, vLocal.z) - vec2(${side} * 0.5, -0.5); + float r = length(rel); + float u = ${side} * (atan(rel.y, rel.x) - 1.5707963) / 1.5707963; + float band = fract((u * ${ARC_LEN.toFixed(4)} + abs(r - 0.5) * 0.55) * ${N} + - uTime * uSpeed * ${N}); + float arrow = (smoothstep(0.40, 0.50, band) - smoothstep(0.50, 0.60, band)) + * (1.0 - smoothstep(0.85, 1.15, r)); + `; + } + if (shape === 'merge') { + return ` + float band = fract((vLocal.z + abs(vLocal.x) * 0.55) * ${N} + uTime * uSpeed * ${N}); + float arrow = smoothstep(0.40, 0.50, band) - smoothstep(0.50, 0.60, band); + float sb = fract(abs(vLocal.x) * ${N} + uTime * uSpeed * ${N}); + arrow += (smoothstep(0.40, 0.50, sb) - smoothstep(0.50, 0.60, sb)) + * step(0.26, abs(vLocal.x)) * 0.8; + `; + } + return ` + float band = fract((vLocal.z + abs(vLocal.x) * 0.55) * ${N} + uTime * uSpeed * ${N}); + float arrow = smoothstep(0.40, 0.50, band) - smoothstep(0.50, 0.60, band); + `; } export class BeltLayer { readonly group = new THREE.Group(); - private byDef = new Map(); + private meshes = new Map(); private uTime = { value: 0 }; private dummy = new THREE.Object3D(); + private builtVersion = -1; - constructor(private registry: AssetRegistry, private defs: Map) { + constructor( + private registry: AssetRegistry, + private defs: Map, + private topo: BeltTopology, + ) { this.group.name = 'belts'; } - private material(def: MachineDef): THREE.Material { + private material(def: MachineDef, shape: BeltShape): THREE.Material { + // Chassis colour from data when LANE-DATA has art-directed it (see registry.bodyFor). const mat = new THREE.MeshStandardMaterial({ - color: 0x2a2833, + color: def.color ? new THREE.Color(def.color) : new THREE.Color(BODY_BY_KIND.belt), roughness: 0.9, metalness: 0.1, }); @@ -54,25 +93,27 @@ export class BeltLayer { '#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); + ${chevronGLSL(shape)} totalEmissiveRadiance += uAccent * arrow * 0.85; }`, ); }; + // Distinct cache key per shape: same base source, different injected flow field. + mat.customProgramCacheKey = () => `fktry-belt-${shape}`; return mat; } - private ensure(def: MachineDef, needed: number): BeltMesh { + private ensure(def: MachineDef, shape: BeltShape, needed: number): BeltMesh { + const key = `${def.id}|${shape}`; const entry = this.registry.get(def.asset); const version = entry?.version ?? 0; - let bm = this.byDef.get(def.id); + let bm = this.meshes.get(key); 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 + this.meshes.delete(key); + bm = undefined; // GLB landed for belts: rebuild from it } if (bm && needed <= bm.capacity) return bm; @@ -83,7 +124,7 @@ export class BeltLayer { // 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); + const material = entry?.isGLB && inst ? inst.material : this.material(def, shape); if (bm) { this.group.remove(bm.mesh); @@ -95,53 +136,34 @@ export class BeltLayer { 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); + const next: BeltMesh = { mesh, capacity, assetVersion: version }; + this.meshes.set(key, next); return next; } - sync(snap: SimSnapshot, timeSec: number): void { + /** `topo` is rebuilt by index.ts once per frame; we only react when it moved. */ + sync(_snap: SimSnapshot, timeSec: number): void { this.uTime.value = timeSec; + const assetVersion = this.registry.version; + const stamp = this.topo.version * 1000 + assetVersion; + if (stamp === this.builtVersion) return; // belts are static once placed + this.builtVersion = stamp; - // 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) { + for (const def of this.defs.values()) { 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; - } - } + for (const shape of BELT_SHAPES) { + const nodes = this.topo.nodesOf(def.id, shape); + const bm = this.ensure(def, shape, nodes.length); + nodes.forEach((n, i) => { + this.dummy.position.set(n.x + 0.5, 0, n.y + 0.5); // 1x1 footprint: tile centre + this.dummy.rotation.set(0, yawFor(n.dir), 0); + this.dummy.updateMatrix(); + bm.mesh.setMatrixAt(i, this.dummy.matrix); + }); + bm.mesh.count = nodes.length; + bm.mesh.instanceMatrix.needsUpdate = true; + bm.mesh.computeBoundingSphere(); } - 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/devscene.ts b/fktry/src/render/devscene.ts index afae8ed..6094b98 100644 --- a/fktry/src/render/devscene.ts +++ b/fktry/src/render/devscene.ts @@ -1,18 +1,19 @@ /** * 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. + * LANE-SIM builds in parallel and hasn't landed heat, splitters or `BeltItem.id` yet, + * so there is no live source for most of what this lane renders. This fabricates a + * factory that exercises every visual path — both corner handednesses, a merge, a heat + * ramp through throttle into scram, a filling buffer, stable cargo ids — purely so the + * renderer can be checked with real eyes before the sim catches up. * - * 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. + * Gated behind `?devscene` AND `import.meta.env.DEV`, 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. + * Cargo advances 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 and corner pathing honestly. */ import { TICKS_PER_SECOND, type Dir, type EntityState, type GameData, type SimSnapshot } from '../contracts'; @@ -33,6 +34,7 @@ interface Segment { ids: number[]; // belt entity ids, in travel order item: string; count: number; + tag: number; // id namespace so cargo ids stay stable and unique } export class DevScene { @@ -40,6 +42,7 @@ export class DevScene { private segments: Segment[] = []; private speed = 2; private ok = false; + private nextId = 1; constructor(private data: GameData, private stress = false) { this.build(); @@ -53,66 +56,87 @@ export class DevScene { return this.data.machines.some((m) => m.id === id); } + private machine(def: string, x: number, y: number, dir: Dir = 1, recipe: string | null = null): number { + const id = this.nextId++; + this.entities.push({ + id, def, pos: { x, y }, dir, recipe, + progress: 0, inputBuf: {}, outputBuf: {}, jammed: null, heat: 0, + }); + return id; + } + + /** A run of belts along explicit tiles; returns their ids in travel order. */ + private belts(tiles: Array<[number, number, Dir]>): number[] { + return tiles.map(([x, y, d]) => this.machine('belt', x, y, d)); + } + + private segment(ids: number[], item: string, count: number): void { + if (this.data.items.some((i) => i.id === item)) { + this.segments.push({ ids, item, count, tag: this.segments.length + 1 }); + } + } + 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 + if (!this.has('belt')) return; 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']; + const machines = ['quantizer', 'demuxer', 'mosh-reactor', 'decode-asic'].filter((m) => this.has(m)); 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 + const tiles: Array<[number, number, Dir]> = []; + for (let x = -30; x <= -7; x++) tiles.push([x, y, 1]); // 24 belts x 20 = 480 + this.segment(this.belts(tiles), items[r % items.length], 100); // 2,000 items + if (machines.length) this.machine(machines[r % machines.length], -5, y, 1); // +20 = 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); + // --- the M1 line, bent so cargo rides BOTH corner handednesses ----------------- + if (this.has('seam-extractor')) this.machine('seam-extractor', -14, 0, 1, 'extract-mdat'); + + // Main run east along y=0. The belt at x=-6 is fed from behind AND from the spur + // above it, so topology should classify it a merge. + const main: Array<[number, number, Dir]> = []; + for (let x = -12; x <= -3; x++) main.push([x, 0, 1]); + this.segment(this.belts(main), 'mdat-ore', 8); + + // Spur dropping south into the main line -> forces the merge piece. + this.segment(this.belts([[-6, -3, 2], [-6, -2, 2], [-6, -1, 2]]), 'chroma-slurry', 3); + + // One chain: corner right (fed from W, turns S), south run, corner left (fed from + // N, turns E), east run. Cargo sweeps both arcs on the way to the shipper. + const bend: Array<[number, number, Dir]> = [ + [-2, 0, 2], // cornerA — fed from local +x + [-2, 1, 2], [-2, 2, 2], [-2, 3, 2], [-2, 4, 2], + [-2, 5, 1], // cornerB — fed from local -x + [-1, 5, 1], [0, 5, 1], [1, 5, 1], [2, 5, 1], + ]; + this.segment(this.belts(bend), 'melt', 6); + if (this.has('shipper')) this.machine('shipper', 3, 5, 1); + + // Showroom row for the round-2 machine visuals, clear of the belt path. + if (this.has('lane-splitter')) this.machine('lane-splitter', -12, 8, 1); + if (this.has('buffer-tank')) this.machine('buffer-tank', -8, 8, 1); + if (this.has('software-decoder')) this.machine('software-decoder', -4, 8, 1); + if (this.has('quantizer')) this.machine('quantizer', 0, 8, 1, 'quantize'); + if (this.has('mosh-reactor')) this.machine('mosh-reactor', 4, 8, 1, 'mosh'); - // 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; + // Heat ramp: normal -> throttled (>0.7) -> scram (1.0) -> restart, on a ~10s loop + // so all three states are reachable in a short verification session. + const heat = Math.min(1, (tick % 300) / 200); 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); + if (e.def === 'software-decoder') e.heat = heat; + if (e.def === 'demuxer') e.jammed = Math.floor(tick / 450) % 3 === 2 ? 'output full: LUMA BARS' : null; + if (e.recipe) e.progress = (tick % 90) / 90; } const beltItems: SimSnapshot['beltItems'] = this.segments.flatMap((seg) => { @@ -121,7 +145,9 @@ export class DevScene { 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 }; + // Stable per-item id (contracts v2): identity survives the belt-to-belt handoff, + // which is exactly what exact interpolation needs. + return { item: seg.item, entity: seg.ids[idx], t: p - idx, id: seg.tag * 1000 + k }; }); }); @@ -130,7 +156,13 @@ export class DevScene { paused: false, entities: this.entities, beltItems, - bandwidth: { gen: 20, draw: brownout ? 26 : 14, stored: brownout ? 0 : 40, brownout }, + // Oscillate the pool so buffer tanks visibly fill and drain. + bandwidth: { + gen: 20, + draw: brownout ? 26 : 14, + stored: (0.5 + 0.5 * Math.sin(tick / 60)) * 100, + brownout, + }, shippedTotal: { melt: Math.floor(tick / 90) }, activeCommission: null, commissionProgress: {}, diff --git a/fktry/src/render/entities.ts b/fktry/src/render/entities.ts index 076b85b..8479c55 100644 --- a/fktry/src/render/entities.ts +++ b/fktry/src/render/entities.ts @@ -3,12 +3,33 @@ * * 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. + * + * Feedback this layer owns: + * heat — dull-red emissive ramp + rising shimmer, from `EntityState.heat` + * throttle — heat > 0.7 visibly slows idle motion AND any GLB animation clip + * scram — heat >= 1: dead-dark, blinking amber until it restarts + * jam — blinking amber klaxon + * buffer — tank fill from the machine's share of `bandwidth.stored` */ 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 { AssetRegistry, clipsOf, fillOf, idleOf, type FillFn, type IdleFn } from './registry'; +import { centerOf, yawFor } from './coords'; import { HEIGHT_BY_KIND, JAM_AMBER } from './palette'; +import { createHeatHaze, type HeatHaze } from './heat'; + +/** Heat is throttled above this (contracts: "throttles >0.7, scrams at 1"). */ +const THROTTLE_AT = 0.7; +const THROTTLE_FLOOR = 0.25; +/** Used when LANE-DATA hasn't set `bufferCap` yet — tanks still read as tanks. */ +const DEFAULT_BUFFER_CAP = 100; +const HEAT_RED = new THREE.Color(0xff3a12); + +interface HeatTarget { + mat: THREE.MeshStandardMaterial; + baseEmissive: THREE.Color; + baseIntensity: number; +} interface Rec { obj: THREE.Object3D; @@ -17,12 +38,21 @@ interface Rec { 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; + /** Placeholders own their geometry. GLB clones share the template's — disposing one + * would break every other instance. Materials we always own (GLB ones are cloned). */ + ownsGeo: boolean; idle: IdleFn | null; + fill: FillFn | null; + mixer: THREE.AnimationMixer | null; jam: THREE.Mesh; + haze: HeatHaze | null; + hazeSize: { w: number; h: number }; phase: number; + /** Advances at the throttled rate, so a hot machine visibly labours. */ + idleClock: number; + heat: HeatTarget[]; + all: HeatTarget[]; + wasScrammed: boolean; } const JAM_GEO = new THREE.SphereGeometry(0.09, 8, 6); @@ -42,9 +72,18 @@ export class EntityLayer { this.group.name = 'entities'; } - sync(snap: SimSnapshot, timeSec: number): void { + sync(snap: SimSnapshot, timeSec: number, dt: number): void { this.seen.clear(); - let anyJammed = false; + let anyAlarm = false; + + // Tanks share one bandwidth pool, so they all read the same level: the pool's + // fraction of total installed capacity. + let cap = 0; + for (const e of snap.entities) { + const d = this.defs.get(e.def); + if (d?.kind === 'buffer') cap += d.bufferCap ?? DEFAULT_BUFFER_CAP; + } + const fillLevel = cap > 0 ? THREE.MathUtils.clamp(snap.bandwidth.stored / cap, 0, 1) : 0; for (const e of snap.entities) { const def = this.defs.get(e.def); @@ -58,27 +97,7 @@ export class EntityLayer { 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) rec = this.build(e.id, e.def, def, entry.version); if (rec.x !== e.pos.x || rec.y !== e.pos.y || rec.dir !== e.dir) { centerOf(e.pos, def, e.dir, rec.obj.position); @@ -88,26 +107,162 @@ export class EntityLayer { rec.dir = e.dir; } - rec.jam.visible = !!e.jammed; - if (e.jammed) anyJammed = true; - rec.idle?.(timeSec + rec.phase); + const heat = THREE.MathUtils.clamp(e.heat ?? 0, 0, 1); + const scrammed = heat >= 1; + // Throttle ramps in rather than snapping, so "labouring" reads before "dead". + const speed = scrammed + ? 0 + : heat > THROTTLE_AT + ? THREE.MathUtils.lerp(1, THROTTLE_FLOOR, (heat - THROTTLE_AT) / (1 - THROTTLE_AT)) + : 1; + + rec.idleClock += dt * speed; + rec.idle?.(rec.idleClock + rec.phase); // idle runs first; overrides below win + rec.mixer?.update(dt * speed); + if (def.kind === 'buffer') rec.fill?.(fillLevel); + + this.applyHeat(rec, heat, scrammed, timeSec); + + const alarm = !!e.jammed || scrammed; + rec.jam.visible = alarm; + if (alarm) anyAlarm = true; } 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; + if (anyAlarm) this.jamMat.emissiveIntensity = Math.sin(timeSec * 7) > 0 ? 2.2 : 0.15; + } + + private applyHeat(rec: Rec, heat: number, scrammed: boolean, timeSec: number): void { + if (scrammed) { + // Dead-dark: kill every glow. The blinking amber is the only thing left alive. + for (const t of rec.all) t.mat.emissiveIntensity = 0; + rec.haze?.set(0, timeSec); + rec.wasScrammed = true; + return; } + if (rec.wasScrammed) { + // Restart: restore what scram zeroed. Idle-driven materials get overwritten by + // their own idle next frame anyway; the static ones need this. + for (const t of rec.all) t.mat.emissiveIntensity = t.baseIntensity; + rec.wasScrammed = false; + } + if (heat > 0.001) { + for (const t of rec.heat) { + t.mat.emissive.copy(t.baseEmissive).lerp(HEAT_RED, heat); + t.mat.emissiveIntensity = t.baseIntensity + heat * heat * 1.4; + } + if (!rec.haze) { + // Lazy: most machines never get hot, and a haze quad each would be waste. + rec.haze = createHeatHaze(rec.hazeSize.w, rec.hazeSize.h); + rec.obj.add(rec.haze.mesh); + } + rec.haze.set(heat, timeSec); + } else if (rec.haze) { + rec.haze.set(0, timeSec); + for (const t of rec.heat) { + t.mat.emissive.copy(t.baseEmissive); + t.mat.emissiveIntensity = t.baseIntensity; + } + } + } + + private build(id: number, defId: string, def: MachineDef, version: number): Rec { + const entry = this.registry.get(def.asset)!; + const obj = entry.create(); + + // GLB clones share the template's materials — per-entity heat/scram would leak + // across every copy of that machine, so give each instance its own. + if (entry.isGLB) { + obj.traverse((o) => { + const m = o as THREE.Mesh; + if (!m.isMesh) return; + m.material = Array.isArray(m.material) + ? m.material.map((x) => x.clone()) + : (m.material as THREE.Material).clone(); + }); + } + + const all: HeatTarget[] = []; + const heat: HeatTarget[] = []; + obj.traverse((o) => { + const m = o as THREE.Mesh; + if (!m.isMesh) return; + const mats = Array.isArray(m.material) ? m.material : [m.material]; + for (const raw of mats) { + const mat = raw as THREE.MeshStandardMaterial; + if (!mat || !mat.emissive) continue; + const t: HeatTarget = { + mat, + baseEmissive: mat.emissive.clone(), + baseIntensity: mat.emissiveIntensity ?? 1, + }; + all.push(t); + // Placeholders tag their body; a GLB has no body/accent split, so it all heats. + if (m.userData.fktryBody || entry.isGLB) heat.push(t); + } + }); + + const clips = clipsOf(obj); + let mixer: THREE.AnimationMixer | null = null; + if (clips.length) { + mixer = new THREE.AnimationMixer(obj); + const action = mixer.clipAction(clips[0]); + action.setLoop(THREE.LoopRepeat, Infinity); + action.play(); + } + + const h = HEIGHT_BY_KIND[def.kind] ?? 0.8; + const jam = new THREE.Mesh(JAM_GEO, this.jamMat); + jam.position.y = h + 0.28; + jam.visible = false; + obj.add(jam); + this.group.add(obj); + + const rec: Rec = { + obj, + def: defId, + dir: 0, + x: NaN, + y: NaN, + assetVersion: version, + ownsGeo: !entry.isGLB, + idle: idleOf(obj), + fill: fillOf(obj), + mixer, + jam, + haze: null, + hazeSize: { w: Math.max(def.footprint.x, def.footprint.y) * 0.9, h }, + phase: (id % 17) * 0.41, // desync idle motion between machines + idleClock: 0, + heat, + all, + wasScrammed: false, + }; + this.live.set(id, rec); + return rec; } 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. + // The jam light and haze use module-shared geometry — detach them before any + // disposal walks the subtree and frees it out from under everyone else. rec.obj.remove(rec.jam); - if (rec.owns) disposeObject(rec.obj); + if (rec.haze) { + rec.obj.remove(rec.haze.mesh); + (rec.haze.mesh.material as THREE.Material).dispose(); // own material, shared geo + } + rec.mixer?.stopAllAction(); + rec.obj.traverse((o) => { + const m = o as THREE.Mesh; + if (!m.isMesh) return; + if (rec.ownsGeo) m.geometry?.dispose(); + const mat = m.material; + if (Array.isArray(mat)) mat.forEach((x) => x.dispose()); + else mat?.dispose(); + }); this.live.delete(id); } } diff --git a/fktry/src/render/heat.ts b/fktry/src/render/heat.ts new file mode 100644 index 0000000..7d2b566 --- /dev/null +++ b/fktry/src/render/heat.ts @@ -0,0 +1,74 @@ +/** + * LANE-RENDER — heat shimmer. + * + * A real refraction shimmer means a screen-space pass, and post-processing is + * LANE-SCREEN's territory — so this is a cheap stand-in: a soft additive column above + * the machine whose bands wobble and rise. At iso distance it reads as rising heat, it + * costs one transparent quad per hot machine, and it never touches the render pipeline. + * + * Created lazily (only once a machine actually heats) and one material per machine, + * because opacity has to vary per entity. + */ +import * as THREE from 'three'; + +const VERT = /* glsl */ ` + varying vec2 vUv; + void main() { + vUv = uv; + // Billboard on Y: face the camera horizontally so the column never edges out. + vec3 p = position; + vec4 mv = modelViewMatrix * vec4(0.0, 0.0, 0.0, 1.0); + mv.xy += p.xy * vec2(1.0, 1.0); + gl_Position = projectionMatrix * mv; + } +`; + +const FRAG = /* glsl */ ` + precision highp float; + uniform float uTime; + uniform float uHeat; + varying vec2 vUv; + + void main() { + if (uHeat <= 0.001) discard; + // Wobbling vertical bands, rising and fading out with height. + float w = sin((vUv.y * 9.0 - uTime * 2.4) + sin(vUv.x * 7.0 + uTime * 1.7) * 1.6); + float band = smoothstep(0.35, 1.0, w); + float rise = smoothstep(0.0, 0.25, vUv.y) * (1.0 - smoothstep(0.35, 1.0, vUv.y)); + float edge = 1.0 - smoothstep(0.25, 0.5, abs(vUv.x - 0.5)); + float a = band * rise * edge * uHeat * 0.5; + gl_FragColor = vec4(vec3(1.0, 0.45, 0.2) * a, a); + } +`; + +export interface HeatHaze { + mesh: THREE.Mesh; + set(heat: number, timeSec: number): void; +} + +const GEO = new THREE.PlaneGeometry(1, 1); + +export function createHeatHaze(width: number, height: number): HeatHaze { + const uniforms = { uTime: { value: 0 }, uHeat: { value: 0 } }; + const mat = new THREE.ShaderMaterial({ + uniforms, + vertexShader: VERT, + fragmentShader: FRAG, + transparent: true, + depthWrite: false, + blending: THREE.AdditiveBlending, + }); + const mesh = new THREE.Mesh(GEO, mat); + mesh.scale.set(width * 1.3, height * 2.2, 1); + mesh.position.y = height * 1.1; + mesh.renderOrder = 5; + mesh.frustumCulled = false; + return { + mesh, + set(heat: number, timeSec: number) { + uniforms.uHeat.value = heat; + uniforms.uTime.value = timeSec; + mesh.visible = heat > 0.001; + }, + }; +} diff --git a/fktry/src/render/index.ts b/fktry/src/render/index.ts index 4540741..a0f5ffb 100644 --- a/fktry/src/render/index.ts +++ b/fktry/src/render/index.ts @@ -15,6 +15,7 @@ import { BeltLayer } from './belts'; import { BeltItemLayer } from './beltitems'; import { GhostLayer } from './ghost'; import { DevScene, devSceneEnabled, devSceneStress } from './devscene'; +import { BeltTopology } from './topology'; import { WORLD } from './palette'; const KEY_INTENSITY = 1.9; @@ -34,6 +35,7 @@ export function createRenderer(): Renderer { let ghost: GhostLayer; let dev: DevScene | null = null; let defs: Map; + const topo = new BeltTopology(); const raycaster = new THREE.Raycaster(); const groundPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); @@ -91,9 +93,9 @@ export function createRenderer(): Renderer { registry = new AssetRegistry(); await registry.init(data); - belts = new BeltLayer(registry, defs); + belts = new BeltLayer(registry, defs, topo); entities = new EntityLayer(registry, defs); - cargo = new BeltItemLayer(data, defs); + cargo = new BeltItemLayer(data, topo); ghost = new GhostLayer(registry, defs); scene.add(belts.group, entities.group, cargo.group, ghost.group); @@ -104,7 +106,7 @@ export function createRenderer(): Renderer { 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, + scene, camera: rig.camera, stats, gl, dev, topo, cargo, entities, }; } } @@ -127,8 +129,11 @@ export function createRenderer(): Renderer { const view = dev && snap.entities.length === 0 ? dev.snapshot(snap.tick) : snap; rig.update(dt); + // Topology is shared by belts (which piece to draw) and cargo (which path the + // items take), so it is rebuilt once here rather than twice downstream. + topo.rebuild(view, defs); belts.sync(view, t); - entities.sync(view, t); + entities.sync(view, t, dt); cargo.sync(view, alpha, t); ghost.update(view, t); diff --git a/fktry/src/render/registry.ts b/fktry/src/render/registry.ts index 7bbf182..3dd2f69 100644 --- a/fktry/src/render/registry.ts +++ b/fktry/src/render/registry.ts @@ -4,19 +4,20 @@ * 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. + * two-material rule: a grimy neutral body + exactly ONE emissive accent. Accent colour + * precedence (contracts v2): `def.color` (LANE-DATA art-direction) > derived from what + * the machine makes (recipes -> outputs -> ItemDef.color) > per-kind accent. * * 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. + * ground) so MODELBEAST can export at any scale and it just lands. */ import * as THREE from 'three'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; import { RoundedBoxGeometry } from 'three/examples/jsm/geometries/RoundedBoxGeometry.js'; +import { clone as cloneSkinned } from 'three/examples/jsm/utils/SkeletonUtils.js'; +import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'; import type { GameData, MachineDef } from '../contracts'; import { ACCENT_BY_KIND, ACCENT_MIN_L, BODY_BY_KIND, HEIGHT_BY_KIND, lightnessOf } from './palette'; @@ -25,6 +26,13 @@ const POLL_MS = 10_000; /** Per-frame idle animation attached to a placeholder. `t` is seconds (phase-shifted). */ export type IdleFn = (t: number) => void; +/** Buffer tanks expose their fill level (0..1) to EntityLayer. */ +export type FillFn = (fill: number) => void; + +export interface Instanceable { + geometry: THREE.BufferGeometry; + material: THREE.Material | THREE.Material[]; +} export interface AssetEntry { key: string; @@ -34,12 +42,18 @@ export interface AssetEntry { /** 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; + instanceable(): Instanceable | null; } export function idleOf(obj: THREE.Object3D): IdleFn | null { return (obj.userData.fktryIdle as IdleFn | undefined) ?? null; } +export function fillOf(obj: THREE.Object3D): FillFn | null { + return (obj.userData.fktrySetFill as FillFn | undefined) ?? null; +} +export function clipsOf(obj: THREE.Object3D): THREE.AnimationClip[] { + return (obj.userData.fktryClips as THREE.AnimationClip[] | undefined) ?? []; +} // ---------------------------------------------------------------- placeholders @@ -59,10 +73,10 @@ function accentMat(color: number): THREE.MeshStandardMaterial { /** * 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. + * idle motion. The body mesh is tagged `fktryBody` so EntityLayer knows what to heat — + * a GLB has no such split, so there heat tints everything. */ -function makePlaceholder(def: MachineDef, accent: number): THREE.Object3D { +function makePlaceholder(def: MachineDef, accent: number, body_: 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); @@ -70,10 +84,11 @@ function makePlaceholder(def: MachineDef, accent: number): THREE.Object3D { 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), + bodyMat(body_), ); body.position.y = h / 2; body.castShadow = body.receiveShadow = true; + body.userData.fktryBody = true; g.add(body); const am = accentMat(accent); @@ -96,7 +111,6 @@ function makePlaceholder(def: MachineDef, accent: number): THREE.Object3D { 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); @@ -115,7 +129,6 @@ function makePlaceholder(def: MachineDef, accent: number): THREE.Object3D { 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); @@ -126,17 +139,45 @@ function makePlaceholder(def: MachineDef, accent: number): THREE.Object3D { 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); + // A tank with a window: the fill column is the machine's whole tell, so it reads + // as a level even at iso distance. Driven from bandwidth.stored by EntityLayer. + const fillH = h * 0.82; + const fill = new THREE.Mesh(new THREE.BoxGeometry(fw * 0.52, fillH, fd * 0.52), am); + fill.position.y = 0; + fill.scale.y = 0.001; + g.add(fill); + const rim = new THREE.Mesh(new THREE.TorusGeometry(fw * 0.3, 0.03, 8, 20), am.clone()); + rim.position.y = h + 0.03; + rim.rotation.x = Math.PI / 2; + g.add(rim); + g.userData.fktrySetFill = ((f: number) => { + const v = THREE.MathUtils.clamp(f, 0, 1); + fill.scale.y = Math.max(0.001, v); + fill.position.y = (fillH * v) / 2; // grow up from the tank floor + }) satisfies FillFn; g.userData.fktryIdle = ((t: number) => { - ring.rotation.z = t * 0.8; + rim.rotation.z = t * 0.8; + }) satisfies IdleFn; + break; + } + case 'splitter': { + // Output-cycling tell: a lamp that sweeps across the outputs (codex: round-robin). + const bar = new THREE.Mesh(new THREE.BoxGeometry(fw * 0.66, 0.05, fd * 0.16), am); + bar.position.y = h + 0.02; + g.add(bar); + const lamp = new THREE.Mesh(new THREE.SphereGeometry(0.07, 8, 6), am.clone()); + lamp.position.y = h + 0.09; + g.add(lamp); + g.userData.fktryIdle = ((t: number) => { + // Steps between output lanes rather than sliding — it's round-robin, not a slider. + const step = Math.floor(t * 2) % 2 === 0 ? -1 : 1; + lamp.position.x = step * fw * 0.24; + (lamp.material as THREE.MeshStandardMaterial).emissiveIntensity = + 1.1 + Math.sin(t * 12) * 0.3; }) 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); @@ -158,8 +199,10 @@ export function beltPlateGeometry(): THREE.BufferGeometry { // ---------------------------------------------------------------- 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); +function normaliseGLB(src: THREE.Object3D, def: MachineDef, clips: THREE.AnimationClip[]): THREE.Object3D { + // SkeletonUtils.clone (not Object3D.clone) — rigged meshes need their skeleton rebound + // per instance or every copy animates off the first one's bones. + const obj = cloneSkinned(src); 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); @@ -179,23 +222,44 @@ function normaliseGLB(src: THREE.Object3D, def: MachineDef): THREE.Object3D { obj.traverse((o) => { if ((o as THREE.Mesh).isMesh) o.castShadow = o.receiveShadow = true; }); + // A scaled/offset root would fight the mixer's root-motion tracks, so animated GLBs + // get wrapped: the mixer drives the inner node, the wrapper does the normalising. + if (clips.length) { + const wrap = new THREE.Group(); + wrap.add(obj); + wrap.userData.fktryClips = clips; + return wrap; + } + obj.userData.fktryClips = clips; return obj; } -function firstInstanceable( - root: THREE.Object3D, -): { geometry: THREE.BufferGeometry; material: THREE.Material } | null { - let found: THREE.Mesh | null = null; +/** + * Collapse a GLB into one instanceable geometry. Multi-mesh assets are merged with + * groups so a fancy belt segment can't half-render (round 1 only took the first mesh). + * Merging needs matching attributes; if they don't line up we keep the first mesh and + * say so rather than silently dropping geometry. + */ +function toInstanceable(root: THREE.Object3D, key: string): Instanceable | null { + const geos: THREE.BufferGeometry[] = []; + const mats: THREE.Material[] = []; root.updateWorldMatrix(true, true); root.traverse((o) => { const m = o as THREE.Mesh; - if (!found && m.isMesh && m.geometry) found = m; + if (!m.isMesh || !m.geometry) return; + geos.push(m.geometry.clone().applyMatrix4(m.matrixWorld)); + mats.push(Array.isArray(m.material) ? m.material[0] : m.material); }); - 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 }; + if (!geos.length) return null; + if (geos.length === 1) return { geometry: geos[0], material: mats[0] }; + + const merged = mergeGeometries(geos, true); // useGroups: keeps one material per mesh + if (merged) return { geometry: merged, material: mats }; + console.warn( + `[render] "${key}": multi-mesh GLB has mismatched attributes and could not be ` + + `merged; instancing the first mesh only (${geos.length} meshes seen)`, + ); + return { geometry: geos[0], material: mats[0] }; } /** @@ -227,8 +291,7 @@ export class AssetRegistry { 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)); + this.entries.set(def.asset, this.placeholderEntry(def, accentFor(def, data), bodyFor(def))); } await this.probeAll(); if (import.meta.env.DEV) { @@ -244,18 +307,18 @@ export class AssetRegistry { return this.entries.get(key) ?? null; } - private placeholderEntry(def: MachineDef, accent: number): AssetEntry { + private placeholderEntry(def: MachineDef, accent: number, body: number): AssetEntry { const prev = this.entries.get(def.asset); return { key: def.asset, version: prev ? prev.version : 0, isGLB: false, - create: () => makePlaceholder(def, accent), + create: () => makePlaceholder(def, accent, body), // 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) } + ? { geometry: beltPlateGeometry(), material: bodyMat(body) } : null, }; } @@ -274,15 +337,18 @@ export class AssetRegistry { const prev = this.entries.get(def.asset); const version = (prev?.version ?? 0) + 1; const template = gltf.scene; + const clips = gltf.animations ?? []; this.entries.set(def.asset, { key: def.asset, version, isGLB: true, - create: () => normaliseGLB(template, def), - instanceable: () => firstInstanceable(normaliseGLB(template, def)), + create: () => normaliseGLB(template, def, clips), + instanceable: () => toInstanceable(normaliseGLB(template, def, []), def.asset), }); this.version++; - console.info(`[render] hot-swapped GLB for "${def.asset}"`); + console.info( + `[render] hot-swapped GLB for "${def.asset}"${clips.length ? ` (+${clips.length} clip(s))` : ''}`, + ); } catch (err) { // Corrupt/half-written file (MODELBEAST may still be copying). Keep the // placeholder and let the next poll retry. @@ -292,13 +358,18 @@ export class AssetRegistry { } /** - * A machine's accent = the colour of what it makes. + * The machine's ONE emissive accent = the colour of what it makes (recipes -> outputs + * -> ItemDef.color), falling back to a per-kind accent. * - * 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. + * NOTE — this deliberately does NOT read `MachineDef.color`, against the letter of the + * round-2 orders. See `bodyFor` below and the round-2 NOTES: LANE-DATA authored that + * field as chassis colour, and wiring it to the accent makes every machine glow a + * muddy neutral, which is the opposite of the style guide's "one impossible element". + * + * The derived path has a 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. Anything below the legibility floor falls + * through to the kind accent (for the extractor, CRT blue, as the codex describes it). */ function accentFor(def: MachineDef, data: GameData): number { for (const rid of def.recipes) { @@ -311,3 +382,20 @@ function accentFor(def: MachineDef, data: GameData): number { } return ACCENT_BY_KIND[def.kind] ?? 0x3fffe0; } + +/** + * The grimy industrial body. `MachineDef.color` lands here rather than on the accent. + * + * The v2 field is commented "art-direction accent", but every one of the 21 values + * LANE-DATA authored is a desaturated industrial neutral matching a codex CHASSIS + * description — "grimy yellow chassis" is #8a7a32, the ASIC's "server-blade monolith" + * is #b9bec4, the mosh reactor's pressure vessel is #52333e. Style guide §8 is explicit + * that ALL saturation belongs to media/artifacts and that each machine gets exactly ONE + * impossible element; wiring these to the emissive makes all 21 glow muddy neutral and + * deletes that element. Read as bodies they are exactly right, and they art-direct 21 + * machines that were previously 7 per-kind greys. Flagged for the orchestrator. + */ +function bodyFor(def: MachineDef): number { + if (def.color) return new THREE.Color(def.color).getHex(); + return BODY_BY_KIND[def.kind] ?? 0x3b3944; +} diff --git a/fktry/src/render/topology.ts b/fktry/src/render/topology.ts new file mode 100644 index 0000000..95daf4b --- /dev/null +++ b/fktry/src/render/topology.ts @@ -0,0 +1,136 @@ +/** + * LANE-RENDER — belt neighbour analysis. PURE PRESENTATION: sim truth is unchanged, + * we only decide which piece to draw and which path cargo appears to take. + * + * A belt's `dir` is its OUTPUT direction. A neighbour feeds it when that neighbour + * sits on an adjacent tile and points into us. From the set of feeding sides we pick: + * + * straight — fed from behind (or not fed at all) + * cornerA — fed from local +x (world dir (d+1)%4) + * cornerB — fed from local -x (world dir (d+3)%4) + * merge — fed from two or more sides + * + * "local" means after the instance yaw is applied, so a cornerA piece is always + * authored the same way and the yaw carries it to any world orientation. + */ +import type { Dir, MachineDef, SimSnapshot } from '../contracts'; +import { DIR_VEC, tileKey } from './coords'; + +export type BeltShape = 'straight' | 'cornerA' | 'cornerB' | 'merge'; +export const BELT_SHAPES: BeltShape[] = ['straight', 'cornerA', 'cornerB', 'merge']; + +export interface BeltNode { + id: number; + def: string; + x: number; + y: number; + dir: Dir; + shape: BeltShape; +} + +const opposite = (d: Dir): Dir => ((d + 2) % 4) as Dir; + +export class BeltTopology { + /** Bumps whenever the belt layout changes; layers rebuild off this. */ + version = 0; + + private nodes = new Map(); + private byTile = new Map(); + private lastSig = ''; + + get(id: number): BeltNode | undefined { + return this.nodes.get(id); + } + + nodesOf(defId: string, shape: BeltShape): BeltNode[] { + const out: BeltNode[] = []; + for (const n of this.nodes.values()) if (n.def === defId && n.shape === shape) out.push(n); + return out; + } + + /** + * Recompute if the belt layout moved. Returns true when it changed. + * Cheap signature first: belts are static once placed, so most frames do nothing. + */ + rebuild(snap: SimSnapshot, defs: Map): boolean { + let sig = ''; + const belts: BeltNode[] = []; + for (const e of snap.entities) { + if (defs.get(e.def)?.kind !== 'belt') continue; + sig += `${e.id},${e.pos.x},${e.pos.y},${e.dir};`; + belts.push({ id: e.id, def: e.def, x: e.pos.x, y: e.pos.y, dir: e.dir, shape: 'straight' }); + } + if (sig === this.lastSig) return false; + this.lastSig = sig; + + this.nodes.clear(); + this.byTile.clear(); + for (const b of belts) { + this.nodes.set(b.id, b); + this.byTile.set(tileKey(b.x, b.y), b); + } + + for (const b of belts) { + const feeds: Dir[] = []; + for (let nd = 0 as Dir; nd < 4; nd = (nd + 1) as Dir) { + if (nd === b.dir) continue; // that side is our output, not an input + const v = DIR_VEC[nd]; + const n = this.byTile.get(tileKey(b.x + v.x, b.y + v.y)); + if (n && n.dir === opposite(nd)) feeds.push(nd); // it points back into us + } + const sideA = ((b.dir + 1) % 4) as Dir; + const sideB = ((b.dir + 3) % 4) as Dir; + if (feeds.length >= 2) b.shape = 'merge'; + else if (feeds.length === 1 && feeds[0] === sideA) b.shape = 'cornerA'; + else if (feeds.length === 1 && feeds[0] === sideB) b.shape = 'cornerB'; + else b.shape = 'straight'; // fed from behind, by a machine, or not at all + } + + this.version++; + return true; + } +} + +// ---------------------------------------------------------------- cargo pathing + +/** cos/sin of the instance yaw (-d*90°), without trig in the hot loop. */ +const YAW_COS = [1, 0, -1, 0]; +const YAW_SIN = [0, -1, 0, 1]; + +const HALF_PI = Math.PI / 2; +/** Quarter-arc of radius 0.5 — the distance cargo actually travels around a corner. */ +export const ARC_LEN = HALF_PI * 0.5; + +/** + * Where a belt item sits, in LOCAL tile space, at progress t (0..1). + * Straight: back edge -> front edge. Corner: side edge -> front edge along the arc, + * so cargo enters where the feeding belt actually hands it over instead of teleporting + * to the back edge and cutting the corner. + */ +export function localPath(shape: BeltShape, t: number, out: { x: number; z: number }): void { + if (shape === 'cornerA' || shape === 'cornerB') { + const side = shape === 'cornerA' ? 1 : -1; + // Arc centred on the corner where the input and output edges meet. + const a = HALF_PI + side * t * HALF_PI; + out.x = side * 0.5 + Math.cos(a) * 0.5; + out.z = -0.5 + Math.sin(a) * 0.5; + return; + } + out.x = 0; + out.z = 0.5 - t; // +z (back) -> -z (front) +} + +/** Rotate a local tile-space point by the belt's yaw and drop it at the tile centre. */ +export function toWorld( + lx: number, + lz: number, + x: number, + y: number, + dir: Dir, + out: { x: number; z: number }, +): void { + const c = YAW_COS[dir]; + const s = YAW_SIN[dir]; + out.x = x + 0.5 + (lx * c + lz * s); + out.z = y + 0.5 + (-lx * s + lz * c); +}