/** * 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/.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 { 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'; 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): 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 body = new THREE.Mesh( new RoundedBoxGeometry(fw, h, fd, 2, Math.min(0.08, h * 0.25)), bodyMat(body_), ); 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 '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 handling /** Fit an arbitrary GLB into the machine footprint, bottom sat on the ground plane. */ 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); 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. if (clips.length) { const wrap = new THREE.Group(); wrap.add(obj); wrap.userData.fktryClips = clips; return wrap; } obj.userData.fktryClips = clips; return obj; } /** * 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 { 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(); private defs = new Map(); private loader = new GLTFLoader(); private timer: number | null = null; async init(data: GameData): Promise { for (const def of data.machines) { this.defs.set(def.asset, def); this.entries.set(def.asset, this.placeholderEntry(def, accentFor(def, data), bodyFor(def))); } await this.probeAll(); 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): AssetEntry { const prev = this.entries.get(def.asset); return { key: def.asset, version: prev ? prev.version : 0, isGLB: false, 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) } : null, }; } /** Probe every key that hasn't got a GLB yet. Cheap: one HEAD each, 10s apart. */ private async probeAll(): Promise { 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 { 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; const clips = gltf.animations ?? []; this.entries.set(def.asset, { key: def.asset, version, isGLB: true, create: () => normaliseGLB(template, def, clips), 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 = the colour of what it makes (recipes -> outputs * -> ItemDef.color), falling back to a per-kind accent. * * 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) { 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. */ function bodyFor(def: MachineDef): number { if (def.color) return new THREE.Color(def.color).getHex(); return BODY_BY_KIND[def.kind] ?? 0x3b3944; }