glytch/fktry/src/render/beltitems.ts
LANE-SIM d1c3f2b09d [sim] round 3 NOTES
Confirms the units conversion (bufferCap 240 = 8.0s of cover at a 30/s
deficit) and reports scram survival: first scram t434, restart t934,
~718-tick duty cycle, ZERO brownout ticks across 20k, melt rate unchanged.

For the orchestrator:
- heat semantics need a one-line ruling. heatPerTick is GROSS in my model
  (net = heatPerTick - coolPerTick). DATA's committed 0.0025 against the
  0.004 default nets negative -- the decoder could never scram. Their
  in-flight 0.0033/0.001 works, so we agree by luck of timing.
- the codex's literal bloom loop is not expressible with per-grade recipes
- asic-cooler does nothing without a spatial heat mechanic
- my sim code landed inside 45003db [ui] REMOVE mode: the shared tree has
  one git index and an add-everything commit in another session swept it.
  Not a UI ownership violation. Suggest a no-`git add -A` rule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 18:35:47 +10:00

152 lines
5.7 KiB
TypeScript

/**
* 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<string, ItemMesh>();
private items = new Map<string, ItemDef>();
/** Keyed by BeltItem.id — stable across the belt-to-belt handoff. */
private prev = new Map<number, number>();
private cur = new Map<number, number>();
private lastTick = -1;
private dummy = new THREE.Object3D();
private counts = new Map<string, number>();
private written = new Map<string, number>();
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;
}
}
}