Round 2 orders 1-6: - topology.ts: belt neighbour analysis -> straight/cornerA/cornerB/merge, one InstancedMesh per (def x shape), flow field baked per shape. Corners sweep a quarter-arc; merges get inlet arrows. - cargo follows the corner arc: `t` is progress along the tile, so walking items straight through the centre made them cut corners and pop in at the wrong edge. They now enter at the feeding edge (verified 0.089-0.248 off the centreline where straight pins to 0.000). - heat.ts + entities.ts: dull-red body ramp, rising shimmer column, throttle (idle AND GLB clips slow together), scram (dead-dark + amber, frozen) and a restart path that restores what scram zeroed. - buffer tanks show fill from their share of bandwidth.stored / bufferCap. - AnimationMixer plays clip 0 looped; rigged GLBs clone via SkeletonUtils so instances don't share a skeleton. - exact cargo interpolation via BeltItem.id, order-matching kept as fallback. - multi-mesh belt GLBs merge with groups (2 materials, 1 draw call) instead of rendering only the first mesh. MachineDef.color is wired to the BODY, not the accent, against the letter of the orders: all 21 values DATA authored are industrial chassis neutrals per codex/§8, and wiring them to the emissive makes every machine glow muddy and kills the two-material rule. Flagged in NOTES for the orchestrator to settle. 500 entities + 2,000 items still at 3.6ms/frame (4.6x headroom), 5 draw calls. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
402 lines
16 KiB
TypeScript
402 lines
16 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 { 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<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>();
|
|
private loader = new GLTFLoader();
|
|
private timer: number | null = null;
|
|
|
|
async init(data: GameData): Promise<void> {
|
|
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<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;
|
|
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;
|
|
}
|