glytch/fktry/src/render/registry.ts
type-two 72842e270a [render] root-cause fix: the world had no environment, so every metal read black
scene.environment was null for seven rounds. diffuse = albedo * (1 - metalness),
so a metal is nothing but reflected surroundings — and 13 of the 27 shipped GLB
materials are metallicFactor >= 0.8 (16 at >= 0.5, 21 with no MR texture). Round
6's GLB_EMISSIVE_FLOOR = 0.42 was cosmetics over a lighting hole.

- env.ts (new): procedural 128x64 half-float equirect through PMREMGenerator.
  Zero bytes shipped, no asset fetch. Dark industrial room per codex 8; key/fill
  blob directions passed in from index.ts's real lights, never duplicated.
- GLB materials take envMap explicitly (three overrides material.envMapIntensity
  with scene.environmentIntensity on the scene-fallback path), so the assets can
  be dosed at 2.6 while six rounds of hand-tuned world art keep 0.5.
- emissive floor 0.42 -> 0.18: re-tuned, not retired. Still the "never a hole"
  guarantee and still what carries a machine through a brownout.
- registry.init no longer awaits probeAll: renderer.init 284ms -> 28ms, ready at
  339ms -> 88ms. Placement juice suppressed on asset-version bumps so the factory
  doesn't re-place itself a second into every boot; a def change still pops.
- era.ts: eraIndex now breaks a contested unlock FIRST-tech-wins, matching
  LANE-UI's indexTech. It was LAST-tech-wins despite round 4's NOTES claiming
  otherwise; the tests found it.
- registry.test.ts (new): 31 tests, this lane's first. 481 -> 512 repo tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 00:58:04 +10:00

611 lines
26 KiB
TypeScript

/**
* LANE-RENDER — asset registry + MODELBEAST hot-swap.
*
* 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. 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.
*/
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.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';
import { DEFAULT_ERA, applyEraSkin, eraIndex, type Era } from './era';
const MODEL_DIR = 'assets/models';
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;
/** Bumps when a GLB hot-swaps in; layers rebuild meshes whose version moved. */
version: number;
isGLB: boolean;
/** 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(): 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
function bodyMat(color: number): THREE.MeshStandardMaterial {
return new THREE.MeshStandardMaterial({ color, roughness: 0.85, metalness: 0.15 });
}
function accentMat(color: number): THREE.MeshStandardMaterial {
return new THREE.MeshStandardMaterial({
color,
emissive: color,
emissiveIntensity: 1.35,
roughness: 0.35,
metalness: 0.1,
});
}
/**
* Procedural placeholder: footprint-sized bevelled box, one emissive accent, one tiny
* 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, body_: number, era: Era): 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);
const h = HEIGHT_BY_KIND[def.kind] ?? 0.8;
const chassis = bodyMat(body_);
applyEraSkin(chassis, era); // chassis only — the accent is the resource, not the decade
const body = new THREE.Mesh(
new RoundedBoxGeometry(fw, h, fd, 2, Math.min(0.08, h * 0.25)),
chassis,
);
body.position.y = h / 2;
body.castShadow = body.receiveShadow = true;
body.userData.fktryBody = true;
g.add(body);
const am = accentMat(accent);
switch (def.kind) {
case 'extractor': {
// CRT monitor for a head + a drill bit that spins (codex §4).
const crt = new THREE.Mesh(new THREE.BoxGeometry(fw * 0.5, h * 0.3, 0.06), am);
crt.position.set(0, h * 0.72, -fd / 2 - 0.01);
g.add(crt);
const drill = new THREE.Mesh(new THREE.ConeGeometry(fw * 0.16, h * 0.5, 6), am.clone());
drill.position.set(0, h * 0.22, fd * 0.22);
drill.rotation.x = Math.PI;
g.add(drill);
g.userData.fktryIdle = ((t: number) => {
drill.rotation.y = t * 4;
(crt.material as THREE.MeshStandardMaterial).emissiveIntensity =
1.2 + Math.sin(t * 9) * 0.25; // CRT flicker
}) satisfies IdleFn;
break;
}
case 'crafter': {
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);
g.userData.fktryIdle = ((t: number) => {
jaw.position.y = h + 0.02 + Math.abs(Math.sin(t * 1.6)) * 0.09;
}) satisfies IdleFn;
break;
}
case 'power': {
const led = new THREE.Mesh(new THREE.BoxGeometry(fw * 0.7, 0.05, fd * 0.12), am);
led.position.y = h + 0.02;
g.add(led);
g.userData.fktryIdle = ((t: number) => {
am.emissiveIntensity = 1.0 + Math.sin(t * 2.2) * 0.5; // serene LED breath
}) satisfies IdleFn;
break;
}
case 'shipper': {
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);
g.userData.fktryIdle = ((t: number) => {
dish.rotation.y = t * 1.1;
am.emissiveIntensity = 1.2 + Math.sin(t * 6) * 0.35;
}) satisfies IdleFn;
break;
}
case 'buffer': {
// 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) => {
rim.rotation.z = t * 0.8;
}) satisfies IdleFn;
break;
}
case 'lab': {
// A research bench: low slab, and the "bottled artifact triptych" (§2) standing on
// it. Three bottles is the whole silhouette — nothing else in the hall is a row of
// little glowing cylinders, which is the point of a placeholder.
const bottles: THREE.Mesh[] = [];
for (let i = 0; i < 3; i++) {
const b = new THREE.Mesh(
new THREE.CylinderGeometry(fw * 0.07, fw * 0.07, h * 0.3, 8),
i === 0 ? am : am.clone(),
);
b.position.set((i - 1) * fw * 0.2, h + h * 0.15, 0);
g.add(b);
bottles.push(b);
}
g.userData.fktryIdle = ((t: number) => {
// They pulse out of phase — a bench mid-assay, not a machine mid-cycle.
bottles.forEach((b, i) => {
(b.material as THREE.MeshStandardMaterial).emissiveIntensity =
1.0 + Math.sin(t * 2.4 + i * 2.1) * 0.45;
});
}) 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: {
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);
g.userData.fktryIdle = ((t: number) => {
am.emissiveIntensity = 1.1 + Math.sin(t * 3) * 0.25;
}) satisfies IdleFn;
}
}
return g;
}
/** The flat belt plate. Instanced by belts.ts; chevron scroll lives in its material. */
export function beltPlateGeometry(): THREE.BufferGeometry {
const geo = new THREE.BoxGeometry(0.94, HEIGHT_BY_KIND.belt, 0.94);
geo.translate(0, HEIGHT_BY_KIND.belt / 2, 0);
return geo;
}
// ---------------------------------------------------------------- GLB legibility
/**
* GLB legibility treatment. Round 6 shipped half of this; round 7 added the half that
* was actually load-bearing and cut the other half back to size.
*
* MODELBEAST bakes albedo into textures and ships NO emissive, so at game zoom on a
* near-black ground a real asset read as a dark hole. Round 6 answered that with a
* texel-following emissive floor at 0.42 — which worked, but ROUND 7 FOUND THE ROOT
* CAUSE it was masking: 13 of the 27 shipped GLBs carry `metallicFactor >= 0.8` (16 at
* >= 0.5) and 21 have no metallic-roughness texture, and a metal's diffuse term is
* `albedo * (1 - metalness)` — so those materials had almost no diffuse AND, with
* `scene.environment === null`, nothing to reflect. They were near-black by arithmetic.
* The emissive floor was cosmetics over a lighting hole; env.ts fills the hole.
*
* So the treatment is now three things, all keyed off data the placeholders already use:
*
* 1. ENVIRONMENT: the asset's own, stronger share of the procedural IBL. `envMap` is
* assigned per material rather than inherited from `scene.environment` on purpose —
* three.js overrides `material.envMapIntensity` with `scene.environmentIntensity`
* whenever a material falls back to the scene (WebGLRenderer: `material.envMap ===
* null && scene.environment !== null`), so assigning it explicitly is the ONLY way
* the assets can be dosed differently from six rounds of hand-tuned placeholder art.
* 2. EMISSIVE FLOOR: still here, at less than half the round-6 value (see below).
* 3. ACCENT BEACON (`addAccentBeacon`): the ONE impossible element, in the colour of
* what the machine makes — the same accent derivation the placeholder uses.
*/
/**
* The assets' share of the environment. High because the assets are unrealistically
* metallic: `metallicFactor: 1` with no MR texture is what the mesh generators emit by
* default, not an art decision — the codex describes grimy chassis, not chrome. Until
* that is fixed upstream, indirect specular is nearly the whole material response for
* half the catalog, so this is where their light has to come from. **If MODELBEAST ever
* ships sane metalness, drop this to ~1.0** (measured alternative: clamping metalness to
* 0.45 at load for the 21 materials with no MR texture reaches the same luminance at
* `envMapIntensity: 1` and is visually near-identical — not shipped, because rewriting
* an asset's authored PBR values is a bigger claim than lighting the room it stands in).
*/
const GLB_ENV_INTENSITY = 2.6;
/**
* Round 6 shipped 0.42, and with a real environment that is now overexposure: it flattens
* the shading the IBL just bought back and pushes every chassis toward a self-lit sticker.
* Measured across the 20 machines visible at the showroom close-up frame (mean sRGB
* luminance of each body, plain crust ~24 for scale):
*
* env + floor 0.00 -> darkest machine 38 (already clear of the floor — no holes)
* env + floor 0.18 -> darkest 54, demosaicer 77, mv-extractor 71, neural-keyer 81
* no env, floor 0.42 (round 6) -> darkest 57, demosaicer 75, mv-extractor 70, keyer 85
*
* So 0.18 lands the catalog within a few percent of round 6's overall brightness while
* the IBL, not a flat self-glow, does the shaping. NOT retired: a genuinely black texel
* still needs the "never a hole" guarantee, and it's what keeps a machine legible when a
* brownout drops tone-mapping exposure to 0.7.
*/
const GLB_EMISSIVE_FLOOR = 0.18;
function treatGLBMaterials(root: THREE.Object3D, env: THREE.Texture | null): void {
root.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 as { isMeshStandardMaterial?: boolean }).isMeshStandardMaterial) continue;
if (env) {
mat.envMap = env;
mat.envMapIntensity = GLB_ENV_INTENSITY;
}
const e = mat.emissive;
const alreadyLit = !!e && e.r + e.g + e.b > 0.03 && (mat.emissiveIntensity ?? 0) > 0.02;
if (!alreadyLit) {
// MODELBEAST authored no glow here, so give it the floor. (If it DID author one,
// it still gets the environment above — only the emissive is left alone.)
if (mat.map) {
mat.emissiveMap = mat.map; // white * map = the texture, self-lit
mat.emissive = new THREE.Color(0xffffff);
} else {
mat.emissive = mat.color.clone(); // no texture: self-colour floor
}
mat.emissiveIntensity = GLB_EMISSIVE_FLOOR;
}
mat.needsUpdate = true;
}
});
}
/** Unit beacon; instances scale it by footprint and never dispose it (module-shared). */
const BEACON_GEO = new THREE.IcosahedronGeometry(1, 1);
/**
* Perch the machine's ONE emissive accent on top of a baked GLB and give the asset a
* gentle idle pulse — resource colour, identity, and a heartbeat, all from the same
* accent the placeholder derives. The idle reads `beacon.material` at call time (not a
* captured reference) because EntityLayer clones every GLB material per instance; a
* closure over the pre-clone material would drive a mesh nothing renders.
*/
function addAccentBeacon(root: THREE.Object3D, def: MachineDef, accent: number): void {
root.updateMatrixWorld(true);
const box = new THREE.Box3().setFromObject(root);
const fp = Math.min(def.footprint.x, def.footprint.y);
const r = THREE.MathUtils.clamp(fp * 0.1, 0.08, 0.2);
const beacon = new THREE.Mesh(
BEACON_GEO,
new THREE.MeshStandardMaterial({
color: accent, emissive: accent, emissiveIntensity: 1.8, roughness: 0.3, metalness: 0.1,
}),
);
beacon.scale.setScalar(r);
beacon.position.set(
(box.min.x + box.max.x) / 2,
box.max.y + r * 1.3,
(box.min.z + box.max.z) / 2,
);
beacon.userData.fktryBeacon = true;
root.add(beacon);
root.userData.fktryIdle = ((t: number) => {
(beacon.material as THREE.MeshStandardMaterial).emissiveIntensity = 1.8 + Math.sin(t * 2.3) * 0.45;
}) satisfies IdleFn;
}
// ---------------------------------------------------------------- GLB handling
/**
* Fit an arbitrary GLB into the machine footprint, bottom sat on the ground plane.
* Exported for `registry.test.ts` — the fit maths is the thing a bad asset export
* silently breaks, and it needs no GPU to check.
*/
export function normaliseGLB(
src: THREE.Object3D,
def: MachineDef,
clips: THREE.AnimationClip[],
accent?: number,
): 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);
const targetD = Math.max(0.4, def.footprint.y * 0.92);
const s = Math.min(
size.x > 1e-6 ? targetW / size.x : 1,
size.z > 1e-6 ? targetD / size.z : 1,
);
if (Number.isFinite(s) && s > 0) obj.scale.setScalar(s);
// Re-measure after scaling, then centre on X/Z and sit the base at y=0.
const box2 = new THREE.Box3().setFromObject(obj);
const c = box2.getCenter(new THREE.Vector3());
obj.position.x -= c.x;
obj.position.z -= c.z;
obj.position.y -= box2.min.y;
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.
let root: THREE.Object3D;
if (clips.length) {
const wrap = new THREE.Group();
wrap.add(obj);
wrap.userData.fktryClips = clips;
root = wrap;
} else {
obj.userData.fktryClips = clips;
root = obj;
}
// Machine create path passes an accent -> perch the beacon. The instanceable path
// (belts) passes none: an instanced belt has no room for a beacon and no accent to show.
if (accent !== undefined) addAccentBeacon(root, def, accent);
return root;
}
/**
* 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 (!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 (!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] };
}
/**
* True only if the URL is really a GLB. A dev server's SPA fallback happily answers
* 200 + text/html for a missing file, so check the content-type before we trust it.
*/
async function glbExists(url: string): Promise<boolean> {
try {
const res = await fetch(url, { method: 'HEAD', cache: 'no-store' });
if (!res.ok) return false;
const ct = res.headers.get('content-type') ?? '';
return !ct.includes('text/html');
} catch {
return false;
}
}
// ---------------------------------------------------------------- registry
export class AssetRegistry {
/** Bumps on any hot-swap. Layers compare against it to know a rebuild is due. */
version = 0;
private entries = new Map<string, AssetEntry>();
private defs = new Map<string, MachineDef>();
/** The derived emissive accent per asset key — shared by placeholders and hot-swapped
* GLBs so a real asset gets the same one impossible element the placeholder had. */
private accents = new Map<string, number>();
private eras = new Map<string, Era>();
/** Orchestrator integration fix (round-7 pilot): the farm's PBR meshes are shipped
* through `gltf-transform optimize --compress meshopt --texture-compress webp`, which
* cuts a Hunyuan3D asset from 7.6 MB to ~420 KB — smaller than the albedo-only sf3d
* assets it replaces, WITH metallic-roughness maps. That needs three extensions:
* EXT_texture_webp and KHR_mesh_quantization are native to GLTFLoader; meshopt needs
* its decoder handed over. Uncompressed GLBs keep loading unchanged. */
private loader = new GLTFLoader().setMeshoptDecoder(MeshoptDecoder);
private timer: number | null = null;
/** The prefiltered environment (env.ts), handed over by index.ts before init. */
private env: THREE.Texture | null = null;
/** Give hot-swapped GLBs their own share of the IBL. Call before `init`. */
setEnvironment(env: THREE.Texture | null): void {
this.env = env;
}
/** Which era a machine is skinned as (derived from tech unlocks; stream by default). */
eraOf(machineId: string): Era {
return this.eras.get(machineId) ?? DEFAULT_ERA;
}
/**
* Build every placeholder synchronously, then kick the GLB probe off WITHOUT awaiting
* it (round 7).
*
* This used to `await this.probeAll()`, and main.ts awaits `renderer.init()` before it
* starts the frame loop — so the first frame of the game was gated on 27 HEAD requests
* plus ~28 MB of GLB fetch/parse. Placeholders are honorable (MASTERPLAN) and they are
* already fully built one line above: there is nothing to wait for. Assets now land
* through exactly the same hot-swap path a mid-session MODELBEAST drop uses, which is
* a path this lane has shipped and verified since round 1 — the first second of a boot
* is just the shortest possible version of it.
*/
init(data: GameData): void {
this.eras = eraIndex(data);
for (const def of data.machines) {
this.defs.set(def.asset, def);
const accent = accentFor(def, data);
this.accents.set(def.asset, accent);
const entry = this.placeholderEntry(def, accent, bodyFor(def), this.eraOf(def.id));
this.entries.set(def.asset, entry);
}
void this.probeAll(); // deliberately not awaited — see above
if (import.meta.env.DEV) {
this.timer = setInterval(() => void this.probeAll(), POLL_MS) as unknown as number;
}
}
dispose(): void {
if (this.timer !== null) clearInterval(this.timer);
}
get(key: string): AssetEntry | null {
return this.entries.get(key) ?? null;
}
private placeholderEntry(def: MachineDef, accent: number, body: number, era: Era): AssetEntry {
const prev = this.entries.get(def.asset);
return {
key: def.asset,
version: prev ? prev.version : 0,
isGLB: false,
create: () => makePlaceholder(def, accent, body, era),
// 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) }
: null,
};
}
/** Probe every key that hasn't got a GLB yet. Cheap: one HEAD each, 10s apart. */
private async probeAll(): Promise<void> {
const pending = [...this.defs.values()].filter((d) => !this.entries.get(d.asset)?.isGLB);
await Promise.all(pending.map((d) => this.tryLoad(d)));
}
private async tryLoad(def: MachineDef): Promise<void> {
const url = `${import.meta.env.BASE_URL}${MODEL_DIR}/${def.asset}.glb`;
if (!(await glbExists(url))) return;
try {
const gltf = await this.loader.loadAsync(url);
const prev = this.entries.get(def.asset);
const version = (prev?.version ?? 0) + 1;
const template = gltf.scene;
// Legibility treatment, run ONCE on the shared template: environment + emissive
// floor. EntityLayer clones these materials per instance, so per-entity heat/scram
// still works on top (and `clone()` carries envMap/envMapIntensity across).
treatGLBMaterials(template, this.env);
const clips = gltf.animations ?? [];
const accent = this.accents.get(def.asset) ?? ACCENT_BY_KIND[def.kind] ?? 0x3fffe0;
this.entries.set(def.asset, {
key: def.asset,
version,
isGLB: true,
create: () => normaliseGLB(template, def, clips, accent),
instanceable: () => toInstanceable(normaliseGLB(template, def, []), def.asset),
});
this.version++;
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.
console.warn(`[render] GLB for "${def.asset}" failed to parse, keeping placeholder`, err);
}
}
}
/**
* The machine's ONE emissive accent: `MachineDef.accent` (v4) when art-directed,
* otherwise the colour of what it makes (recipes -> outputs -> ItemDef.color), otherwise
* a per-kind accent.
*
* `MachineDef.color` is NOT read here — v3 ruled it the chassis; see `bodyFor` below.
*
* 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).
*/
export function accentFor(def: MachineDef, data: GameData): number {
// v4: explicit art-direction wins outright, with no legibility floor. DATA choosing a
// dark accent is a decision; the floor below exists for DERIVED colours, which are
// incidental (item colours are authored for chips, not for machine accents). Same
// reasoning as `bodyFor` and `def.color`.
if (def.accent) return new THREE.Color(def.accent).getHex();
for (const rid of def.recipes) {
const recipe = data.recipes.find((r) => r.id === rid);
const outId = recipe && Object.keys(recipe.outputs)[0];
const item = outId ? data.items.find((i) => i.id === outId) : undefined;
if (item && lightnessOf(item.color) >= ACCENT_MIN_L) {
return new THREE.Color(item.color).getHex();
}
}
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.
*/
export function bodyFor(def: MachineDef): number {
if (def.color) return new THREE.Color(def.color).getHex();
return BODY_BY_KIND[def.kind] ?? 0x3b3944;
}