Compare commits

...

2 Commits

Author SHA1 Message Date
type-two
c97dd80a9b [render] round 4 NOTES
Verification numbers; the lock-rule divergence with UI (they were right, I was
wrong — the scrammed lesson misapplied); the showroom overflow postmortem; one
contract request (an inspected-entity channel for placed-and-selected auras).

Notes that round 3's MODELBEAST manifest is stale — ?showroom is the live one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:12:04 +10:00
type-two
5e8e49ef0e [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>
2026-07-17 20:12:04 +10:00
9 changed files with 449 additions and 68 deletions

View File

@ -422,3 +422,97 @@ Cheapest real fixes, in order:
- Real MODELBEAST assets through `?showroom`; splitter lamp driven by real round-robin
state instead of a timer; bloom-loop belt tell; HF dust piles; wildlife; save/load
camera state; LOD/culling if a megabase ever drags the frame.
### Round 4 — 2026-07-17 — Opus 4.8
**SHIPPED** — all 4 orders + housekeeping, verified against LANE-DATA's real v4 data
(which landed mid-round: 29 machines, 60 items, `archaeology-lab`, `asic-cooler
coolRadius:2`, `accent` on 8 machines) and UI's real migrated flow.
1. **Ghost keys off `mode`.** The `'__remove'` sentinel is ignored — mode is the truth,
and the `REMOVE_DEF` constant is deleted from this lane. Verified through UI's own
REMOVE tab: `selection()``{mode:'remove'}`, demolition decal + wash on the mosh
reactor's whole 3×3.
2. **Locked-machine ghost.** Padlock-amber `#ffb02e` — deliberately not the occupied-red,
because "not yet" and "not here" are different sentences. A padlock floats above,
tinted by the era gating it. Verified: `telecine-puller` → amber ghost + `#d8a24a`
reel brass; `asic-cooler` → amber + `#5fd08a` broadcast phosphor.
- **Era tell needed its own palette.** `ERA_SKIN.tint` can't do this job: it's a
multiplier tuned to leave DATA's chassis alone (stream is literally `#ffffff`), so
three of five eras would have told you nothing. Added `ERA_TELL` — five hues chosen
to be told apart, used as signal rather than surface.
3. **Cooler auras.** Verified on `asic-cooler` (fp 2×2, coolRadius 2) → **6×6 tiles**,
centred on the ghost, dashed + tick-marked, no fill.
- **It's a rectangle, not a ring.** `coolRadius` is CHEBYSHEV from the footprint, so
the cooled region is the footprint grown by r on each side. A circle would have been
prettier and would have lied — the corners it left out are cooled, and players would
plan around a boundary that doesn't exist. Drawn in the geometry the sim measures in.
4. **Lab visuals.** `archaeology-lab` (kind `lab`, 3×3) renders as a bench + the "bottled
artifact triptych" (§2) — three out-of-phase pulsing bottles. Verified geometry
`Box,Cylinder,Cylinder,Cylinder` and **`accent` consumed from data**: bottles emissive
`#ffc24a`, exactly DATA's authored value.
5. **Housekeeping.** Splitter lamp left timer-driven as approved. Showroom absorbed the
new catalog — see the bug below.
**VERIFIED**
- Perf standing rule holds: 500 entities + 2,000 items at **3.6ms/frame**, 5 instanced
draw calls. (R1 3.2 / R2 3.6 / R3 3.3 / R4 3.6 — auras, padlocks and the locked path
cost nothing measurable.)
- Showroom absorbs 29 machines + 60 items, all five eras present, hall inside the world.
**DECISIONS**
- **Matched LANE-UI's lock rule rather than my own, and it was a real divergence.** My
first cut treated absent `research` as "nothing is locked" — don't lie when the field
isn't wired. UI's `research.ts` reads the same rule the opposite way: nothing has
completed, so everything gated shows its padlock. **UI is right and I was wrong**: this
is 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". Left unresolved, the build bar would have padlocked a
machine while the ghost waved it through — on the same screen. Also matched their
first-tech-wins tie-break for ids unlocked by two techs: a data bug either way, but we
must both blame the same tech.
- **Buffer fill now reads `bandwidth.capacity`** instead of re-summing `bufferCap`
locally. v4 publishes the total; a second copy of the sim's arithmetic in this lane is
exactly what the scram latch taught us not to keep. Falls back to the local sum only
while the field is absent.
- **`MachineDef.accent` wins outright, no legibility floor** — same reasoning as
`def.color`: explicit art-direction is a decision; the floor exists for *derived*
colours, which are incidental.
**BLOCKED/BROKEN** — nothing blocking.
- **The showroom bug I predicted last round arrived, and my fix was half a fix.** Round 3
noted the item hall would overflow when the catalog grew. It did (51 → 60 items), but
the failure wasn't the one I expected: the *machine* hall grew to 5 rows, ate the depth
budget, collapsed the item grid to **one row of 60 columns — a 206-tile row across a
64-tile world**. My round-3 "adaptive" fit budgeted depth only. Now fitted to both axes,
and it `console.warn`s if the catalog ever exceeds the hall rather than silently
stacking items off-world. Lesson: an auto-fit that constrains one axis just moves the
overflow to the other one.
- **"Placed-and-selected" auras aren't reachable** (order 3's third case). `SelectionState`
is `build | remove | null` — UI's click-to-inspect selection has no contract channel, so
a placed cooler rings on **hover**, not on inspect. Hover covers the same intent for
now; see CONTRACT REQUEST 1.
- Two tests fail repo-wide (`sim/reference.test.ts`, `ui/live.test.ts`) — both SIM/UI
in-flight research work, neither imports `src/render`. tsc is clean repo-wide.
**CONTRACT REQUEST**
1. **An inspected-entity channel**, if you want order 3's "placed-and-selected" case for
real. Cheapest shape: extend `SelectionState` with `{ mode: 'inspect'; entity: number }`,
or add `UIBus.inspected?(): number | null`. Renderer has no other way to know what the
player clicked. Low priority — hover already answers "what does this cool?".
2. Nothing else. v4 granted round 3's `setGhostMode` ask (as `setGhost(..., mode)`) and it
landed clean; the sentinel bridge can die in v5 whenever you like — this lane no longer
reads it.
**NOTE FOR THE MODELBEAST ORDER** — the manifest in round 3's NOTES is now stale: DATA
added 6 machines (`roto-bench`, `matte-bath`, `optical-printer`, `chroma-keyer`,
`neural-keyer`, `archaeology-lab`) and the tech tree now gates 13 across all five eras.
`?showroom` is the live manifest — it labels every machine with its exact `<asset>.glb`
filename and era, and re-reads data on load, so it can't go stale the way a table in a
doc does. Read it there rather than from round 3.
**NEXT (round 5 candidates, not self-assigned)**
- Real MODELBEAST assets through `?showroom`; heat-shimmer suppression reading as
*relief* inside a cooler's rectangle once SIM lands spatial cooling; bloom-loop belt
tell; HF dust piles; wildlife; save/load camera state; LOD/culling if a megabase ever
drags the frame.

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. */