[render] belt topology + corner cargo pathing, heat/throttle/scram, buffer fill
Round 2 orders 1-6: - topology.ts: belt neighbour analysis -> straight/cornerA/cornerB/merge, one InstancedMesh per (def x shape), flow field baked per shape. Corners sweep a quarter-arc; merges get inlet arrows. - cargo follows the corner arc: `t` is progress along the tile, so walking items straight through the centre made them cut corners and pop in at the wrong edge. They now enter at the feeding edge (verified 0.089-0.248 off the centreline where straight pins to 0.000). - heat.ts + entities.ts: dull-red body ramp, rising shimmer column, throttle (idle AND GLB clips slow together), scram (dead-dark + amber, frozen) and a restart path that restores what scram zeroed. - buffer tanks show fill from their share of bandwidth.stored / bufferCap. - AnimationMixer plays clip 0 looped; rigged GLBs clone via SkeletonUtils so instances don't share a skeleton. - exact cargo interpolation via BeltItem.id, order-matching kept as fallback. - multi-mesh belt GLBs merge with groups (2 materials, 1 draw call) instead of rendering only the first mesh. MachineDef.color is wired to the BODY, not the accent, against the letter of the orders: all 21 values DATA authored are industrial chassis neutrals per codex/§8, and wiring them to the emissive makes every machine glow muddy and kills the two-material rule. Flagged in NOTES for the orchestrator to settle. 500 entities + 2,000 items still at 3.6ms/frame (4.6x headroom), 5 draw calls. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
dfd1860d09
commit
56c29eaba6
@ -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<string, number>();
|
||||
private lastTick = -1;
|
||||
private dummy = new THREE.Object3D();
|
||||
private belts = new Map<number, { x: number; y: number; dir: 0 | 1 | 2 | 3 }>();
|
||||
private counts = new Map<string, number>();
|
||||
private seq = new Map<string, number>();
|
||||
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<string, MachineDef>) {
|
||||
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<string, number>();
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<number, { x: number; y: number; dir: number }>;
|
||||
}
|
||||
|
||||
/** 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<string, BeltMesh>();
|
||||
private meshes = new Map<string, BeltMesh>();
|
||||
private uTime = { value: 0 };
|
||||
private dummy = new THREE.Object3D();
|
||||
private builtVersion = -1;
|
||||
|
||||
constructor(private registry: AssetRegistry, private defs: Map<string, MachineDef>) {
|
||||
constructor(
|
||||
private registry: AssetRegistry,
|
||||
private defs: Map<string, MachineDef>,
|
||||
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 <emissivemap_fragment>',
|
||||
`#include <emissivemap_fragment>
|
||||
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<string, Array<{ id: number; x: number; y: number; dir: number }>>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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: {},
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
74
fktry/src/render/heat.ts
Normal file
74
fktry/src/render/heat.ts
Normal file
@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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<string, MachineDef>;
|
||||
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);
|
||||
|
||||
|
||||
@ -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/<key>.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<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
136
fktry/src/render/topology.ts
Normal file
136
fktry/src/render/topology.ts
Normal file
@ -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<number, BeltNode>();
|
||||
private byTile = new Map<number, BeltNode>();
|
||||
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<string, MachineDef>): 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);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user