[sim] round 3 NOTES

Confirms the units conversion (bufferCap 240 = 8.0s of cover at a 30/s
deficit) and reports scram survival: first scram t434, restart t934,
~718-tick duty cycle, ZERO brownout ticks across 20k, melt rate unchanged.

For the orchestrator:
- heat semantics need a one-line ruling. heatPerTick is GROSS in my model
  (net = heatPerTick - coolPerTick). DATA's committed 0.0025 against the
  0.004 default nets negative -- the decoder could never scram. Their
  in-flight 0.0033/0.001 works, so we agree by luck of timing.
- the codex's literal bloom loop is not expressible with per-grade recipes
- asic-cooler does nothing without a spatial heat mechanic
- my sim code landed inside 45003db [ui] REMOVE mode: the shared tree has
  one git index and an add-everything commit in another session swept it.
  Not a UI ownership violation. Suggest a no-`git add -A` rule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
LANE-SIM 2026-07-17 18:35:47 +10:00
parent 34e8b39fed
commit d1c3f2b09d
11 changed files with 583 additions and 86 deletions

View File

@ -348,6 +348,15 @@ BLOCKED/BROKEN
ids. Either accept the escalator reading, or that's a schema change.
- Ownership wart is gone: `data/validate.test.ts` is staged by DATA and `src/sim/**` is purely
mine again. Thank you.
- **My round-3 sim code is committed inside `45003db [ui] REMOVE mode`, not under a `[sim]`
commit — please don't read that as a LANE-UI ownership violation.** All five lanes share one
working tree and one index. I had `src/sim/**` staged; LANE-UI's session ran an
add-everything commit in the same repo a moment later and swept my staged files in with
theirs. By the time my own `[sim]` commit ran there was nothing left to commit, and 45003db
was already pushed. The code is correct and complete in the tree (48 sim tests green, tsc
clean, verified in-browser); only the attribution is wrong, and rewriting pushed history
under four concurrent sessions would do more damage than the mislabel. Flagging it so your
per-lane diff doesn't fire. See PROPOSAL.
CONTRACT REQUEST
- None. v3 covered everything.
@ -360,6 +369,12 @@ PROPOSAL
with leftover sims and got anywhere from 59× to 12× for the *same* build; a fresh page gives
74.9× consistently across three runs. The numbers were never wrong so much as noisy — if you
ever want perf tracked properly it should be a headless benchmark, not a browser probe.
- **The shared working tree needs a commit rule.** Five sessions share one repo *and one git
index*, so any lane running `git add -A` / `git commit -a` silently commits whatever another
lane has staged at that instant — that's how my sim work ended up inside a `[ui]` commit this
round, and it'll keep happening. Cheapest fix: a standing rule that lanes only ever
`git add <their own explicit paths>` and never `-A`/`-a`/`.`. Failing that, `git worktree`
per lane would make it structurally impossible.
NEXT (round 4 if asked)
- HF dust piles + mosquito swarms — the seeded RNG is still unrolled, and save/load already

View File

@ -7,10 +7,8 @@
* fixed-timestep interpolation. One tick of latency, and no extrapolation jitter when
* an item is blocked by back-pressure (prev == cur -> it simply sits still).
*
* Identity: contracts v2 added `BeltItem.id`. When present we key off it and the
* interpolation is EXACT. When absent we fall back to matching by (belt, type, order
* along the belt) correct except for the one tick where the lead item leaves and the
* queue behind it shifts index. That fallback goes away when v3 makes `id` required.
* Identity: contracts v3 makes `BeltItem.id` required, so interpolation is EXACT and
* the old order-matching fallback (and its per-frame sort of every item) is gone.
*
* Pathing: cargo follows the belt's topology shape, so on a corner it enters at the
* feeding edge and sweeps the arc rather than teleporting to the back edge and cutting
@ -29,8 +27,13 @@ interface ItemMesh {
capacity: number;
}
/** Products glow; raw ore doesn't — saturation belongs to media (style guide §8). */
function itemMaterial(def: ItemDef): THREE.MeshStandardMaterial {
/**
* Products glow; raw ore doesn't saturation belongs to media (style guide §8).
* The base colour goes through `readable()` because MDAT ORE's authored #1a1a22 is
* invisible against the belt; ore still reads as the darkest thing on the line, just
* not as a hole in the world.
*/
export function itemMaterial(def: ItemDef): THREE.MeshStandardMaterial {
return new THREE.MeshStandardMaterial({
color: readable(def.color),
emissive: new THREE.Color(def.color),
@ -40,7 +43,8 @@ function itemMaterial(def: ItemDef): THREE.MeshStandardMaterial {
});
}
function itemGeometry(def: ItemDef): THREE.BufferGeometry {
export function itemGeometry(def: ItemDef): THREE.BufferGeometry {
// Products read as faceted crystals; raw/refined as crate-ish boxes.
return def.tier >= 2
? new THREE.OctahedronGeometry(ITEM_SIZE * 0.7)
: new THREE.BoxGeometry(ITEM_SIZE, ITEM_SIZE * 0.8, ITEM_SIZE);
@ -50,17 +54,15 @@ export class BeltItemLayer {
readonly group = new THREE.Group();
private byItem = new Map<string, ItemMesh>();
private items = new Map<string, ItemDef>();
private prev = new Map<string, number>();
private cur = new Map<string, number>();
/** Keyed by BeltItem.id — stable across the belt-to-belt handoff. */
private prev = new Map<number, number>();
private cur = new Map<number, number>();
private lastTick = -1;
private dummy = new THREE.Object3D();
private counts = new Map<string, number>();
private seq = new Map<string, number>();
private sorted: SimSnapshot['beltItems'][number][] = [];
private written = new Map<string, number>();
private lp = { x: 0, z: 0 };
private wp = { x: 0, z: 0 };
/** Set once per frame so NOTES/telemetry can report which identity path ran. */
exactIds = false;
constructor(data: GameData, private topo: BeltTopology) {
this.group.name = 'beltItems';
@ -71,6 +73,7 @@ export class BeltItemLayer {
const existing = this.byItem.get(def.id);
if (existing && needed <= existing.capacity) return existing;
const capacity = Math.max(128, 1 << Math.ceil(Math.log2(Math.max(1, needed))));
// Re-use geo/mat across growth; only the InstancedMesh itself is replaced.
const geometry = existing?.mesh.geometry ?? itemGeometry(def);
const material = (existing?.mesh.material as THREE.Material) ?? itemMaterial(def);
if (existing) {
@ -89,17 +92,7 @@ export class BeltItemLayer {
}
sync(snap: SimSnapshot, alpha: number, timeSec: number): void {
const src = snap.beltItems;
// Sim is internally consistent: if it stamps ids, it stamps all of them.
this.exactIds = src.length > 0 && src[0].id !== undefined;
this.sorted.length = 0;
for (const bi of src) if (this.topo.get(bi.entity)) this.sorted.push(bi);
if (!this.exactIds) {
// Order-match fallback needs cargo ordered front-to-back per belt.
this.sorted.sort((a, b) => (a.entity !== b.entity ? a.entity - b.entity : b.t - a.t));
}
// Roll the tick window: cur becomes prev the moment the sim advances.
const advanced = snap.tick !== this.lastTick;
if (advanced) {
const swap = this.prev;
@ -109,12 +102,12 @@ export class BeltItemLayer {
this.lastTick = snap.tick;
}
// Pass 1 — count per type, and record this tick's positions for the next frame.
// Pass 1 — count per item type, and record this tick's positions for next frame.
this.counts.clear();
this.seq.clear();
for (const bi of this.sorted) {
for (const bi of snap.beltItems) {
if (!this.topo.get(bi.entity)) continue; // cargo on a belt we haven't seen yet
this.counts.set(bi.item, (this.counts.get(bi.item) ?? 0) + 1);
if (advanced) this.cur.set(this.keyOf(bi), bi.t);
if (advanced) this.cur.set(bi.id, bi.t);
}
for (const [itemId, count] of this.counts) {
const def = this.items.get(itemId);
@ -122,15 +115,15 @@ export class BeltItemLayer {
}
// Pass 2 — write instance matrices.
this.seq.clear();
const written = new Map<string, number>();
for (const bi of this.sorted) {
const node = this.topo.get(bi.entity)!;
this.written.clear();
for (const bi of snap.beltItems) {
const node = this.topo.get(bi.entity);
if (!node) continue;
const def = this.items.get(bi.item);
const im = this.byItem.get(bi.item);
if (!def || !im) continue;
const from = this.prev.get(this.keyOf(bi));
const from = this.prev.get(bi.id);
const t = from === undefined ? bi.t : from + (bi.t - from) * alpha;
localPath(node.shape, t, this.lp);
@ -143,25 +136,16 @@ export class BeltItemLayer {
}
this.dummy.updateMatrix();
const n = written.get(bi.item) ?? 0;
const n = this.written.get(bi.item) ?? 0;
if (n < im.capacity) {
im.mesh.setMatrixAt(n, this.dummy.matrix);
written.set(bi.item, n + 1);
this.written.set(bi.item, n + 1);
}
}
for (const [id, im] of this.byItem) {
im.mesh.count = written.get(id) ?? 0;
im.mesh.count = this.written.get(id) ?? 0;
im.mesh.instanceMatrix.needsUpdate = true;
}
}
/** Exact when the sim stamps ids; order-matched otherwise. */
private keyOf(bi: SimSnapshot['beltItems'][number]): string {
if (bi.id !== undefined) return `#${bi.id}`;
const sk = `${bi.entity}:${bi.item}`;
const idx = this.seq.get(sk) ?? 0;
this.seq.set(sk, idx + 1);
return `${bi.entity}:${bi.item}:${idx}`;
}
}

View File

@ -150,6 +150,14 @@ export class CameraRig {
this.applyFrustum();
}
/** Jump the rig to frame a specific spot — the showroom's camera preset. */
frame(x: number, z: number, zoom: number): void {
this.target.set(x, 0, z);
this.zoomD = THREE.MathUtils.clamp(zoom, ZOOM_MIN, ZOOM_MAX);
this.applyFrustum();
this.place();
}
dispose(): void {
for (const d of this.disposers) d();
this.disposers = [];

View File

@ -43,6 +43,8 @@ export class DevScene {
private speed = 2;
private ok = false;
private nextId = 1;
/** The mock's own scram latch — see snapshot(). */
private scrammed = false;
constructor(private data: GameData, private stress = false) {
this.build();
@ -132,9 +134,17 @@ export class DevScene {
// so all three states are reachable in a short verification session.
const heat = Math.min(1, (tick % 300) / 200);
const brownout = Math.floor(tick / 300) % 4 === 3;
// The mock owns its own latch, the way the real sim owns its own: scram at 1.0 and
// stay dead while cooling. The renderer must read `scrammed` and never infer it —
// that's the whole point of the v3 field.
if (heat >= 1) this.scrammed = true;
else if (heat < 0.5) this.scrammed = false;
for (const e of this.entities) {
if (e.def === 'software-decoder') e.heat = heat;
if (e.def === 'software-decoder') {
e.heat = heat;
e.scrammed = this.scrammed;
}
if (e.def === 'demuxer') e.jammed = Math.floor(tick / 450) % 3 === 2 ? 'output full: LUMA BARS' : null;
if (e.recipe) e.progress = (tick % 90) / 90;
}

View File

@ -21,19 +21,6 @@ import { createHeatHaze, type HeatHaze } from './heat';
/** Heat is throttled above this (contracts: "throttles >0.7, scrams at 1"). */
const THROTTLE_AT = 0.7;
const THROTTLE_FLOOR = 0.25;
/**
* Scram releases below this mirroring LANE-SIM's `HEAT_RESTART`.
*
* Scram is a LATCH in the sim, but it lives on its internal `Ent`, never on
* `EntityState`; it clears `jammed` on scram ("reported by its own event, not as a flow
* jam"), and `Renderer` only ever receives snapshots, never events. So the latched-off
* state is not snapshot-visible, and a plain `heat >= 1` test un-scrams the moment the
* machine starts cooling showing it alive and glowing for the ~125 ticks (~4s) it
* spends dead between 1.0 and HEAT_RESTART. Mirroring the sim's hysteresis here is
* correct today but couples this constant to theirs; `EntityState.scrammed` would kill
* the duplication. Filed as a CONTRACT REQUEST.
*/
const SCRAM_RELEASE = 0.5;
/** Used when LANE-DATA hasn't set `bufferCap` yet — tanks still read as tanks. */
const DEFAULT_BUFFER_CAP = 100;
const HEAT_RED = new THREE.Color(0xff3a12);
@ -65,8 +52,6 @@ interface Rec {
idleClock: number;
heat: HeatTarget[];
all: HeatTarget[];
/** Latched scram state, mirroring the sim's (see SCRAM_RELEASE). */
scramLatch: boolean;
/** Whether dead-dark is currently applied, so restart knows to restore. */
wasScrammed: boolean;
}
@ -124,10 +109,12 @@ export class EntityLayer {
}
const heat = THREE.MathUtils.clamp(e.heat ?? 0, 0, 1);
// Latch with hysteresis, exactly as the sim does — see SCRAM_RELEASE.
if (heat >= 1) rec.scramLatch = true;
else if (heat < SCRAM_RELEASE) rec.scramLatch = false;
const scrammed = rec.scramLatch;
// v3: scram comes from the sim's own latch. `heat` alone cannot express the
// hysteresis window — a machine stays dead while cooling from 1.0 back down —
// so deriving it here meant duplicating the sim's restart threshold and silently
// desyncing the day they retuned it. The `?? heat >= 1` is only a bridge for a
// sim that hasn't wired the field yet; it carries no local constant.
const scrammed = e.scrammed ?? heat >= 1;
// Throttle ramps in rather than snapping, so "labouring" reads before "dead".
const speed = scrammed
? 0
@ -258,7 +245,6 @@ export class EntityLayer {
idleClock: 0,
heat,
all,
scramLatch: false,
wasScrammed: false,
};
this.live.set(id, rec);

58
fktry/src/render/era.ts Normal file
View File

@ -0,0 +1,58 @@
/**
* LANE-RENDER era skins (style guide §8).
*
* "Reel Beds = sepia/brass/celluloid; Broadcast = phosphor green + wood-grain TV;
* Disc = rainbow-on-black gloss; Stream = flat white plastic + LED; Fortress =
* blinding matte white."
*
* Plumbing only this round. A machine's era is DERIVED from which tech unlocks it, so
* when LANE-DATA adds reel/broadcast techs those machines re-skin with zero renderer
* work. Machines no tech unlocks are starting equipment and take the default.
*
* The skin touches the CHASSIS only the emissive accent stays the colour of what the
* machine makes, because that's the resource, not the decade.
*
* `stream` is deliberately the identity skin (same roughness/metalness as the base body
* material, no tint), so today's look is byte-for-byte unchanged until data says otherwise.
*/
import * as THREE from 'three';
import type { GameData, TechDef } from '../contracts';
export type Era = TechDef['era'];
export const DEFAULT_ERA: Era = 'stream';
export interface EraSkin {
/** Multiplied into the chassis colour — 0xffffff leaves DATA's value alone. */
tint: number;
roughness: number;
metalness: number;
}
export const ERA_SKIN: Record<Era, EraSkin> = {
reel: { tint: 0xffd2a0, roughness: 0.65, metalness: 0.45 }, // sepia/brass/celluloid
broadcast: { tint: 0xc4e8c4, roughness: 0.8, metalness: 0.1 }, // phosphor + woodgrain
disc: { tint: 0x9aa0b8, roughness: 0.2, metalness: 0.65 }, // rainbow-on-black gloss
stream: { tint: 0xffffff, roughness: 0.85, metalness: 0.15 }, // identity — the default
fortress: { tint: 0xffffff, roughness: 0.95, metalness: 0.0 }, // blinding matte white
};
/**
* machine id -> era, via the tech that unlocks it. Anything unmapped is starting
* equipment (no tech gates it) and takes DEFAULT_ERA.
*/
export function eraIndex(data: GameData): Map<string, Era> {
const out = new Map<string, Era>();
const machines = new Set(data.machines.map((m) => m.id));
for (const t of data.tech) {
for (const u of t.unlocks) if (machines.has(u)) out.set(u, t.era);
}
return out;
}
/** Skin a chassis material in place. Accents must never be passed here. */
export function applyEraSkin(mat: THREE.MeshStandardMaterial, era: Era): void {
const skin = ERA_SKIN[era] ?? ERA_SKIN[DEFAULT_ERA];
mat.color.multiply(new THREE.Color(skin.tint));
mat.roughness = skin.roughness;
mat.metalness = skin.metalness;
}

View File

@ -1,20 +1,37 @@
/**
* LANE-RENDER build ghost. 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, 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.
*
* 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.
*
* 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.
*/
import * as THREE from 'three';
import type { Dir, MachineDef, SimSnapshot, Vec2 } from '../contracts';
import type { Dir, EntityState, MachineDef, SimSnapshot, Vec2 } from '../contracts';
import { AssetRegistry } from './registry';
import { centerOf, tileKey, tilesOf, yawFor } from './coords';
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';
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';
function ghostMaterial(color: number): THREE.MeshStandardMaterial {
return new THREE.MeshStandardMaterial({
color,
@ -38,20 +55,36 @@ export class GhostLayer {
private def: MachineDef | null = null;
private pos: Vec2 | null = null;
private dir: Dir = 0;
private occupied = new Set<number>();
private removing = false;
private occupied = new Map<number, EntityState>();
private matValid = ghostMaterial(VALID);
private matInvalid = ghostMaterial(INVALID);
readonly remove = new RemoveHover();
constructor(private registry: AssetRegistry, private defs: Map<string, MachineDef>) {
this.group.name = 'ghost';
this.group.renderOrder = 10;
this.group.add(this.remove.group);
}
set(defId: string | null, pos: Vec2 | null, dir: Dir): void {
this.removing = defId === REMOVE_DEF;
if (this.removing) {
// The build ghost stays hidden; update() resolves what's under the cursor.
this.def = null;
this.pos = pos;
this.dir = dir;
if (this.obj) this.obj.visible = false;
this.group.visible = true;
return;
}
this.remove.hide();
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) {
this.group.visible = false;
@ -101,15 +134,22 @@ export class GhostLayer {
}
update(snap: SimSnapshot, timeSec: number): void {
if (!this.group.visible || !this.def || !this.pos || !this.obj) return;
if (!this.group.visible) return;
if (!this.removing && (!this.def || !this.pos || !this.obj)) return;
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.add(tileKey(t.x, t.y));
for (const t of tilesOf(e.pos, d, e.dir)) this.occupied.set(tileKey(t.x, t.y), e);
}
if (this.removing) {
this.updateRemove(timeSec);
return;
}
if (!this.def || !this.pos || !this.obj) return;
const want = tilesOf(this.pos, this.def, this.dir);
const ok = want.every(
(t) =>
@ -128,4 +168,22 @@ export class GhostLayer {
this.obj.rotation.y = yawFor(this.dir);
this.idle?.(timeSec);
}
/**
* Highlight whatever machine sits under the cursor. Hovering bare ground shows
* nothing there is no honest way to demolish a hole.
*/
private updateRemove(timeSec: number): void {
const target = this.pos ? this.occupied.get(tileKey(this.pos.x, this.pos.y)) : undefined;
const def = target ? this.defs.get(target.def) : undefined;
if (!target || !def) {
this.remove.hide();
return;
}
// The whole machine lights up, not just the hovered tile: `remove` takes a position
// but kills the entity that owns it, and the hover should say so.
const f = rotatedFootprint(def, target.dir);
const c = centerOf(target.pos, def, target.dir);
this.remove.show(c.x, c.z, f.x, f.y, HEIGHT_BY_KIND[def.kind] ?? 0.8, timeSec);
}
}

View File

@ -15,6 +15,7 @@ import { BeltLayer } from './belts';
import { BeltItemLayer } from './beltitems';
import { GhostLayer } from './ghost';
import { DevScene, devSceneEnabled, devSceneStress } from './devscene';
import { Showroom, showroomEnabled } from './showroom';
import { BeltTopology } from './topology';
import { WORLD } from './palette';
@ -34,7 +35,11 @@ export function createRenderer(): Renderer {
let cargo: BeltItemLayer;
let ghost: GhostLayer;
let dev: DevScene | null = null;
let showroom: Showroom | null = null;
let host: HTMLElement;
let defs: Map<string, MachineDef>;
/** True on any dev scaffold page (devscene/showroom): enables stats + the debug hook. */
let debug = false;
const topo = new BeltTopology();
const raycaster = new THREE.Raycaster();
@ -50,6 +55,7 @@ export function createRenderer(): Renderer {
return {
async init(container: HTMLElement, data: GameData) {
defs = new Map(data.machines.map((m) => [m.id, m]));
host = container;
gl = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
gl.setPixelRatio(Math.min(devicePixelRatio, 2));
@ -99,18 +105,37 @@ export function createRenderer(): Renderer {
ghost = new GhostLayer(registry, defs);
scene.add(belts.group, entities.group, cargo.group, ghost.group);
debug = devSceneEnabled() || showroomEnabled();
if (showroomEnabled()) {
showroom = new Showroom(data, registry);
scene.add(showroom.decor);
showroom.mountLabels(container);
rig.frame(showroom.view.x, showroom.view.z, showroom.view.zoom);
console.info(
`[render] showroom: ${data.machines.length} machines, ${data.items.length} items —` +
' drop a GLB into public/assets/models/<asset>.glb and it lands here in ~10s',
);
}
if (devSceneEnabled()) {
const d = new DevScene(data, devSceneStress());
if (d.usable) {
dev = d;
console.info('[render] devscene active — mock line until LANE-SIM lands entities');
// Dev-only introspection hook (devscene builds only). Never touched in prod.
(window as unknown as { __render: unknown }).__render = {
scene, camera: rig.camera, stats, gl, dev, topo, cargo, entities,
};
}
}
// The literal `import.meta.env.DEV` is what makes this disappear from the bundle:
// Vite substitutes `false` and Rollup drops the branch. Testing a `debug` VARIABLE
// here instead left the whole hook — internals list and all — as dead code in
// dist/. Keep the check inline; verified by grepping the bundle.
if (import.meta.env.DEV && (devSceneEnabled() || showroomEnabled())) {
(window as unknown as { __render: unknown }).__render = {
scene, camera: rig.camera, rig, stats, gl, dev, showroom, topo, cargo, entities, ghost,
};
}
const onResize = () => {
gl.setSize(container.clientWidth, container.clientHeight);
rig.resize();
@ -121,12 +146,19 @@ export function createRenderer(): Renderer {
render(snap: SimSnapshot, alpha: number) {
if (!gl) return;
const t0 = dev ? performance.now() : 0;
const t0 = debug ? performance.now() : 0;
const dt = clock.getDelta();
const t = clock.elapsedTime;
// Dev scaffold yields the instant the real sim has anything to show.
const view = dev && snap.entities.length === 0 ? dev.snapshot(snap.tick) : snap;
// The showroom is a standalone page, so it owns the frame outright. The devscene
// is only a stand-in and yields the instant the real sim has anything to show.
let view = snap;
if (showroom) {
view = showroom.snapshot(snap.tick);
showroom.frame(t);
} else if (dev && snap.entities.length === 0) {
view = dev.snapshot(snap.tick);
}
rig.update(dt);
// Topology is shared by belts (which piece to draw) and cargo (which path the
@ -148,8 +180,9 @@ export function createRenderer(): Renderer {
gl.toneMappingExposure = BASE_EXPOSURE * dim * flicker;
gl.render(scene, rig.camera);
showroom?.updateLabels(rig.camera, host);
if (dev) {
if (debug) {
stats.ms = performance.now() - t0;
stats.frames++;
stats.avg += (stats.ms - stats.avg) * 0.05; // rolling

View File

@ -20,6 +20,7 @@ import { clone as cloneSkinned } from 'three/examples/jsm/utils/SkeletonUtils.js
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
import type { GameData, MachineDef } from '../contracts';
import { ACCENT_BY_KIND, ACCENT_MIN_L, BODY_BY_KIND, HEIGHT_BY_KIND, lightnessOf } from './palette';
import { DEFAULT_ERA, applyEraSkin, eraIndex, type Era } from './era';
const MODEL_DIR = 'assets/models';
const POLL_MS = 10_000;
@ -76,15 +77,17 @@ function accentMat(color: number): THREE.MeshStandardMaterial {
* idle motion. The body mesh is tagged `fktryBody` so EntityLayer knows what to heat
* a GLB has no such split, so there heat tints everything.
*/
function makePlaceholder(def: MachineDef, accent: number, body_: number): THREE.Object3D {
function makePlaceholder(def: MachineDef, accent: number, body_: number, era: Era): THREE.Object3D {
const g = new THREE.Group();
const fw = Math.max(0.4, def.footprint.x * 0.92);
const fd = Math.max(0.4, def.footprint.y * 0.92);
const h = HEIGHT_BY_KIND[def.kind] ?? 0.8;
const chassis = bodyMat(body_);
applyEraSkin(chassis, era); // chassis only — the accent is the resource, not the decade
const body = new THREE.Mesh(
new RoundedBoxGeometry(fw, h, fd, 2, Math.min(0.08, h * 0.25)),
bodyMat(body_),
chassis,
);
body.position.y = h / 2;
body.castShadow = body.receiveShadow = true;
@ -285,13 +288,21 @@ export class AssetRegistry {
private entries = new Map<string, AssetEntry>();
private defs = new Map<string, MachineDef>();
private eras = new Map<string, Era>();
private loader = new GLTFLoader();
private timer: number | null = null;
/** Which era a machine is skinned as (derived from tech unlocks; stream by default). */
eraOf(machineId: string): Era {
return this.eras.get(machineId) ?? DEFAULT_ERA;
}
async init(data: GameData): Promise<void> {
this.eras = eraIndex(data);
for (const def of data.machines) {
this.defs.set(def.asset, def);
this.entries.set(def.asset, this.placeholderEntry(def, accentFor(def, data), bodyFor(def)));
const entry = this.placeholderEntry(def, accentFor(def, data), bodyFor(def), this.eraOf(def.id));
this.entries.set(def.asset, entry);
}
await this.probeAll();
if (import.meta.env.DEV) {
@ -307,13 +318,13 @@ export class AssetRegistry {
return this.entries.get(key) ?? null;
}
private placeholderEntry(def: MachineDef, accent: number, body: number): AssetEntry {
private placeholderEntry(def: MachineDef, accent: number, body: number, era: Era): AssetEntry {
const prev = this.entries.get(def.asset);
return {
key: def.asset,
version: prev ? prev.version : 0,
isGLB: false,
create: () => makePlaceholder(def, accent, body),
create: () => makePlaceholder(def, accent, body, era),
// Belts are instanced by belts.ts, which supplies the chevron material itself —
// this material is only a fallback and is normally discarded.
instanceable: () =>

107
fktry/src/render/remove.ts Normal file
View File

@ -0,0 +1,107 @@
/**
* LANE-RENDER remove-mode hover.
*
* Two reads at once: a hazard-striped decal on the machine's footprint (this is the
* ground you are about to clear) and a red wash over its volume (this is the thing that
* is going away). Stripes because the codex dresses everything horrifying as routine
* industrial equipment demolition should look like a permit, not an explosion.
*
* Driven by the `__remove` ghost sentinel; see ghost.ts.
*/
import * as THREE from 'three';
const DEMOLITION = 0xff3b30;
const DECAL_VERT = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const DECAL_FRAG = /* glsl */ `
precision highp float;
uniform float uTime;
uniform vec2 uScale; // footprint in tiles, so stripes don't stretch on big machines
uniform vec3 uColor;
varying vec2 vUv;
void main() {
// Border: constant thickness regardless of footprint.
vec2 d = min(vUv, 1.0 - vUv) * uScale;
float edge = min(d.x, d.y);
float border = 1.0 - smoothstep(0.04, 0.10, edge);
// Hazard stripes, crawling.
vec2 p = vUv * uScale;
float s = fract((p.x + p.y) * 1.6 - uTime * 0.5);
float stripe = step(0.5, s);
float a = max(border, stripe * 0.28);
if (a < 0.01) discard;
gl_FragColor = vec4(uColor, a * 0.85);
}
`;
export class RemoveHover {
readonly group = new THREE.Group();
private decal: THREE.Mesh;
private wash: THREE.Mesh;
private uniforms = {
uTime: { value: 0 },
uScale: { value: new THREE.Vector2(1, 1) },
uColor: { value: new THREE.Color(DEMOLITION) },
};
constructor() {
this.group.name = 'removeHover';
this.group.visible = false;
this.group.renderOrder = 11;
this.decal = new THREE.Mesh(
new THREE.PlaneGeometry(1, 1),
new THREE.ShaderMaterial({
uniforms: this.uniforms,
vertexShader: DECAL_VERT,
fragmentShader: DECAL_FRAG,
transparent: true,
depthWrite: false,
}),
);
this.decal.rotation.x = -Math.PI / 2;
this.decal.position.y = 0.02; // just clear of the ground plane
this.group.add(this.decal);
this.wash = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1),
new THREE.MeshBasicMaterial({
color: DEMOLITION,
transparent: true,
opacity: 0.3,
depthWrite: false,
}),
);
this.group.add(this.wash);
}
hide(): void {
this.group.visible = false;
}
/**
* Frame the machine we're about to demolish. `w`/`d` are its ROTATED footprint, so a
* 2x3 turned east reads as 3x2 the decal has to match what's actually occupied.
*/
show(cx: number, cz: number, w: number, d: number, height: number, timeSec: number): void {
this.group.visible = true;
this.uniforms.uTime.value = timeSec;
this.uniforms.uScale.value.set(w, d);
this.decal.scale.set(w, d, 1);
this.decal.position.set(cx, 0.02, cz);
this.wash.scale.set(w * 0.94, height, d * 0.94);
this.wash.position.set(cx, height / 2, cz);
}
}

View File

@ -0,0 +1,227 @@
/**
* LANE-RENDER `?showroom`: the MODELBEAST asset QA page.
*
* Every machine and every item, on labelled plinths, under a fixed camera. Drop a GLB
* into `public/assets/models/<asset>.glb` and it appears here within ~10s with no
* reload this is the eyeball step for an asset drop: right scale? sits on the ground?
* silhouette reads at iso distance? clip loops?
*
* The machines are fabricated as a normal SimSnapshot rather than hand-built meshes, so
* they go through the SAME EntityLayer path as the real game hot-swap, GLB
* normalisation, AnimationMixer, era skin and all. A bespoke display path would be a
* QA page that tests itself instead of the renderer.
*
* Items aren't entities (they have no `asset` key and never hot-swap), so those are
* built directly but from beltitems.ts's own geometry/material factories, so what you
* see here is literally what rides the belts.
*
* Dev-only, like the rest of the scaffold: `import.meta.env.DEV` plus the query flag.
*/
import * as THREE from 'three';
import type { Dir, EntityState, GameData, SimSnapshot } from '../contracts';
import { AssetRegistry } from './registry';
import { itemGeometry, itemMaterial } from './beltitems';
export const showroomEnabled = (): boolean =>
import.meta.env.DEV && new URLSearchParams(location.search).has('showroom');
/** 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;
const M_SPACING = 6.5;
const M_Z0 = -12;
const I_COLS = 12;
const I_SPACING = 3.5;
const I_GAP = 7; // between the two halls
const PLINTH_H = 0.05;
interface Slot {
label: string;
sub: string;
x: number;
z: number;
/** Cached label size, measured once — re-reading offsetWidth every frame thrashes layout. */
w: number;
h: number;
}
export class Showroom {
readonly decor = new THREE.Group();
private entities: EntityState[] = [];
private slots: Slot[] = [];
private labelHost: HTMLDivElement | null = null;
private labelEls: HTMLDivElement[] = [];
private v = new THREE.Vector3();
/** Camera preset framing the whole hall. */
readonly view = { x: 0, z: 2, zoom: 21 };
constructor(private data: GameData, private registry: AssetRegistry) {
this.decor.name = 'showroom';
this.build();
}
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) => {
const cx = -mCentre + (i % M_COLS) * M_SPACING;
const cz = M_Z0 + Math.floor(i / M_COLS) * M_SPACING;
// Centre each footprint on its slot: pos is the min corner.
const x = Math.round(cx - def.footprint.x / 2);
const z = Math.round(cz - def.footprint.y / 2);
this.entities.push({
id: id++, def: def.id, pos: { x, y: z }, dir: FACING, recipe: null,
progress: 0, inputBuf: {}, outputBuf: {}, jammed: null, heat: 0, scrammed: false,
});
this.slots.push({
label: def.name,
sub: `${def.asset}.glb · ${this.registry.eraOf(def.id)}`,
x: cx, z: cz, w: 0, h: 0,
});
this.decor.add(plinth(def.footprint.x + 0.8, def.footprint.y + 0.8, cx, cz));
});
const itemZ0 = M_Z0 + Math.ceil(this.data.machines.length / M_COLS) * M_SPACING + I_GAP;
// --- 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 mesh = new THREE.Mesh(itemGeometry(def), itemMaterial(def));
mesh.position.set(cx, PLINTH_H + 0.35, cz);
mesh.castShadow = true;
mesh.userData.spin = true;
this.decor.add(mesh);
this.decor.add(plinth(1.4, 1.4, cx, cz));
this.slots.push({
label: def.name, sub: `tier ${def.tier} · ${def.color}`, x: cx, z: cz, w: 0, h: 0,
});
});
// 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).
const mRows = Math.ceil(this.data.machines.length / M_COLS);
this.view.z = M_Z0 + ((mRows - 1) * M_SPACING) / 2;
this.view.zoom = 19;
}
/** All machines, standing still. Items live in `decor`, not the snapshot. */
snapshot(tick: number): SimSnapshot {
return {
tick,
paused: false,
entities: this.entities,
beltItems: [],
bandwidth: { gen: 0, draw: 0, stored: 0, brownout: false },
shippedTotal: {},
activeCommission: null,
commissionProgress: {},
};
}
frame(timeSec: number): void {
for (const o of this.decor.children) {
if (o.userData.spin) o.rotation.y = timeSec * 0.9;
}
}
// ---------------------------------------------------------------- labels
/** DOM labels: crisp at any zoom, and no font atlas to maintain for a QA page. */
mountLabels(container: HTMLElement): void {
const host = document.createElement('div');
host.style.cssText = 'position:absolute;inset:0;pointer-events:none;overflow:hidden';
this.labelEls = this.slots.map((s) => {
const el = document.createElement('div');
el.style.cssText = [
'position:absolute',
'transform:translate(-50%,0)',
'font:10px/1.35 "Courier New",ui-monospace,monospace',
'color:#cfe6ff',
'text-align:center',
'text-shadow:0 1px 3px #000,0 0 8px #000',
'white-space:nowrap',
].join(';');
el.innerHTML = `<div>${esc(s.label)}</div><div style="color:#7d8aa8">${esc(s.sub)}</div>`;
host.appendChild(el);
return el;
});
container.appendChild(host);
this.labelHost = host;
// Measure once: sizes never change, and reading offsetWidth per frame would force
// a layout for every one of ~74 labels.
this.slots.forEach((s, i) => {
s.w = this.labelEls[i].offsetWidth;
s.h = this.labelEls[i].offsetHeight;
});
}
/**
* Project, then drop any label that would collide with one already placed. There are
* ~74 of these and at overview zoom they cannot all fit; a readable subset that
* resolves as you zoom in beats an unreadable pile. Front-to-back so the labels you
* lose are the ones furthest away.
*/
updateLabels(camera: THREE.Camera, container: HTMLElement): void {
if (!this.labelHost) return;
const w = container.clientWidth;
const h = container.clientHeight;
this.placed.length = 0;
const order = this.slots
.map((s, i) => ({ s, i, d: s.z })) // nearer the camera = larger z in this rig
.sort((a, b) => b.d - a.d);
for (const { s, i } of order) {
const el = this.labelEls[i];
this.v.set(s.x, 0, s.z + 1.5).project(camera);
if (this.v.z > 1 || Math.abs(this.v.x) > 1.15 || Math.abs(this.v.y) > 1.15) {
el.style.display = 'none';
continue;
}
const px = (this.v.x * 0.5 + 0.5) * w;
const py = (-this.v.y * 0.5 + 0.5) * h;
const l = px - s.w / 2;
const r = px + s.w / 2;
const t = py;
const b = py + s.h;
let hit = false;
for (const p of this.placed) {
if (l < p[2] && r > p[0] && t < p[3] && b > p[1]) { hit = true; break; }
}
if (hit) {
el.style.display = 'none';
continue;
}
this.placed.push([l, t, r, b]);
el.style.display = '';
el.style.left = `${px}px`;
el.style.top = `${py}px`;
}
}
private placed: Array<[number, number, number, number]> = [];
}
function plinth(w: number, d: number, cx: number, cz: number): THREE.Mesh {
const m = new THREE.Mesh(
new THREE.BoxGeometry(w, PLINTH_H, d),
new THREE.MeshStandardMaterial({
color: 0x14121d,
emissive: 0x2a3550,
emissiveIntensity: 0.35,
roughness: 0.9,
}),
);
m.position.set(cx, PLINTH_H / 2, cz);
m.receiveShadow = true;
return m;
}
const esc = (s: string): string =>
s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);