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>
228 lines
8.1 KiB
TypeScript
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)};`);
|