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>
611 lines
26 KiB
TypeScript
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;
|
|
}
|