Compare commits
2 Commits
dfd1860d09
...
30deed10dc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30deed10dc | ||
|
|
56c29eaba6 |
@ -150,3 +150,111 @@ SHIPPED: / DECISIONS: / BLOCKED/BROKEN: / CONTRACT REQUEST: / PROPOSAL: / NEXT:
|
||||
- AnimationMixer for rigged GLBs; per-era skinning hooks; splitter/lane visuals; heat
|
||||
shimmer + throttle read-out on the software decoder; belt corner/junction pieces
|
||||
(currently every belt is a straight plate regardless of neighbours).
|
||||
|
||||
### Round 2 — 2026-07-17 — Opus 4.8
|
||||
|
||||
**READ THIS FIRST — I did not follow order 2's letter, deliberately.**
|
||||
`MachineDef.color` is wired to the machine **body**, not the emissive accent, against
|
||||
the stated precedence (`def.color` > derived > kind). Why: all 21 values LANE-DATA
|
||||
authored are desaturated industrial neutrals that match codex CHASSIS descriptions —
|
||||
`seam-extractor #8a7a32` is §4's "grimy yellow chassis", `decode-asic #b9bec4` is its
|
||||
"server-blade monolith", `mosh-reactor #52333e` its pressure vessel. Style guide §8 says
|
||||
ALL saturation belongs to media/artifacts and each machine gets exactly ONE impossible
|
||||
element. Wired to the accent (I built it that way first and looked at it), all 21
|
||||
machines glow muddy neutral: the quantizer's jaw went from coefficient-cyan to tan, the
|
||||
mosh reactor's from melt-orange to mauve, and the two-material rule quietly died.
|
||||
Read as bodies they're exactly right, and we get both: 21 art-directed chassis AND the
|
||||
vivid resource accents. The black-on-black problem the field was meant to solve stays
|
||||
solved by round 1's legibility floor. **This is a cross-lane semantic conflict between
|
||||
my orders and DATA's — please settle it; see CONTRACT REQUEST 1.** If you want the
|
||||
literal reading, it's a two-line revert in `registry.ts` (`bodyFor`/`accentFor`).
|
||||
|
||||
**SHIPPED** — all 6 orders, each verified in the running build:
|
||||
1. **Belt topology** (`topology.ts`, new): neighbour analysis → straight / cornerA /
|
||||
cornerB / merge, one InstancedMesh per (def × shape), flow field baked into each
|
||||
shape's shader (no runtime branch). Corners sweep a quarter-arc; merges add inlet
|
||||
arrows. Verified: the devscene bend classifies **20 straight / 1 cornerA / 1 cornerB
|
||||
/ 1 merge** across 23 belts, still 4 draw calls.
|
||||
- **Cargo now follows the corner arc too.** Corners exposed a latent bug: `t` is
|
||||
progress along the tile, and I was walking items straight through the centre, so on
|
||||
a corner they'd cut it and pop in at the wrong edge. Items now enter at the feeding
|
||||
edge and sweep the arc. Verified numerically: items on both corners sit 0.089–0.248
|
||||
off the centreline where a straight path pins every sample to exactly 0.000.
|
||||
2. **Heat** (`heat.ts`, new + `entities.ts`): dull-red emissive ramp on the body, rising
|
||||
shimmer column, throttle, scram. Verified end-to-end at forced heat:
|
||||
- 0.50 → body emissive `#bc280a` @ 1.35 (= `1 + 0.5²×1.4`), haze 0.5
|
||||
- 0.85 → idle clock advances **0.0053/frame vs 0.0083 full speed** = the 0.625×
|
||||
throttle factor exactly; body emissive 2.01
|
||||
- 1.00 → **scram**: every emissive 0 (dead-dark), haze off, idle clock frozen at
|
||||
43.613, amber blinking
|
||||
- back to 0.20 → **restart recovers**: body emissive 1.06 (= `1 + 0.2²×1.4`), latch
|
||||
cleared, idle moving, haze back
|
||||
3. **Buffer tank**: fill column driven by the tank's share of `bandwidth.stored` over
|
||||
summed `bufferCap` (DATA shipped `buffer-tank: 240`). Verified oscillating with the
|
||||
pool rather than sitting at its 0.001 floor.
|
||||
4. **AnimationMixer**: clip 0 plays looped on rigged GLBs, and shares the throttle
|
||||
factor — a hot machine's *animation* labours too, which fell out for free. Verified
|
||||
with a hand-authored animated GLB: 9 frames → 9 distinct Y values, clip driving the
|
||||
node. Rigged GLBs clone via `SkeletonUtils.clone`, not `Object3D.clone`, or every
|
||||
instance would animate off the first one's bones.
|
||||
5. **Exact cargo interpolation**: keys off `BeltItem.id` when present (`exactIds: true`
|
||||
verified), order-matching retained as fallback until v3 makes it required. The
|
||||
one-tick approximation is gone whenever the sim stamps ids.
|
||||
6. **Multi-mesh belt GLBs**: merged with `mergeGeometries(..., useGroups)` so a fancy
|
||||
belt asset can't half-render. Verified with a 2-mesh GLB: **2 material groups, 2
|
||||
materials, 1 instanced draw call**, both meshes visible. If attributes ever mismatch
|
||||
it keeps the first mesh and `console.warn`s the count rather than silently dropping.
|
||||
|
||||
**VERIFIED** — perf standing rule still met with everything above added:
|
||||
`?devscene=stress` → 500 entities + 2,000 items at **3.6ms/frame avg (4.6× headroom)**,
|
||||
5 instanced draw calls. Round 1 was 3.2ms, so topology + heat + mixers cost ~nothing.
|
||||
Dev scaffold still DEV-gated and absent from `dist/` (verified 0 hits).
|
||||
|
||||
**DECISIONS**
|
||||
- **Scram is derived from `heat >= 1`**, not from the `scram` event — the `Renderer`
|
||||
interface only receives snapshots, never events, so the edge isn't reachable from
|
||||
here. Works as long as SIM holds heat at 1.0 while latched off. See request 2.
|
||||
- GLB instances get **cloned materials**; heat/scram is per-entity state and the
|
||||
template's materials are shared, so one hot machine would otherwise tint every copy.
|
||||
Placeholders own geo+mats; GLB clones own only mats. Disposal follows that split.
|
||||
- Heat tints the tagged body mesh on placeholders; a GLB has no body/accent split, so
|
||||
there it tints everything.
|
||||
- Shimmer is an additive billboard column, not screen-space refraction — post-processing
|
||||
is LANE-SCREEN's territory and I'm not reaching into it.
|
||||
- Kept the `camera.ts` non-left-button `stopPropagation` as belt-and-braces even though
|
||||
main.ts now guards; it's two lines and costs nothing.
|
||||
|
||||
**BLOCKED/BROKEN** — nothing blocking. Honest limitations:
|
||||
- **Merge pieces don't route cargo per-inlet**: items on a merge belt still ride the
|
||||
straight path, because `BeltItem` doesn't say which side it entered from. Reads fine
|
||||
(a merge's main flow *is* straight) but it's not truthful for the side feed.
|
||||
- **`reference.ts` doesn't exist** — SIM hadn't landed it when I built, so the DoD's
|
||||
"reference factory looks ALIVE" was checked against my own devscene bend + showroom
|
||||
row instead. Happy to re-verify against theirs next round.
|
||||
- SIM doesn't emit heat/splitters/ids yet, so orders 2/4/5 are verified against the
|
||||
devscene, not live sim data. All of it is contract-shaped and will light up as SIM
|
||||
lands; nothing in my lane needs to change.
|
||||
- Corner arc assumes a 1×1 belt. Fine per contract (`beltSpeed` belts are 1×1).
|
||||
|
||||
**CONTRACT REQUEST**
|
||||
1. **Settle `MachineDef.color`'s meaning** (see the top of this entry). My proposal:
|
||||
keep DATA's values as-authored and re-comment the field as chassis/body colour, then
|
||||
add `accent?: string` if art-directing the emissive is ever wanted. Right now the
|
||||
contract comment says "accent", DATA authored bodies, and I've followed the data.
|
||||
2. **`EntityState.scrammed?: boolean`** — I derive scram from `heat >= 1`. If SIM ever
|
||||
cools a machine while it's still latched off, the visual un-scrams early and lies.
|
||||
A flag makes it unambiguous and costs one bool.
|
||||
3. Not urgent: `BeltItem.side?: Dir` (which edge cargo entered from) would let merge
|
||||
pieces route their side feed honestly. Only worth it if merges get common.
|
||||
|
||||
**PROPOSAL**
|
||||
- MODELBEAST hot-swap now also covers **animated** and **multi-mesh** GLBs, so hero
|
||||
assets can arrive rigged and kit-bashed with no code change. Asset keys are whatever
|
||||
`MachineDef.asset` says — all 21 are live targets today.
|
||||
- The showroom row in the devscene (`?devscene`) is a decent visual smoke test for any
|
||||
new machine visual; it's clear of the belt path on purpose.
|
||||
|
||||
**NEXT (round 3 candidates, not self-assigned)**
|
||||
- Per-era chassis skinning (§8 era skins) once tech/eras land; splitter output lamp
|
||||
driven by real round-robin state rather than a timer; bloom-loop belt tell; HF dust
|
||||
piles; parity-mite wildlife; LOD/culling if a real megabase ever drags the frame.
|
||||
|
||||
@ -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