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>
152 lines
5.7 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|