/** * LANE-RENDER — `?showroom`: the MODELBEAST asset QA page. * * Every machine and every item, on labelled plinths, under a fixed camera. Drop a GLB * into `public/assets/models/.glb` and it appears here within ~10s with no * reload — this is the eyeball step for an asset drop: right scale? sits on the ground? * silhouette reads at iso distance? clip loops? * * The machines are fabricated as a normal SimSnapshot rather than hand-built meshes, so * they go through the SAME EntityLayer path as the real game — hot-swap, GLB * normalisation, AnimationMixer, era skin and all. A bespoke display path would be a * QA page that tests itself instead of the renderer. * * Items aren't entities (they have no `asset` key and never hot-swap), so those are * built directly — but from beltitems.ts's own geometry/material factories, so what you * see here is literally what rides the belts. * * Dev-only, like the rest of the scaffold: `import.meta.env.DEV` plus the query flag. */ import * as THREE from 'three'; import type { Dir, EntityState, GameData, SimSnapshot } from '../contracts'; import { AssetRegistry } from './registry'; import { itemGeometry, itemMaterial } from './beltitems'; export const showroomEnabled = (): boolean => import.meta.env.DEV && new URLSearchParams(location.search).has('showroom'); /** Machines face the camera (dir 2 = S = +z), which is where the iso rig looks from. */ const FACING: Dir = 2; /** Machine hall to the north, item hall to the south, both inside the ±32 world. */ const M_COLS = 6; const M_SPACING = 6.5; const M_Z0 = -12; const I_COLS = 12; const I_SPACING = 3.5; const I_GAP = 7; // between the two halls const PLINTH_H = 0.05; interface Slot { label: string; sub: string; x: number; z: number; /** Cached label size, measured once — re-reading offsetWidth every frame thrashes layout. */ w: number; h: number; } export class Showroom { readonly decor = new THREE.Group(); private entities: EntityState[] = []; private slots: Slot[] = []; private labelHost: HTMLDivElement | null = null; private labelEls: HTMLDivElement[] = []; private v = new THREE.Vector3(); /** Camera preset framing the whole hall. */ readonly view = { x: 0, z: 2, zoom: 21 }; constructor(private data: GameData, private registry: AssetRegistry) { this.decor.name = 'showroom'; this.build(); } private build(): void { let id = 1; const mCentre = ((M_COLS - 1) * M_SPACING) / 2; const iCentre = ((I_COLS - 1) * I_SPACING) / 2; // --- machines: fabricated entities, rendered by the real EntityLayer ------------- this.data.machines.forEach((def, i) => { const cx = -mCentre + (i % M_COLS) * M_SPACING; const cz = M_Z0 + Math.floor(i / M_COLS) * M_SPACING; // Centre each footprint on its slot: pos is the min corner. const x = Math.round(cx - def.footprint.x / 2); const z = Math.round(cz - def.footprint.y / 2); this.entities.push({ id: id++, def: def.id, pos: { x, y: z }, dir: FACING, recipe: null, progress: 0, inputBuf: {}, outputBuf: {}, jammed: null, heat: 0, scrammed: false, }); this.slots.push({ label: def.name, sub: `${def.asset}.glb · ${this.registry.eraOf(def.id)}`, x: cx, z: cz, w: 0, h: 0, }); this.decor.add(plinth(def.footprint.x + 0.8, def.footprint.y + 0.8, cx, cz)); }); const itemZ0 = M_Z0 + Math.ceil(this.data.machines.length / M_COLS) * M_SPACING + I_GAP; // --- items: built from the belt layer's own factories ---------------------------- this.data.items.forEach((def, i) => { const cx = -iCentre + (i % I_COLS) * I_SPACING; const cz = itemZ0 + Math.floor(i / I_COLS) * I_SPACING; const mesh = new THREE.Mesh(itemGeometry(def), itemMaterial(def)); mesh.position.set(cx, PLINTH_H + 0.35, cz); mesh.castShadow = true; mesh.userData.spin = true; this.decor.add(mesh); this.decor.add(plinth(1.4, 1.4, cx, cz)); this.slots.push({ label: def.name, sub: `tier ${def.tier} · ${def.color}`, x: cx, z: cz, w: 0, h: 0, }); }); // Preset frames the MACHINE hall: assets are what this page exists to inspect, and // a view wide enough to include all 51 items renders every machine too small to // judge. The item hall is one pan south (WASD / drag). const mRows = Math.ceil(this.data.machines.length / M_COLS); this.view.z = M_Z0 + ((mRows - 1) * M_SPACING) / 2; this.view.zoom = 19; } /** All machines, standing still. Items live in `decor`, not the snapshot. */ snapshot(tick: number): SimSnapshot { return { tick, paused: false, entities: this.entities, beltItems: [], bandwidth: { gen: 0, draw: 0, stored: 0, brownout: false }, shippedTotal: {}, activeCommission: null, commissionProgress: {}, }; } frame(timeSec: number): void { for (const o of this.decor.children) { if (o.userData.spin) o.rotation.y = timeSec * 0.9; } } // ---------------------------------------------------------------- labels /** DOM labels: crisp at any zoom, and no font atlas to maintain for a QA page. */ mountLabels(container: HTMLElement): void { const host = document.createElement('div'); host.style.cssText = 'position:absolute;inset:0;pointer-events:none;overflow:hidden'; this.labelEls = this.slots.map((s) => { const el = document.createElement('div'); el.style.cssText = [ 'position:absolute', 'transform:translate(-50%,0)', 'font:10px/1.35 "Courier New",ui-monospace,monospace', 'color:#cfe6ff', 'text-align:center', 'text-shadow:0 1px 3px #000,0 0 8px #000', 'white-space:nowrap', ].join(';'); el.innerHTML = `
${esc(s.label)}
${esc(s.sub)}
`; host.appendChild(el); return el; }); container.appendChild(host); this.labelHost = host; // Measure once: sizes never change, and reading offsetWidth per frame would force // a layout for every one of ~74 labels. this.slots.forEach((s, i) => { s.w = this.labelEls[i].offsetWidth; s.h = this.labelEls[i].offsetHeight; }); } /** * Project, then drop any label that would collide with one already placed. There are * ~74 of these and at overview zoom they cannot all fit; a readable subset that * resolves as you zoom in beats an unreadable pile. Front-to-back so the labels you * lose are the ones furthest away. */ updateLabels(camera: THREE.Camera, container: HTMLElement): void { if (!this.labelHost) return; const w = container.clientWidth; const h = container.clientHeight; this.placed.length = 0; const order = this.slots .map((s, i) => ({ s, i, d: s.z })) // nearer the camera = larger z in this rig .sort((a, b) => b.d - a.d); for (const { s, i } of order) { const el = this.labelEls[i]; this.v.set(s.x, 0, s.z + 1.5).project(camera); if (this.v.z > 1 || Math.abs(this.v.x) > 1.15 || Math.abs(this.v.y) > 1.15) { el.style.display = 'none'; continue; } const px = (this.v.x * 0.5 + 0.5) * w; const py = (-this.v.y * 0.5 + 0.5) * h; const l = px - s.w / 2; const r = px + s.w / 2; const t = py; const b = py + s.h; let hit = false; for (const p of this.placed) { if (l < p[2] && r > p[0] && t < p[3] && b > p[1]) { hit = true; break; } } if (hit) { el.style.display = 'none'; continue; } this.placed.push([l, t, r, b]); el.style.display = ''; el.style.left = `${px}px`; el.style.top = `${py}px`; } } private placed: Array<[number, number, number, number]> = []; } function plinth(w: number, d: number, cx: number, cz: number): THREE.Mesh { const m = new THREE.Mesh( new THREE.BoxGeometry(w, PLINTH_H, d), new THREE.MeshStandardMaterial({ color: 0x14121d, emissive: 0x2a3550, emissiveIntensity: 0.35, roughness: 0.9, }), ); m.position.set(cx, PLINTH_H / 2, cz); m.receiveShadow = true; return m; } const esc = (s: string): string => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);