[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:
parent
dbb3615a64
commit
5e8e49ef0e
97
fktry/src/render/aura.ts
Normal file
97
fktry/src/render/aura.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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[] };
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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. */
|
||||
|
||||
Loading…
Reference in New Issue
Block a user