From 5e8e49ef0e6bfa20ec60b4aba2c42c25203be7e6 Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 17 Jul 2026 20:12:04 +1000 Subject: [PATCH] [render] v4: ghost mode, locked-build ghost, cooler auras, lab visuals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- fktry/src/render/aura.ts | 97 +++++++++++++++++ fktry/src/render/devscene.ts | 8 ++ fktry/src/render/entities.ts | 14 ++- fktry/src/render/era.ts | 15 +++ fktry/src/render/ghost.ts | 199 +++++++++++++++++++++++++++-------- fktry/src/render/index.ts | 8 +- fktry/src/render/registry.ts | 38 +++++-- fktry/src/render/showroom.ts | 44 ++++++-- 8 files changed, 355 insertions(+), 68 deletions(-) create mode 100644 fktry/src/render/aura.ts diff --git a/fktry/src/render/aura.ts b/fktry/src/render/aura.ts new file mode 100644 index 0000000..3917417 --- /dev/null +++ b/fktry/src/render/aura.ts @@ -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); + } +} diff --git a/fktry/src/render/devscene.ts b/fktry/src/render/devscene.ts index 4492738..61d0100 100644 --- a/fktry/src/render/devscene.ts +++ b/fktry/src/render/devscene.ts @@ -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[] }; } diff --git a/fktry/src/render/entities.ts b/fktry/src/render/entities.ts index 31d809d..7e9c185 100644 --- a/fktry/src/render/entities.ts +++ b/fktry/src/render/entities.ts @@ -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; diff --git a/fktry/src/render/era.ts b/fktry/src/render/era.ts index ccb1042..836ddea 100644 --- a/fktry/src/render/era.ts +++ b/fktry/src/render/era.ts @@ -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 = { + reel: 0xd8a24a, // brass + broadcast: 0x5fd08a, // phosphor + disc: 0x6fd7ff, // iridescent + stream: 0xdfe6f2, // flat plastic white + fortress: 0xf2f7ff, // blinding +}; diff --git a/fktry/src/render/ghost.ts b/fktry/src/render/ghost.ts index d3bdb61..ecb960b 100644 --- a/fktry/src/render/ghost.ts +++ b/fktry/src/render/ghost.ts @@ -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(); 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(); + /** Skip the per-frame occupancy walk entirely when no machine in data can cool. */ + private anyCoolers = false; - constructor(private registry: AssetRegistry, private defs: Map) { + constructor( + private registry: AssetRegistry, + private defs: Map, + 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(); } /** diff --git a/fktry/src/render/index.ts b/fktry/src/render/index.ts index a87fe4e..ba3d784 100644 --- a/fktry/src/render/index.ts +++ b/fktry/src/render/index.ts @@ -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); }, }; } diff --git a/fktry/src/render/registry.ts b/fktry/src/render/registry.ts index 04c2a9a..a543c93 100644 --- a/fktry/src/render/registry.ts +++ b/fktry/src/render/registry.ts @@ -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]; diff --git a/fktry/src/render/showroom.ts b/fktry/src/render/showroom.ts index e150214..ecce169 100644 --- a/fktry/src/render/showroom.ts +++ b/fktry/src/render/showroom.ts @@ -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. */