[render] v4: ghost mode, locked-build ghost, cooler auras, lab visuals

Round 4 orders 1-5:

- ghost keys demolition off setGhost's `mode` arg; the '__remove' sentinel is
  ignored and REMOVE_DEF is gone from this lane.
- locked-build ghost: padlock-amber (not occupied-red — "not yet" and "not
  here" are different sentences) + a padlock tinted by the gating era. Needed
  a new ERA_TELL palette: ERA_SKIN.tint is a multiplier tuned to leave DATA's
  chassis alone, so it can't carry a signal (stream is #ffffff).
- aura.ts: cooling coverage as a dashed RECTANGLE. coolRadius is chebyshev, so
  the cooled region is the footprint grown by r per side; a circle would look
  better and lie about the corners.
- lab placeholder: bench + the "bottled artifact triptych" (§2); consumes
  MachineDef.accent when present, derivation stays the fallback.

Also:
- buffer fill reads bandwidth.capacity (v4) instead of re-summing bufferCap —
  a second copy of the sim's arithmetic is what the scram latch warned about.
- lock rule matched to LANE-UI's reading: absent research means nothing has
  completed, so gated machines padlock. Mine said the opposite; theirs is the
  literal contract text, and the build bar and ghost disagreeing on the same
  screen is worse than either answer.
- showroom fitted to BOTH axes. Round 3's depth-only fit collapsed the item
  grid to one 206-tile row once the machine hall grew and ate the depth.

Verified against DATA's real v4 data and UI's migrated flow: 6x6 aura on
asic-cooler, reel/broadcast era tells, lab accent #ffc24a from data, 29
machines + 60 items in the showroom, 500 entities + 2,000 items at 3.6ms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-17 20:12:04 +10:00
parent dbb3615a64
commit 5e8e49ef0e
8 changed files with 355 additions and 68 deletions

97
fktry/src/render/aura.ts Normal file
View File

@ -0,0 +1,97 @@
/**
* LANE-RENDER cooling aura footprint.
*
* `coolRadius` is CHEBYSHEV distance from the machine's footprint, so the region it
* actually cools is a RECTANGLE the footprint grown by r tiles on every side. Drawing
* a circle here would be prettier and would lie: the corners it left out are cooled, and
* players would plan around a boundary that isn't real. So this is a rectangle, aligned
* to the grid it's actually measured in.
*
* Engineering-drawing vibe, not a buff circle: a thin dashed outline with tick marks at
* the tile grid, no fill, no bloom. It's a coverage annotation, not a power-up.
*/
import * as THREE from 'three';
const AURA = 0x7fd4ff;
const VERT = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const FRAG = /* glsl */ `
precision highp float;
uniform vec2 uSize; // rectangle size in TILES, so dashes stay tile-sized at any scale
uniform vec3 uColor;
uniform float uTime;
varying vec2 vUv;
void main() {
vec2 p = vUv * uSize; // position in tiles from the corner
vec2 d = min(p, uSize - p); // distance to the nearest edge, in tiles
float edge = min(d.x, d.y);
// Dashed border: march the dash phase around the perimeter, not across the face,
// so corners don't smear.
float along = (d.x < d.y) ? p.y : p.x;
float dash = step(0.35, fract(along * 1.5 - uTime * 0.25));
float line = (1.0 - smoothstep(0.03, 0.07, edge)) * dash;
// Tick marks every tile, just inside the border — the drafting touch.
float tick = step(0.94, fract(along)) * (1.0 - smoothstep(0.10, 0.16, edge));
float a = max(line, tick * 0.7);
if (a < 0.02) discard;
gl_FragColor = vec4(uColor, a * 0.55);
}
`;
export class CoolAura {
readonly group = new THREE.Group();
private mesh: THREE.Mesh;
private uniforms = {
uSize: { value: new THREE.Vector2(1, 1) },
uColor: { value: new THREE.Color(AURA) },
uTime: { value: 0 },
};
constructor() {
this.group.name = 'coolAura';
this.group.visible = false;
this.mesh = new THREE.Mesh(
new THREE.PlaneGeometry(1, 1),
new THREE.ShaderMaterial({
uniforms: this.uniforms,
vertexShader: VERT,
fragmentShader: FRAG,
transparent: true,
depthWrite: false,
}),
);
this.mesh.rotation.x = -Math.PI / 2;
this.mesh.position.y = 0.03;
this.mesh.renderOrder = 9;
this.group.add(this.mesh);
}
hide(): void {
this.group.visible = false;
}
/**
* `cx`/`cz` centre the machine; `fw`/`fd` are its ROTATED footprint; `r` is coolRadius.
* Covered region = footprint + r tiles on each side (chebyshev).
*/
show(cx: number, cz: number, fw: number, fd: number, r: number, timeSec: number): void {
const w = fw + r * 2;
const d = fd + r * 2;
this.group.visible = true;
this.uniforms.uSize.value.set(w, d);
this.uniforms.uTime.value = timeSec;
this.mesh.scale.set(w, d, 1);
this.mesh.position.set(cx, 0.03, cz);
}
}

View File

@ -171,11 +171,19 @@ export class DevScene {
gen: 20,
draw: brownout ? 26 : 14,
stored: (0.5 + 0.5 * Math.sin(tick / 60)) * 100,
capacity: 100,
brownout,
},
shippedTotal: { melt: Math.floor(tick / 90) },
activeCommission: null,
commissionProgress: {},
// v4: nothing researched, so every tech-gated machine reads locked — which is what
// the ghost's padlock is for. `unlocked` is writable from the console to check the
// other half (see NOTES).
research: this.research,
};
}
/** Mutable so a verification session can unlock techs and watch padlocks clear. */
readonly research = { active: null as string | null, progress: {}, unlocked: [] as string[] };
}

View File

@ -78,11 +78,15 @@ export class EntityLayer {
let anyAlarm = false;
// Tanks share one bandwidth pool, so they all read the same level: the pool's
// fraction of total installed capacity.
let cap = 0;
for (const e of snap.entities) {
const d = this.defs.get(e.def);
if (d?.kind === 'buffer') cap += d.bufferCap ?? DEFAULT_BUFFER_CAP;
// fraction of total installed capacity. v4 publishes that total, so take it from
// truth rather than re-summing bufferCap here — a second copy of the sim's
// arithmetic is exactly what the scram latch taught us not to keep.
let cap = snap.bandwidth.capacity ?? 0;
if (!snap.bandwidth.capacity) {
for (const e of snap.entities) {
const d = this.defs.get(e.def);
if (d?.kind === 'buffer') cap += d.bufferCap ?? DEFAULT_BUFFER_CAP;
}
}
const fillLevel = cap > 0 ? THREE.MathUtils.clamp(snap.bandwidth.stored / cap, 0, 1) : 0;

View File

@ -56,3 +56,18 @@ export function applyEraSkin(mat: THREE.MeshStandardMaterial, era: Era): void {
mat.roughness = skin.roughness;
mat.metalness = skin.metalness;
}
/**
* Era read as a SIGNAL rather than a surface used by the locked-build ghost to say
* *which* era is holding a machine back. `ERA_SKIN.tint` can't do this job: it's a
* multiplier tuned to leave DATA's chassis alone (stream is literally white), so three
* of the five eras would tell you nothing. These are chosen to be told apart at a
* glance, in codex order.
*/
export const ERA_TELL: Record<Era, number> = {
reel: 0xd8a24a, // brass
broadcast: 0x5fd08a, // phosphor
disc: 0x6fd7ff, // iridescent
stream: 0xdfe6f2, // flat plastic white
fortress: 0xf2f7ff, // blinding
};

View File

@ -1,36 +1,37 @@
/**
* LANE-RENDER build ghost, and remove-mode hover. Shows the selected machine at the
* hovered tile: cyan when it fits, red when its footprint collides with something
* already placed.
* LANE-RENDER build ghost, remove-mode hover, and cooling-aura annotation.
*
* Occupancy is read straight off the snapshot's entity footprints the sim still gets
* the final say when the place command lands; this is only the player's preview.
* The ghost answers one question: what happens if I click here?
* cyan it fits
* red its footprint collides with something already placed
* amber the machine exists but research hasn't unlocked it yet
*
* REMOVE MODE: `setGhost('__remove', pos, dir)` switches to the demolition hover
* (see remove.ts). The sentinel is the channel because UI holds no Renderer reference
* its only view of what the player is holding is what main.ts feeds `setGhost` from
* `selectedBuild()`. Convention agreed with LANE-UI (`src/ui/selection.ts` documents the
* other half); both lanes have filed for a real `setGhostMode` hook to replace it.
* That third state is why this reads `research`: the sim silently drops a `place` for a
* locked def, so without a tell the click just does nothing and reads as a bug. Amber is
* deliberately not red "not yet" and "not here" are different sentences.
*
* Occupancy and lock state are previews only; the sim still gets the final say when the
* command lands.
*
* v4: the mode comes from `setGhost`'s 4th argument. The old `'__remove'` def sentinel
* still arrives from main.ts as a bridge and is simply ignored mode is the truth.
*/
import * as THREE from 'three';
import type { Dir, EntityState, MachineDef, SimSnapshot, Vec2 } from '../contracts';
import type {
Dir, EntityState, GameData, MachineDef, SimSnapshot, TechDef, Vec2,
} from '../contracts';
import { AssetRegistry } from './registry';
import { centerOf, rotatedFootprint, tileKey, tilesOf, yawFor } from './coords';
import { idleOf, type IdleFn } from './registry';
import { WORLD_HALF } from './camera';
import { HEIGHT_BY_KIND } from './palette';
import { RemoveHover } from './remove';
import { CoolAura } from './aura';
import { ERA_TELL } from './era';
const VALID = 0x3fd4ff;
const INVALID = 0xff3b30;
/**
* Ghost sentinel meaning "remove mode is armed". Same literal as LANE-UI's `REMOVE_DEF`.
* Duplicated rather than imported: a renderer that imports UI internals is the wrong
* layering, and there's no contract field to hold it yet. Unlike a silent tuning
* constant, a drift here fails loudly and visibly (the hover just stops appearing).
*/
const REMOVE_DEF = '__remove';
const LOCKED = 0xffb02e;
function ghostMaterial(color: number): THREE.MeshStandardMaterial {
return new THREE.MeshStandardMaterial({
@ -44,6 +45,27 @@ function ghostMaterial(color: number): THREE.MeshStandardMaterial {
});
}
/** A padlock, floating over a build you can't have yet. Tinted by the era gating it. */
function makePadlock(): { obj: THREE.Group; tint: (c: number) => void } {
const g = new THREE.Group();
const mat = new THREE.MeshStandardMaterial({
color: LOCKED, emissive: LOCKED, emissiveIntensity: 1.4,
transparent: true, opacity: 0.9, depthWrite: false,
});
const body = new THREE.Mesh(new THREE.BoxGeometry(0.26, 0.2, 0.12), mat);
const shackle = new THREE.Mesh(new THREE.TorusGeometry(0.08, 0.025, 6, 12, Math.PI), mat);
shackle.position.y = 0.1;
g.add(body, shackle);
g.renderOrder = 12;
return {
obj: g,
tint: (c: number) => {
mat.color.setHex(c);
mat.emissive.setHex(c);
},
};
}
export class GhostLayer {
readonly group = new THREE.Group();
private obj: THREE.Object3D | null = null;
@ -59,22 +81,47 @@ export class GhostLayer {
private occupied = new Map<number, EntityState>();
private matValid = ghostMaterial(VALID);
private matInvalid = ghostMaterial(INVALID);
private matLocked = ghostMaterial(LOCKED);
readonly remove = new RemoveHover();
/** Lives outside `group` so it can annotate a hovered machine with nothing selected. */
readonly aura = new CoolAura();
private padlock = makePadlock();
/** machine id -> the tech that gates it (v4 rule: unreferenced ids are always open). */
private lockedBy = new Map<string, TechDef>();
/** Skip the per-frame occupancy walk entirely when no machine in data can cool. */
private anyCoolers = false;
constructor(private registry: AssetRegistry, private defs: Map<string, MachineDef>) {
constructor(
private registry: AssetRegistry,
private defs: Map<string, MachineDef>,
data: GameData,
) {
this.group.name = 'ghost';
this.group.renderOrder = 10;
this.group.add(this.remove.group);
this.padlock.obj.visible = false;
this.group.add(this.padlock.obj);
const machines = new Set(data.machines.map((m) => m.id));
for (const t of data.tech) {
for (const u of t.unlocks) {
// First tech to claim an id wins — matching LANE-UI's `indexTech`. Two techs
// unlocking one id is a data bug either way, but we must both blame the same one.
if (machines.has(u) && !this.lockedBy.has(u)) this.lockedBy.set(u, t);
}
}
this.anyCoolers = data.machines.some((m) => m.coolRadius);
}
set(defId: string | null, pos: Vec2 | null, dir: Dir): void {
this.removing = defId === REMOVE_DEF;
set(defId: string | null, pos: Vec2 | null, dir: Dir, mode: 'build' | 'remove' = 'build'): void {
this.removing = mode === 'remove';
this.pos = pos;
this.dir = dir;
if (this.removing) {
// The build ghost stays hidden; update() resolves what's under the cursor.
this.def = null;
this.pos = pos;
this.dir = dir;
this.def = null; // the sentinel def is ignored: mode is the truth
if (this.obj) this.obj.visible = false;
this.padlock.obj.visible = false;
this.group.visible = true;
return;
}
@ -82,11 +129,7 @@ export class GhostLayer {
const def = defId ? this.defs.get(defId) ?? null : null;
this.def = def;
this.pos = def && pos ? pos : null;
this.dir = dir;
if (this.obj) this.obj.visible = true;
if (!def || !this.pos) {
if (!def || !pos) {
this.group.visible = false;
return;
}
@ -95,6 +138,7 @@ export class GhostLayer {
this.group.visible = false;
return;
}
if (this.obj) this.obj.visible = true;
if (!this.obj || this.objDef !== def.id || this.objVersion !== entry.version) {
this.rebuild(def);
this.objDef = def.id;
@ -133,31 +177,65 @@ export class GhostLayer {
this.obj = null;
}
update(snap: SimSnapshot, timeSec: number): void {
if (!this.group.visible) return;
if (!this.removing && (!this.def || !this.pos || !this.obj)) return;
/**
* v4 gating rule, verbatim: "a machine/recipe id that appears in ANY TechDef.unlocks
* is locked until that tech completes; ids referenced by no tech are always available."
* Returns the gating tech, or null if it's buildable.
*
* Absent `research` means NOTHING has completed, so everything gated reads locked. My
* instinct was the opposite treat a missing field as "don't lie" but that's the
* `scrammed` lesson misapplied: there the field's absence meant the sim hadn't wired a
* behaviour, here the rule is satisfiable from data alone and the contract says
* "until that tech completes". LANE-UI's `research.ts` reads it the same way, and the
* build bar and the ghost disagreeing about what's buildable would be worse than
* either answer.
*/
private lockOf(defId: string, snap: SimSnapshot): TechDef | null {
const tech = this.lockedBy.get(defId);
if (!tech) return null;
return snap.research?.unlocked.includes(tech.id) ? null : tech;
}
this.occupied.clear();
for (const e of snap.entities) {
const d = this.defs.get(e.def);
if (!d) continue;
for (const t of tilesOf(e.pos, d, e.dir)) this.occupied.set(tileKey(t.x, t.y), e);
update(snap: SimSnapshot, timeSec: number): void {
if (!this.pos) {
this.aura.hide();
this.remove.hide();
this.group.visible = false;
return;
}
const needOccupancy = this.removing || !!this.def || this.anyCoolers;
if (needOccupancy) {
this.occupied.clear();
for (const e of snap.entities) {
const d = this.defs.get(e.def);
if (!d) continue;
for (const t of tilesOf(e.pos, d, e.dir)) this.occupied.set(tileKey(t.x, t.y), e);
}
}
this.updateAura(timeSec);
if (this.removing) {
this.updateRemove(timeSec);
return;
}
if (!this.def || !this.pos || !this.obj) return;
if (!this.def || !this.obj) {
this.group.visible = false;
return;
}
const want = tilesOf(this.pos, this.def, this.dir);
const ok = want.every(
const fits = want.every(
(t) =>
!this.occupied.has(tileKey(t.x, t.y)) &&
t.x >= -WORLD_HALF && t.x < WORLD_HALF &&
t.y >= -WORLD_HALF && t.y < WORLD_HALF,
);
const mat = ok ? this.matValid : this.matInvalid;
const lock = this.lockOf(this.def.id, snap);
// Locked outranks collision: "you don't have this yet" is the more useful sentence,
// and a locked click does nothing wherever you aim it.
const mat = lock ? this.matLocked : fits ? this.matValid : this.matInvalid;
this.obj.traverse((o) => {
const m = o as THREE.Mesh;
if (m.isMesh) m.material = mat;
@ -167,6 +245,43 @@ export class GhostLayer {
this.obj.position.y += 0.02;
this.obj.rotation.y = yawFor(this.dir);
this.idle?.(timeSec);
if (lock) {
const h = HEIGHT_BY_KIND[this.def.kind] ?? 0.8;
this.padlock.obj.visible = true;
this.padlock.obj.position.copy(this.obj.position);
this.padlock.obj.position.y = h + 0.5 + Math.sin(timeSec * 2) * 0.05;
this.padlock.obj.rotation.y = timeSec * 0.8;
this.padlock.tint(ERA_TELL[lock.era] ?? LOCKED); // which era is holding it back
} else {
this.padlock.obj.visible = false;
}
}
/**
* Ring the cooler being placed, or the one under the cursor. Placing wins: while you're
* holding a cooler the question is where its coverage lands, not what's already there.
*/
private updateAura(timeSec: number): void {
if (!this.anyCoolers || !this.pos) {
this.aura.hide();
return;
}
if (!this.removing && this.def?.coolRadius) {
const f = rotatedFootprint(this.def, this.dir);
const c = centerOf(this.pos, this.def, this.dir);
this.aura.show(c.x, c.z, f.x, f.y, this.def.coolRadius, timeSec);
return;
}
const under = this.occupied.get(tileKey(this.pos.x, this.pos.y));
const udef = under ? this.defs.get(under.def) : undefined;
if (under && udef?.coolRadius) {
const f = rotatedFootprint(udef, under.dir);
const c = centerOf(under.pos, udef, under.dir);
this.aura.show(c.x, c.z, f.x, f.y, udef.coolRadius, timeSec);
return;
}
this.aura.hide();
}
/**

View File

@ -102,8 +102,8 @@ export function createRenderer(): Renderer {
belts = new BeltLayer(registry, defs, topo);
entities = new EntityLayer(registry, defs);
cargo = new BeltItemLayer(data, topo);
ghost = new GhostLayer(registry, defs);
scene.add(belts.group, entities.group, cargo.group, ghost.group);
ghost = new GhostLayer(registry, defs, data);
scene.add(belts.group, entities.group, cargo.group, ghost.group, ghost.aura.group);
debug = devSceneEnabled() || showroomEnabled();
@ -202,8 +202,8 @@ export function createRenderer(): Renderer {
return { x, y };
},
setGhost(def: string | null, pos: Vec2 | null, dir: Dir) {
ghost?.set(def, pos, dir);
setGhost(def: string | null, pos: Vec2 | null, dir: Dir, mode: 'build' | 'remove' = 'build') {
ghost?.set(def, pos, dir, mode);
},
};
}

View File

@ -163,6 +163,29 @@ function makePlaceholder(def: MachineDef, accent: number, body_: number, era: Er
}) satisfies IdleFn;
break;
}
case 'lab': {
// A research bench: low slab, and the "bottled artifact triptych" (§2) standing on
// it. Three bottles is the whole silhouette — nothing else in the hall is a row of
// little glowing cylinders, which is the point of a placeholder.
const bottles: THREE.Mesh[] = [];
for (let i = 0; i < 3; i++) {
const b = new THREE.Mesh(
new THREE.CylinderGeometry(fw * 0.07, fw * 0.07, h * 0.3, 8),
i === 0 ? am : am.clone(),
);
b.position.set((i - 1) * fw * 0.2, h + h * 0.15, 0);
g.add(b);
bottles.push(b);
}
g.userData.fktryIdle = ((t: number) => {
// They pulse out of phase — a bench mid-assay, not a machine mid-cycle.
bottles.forEach((b, i) => {
(b.material as THREE.MeshStandardMaterial).emissiveIntensity =
1.0 + Math.sin(t * 2.4 + i * 2.1) * 0.45;
});
}) 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);
@ -369,13 +392,11 @@ export class AssetRegistry {
}
/**
* The machine's ONE emissive accent = the colour of what it makes (recipes -> outputs
* -> ItemDef.color), falling back to a per-kind accent.
* The machine's ONE emissive accent: `MachineDef.accent` (v4) when art-directed,
* otherwise the colour of what it makes (recipes -> outputs -> ItemDef.color), otherwise
* 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".
* `MachineDef.color` is NOT read here v3 ruled it the chassis; see `bodyFor` below.
*
* 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
@ -383,6 +404,11 @@ export class AssetRegistry {
* through to the kind accent (for the extractor, CRT blue, as the codex describes it).
*/
function accentFor(def: MachineDef, data: GameData): number {
// v4: explicit art-direction wins outright, with no legibility floor. DATA choosing a
// dark accent is a decision; the floor below exists for DERIVED colours, which are
// incidental (item colours are authored for chips, not for machine accents). Same
// reasoning as `bodyFor` and `def.color`.
if (def.accent) return new THREE.Color(def.accent).getHex();
for (const rid of def.recipes) {
const recipe = data.recipes.find((r) => r.id === rid);
const outId = recipe && Object.keys(recipe.outputs)[0];

View File

@ -27,13 +27,18 @@ export const showroomEnabled = (): boolean =>
/** 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;
/**
* Machine hall to the north, item hall to the south, both inside the ±32 world.
* Both catalogs grow every round (9 items -> 60; 7 machines -> 29), so nothing here is
* a fixed row/column count: the halls are packed into the world box at build time.
*/
const M_COLS = 8;
const M_SPACING = 6.5;
const M_Z0 = -12;
const I_COLS = 12;
const M_Z0 = -24;
const I_SPACING = 3.5;
const I_GAP = 7; // between the two halls
const I_Z_LIMIT = 30; // keep the item hall inside the world (camera clamps at ±32)
const X_LIMIT = 30;
const PLINTH_H = 0.05;
interface Slot {
@ -64,7 +69,6 @@ export class Showroom {
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) => {
@ -87,10 +91,28 @@ export class Showroom {
const itemZ0 = M_Z0 + Math.ceil(this.data.machines.length / M_COLS) * M_SPACING + I_GAP;
// Fit the item hall to the world box — BOTH axes. Budgeting only depth is what put
// all 60 items on one 206-tile-wide row the moment the machine hall grew to 5 rows
// and ate the remaining depth: rows collapsed to 1, so columns took the overflow and
// walked straight out of the world.
const iRows = Math.max(1, Math.floor((I_Z_LIMIT - itemZ0) / I_SPACING) + 1);
const maxCols = Math.max(1, Math.floor((X_LIMIT * 2) / I_SPACING) + 1);
const iCols = Math.min(maxCols, Math.max(1, Math.ceil(this.data.items.length / iRows)));
const iCentre = ((iCols - 1) * I_SPACING) / 2;
// If both axes are full the catalog has outgrown the hall — say so rather than
// quietly stacking items on top of each other off-world.
const capacity = iRows * iCols;
if (this.data.items.length > capacity) {
console.warn(
`[render] showroom: ${this.data.items.length} items exceed the hall's ${capacity} ` +
`slots (${iCols}x${iRows}); the overflow will sit past the world edge. Time to page.`,
);
}
// --- 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 cx = -iCentre + (i % iCols) * I_SPACING;
const cz = itemZ0 + Math.floor(i / iCols) * I_SPACING;
const mesh = new THREE.Mesh(itemGeometry(def), itemMaterial(def));
mesh.position.set(cx, PLINTH_H + 0.35, cz);
mesh.castShadow = true;
@ -102,12 +124,12 @@ export class Showroom {
});
});
// 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).
// Preset frames the MACHINE hall: assets are what this page exists to inspect, and a
// view wide enough to include every item renders each 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;
this.view.zoom = 22;
}
/** All machines, standing still. Items live in `decor`, not the snapshot. */