glytch/fktry/src/render/showroom.ts
LANE-SIM d1c3f2b09d [sim] round 3 NOTES
Confirms the units conversion (bufferCap 240 = 8.0s of cover at a 30/s
deficit) and reports scram survival: first scram t434, restart t934,
~718-tick duty cycle, ZERO brownout ticks across 20k, melt rate unchanged.

For the orchestrator:
- heat semantics need a one-line ruling. heatPerTick is GROSS in my model
  (net = heatPerTick - coolPerTick). DATA's committed 0.0025 against the
  0.004 default nets negative -- the decoder could never scram. Their
  in-flight 0.0033/0.001 works, so we agree by luck of timing.
- the codex's literal bloom loop is not expressible with per-grade recipes
- asic-cooler does nothing without a spatial heat mechanic
- my sim code landed inside 45003db [ui] REMOVE mode: the shared tree has
  one git index and an add-everything commit in another session swept it.
  Not a UI ownership violation. Suggest a no-`git add -A` rule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 18:35:47 +10:00

228 lines
8.1 KiB
TypeScript

/**
* 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/<asset>.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 = `<div>${esc(s.label)}</div><div style="color:#7d8aa8">${esc(s.sub)}</div>`;
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)};`);