[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,
|
gen: 20,
|
||||||
draw: brownout ? 26 : 14,
|
draw: brownout ? 26 : 14,
|
||||||
stored: (0.5 + 0.5 * Math.sin(tick / 60)) * 100,
|
stored: (0.5 + 0.5 * Math.sin(tick / 60)) * 100,
|
||||||
|
capacity: 100,
|
||||||
brownout,
|
brownout,
|
||||||
},
|
},
|
||||||
shippedTotal: { melt: Math.floor(tick / 90) },
|
shippedTotal: { melt: Math.floor(tick / 90) },
|
||||||
activeCommission: null,
|
activeCommission: null,
|
||||||
commissionProgress: {},
|
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;
|
let anyAlarm = false;
|
||||||
|
|
||||||
// Tanks share one bandwidth pool, so they all read the same level: the pool's
|
// Tanks share one bandwidth pool, so they all read the same level: the pool's
|
||||||
// fraction of total installed capacity.
|
// fraction of total installed capacity. v4 publishes that total, so take it from
|
||||||
let cap = 0;
|
// truth rather than re-summing bufferCap here — a second copy of the sim's
|
||||||
for (const e of snap.entities) {
|
// arithmetic is exactly what the scram latch taught us not to keep.
|
||||||
const d = this.defs.get(e.def);
|
let cap = snap.bandwidth.capacity ?? 0;
|
||||||
if (d?.kind === 'buffer') cap += d.bufferCap ?? DEFAULT_BUFFER_CAP;
|
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;
|
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.roughness = skin.roughness;
|
||||||
mat.metalness = skin.metalness;
|
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
|
* LANE-RENDER — build ghost, remove-mode hover, and cooling-aura annotation.
|
||||||
* hovered tile: cyan when it fits, red when its footprint collides with something
|
|
||||||
* already placed.
|
|
||||||
*
|
*
|
||||||
* Occupancy is read straight off the snapshot's entity footprints — the sim still gets
|
* The ghost answers one question: what happens if I click here?
|
||||||
* the final say when the place command lands; this is only the player's preview.
|
* 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
|
* That third state is why this reads `research`: the sim silently drops a `place` for a
|
||||||
* (see remove.ts). The sentinel is the channel because UI holds no Renderer reference —
|
* locked def, so without a tell the click just does nothing and reads as a bug. Amber is
|
||||||
* its only view of what the player is holding is what main.ts feeds `setGhost` from
|
* deliberately not red — "not yet" and "not here" are different sentences.
|
||||||
* `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.
|
* 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 * 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 { AssetRegistry } from './registry';
|
||||||
import { centerOf, rotatedFootprint, tileKey, tilesOf, yawFor } from './coords';
|
import { centerOf, rotatedFootprint, tileKey, tilesOf, yawFor } from './coords';
|
||||||
import { idleOf, type IdleFn } from './registry';
|
import { idleOf, type IdleFn } from './registry';
|
||||||
import { WORLD_HALF } from './camera';
|
import { WORLD_HALF } from './camera';
|
||||||
import { HEIGHT_BY_KIND } from './palette';
|
import { HEIGHT_BY_KIND } from './palette';
|
||||||
import { RemoveHover } from './remove';
|
import { RemoveHover } from './remove';
|
||||||
|
import { CoolAura } from './aura';
|
||||||
|
import { ERA_TELL } from './era';
|
||||||
|
|
||||||
const VALID = 0x3fd4ff;
|
const VALID = 0x3fd4ff;
|
||||||
const INVALID = 0xff3b30;
|
const INVALID = 0xff3b30;
|
||||||
|
const LOCKED = 0xffb02e;
|
||||||
/**
|
|
||||||
* 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';
|
|
||||||
|
|
||||||
function ghostMaterial(color: number): THREE.MeshStandardMaterial {
|
function ghostMaterial(color: number): THREE.MeshStandardMaterial {
|
||||||
return new 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 {
|
export class GhostLayer {
|
||||||
readonly group = new THREE.Group();
|
readonly group = new THREE.Group();
|
||||||
private obj: THREE.Object3D | null = null;
|
private obj: THREE.Object3D | null = null;
|
||||||
@ -59,22 +81,47 @@ export class GhostLayer {
|
|||||||
private occupied = new Map<number, EntityState>();
|
private occupied = new Map<number, EntityState>();
|
||||||
private matValid = ghostMaterial(VALID);
|
private matValid = ghostMaterial(VALID);
|
||||||
private matInvalid = ghostMaterial(INVALID);
|
private matInvalid = ghostMaterial(INVALID);
|
||||||
|
private matLocked = ghostMaterial(LOCKED);
|
||||||
readonly remove = new RemoveHover();
|
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.name = 'ghost';
|
||||||
this.group.renderOrder = 10;
|
this.group.renderOrder = 10;
|
||||||
this.group.add(this.remove.group);
|
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 {
|
set(defId: string | null, pos: Vec2 | null, dir: Dir, mode: 'build' | 'remove' = 'build'): void {
|
||||||
this.removing = defId === REMOVE_DEF;
|
this.removing = mode === 'remove';
|
||||||
|
this.pos = pos;
|
||||||
|
this.dir = dir;
|
||||||
|
|
||||||
if (this.removing) {
|
if (this.removing) {
|
||||||
// The build ghost stays hidden; update() resolves what's under the cursor.
|
this.def = null; // the sentinel def is ignored: mode is the truth
|
||||||
this.def = null;
|
|
||||||
this.pos = pos;
|
|
||||||
this.dir = dir;
|
|
||||||
if (this.obj) this.obj.visible = false;
|
if (this.obj) this.obj.visible = false;
|
||||||
|
this.padlock.obj.visible = false;
|
||||||
this.group.visible = true;
|
this.group.visible = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -82,11 +129,7 @@ export class GhostLayer {
|
|||||||
|
|
||||||
const def = defId ? this.defs.get(defId) ?? null : null;
|
const def = defId ? this.defs.get(defId) ?? null : null;
|
||||||
this.def = def;
|
this.def = def;
|
||||||
this.pos = def && pos ? pos : null;
|
if (!def || !pos) {
|
||||||
this.dir = dir;
|
|
||||||
if (this.obj) this.obj.visible = true;
|
|
||||||
|
|
||||||
if (!def || !this.pos) {
|
|
||||||
this.group.visible = false;
|
this.group.visible = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -95,6 +138,7 @@ export class GhostLayer {
|
|||||||
this.group.visible = false;
|
this.group.visible = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (this.obj) this.obj.visible = true;
|
||||||
if (!this.obj || this.objDef !== def.id || this.objVersion !== entry.version) {
|
if (!this.obj || this.objDef !== def.id || this.objVersion !== entry.version) {
|
||||||
this.rebuild(def);
|
this.rebuild(def);
|
||||||
this.objDef = def.id;
|
this.objDef = def.id;
|
||||||
@ -133,31 +177,65 @@ export class GhostLayer {
|
|||||||
this.obj = null;
|
this.obj = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
update(snap: SimSnapshot, timeSec: number): void {
|
/**
|
||||||
if (!this.group.visible) return;
|
* v4 gating rule, verbatim: "a machine/recipe id that appears in ANY TechDef.unlocks
|
||||||
if (!this.removing && (!this.def || !this.pos || !this.obj)) return;
|
* 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();
|
update(snap: SimSnapshot, timeSec: number): void {
|
||||||
for (const e of snap.entities) {
|
if (!this.pos) {
|
||||||
const d = this.defs.get(e.def);
|
this.aura.hide();
|
||||||
if (!d) continue;
|
this.remove.hide();
|
||||||
for (const t of tilesOf(e.pos, d, e.dir)) this.occupied.set(tileKey(t.x, t.y), e);
|
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) {
|
if (this.removing) {
|
||||||
this.updateRemove(timeSec);
|
this.updateRemove(timeSec);
|
||||||
return;
|
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 want = tilesOf(this.pos, this.def, this.dir);
|
||||||
const ok = want.every(
|
const fits = want.every(
|
||||||
(t) =>
|
(t) =>
|
||||||
!this.occupied.has(tileKey(t.x, t.y)) &&
|
!this.occupied.has(tileKey(t.x, t.y)) &&
|
||||||
t.x >= -WORLD_HALF && t.x < WORLD_HALF &&
|
t.x >= -WORLD_HALF && t.x < WORLD_HALF &&
|
||||||
t.y >= -WORLD_HALF && t.y < 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) => {
|
this.obj.traverse((o) => {
|
||||||
const m = o as THREE.Mesh;
|
const m = o as THREE.Mesh;
|
||||||
if (m.isMesh) m.material = mat;
|
if (m.isMesh) m.material = mat;
|
||||||
@ -167,6 +245,43 @@ export class GhostLayer {
|
|||||||
this.obj.position.y += 0.02;
|
this.obj.position.y += 0.02;
|
||||||
this.obj.rotation.y = yawFor(this.dir);
|
this.obj.rotation.y = yawFor(this.dir);
|
||||||
this.idle?.(timeSec);
|
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);
|
belts = new BeltLayer(registry, defs, topo);
|
||||||
entities = new EntityLayer(registry, defs);
|
entities = new EntityLayer(registry, defs);
|
||||||
cargo = new BeltItemLayer(data, topo);
|
cargo = new BeltItemLayer(data, topo);
|
||||||
ghost = new GhostLayer(registry, defs);
|
ghost = new GhostLayer(registry, defs, data);
|
||||||
scene.add(belts.group, entities.group, cargo.group, ghost.group);
|
scene.add(belts.group, entities.group, cargo.group, ghost.group, ghost.aura.group);
|
||||||
|
|
||||||
debug = devSceneEnabled() || showroomEnabled();
|
debug = devSceneEnabled() || showroomEnabled();
|
||||||
|
|
||||||
@ -202,8 +202,8 @@ export function createRenderer(): Renderer {
|
|||||||
return { x, y };
|
return { x, y };
|
||||||
},
|
},
|
||||||
|
|
||||||
setGhost(def: string | null, pos: Vec2 | null, dir: Dir) {
|
setGhost(def: string | null, pos: Vec2 | null, dir: Dir, mode: 'build' | 'remove' = 'build') {
|
||||||
ghost?.set(def, pos, dir);
|
ghost?.set(def, pos, dir, mode);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -163,6 +163,29 @@ function makePlaceholder(def: MachineDef, accent: number, body_: number, era: Er
|
|||||||
}) satisfies IdleFn;
|
}) satisfies IdleFn;
|
||||||
break;
|
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': {
|
case 'splitter': {
|
||||||
// Output-cycling tell: a lamp that sweeps across the outputs (codex: round-robin).
|
// 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);
|
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
|
* The machine's ONE emissive accent: `MachineDef.accent` (v4) when art-directed,
|
||||||
* -> ItemDef.color), falling back to a per-kind accent.
|
* 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
|
* `MachineDef.color` is NOT read here — v3 ruled it the chassis; see `bodyFor` below.
|
||||||
* 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".
|
|
||||||
*
|
*
|
||||||
* The derived path has a guard: raw products are near-black by design (MDAT ORE is
|
* 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
|
* #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).
|
* through to the kind accent (for the extractor, CRT blue, as the codex describes it).
|
||||||
*/
|
*/
|
||||||
function accentFor(def: MachineDef, data: GameData): number {
|
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) {
|
for (const rid of def.recipes) {
|
||||||
const recipe = data.recipes.find((r) => r.id === rid);
|
const recipe = data.recipes.find((r) => r.id === rid);
|
||||||
const outId = recipe && Object.keys(recipe.outputs)[0];
|
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. */
|
/** Machines face the camera (dir 2 = S = +z), which is where the iso rig looks from. */
|
||||||
const FACING: Dir = 2;
|
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_SPACING = 6.5;
|
||||||
const M_Z0 = -12;
|
const M_Z0 = -24;
|
||||||
const I_COLS = 12;
|
|
||||||
const I_SPACING = 3.5;
|
const I_SPACING = 3.5;
|
||||||
const I_GAP = 7; // between the two halls
|
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;
|
const PLINTH_H = 0.05;
|
||||||
|
|
||||||
interface Slot {
|
interface Slot {
|
||||||
@ -64,7 +69,6 @@ export class Showroom {
|
|||||||
private build(): void {
|
private build(): void {
|
||||||
let id = 1;
|
let id = 1;
|
||||||
const mCentre = ((M_COLS - 1) * M_SPACING) / 2;
|
const mCentre = ((M_COLS - 1) * M_SPACING) / 2;
|
||||||
const iCentre = ((I_COLS - 1) * I_SPACING) / 2;
|
|
||||||
|
|
||||||
// --- machines: fabricated entities, rendered by the real EntityLayer -------------
|
// --- machines: fabricated entities, rendered by the real EntityLayer -------------
|
||||||
this.data.machines.forEach((def, i) => {
|
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;
|
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 ----------------------------
|
// --- items: built from the belt layer's own factories ----------------------------
|
||||||
this.data.items.forEach((def, i) => {
|
this.data.items.forEach((def, i) => {
|
||||||
const cx = -iCentre + (i % I_COLS) * I_SPACING;
|
const cx = -iCentre + (i % iCols) * I_SPACING;
|
||||||
const cz = itemZ0 + Math.floor(i / I_COLS) * I_SPACING;
|
const cz = itemZ0 + Math.floor(i / iCols) * I_SPACING;
|
||||||
const mesh = new THREE.Mesh(itemGeometry(def), itemMaterial(def));
|
const mesh = new THREE.Mesh(itemGeometry(def), itemMaterial(def));
|
||||||
mesh.position.set(cx, PLINTH_H + 0.35, cz);
|
mesh.position.set(cx, PLINTH_H + 0.35, cz);
|
||||||
mesh.castShadow = true;
|
mesh.castShadow = true;
|
||||||
@ -102,12 +124,12 @@ export class Showroom {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Preset frames the MACHINE hall: assets are what this page exists to inspect, and
|
// Preset frames the MACHINE hall: assets are what this page exists to inspect, and a
|
||||||
// a view wide enough to include all 51 items renders every machine too small to
|
// view wide enough to include every item renders each machine too small to judge.
|
||||||
// judge. The item hall is one pan south (WASD / drag).
|
// The item hall is one pan south (WASD / drag).
|
||||||
const mRows = Math.ceil(this.data.machines.length / M_COLS);
|
const mRows = Math.ceil(this.data.machines.length / M_COLS);
|
||||||
this.view.z = M_Z0 + ((mRows - 1) * M_SPACING) / 2;
|
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. */
|
/** All machines, standing still. Items live in `decor`, not the snapshot. */
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user