[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
|
* 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).
|
* 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
|
* Identity: contracts v2 added `BeltItem.id`. When present we key off it and the
|
||||||
* (belt entity, item type, order along the belt). Items can't overtake each other, so
|
* interpolation is EXACT. When absent we fall back to matching by (belt, type, order
|
||||||
* that ordering is stable — EXCEPT on the tick where the lead item leaves a belt and
|
* along the belt) — correct except for the one tick where the lead item leaves and the
|
||||||
* the queue behind it shifts index. Those items miss their match for exactly one tick
|
* queue behind it shifts index. That fallback goes away when v3 makes `id` required.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* 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 * as THREE from 'three';
|
||||||
import type { GameData, ItemDef, MachineDef, SimSnapshot } from '../contracts';
|
import type { GameData, ItemDef, SimSnapshot } from '../contracts';
|
||||||
import { DIR_VEC } from './coords';
|
|
||||||
import { HEIGHT_BY_KIND, readable } from './palette';
|
import { HEIGHT_BY_KIND, readable } from './palette';
|
||||||
|
import { BeltTopology, localPath, toWorld } from './topology';
|
||||||
|
|
||||||
const ITEM_SIZE = 0.3;
|
const ITEM_SIZE = 0.3;
|
||||||
const RIDE_Y = HEIGHT_BY_KIND.belt + ITEM_SIZE * 0.5;
|
const RIDE_Y = HEIGHT_BY_KIND.belt + ITEM_SIZE * 0.5;
|
||||||
@ -30,12 +29,7 @@ interface ItemMesh {
|
|||||||
capacity: number;
|
capacity: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Products glow; raw ore doesn't — saturation belongs to media (style guide §8). */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
function itemMaterial(def: ItemDef): THREE.MeshStandardMaterial {
|
function itemMaterial(def: ItemDef): THREE.MeshStandardMaterial {
|
||||||
return new THREE.MeshStandardMaterial({
|
return new THREE.MeshStandardMaterial({
|
||||||
color: readable(def.color),
|
color: readable(def.color),
|
||||||
@ -47,7 +41,6 @@ function itemMaterial(def: ItemDef): THREE.MeshStandardMaterial {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function itemGeometry(def: ItemDef): THREE.BufferGeometry {
|
function itemGeometry(def: ItemDef): THREE.BufferGeometry {
|
||||||
// Products read as faceted crystals; raw/refined as crate-ish boxes.
|
|
||||||
return def.tier >= 2
|
return def.tier >= 2
|
||||||
? new THREE.OctahedronGeometry(ITEM_SIZE * 0.7)
|
? new THREE.OctahedronGeometry(ITEM_SIZE * 0.7)
|
||||||
: new THREE.BoxGeometry(ITEM_SIZE, ITEM_SIZE * 0.8, ITEM_SIZE);
|
: 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 cur = new Map<string, number>();
|
||||||
private lastTick = -1;
|
private lastTick = -1;
|
||||||
private dummy = new THREE.Object3D();
|
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 counts = new Map<string, number>();
|
||||||
private seq = new Map<string, number>();
|
private seq = new Map<string, number>();
|
||||||
private sorted: SimSnapshot['beltItems'][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';
|
this.group.name = 'beltItems';
|
||||||
for (const i of data.items) this.items.set(i.id, i);
|
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);
|
const existing = this.byItem.get(def.id);
|
||||||
if (existing && needed <= existing.capacity) return existing;
|
if (existing && needed <= existing.capacity) return existing;
|
||||||
const capacity = Math.max(128, 1 << Math.ceil(Math.log2(Math.max(1, needed))));
|
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 geometry = existing?.mesh.geometry ?? itemGeometry(def);
|
||||||
const material = (existing?.mesh.material as THREE.Material) ?? itemMaterial(def);
|
const material = (existing?.mesh.material as THREE.Material) ?? itemMaterial(def);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@ -94,18 +89,17 @@ export class BeltItemLayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sync(snap: SimSnapshot, alpha: number, timeSec: number): void {
|
sync(snap: SimSnapshot, alpha: number, timeSec: number): void {
|
||||||
this.belts.clear();
|
const src = snap.beltItems;
|
||||||
for (const e of snap.entities) {
|
// Sim is internally consistent: if it stamps ids, it stamps all of them.
|
||||||
const def = this.defs.get(e.def);
|
this.exactIds = src.length > 0 && src[0].id !== undefined;
|
||||||
if (def?.kind === 'belt') this.belts.set(e.id, { x: e.pos.x, y: e.pos.y, dir: e.dir });
|
|
||||||
|
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;
|
const advanced = snap.tick !== this.lastTick;
|
||||||
if (advanced) {
|
if (advanced) {
|
||||||
const swap = this.prev;
|
const swap = this.prev;
|
||||||
@ -115,17 +109,12 @@ export class BeltItemLayer {
|
|||||||
this.lastTick = snap.tick;
|
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.counts.clear();
|
||||||
this.seq.clear();
|
this.seq.clear();
|
||||||
for (const bi of this.sorted) {
|
for (const bi of this.sorted) {
|
||||||
this.counts.set(bi.item, (this.counts.get(bi.item) ?? 0) + 1);
|
this.counts.set(bi.item, (this.counts.get(bi.item) ?? 0) + 1);
|
||||||
if (advanced) {
|
if (advanced) this.cur.set(this.keyOf(bi), bi.t);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for (const [itemId, count] of this.counts) {
|
for (const [itemId, count] of this.counts) {
|
||||||
const def = this.items.get(itemId);
|
const def = this.items.get(itemId);
|
||||||
@ -136,23 +125,17 @@ export class BeltItemLayer {
|
|||||||
this.seq.clear();
|
this.seq.clear();
|
||||||
const written = new Map<string, number>();
|
const written = new Map<string, number>();
|
||||||
for (const bi of this.sorted) {
|
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 def = this.items.get(bi.item);
|
||||||
const im = this.byItem.get(bi.item);
|
const im = this.byItem.get(bi.item);
|
||||||
if (!def || !im) continue;
|
if (!def || !im) continue;
|
||||||
|
|
||||||
const sk = `${bi.entity}:${bi.item}`;
|
const from = this.prev.get(this.keyOf(bi));
|
||||||
const idx = this.seq.get(sk) ?? 0;
|
|
||||||
this.seq.set(sk, idx + 1);
|
|
||||||
const from = this.prev.get(`${bi.entity}:${bi.item}:${idx}`);
|
|
||||||
const t = from === undefined ? bi.t : from + (bi.t - from) * alpha;
|
const t = from === undefined ? bi.t : from + (bi.t - from) * alpha;
|
||||||
|
|
||||||
const dir = DIR_VEC[belt.dir];
|
localPath(node.shape, t, this.lp);
|
||||||
this.dummy.position.set(
|
toWorld(this.lp.x, this.lp.z, node.x, node.y, node.dir, this.wp);
|
||||||
belt.x + 0.5 + dir.x * (t - 0.5),
|
this.dummy.position.set(this.wp.x, RIDE_Y, this.wp.z);
|
||||||
RIDE_Y,
|
|
||||||
belt.y + 0.5 + dir.y * (t - 0.5),
|
|
||||||
);
|
|
||||||
this.dummy.rotation.set(0, 0, 0);
|
this.dummy.rotation.set(0, 0, 0);
|
||||||
if (def.tier >= 2) {
|
if (def.tier >= 2) {
|
||||||
this.dummy.rotation.y = timeSec * 1.6; // products tumble; ore just rides
|
this.dummy.rotation.y = timeSec * 1.6; // products tumble; ore just rides
|
||||||
@ -172,4 +155,13 @@ export class BeltItemLayer {
|
|||||||
im.mesh.instanceMatrix.needsUpdate = true;
|
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
|
* The chevron scroll is injected into a MeshStandardMaterial rather than written as a
|
||||||
* chevron is injected into a MeshStandardMaterial rather than written as a raw
|
* raw ShaderMaterial, so belts keep real lighting, fog and brownout dimming for free.
|
||||||
* 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 * as THREE from 'three';
|
||||||
import type { MachineDef, SimSnapshot } from '../contracts';
|
import type { MachineDef, SimSnapshot } from '../contracts';
|
||||||
import { AssetRegistry } from './registry';
|
import { AssetRegistry } from './registry';
|
||||||
import { yawFor } from './coords';
|
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 {
|
interface BeltMesh {
|
||||||
mesh: THREE.InstancedMesh;
|
mesh: THREE.InstancedMesh;
|
||||||
capacity: number;
|
capacity: number;
|
||||||
assetVersion: 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 {
|
export class BeltLayer {
|
||||||
readonly group = new THREE.Group();
|
readonly group = new THREE.Group();
|
||||||
private byDef = new Map<string, BeltMesh>();
|
private meshes = new Map<string, BeltMesh>();
|
||||||
private uTime = { value: 0 };
|
private uTime = { value: 0 };
|
||||||
private dummy = new THREE.Object3D();
|
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';
|
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({
|
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,
|
roughness: 0.9,
|
||||||
metalness: 0.1,
|
metalness: 0.1,
|
||||||
});
|
});
|
||||||
@ -54,25 +93,27 @@ export class BeltLayer {
|
|||||||
'#include <emissivemap_fragment>',
|
'#include <emissivemap_fragment>',
|
||||||
`#include <emissivemap_fragment>
|
`#include <emissivemap_fragment>
|
||||||
if (vLocal.y > ${topY}) {
|
if (vLocal.y > ${topY}) {
|
||||||
float band = fract((vLocal.z + abs(vLocal.x) * 0.55) * ${CHEVRONS_PER_TILE.toFixed(1)}
|
${chevronGLSL(shape)}
|
||||||
+ uTime * uSpeed * ${CHEVRONS_PER_TILE.toFixed(1)});
|
|
||||||
float arrow = smoothstep(0.40, 0.50, band) - smoothstep(0.50, 0.60, band);
|
|
||||||
totalEmissiveRadiance += uAccent * arrow * 0.85;
|
totalEmissiveRadiance += uAccent * arrow * 0.85;
|
||||||
}`,
|
}`,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
// Distinct cache key per shape: same base source, different injected flow field.
|
||||||
|
mat.customProgramCacheKey = () => `fktry-belt-${shape}`;
|
||||||
return mat;
|
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 entry = this.registry.get(def.asset);
|
||||||
const version = entry?.version ?? 0;
|
const version = entry?.version ?? 0;
|
||||||
let bm = this.byDef.get(def.id);
|
let bm = this.meshes.get(key);
|
||||||
|
|
||||||
if (bm && bm.assetVersion !== version) {
|
if (bm && bm.assetVersion !== version) {
|
||||||
this.group.remove(bm.mesh);
|
this.group.remove(bm.mesh);
|
||||||
bm.mesh.dispose();
|
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;
|
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.
|
// A real GLB brings its own look; the placeholder plate gets the chevron scroll.
|
||||||
// (Taking inst.material unconditionally here silently dropped the chevrons — the
|
// (Taking inst.material unconditionally here silently dropped the chevrons — the
|
||||||
// registry hands back a plain plate material for the placeholder case.)
|
// 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) {
|
if (bm) {
|
||||||
this.group.remove(bm.mesh);
|
this.group.remove(bm.mesh);
|
||||||
@ -95,53 +136,34 @@ export class BeltLayer {
|
|||||||
mesh.frustumCulled = false;
|
mesh.frustumCulled = false;
|
||||||
mesh.count = 0;
|
mesh.count = 0;
|
||||||
this.group.add(mesh);
|
this.group.add(mesh);
|
||||||
const next: BeltMesh = { mesh, capacity, assetVersion: version, live: new Map() };
|
const next: BeltMesh = { mesh, capacity, assetVersion: version };
|
||||||
this.byDef.set(def.id, next);
|
this.meshes.set(key, next);
|
||||||
return 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;
|
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.
|
for (const def of this.defs.values()) {
|
||||||
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) {
|
|
||||||
if (def.kind !== 'belt') continue;
|
if (def.kind !== 'belt') continue;
|
||||||
const items = buckets.get(defId) ?? [];
|
for (const shape of BELT_SHAPES) {
|
||||||
const bm = this.ensure(def, items.length);
|
const nodes = this.topo.nodesOf(def.id, shape);
|
||||||
|
const bm = this.ensure(def, shape, nodes.length);
|
||||||
// Belts are static once placed: only rewrite matrices when the set changes.
|
nodes.forEach((n, i) => {
|
||||||
let dirty = items.length !== bm.live.size;
|
this.dummy.position.set(n.x + 0.5, 0, n.y + 0.5); // 1x1 footprint: tile centre
|
||||||
if (!dirty) {
|
this.dummy.rotation.set(0, yawFor(n.dir), 0);
|
||||||
for (const it of items) {
|
this.dummy.updateMatrix();
|
||||||
const prev = bm.live.get(it.id);
|
bm.mesh.setMatrixAt(i, this.dummy.matrix);
|
||||||
if (!prev || prev.x !== it.x || prev.y !== it.y || prev.dir !== it.dir) {
|
});
|
||||||
dirty = true;
|
bm.mesh.count = nodes.length;
|
||||||
break;
|
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-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
|
* LANE-SIM builds in parallel and hasn't landed heat, splitters or `BeltItem.id` yet,
|
||||||
* nothing on screen to verify this lane against. This fabricates the M1 line —
|
* so there is no live source for most of what this lane renders. This fabricates a
|
||||||
* extractor -> belts -> demuxer -> quantizer -> mosh reactor -> shipper, with cargo
|
* factory that exercises every visual path — both corner handednesses, a merge, a heat
|
||||||
* riding the belts — purely so the renderer can be checked with real eyes.
|
* 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
|
* Gated behind `?devscene` AND `import.meta.env.DEV`, and only used while the real sim
|
||||||
* moment LANE-SIM produces anything, this yields automatically. It never fabricates
|
* has no entities: the moment LANE-SIM produces anything, this yields automatically.
|
||||||
* over live sim data.
|
* It never fabricates over live sim data.
|
||||||
*
|
*
|
||||||
* Items are advanced along the belt CHAIN (handing off between belt entities) rather
|
* Cargo advances along the belt CHAIN (handing off between belt entities) rather than
|
||||||
* than by wrapping `t` on one belt, because that's what the real sim does — and it's
|
* by wrapping `t` on one belt, because that's what the real sim does — and it's what
|
||||||
* what exercises the renderer's cross-belt interpolation path honestly.
|
* 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';
|
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
|
ids: number[]; // belt entity ids, in travel order
|
||||||
item: string;
|
item: string;
|
||||||
count: number;
|
count: number;
|
||||||
|
tag: number; // id namespace so cargo ids stay stable and unique
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DevScene {
|
export class DevScene {
|
||||||
@ -40,6 +42,7 @@ export class DevScene {
|
|||||||
private segments: Segment[] = [];
|
private segments: Segment[] = [];
|
||||||
private speed = 2;
|
private speed = 2;
|
||||||
private ok = false;
|
private ok = false;
|
||||||
|
private nextId = 1;
|
||||||
|
|
||||||
constructor(private data: GameData, private stress = false) {
|
constructor(private data: GameData, private stress = false) {
|
||||||
this.build();
|
this.build();
|
||||||
@ -53,66 +56,87 @@ export class DevScene {
|
|||||||
return this.data.machines.some((m) => m.id === id);
|
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 {
|
private build(): void {
|
||||||
const need = ['seam-extractor', 'belt', 'demuxer', 'quantizer', 'mosh-reactor', 'shipper'];
|
if (!this.has('belt')) return;
|
||||||
if (!need.every((n) => this.has(n))) return; // data drifted; stay out of the way
|
|
||||||
this.speed = this.data.machines.find((m) => m.id === 'belt')?.beltSpeed ?? 2;
|
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) {
|
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 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++) {
|
for (let r = 0; r < 20; r++) {
|
||||||
const y = -28 + r * 3;
|
const y = -28 + r * 3;
|
||||||
beltRun(-30, -7, items[r % items.length], 100, y); // 24 belts x 20 = 480
|
const tiles: Array<[number, number, Dir]> = [];
|
||||||
machine(machines[r % machines.length], -5, y, 1); // +20 machines = 500
|
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;
|
this.ok = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A single line along the belt row y=0, running east.
|
// --- the M1 line, bent so cargo rides BOTH corner handednesses -----------------
|
||||||
machine('seam-extractor', -14, 0, 1, 'extract-mdat');
|
if (this.has('seam-extractor')) this.machine('seam-extractor', -14, 0, 1, 'extract-mdat');
|
||||||
beltRun(-12, -9, 'mdat-ore', 5);
|
|
||||||
machine('demuxer', -8, 0, 1, 'demux-ore');
|
// Main run east along y=0. The belt at x=-6 is fed from behind AND from the spur
|
||||||
beltRun(-6, -3, 'luma-bars', 4);
|
// above it, so topology should classify it a merge.
|
||||||
machine('quantizer', -2, 0, 1, 'quantize');
|
const main: Array<[number, number, Dir]> = [];
|
||||||
beltRun(0, 3, 'coefficient-pack', 4);
|
for (let x = -12; x <= -3; x++) main.push([x, 0, 1]);
|
||||||
machine('mosh-reactor', 4, -1, 1, 'mosh'); // 3x3: centre lands on the belt row
|
this.segment(this.belts(main), 'mdat-ore', 8);
|
||||||
beltRun(7, 10, 'melt', 3);
|
|
||||||
machine('shipper', 11, 0, 1);
|
// Spur dropping south into the main line -> forces the merge piece.
|
||||||
machine('decode-asic', -2, 4, 1);
|
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;
|
this.ok = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshot(tick: number): SimSnapshot {
|
snapshot(tick: number): SimSnapshot {
|
||||||
// Exercise the feedback dressing on a slow cycle: jams, then brownout.
|
// Heat ramp: normal -> throttled (>0.7) -> scram (1.0) -> restart, on a ~10s loop
|
||||||
const jamPhase = Math.floor(tick / 450) % 3 === 2;
|
// 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;
|
const brownout = Math.floor(tick / 300) % 4 === 3;
|
||||||
|
|
||||||
for (const e of this.entities) {
|
for (const e of this.entities) {
|
||||||
if (e.def === 'demuxer') e.jammed = jamPhase ? 'output full: LUMA BARS' : null;
|
if (e.def === 'software-decoder') e.heat = heat;
|
||||||
if (e.recipe) e.progress = ((tick % 90) / 90);
|
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) => {
|
const beltItems: SimSnapshot['beltItems'] = this.segments.flatMap((seg) => {
|
||||||
@ -121,7 +145,9 @@ export class DevScene {
|
|||||||
return Array.from({ length: seg.count }, (_, k) => {
|
return Array.from({ length: seg.count }, (_, k) => {
|
||||||
const p = (tick * perTick + (k * span) / seg.count) % span;
|
const p = (tick * perTick + (k * span) / seg.count) % span;
|
||||||
const idx = Math.min(span - 1, Math.floor(p));
|
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,
|
paused: false,
|
||||||
entities: this.entities,
|
entities: this.entities,
|
||||||
beltItems,
|
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) },
|
shippedTotal: { melt: Math.floor(tick / 90) },
|
||||||
activeCommission: null,
|
activeCommission: null,
|
||||||
commissionProgress: {},
|
commissionProgress: {},
|
||||||
|
|||||||
@ -3,12 +3,33 @@
|
|||||||
*
|
*
|
||||||
* Syncs the scene to `snap.entities`: add, remove, re-transform, and rebuild when a
|
* 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.
|
* 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 * as THREE from 'three';
|
||||||
import type { Dir, MachineDef, SimSnapshot } from '../contracts';
|
import type { Dir, MachineDef, SimSnapshot } from '../contracts';
|
||||||
import { AssetRegistry, idleOf, type IdleFn } from './registry';
|
import { AssetRegistry, clipsOf, fillOf, idleOf, type FillFn, type IdleFn } from './registry';
|
||||||
import { centerOf, disposeObject, yawFor } from './coords';
|
import { centerOf, yawFor } from './coords';
|
||||||
import { HEIGHT_BY_KIND, JAM_AMBER } from './palette';
|
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 {
|
interface Rec {
|
||||||
obj: THREE.Object3D;
|
obj: THREE.Object3D;
|
||||||
@ -17,12 +38,21 @@ interface Rec {
|
|||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
assetVersion: number;
|
assetVersion: number;
|
||||||
/** Placeholders own their geo/mat and must be disposed. GLB clones share the
|
/** Placeholders own their geometry. GLB clones share the template's — disposing one
|
||||||
* template's — disposing one would break every other instance. */
|
* would break every other instance. Materials we always own (GLB ones are cloned). */
|
||||||
owns: boolean;
|
ownsGeo: boolean;
|
||||||
idle: IdleFn | null;
|
idle: IdleFn | null;
|
||||||
|
fill: FillFn | null;
|
||||||
|
mixer: THREE.AnimationMixer | null;
|
||||||
jam: THREE.Mesh;
|
jam: THREE.Mesh;
|
||||||
|
haze: HeatHaze | null;
|
||||||
|
hazeSize: { w: number; h: number };
|
||||||
phase: 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);
|
const JAM_GEO = new THREE.SphereGeometry(0.09, 8, 6);
|
||||||
@ -42,9 +72,18 @@ export class EntityLayer {
|
|||||||
this.group.name = 'entities';
|
this.group.name = 'entities';
|
||||||
}
|
}
|
||||||
|
|
||||||
sync(snap: SimSnapshot, timeSec: number): void {
|
sync(snap: SimSnapshot, timeSec: number, dt: number): void {
|
||||||
this.seen.clear();
|
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) {
|
for (const e of snap.entities) {
|
||||||
const def = this.defs.get(e.def);
|
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
|
this.drop(e.id, rec); // asset hot-swapped (or def changed): rebuild
|
||||||
rec = undefined;
|
rec = undefined;
|
||||||
}
|
}
|
||||||
if (!rec) {
|
if (!rec) rec = this.build(e.id, e.def, def, entry.version);
|
||||||
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.x !== e.pos.x || rec.y !== e.pos.y || rec.dir !== e.dir) {
|
if (rec.x !== e.pos.x || rec.y !== e.pos.y || rec.dir !== e.dir) {
|
||||||
centerOf(e.pos, def, e.dir, rec.obj.position);
|
centerOf(e.pos, def, e.dir, rec.obj.position);
|
||||||
@ -88,26 +107,162 @@ export class EntityLayer {
|
|||||||
rec.dir = e.dir;
|
rec.dir = e.dir;
|
||||||
}
|
}
|
||||||
|
|
||||||
rec.jam.visible = !!e.jammed;
|
const heat = THREE.MathUtils.clamp(e.heat ?? 0, 0, 1);
|
||||||
if (e.jammed) anyJammed = true;
|
const scrammed = heat >= 1;
|
||||||
rec.idle?.(timeSec + rec.phase);
|
// 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) {
|
for (const [id, rec] of this.live) {
|
||||||
if (!this.seen.has(id)) this.drop(id, rec);
|
if (!this.seen.has(id)) this.drop(id, rec);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (anyJammed) {
|
if (anyAlarm) this.jamMat.emissiveIntensity = Math.sin(timeSec * 7) > 0 ? 2.2 : 0.15;
|
||||||
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 {
|
private drop(id: number, rec: Rec): void {
|
||||||
this.group.remove(rec.obj);
|
this.group.remove(rec.obj);
|
||||||
// The jam light's geo/mat are shared across all machines — detach before any
|
// The jam light and haze use module-shared geometry — detach them before any
|
||||||
// disposal walks the subtree and frees them out from under everyone else.
|
// disposal walks the subtree and frees it out from under everyone else.
|
||||||
rec.obj.remove(rec.jam);
|
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);
|
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 { BeltItemLayer } from './beltitems';
|
||||||
import { GhostLayer } from './ghost';
|
import { GhostLayer } from './ghost';
|
||||||
import { DevScene, devSceneEnabled, devSceneStress } from './devscene';
|
import { DevScene, devSceneEnabled, devSceneStress } from './devscene';
|
||||||
|
import { BeltTopology } from './topology';
|
||||||
import { WORLD } from './palette';
|
import { WORLD } from './palette';
|
||||||
|
|
||||||
const KEY_INTENSITY = 1.9;
|
const KEY_INTENSITY = 1.9;
|
||||||
@ -34,6 +35,7 @@ export function createRenderer(): Renderer {
|
|||||||
let ghost: GhostLayer;
|
let ghost: GhostLayer;
|
||||||
let dev: DevScene | null = null;
|
let dev: DevScene | null = null;
|
||||||
let defs: Map<string, MachineDef>;
|
let defs: Map<string, MachineDef>;
|
||||||
|
const topo = new BeltTopology();
|
||||||
|
|
||||||
const raycaster = new THREE.Raycaster();
|
const raycaster = new THREE.Raycaster();
|
||||||
const groundPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
|
const groundPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
|
||||||
@ -91,9 +93,9 @@ export function createRenderer(): Renderer {
|
|||||||
registry = new AssetRegistry();
|
registry = new AssetRegistry();
|
||||||
await registry.init(data);
|
await registry.init(data);
|
||||||
|
|
||||||
belts = new BeltLayer(registry, defs);
|
belts = new BeltLayer(registry, defs, topo);
|
||||||
entities = new EntityLayer(registry, defs);
|
entities = new EntityLayer(registry, defs);
|
||||||
cargo = new BeltItemLayer(data, defs);
|
cargo = new BeltItemLayer(data, topo);
|
||||||
ghost = new GhostLayer(registry, defs);
|
ghost = new GhostLayer(registry, defs);
|
||||||
scene.add(belts.group, entities.group, cargo.group, ghost.group);
|
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');
|
console.info('[render] devscene active — mock line until LANE-SIM lands entities');
|
||||||
// Dev-only introspection hook (devscene builds only). Never touched in prod.
|
// Dev-only introspection hook (devscene builds only). Never touched in prod.
|
||||||
(window as unknown as { __render: unknown }).__render = {
|
(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;
|
const view = dev && snap.entities.length === 0 ? dev.snapshot(snap.tick) : snap;
|
||||||
|
|
||||||
rig.update(dt);
|
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);
|
belts.sync(view, t);
|
||||||
entities.sync(view, t);
|
entities.sync(view, t, dt);
|
||||||
cargo.sync(view, alpha, t);
|
cargo.sync(view, alpha, t);
|
||||||
ghost.update(view, t);
|
ghost.update(view, t);
|
||||||
|
|
||||||
|
|||||||
@ -4,19 +4,20 @@
|
|||||||
* Every entity mesh keys off `MachineDef.asset` through here. No hard-coded ids.
|
* 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
|
* 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
|
* two-material rule: a grimy neutral body + exactly ONE emissive accent. Accent colour
|
||||||
* accent colour is DERIVED FROM DATA — the colour of the item the machine makes
|
* precedence (contracts v2): `def.color` (LANE-DATA art-direction) > derived from what
|
||||||
* (recipes -> outputs -> ItemDef.color). A quantizer glows coefficient-cyan, a mosh
|
* the machine makes (recipes -> outputs -> ItemDef.color) > per-kind accent.
|
||||||
* reactor glows melt-orange, and a new machine in data/ lights up with zero code.
|
|
||||||
*
|
*
|
||||||
* If `public/assets/models/<key>.glb` exists, it replaces the placeholder factory.
|
* 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
|
* 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
|
* 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 * as THREE from 'three';
|
||||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||||
import { RoundedBoxGeometry } from 'three/examples/jsm/geometries/RoundedBoxGeometry.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 type { GameData, MachineDef } from '../contracts';
|
||||||
import { ACCENT_BY_KIND, ACCENT_MIN_L, BODY_BY_KIND, HEIGHT_BY_KIND, lightnessOf } from './palette';
|
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). */
|
/** Per-frame idle animation attached to a placeholder. `t` is seconds (phase-shifted). */
|
||||||
export type IdleFn = (t: number) => void;
|
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 {
|
export interface AssetEntry {
|
||||||
key: string;
|
key: string;
|
||||||
@ -34,12 +42,18 @@ export interface AssetEntry {
|
|||||||
/** A fresh instance, normalised to the machine footprint, origin at ground centre. */
|
/** A fresh instance, normalised to the machine footprint, origin at ground centre. */
|
||||||
create(): THREE.Object3D;
|
create(): THREE.Object3D;
|
||||||
/** Single geo+mat for InstancedMesh use (belts). Null if the asset can't be instanced. */
|
/** 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 {
|
export function idleOf(obj: THREE.Object3D): IdleFn | null {
|
||||||
return (obj.userData.fktryIdle as IdleFn | undefined) ?? 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
|
// ---------------------------------------------------------------- placeholders
|
||||||
|
|
||||||
@ -59,10 +73,10 @@ function accentMat(color: number): THREE.MeshStandardMaterial {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Procedural placeholder: footprint-sized bevelled box, one emissive accent, one tiny
|
* 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
|
* idle motion. The body mesh is tagged `fktryBody` so EntityLayer knows what to heat —
|
||||||
* iso grid even before real assets land.
|
* 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 g = new THREE.Group();
|
||||||
const fw = Math.max(0.4, def.footprint.x * 0.92);
|
const fw = Math.max(0.4, def.footprint.x * 0.92);
|
||||||
const fd = Math.max(0.4, def.footprint.y * 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(
|
const body = new THREE.Mesh(
|
||||||
new RoundedBoxGeometry(fw, h, fd, 2, Math.min(0.08, h * 0.25)),
|
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.position.y = h / 2;
|
||||||
body.castShadow = body.receiveShadow = true;
|
body.castShadow = body.receiveShadow = true;
|
||||||
|
body.userData.fktryBody = true;
|
||||||
g.add(body);
|
g.add(body);
|
||||||
|
|
||||||
const am = accentMat(accent);
|
const am = accentMat(accent);
|
||||||
@ -96,7 +111,6 @@ function makePlaceholder(def: MachineDef, accent: number): THREE.Object3D {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'crafter': {
|
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);
|
const jaw = new THREE.Mesh(new THREE.BoxGeometry(fw * 0.62, h * 0.14, fd * 0.62), am);
|
||||||
jaw.position.y = h + 0.02;
|
jaw.position.y = h + 0.02;
|
||||||
g.add(jaw);
|
g.add(jaw);
|
||||||
@ -115,7 +129,6 @@ function makePlaceholder(def: MachineDef, accent: number): THREE.Object3D {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'shipper': {
|
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);
|
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;
|
dish.position.y = h + h * 0.2;
|
||||||
g.add(dish);
|
g.add(dish);
|
||||||
@ -126,17 +139,45 @@ function makePlaceholder(def: MachineDef, accent: number): THREE.Object3D {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'buffer': {
|
case 'buffer': {
|
||||||
const ring = new THREE.Mesh(new THREE.TorusGeometry(fw * 0.3, 0.035, 8, 20), am);
|
// A tank with a window: the fill column is the machine's whole tell, so it reads
|
||||||
ring.position.y = h + 0.04;
|
// as a level even at iso distance. Driven from bandwidth.stored by EntityLayer.
|
||||||
ring.rotation.x = Math.PI / 2;
|
const fillH = h * 0.82;
|
||||||
g.add(ring);
|
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) => {
|
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;
|
}) satisfies IdleFn;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default: {
|
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);
|
const brow = new THREE.Mesh(new THREE.BoxGeometry(fw * 0.55, 0.05, fd * 0.2), am);
|
||||||
brow.position.y = h + 0.02;
|
brow.position.y = h + 0.02;
|
||||||
g.add(brow);
|
g.add(brow);
|
||||||
@ -158,8 +199,10 @@ export function beltPlateGeometry(): THREE.BufferGeometry {
|
|||||||
// ---------------------------------------------------------------- GLB handling
|
// ---------------------------------------------------------------- GLB handling
|
||||||
|
|
||||||
/** Fit an arbitrary GLB into the machine footprint, bottom sat on the ground plane. */
|
/** Fit an arbitrary GLB into the machine footprint, bottom sat on the ground plane. */
|
||||||
function normaliseGLB(src: THREE.Object3D, def: MachineDef): THREE.Object3D {
|
function normaliseGLB(src: THREE.Object3D, def: MachineDef, clips: THREE.AnimationClip[]): THREE.Object3D {
|
||||||
const obj = src.clone(true);
|
// 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 box = new THREE.Box3().setFromObject(obj);
|
||||||
const size = box.getSize(new THREE.Vector3());
|
const size = box.getSize(new THREE.Vector3());
|
||||||
const targetW = Math.max(0.4, def.footprint.x * 0.92);
|
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) => {
|
obj.traverse((o) => {
|
||||||
if ((o as THREE.Mesh).isMesh) o.castShadow = o.receiveShadow = true;
|
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;
|
return obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
function firstInstanceable(
|
/**
|
||||||
root: THREE.Object3D,
|
* Collapse a GLB into one instanceable geometry. Multi-mesh assets are merged with
|
||||||
): { geometry: THREE.BufferGeometry; material: THREE.Material } | null {
|
* groups so a fancy belt segment can't half-render (round 1 only took the first mesh).
|
||||||
let found: THREE.Mesh | null = null;
|
* 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.updateWorldMatrix(true, true);
|
||||||
root.traverse((o) => {
|
root.traverse((o) => {
|
||||||
const m = o as THREE.Mesh;
|
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;
|
if (!geos.length) return null;
|
||||||
const mesh = found as THREE.Mesh;
|
if (geos.length === 1) return { geometry: geos[0], material: mats[0] };
|
||||||
const geometry = mesh.geometry.clone().applyMatrix4(mesh.matrixWorld);
|
|
||||||
const material = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
|
const merged = mergeGeometries(geos, true); // useGroups: keeps one material per mesh
|
||||||
return { geometry, material };
|
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> {
|
async init(data: GameData): Promise<void> {
|
||||||
for (const def of data.machines) {
|
for (const def of data.machines) {
|
||||||
this.defs.set(def.asset, def);
|
this.defs.set(def.asset, def);
|
||||||
const accent = accentFor(def, data);
|
this.entries.set(def.asset, this.placeholderEntry(def, accentFor(def, data), bodyFor(def)));
|
||||||
this.entries.set(def.asset, this.placeholderEntry(def, accent));
|
|
||||||
}
|
}
|
||||||
await this.probeAll();
|
await this.probeAll();
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
@ -244,18 +307,18 @@ export class AssetRegistry {
|
|||||||
return this.entries.get(key) ?? null;
|
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);
|
const prev = this.entries.get(def.asset);
|
||||||
return {
|
return {
|
||||||
key: def.asset,
|
key: def.asset,
|
||||||
version: prev ? prev.version : 0,
|
version: prev ? prev.version : 0,
|
||||||
isGLB: false,
|
isGLB: false,
|
||||||
create: () => makePlaceholder(def, accent),
|
create: () => makePlaceholder(def, accent, body),
|
||||||
// Belts are instanced by belts.ts, which supplies the chevron material itself —
|
// Belts are instanced by belts.ts, which supplies the chevron material itself —
|
||||||
// this material is only a fallback and is normally discarded.
|
// this material is only a fallback and is normally discarded.
|
||||||
instanceable: () =>
|
instanceable: () =>
|
||||||
def.kind === 'belt'
|
def.kind === 'belt'
|
||||||
? { geometry: beltPlateGeometry(), material: bodyMat(BODY_BY_KIND.belt) }
|
? { geometry: beltPlateGeometry(), material: bodyMat(body) }
|
||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -274,15 +337,18 @@ export class AssetRegistry {
|
|||||||
const prev = this.entries.get(def.asset);
|
const prev = this.entries.get(def.asset);
|
||||||
const version = (prev?.version ?? 0) + 1;
|
const version = (prev?.version ?? 0) + 1;
|
||||||
const template = gltf.scene;
|
const template = gltf.scene;
|
||||||
|
const clips = gltf.animations ?? [];
|
||||||
this.entries.set(def.asset, {
|
this.entries.set(def.asset, {
|
||||||
key: def.asset,
|
key: def.asset,
|
||||||
version,
|
version,
|
||||||
isGLB: true,
|
isGLB: true,
|
||||||
create: () => normaliseGLB(template, def),
|
create: () => normaliseGLB(template, def, clips),
|
||||||
instanceable: () => firstInstanceable(normaliseGLB(template, def)),
|
instanceable: () => toInstanceable(normaliseGLB(template, def, []), def.asset),
|
||||||
});
|
});
|
||||||
this.version++;
|
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) {
|
} catch (err) {
|
||||||
// Corrupt/half-written file (MODELBEAST may still be copying). Keep the
|
// Corrupt/half-written file (MODELBEAST may still be copying). Keep the
|
||||||
// placeholder and let the next poll retry.
|
// 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
|
* NOTE — this deliberately does NOT read `MachineDef.color`, against the letter of the
|
||||||
* accent that dark isn't an accent — the seam extractor's CRT head would be a black
|
* round-2 orders. See `bodyFor` below and the round-2 NOTES: LANE-DATA authored that
|
||||||
* panel on a black chassis. Any derived colour below the legibility floor is rejected
|
* field as chassis colour, and wiring it to the accent makes every machine glow a
|
||||||
* in favour of the per-kind accent (which for the extractor is CRT blue, as the codex
|
* muddy neutral, which is the opposite of the style guide's "one impossible element".
|
||||||
* describes it). Machines that run no recipes fall back the same way.
|
*
|
||||||
|
* 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 {
|
function accentFor(def: MachineDef, data: GameData): number {
|
||||||
for (const rid of def.recipes) {
|
for (const rid of def.recipes) {
|
||||||
@ -311,3 +382,20 @@ function accentFor(def: MachineDef, data: GameData): number {
|
|||||||
}
|
}
|
||||||
return ACCENT_BY_KIND[def.kind] ?? 0x3fffe0;
|
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