/** * 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). * * Identity: contracts v3 makes `BeltItem.id` required, so interpolation is EXACT and * the old order-matching fallback (and its per-frame sort of every item) is gone. * * 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, 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; 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. */ export 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, }); } export 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(); /** Keyed by BeltItem.id — stable across the belt-to-belt handoff. */ private prev = new Map(); private cur = new Map(); private lastTick = -1; private dummy = new THREE.Object3D(); private counts = new Map(); private written = new Map(); private lp = { x: 0, z: 0 }; private wp = { x: 0, z: 0 }; constructor(data: GameData, private topo: BeltTopology) { 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 { // 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(); for (const bi of snap.beltItems) { if (!this.topo.get(bi.entity)) continue; // cargo on a belt we haven't seen yet this.counts.set(bi.item, (this.counts.get(bi.item) ?? 0) + 1); if (advanced) this.cur.set(bi.id, 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.written.clear(); for (const bi of snap.beltItems) { const node = this.topo.get(bi.entity); if (!node) continue; const def = this.items.get(bi.item); const im = this.byItem.get(bi.item); if (!def || !im) continue; const from = this.prev.get(bi.id); const t = from === undefined ? bi.t : from + (bi.t - from) * alpha; 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 this.dummy.position.y += Math.sin(timeSec * 3 + bi.entity) * 0.02; } this.dummy.updateMatrix(); const n = this.written.get(bi.item) ?? 0; if (n < im.capacity) { im.mesh.setMatrixAt(n, this.dummy.matrix); this.written.set(bi.item, n + 1); } } for (const [id, im] of this.byItem) { im.mesh.count = this.written.get(id) ?? 0; im.mesh.instanceMatrix.needsUpdate = true; } } }