[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:
parent
34e8b39fed
commit
d1c3f2b09d
@ -348,6 +348,15 @@ BLOCKED/BROKEN
|
|||||||
ids. Either accept the escalator reading, or that's a schema change.
|
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
|
- Ownership wart is gone: `data/validate.test.ts` is staged by DATA and `src/sim/**` is purely
|
||||||
mine again. Thank you.
|
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
|
CONTRACT REQUEST
|
||||||
- None. v3 covered everything.
|
- 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
|
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
|
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.
|
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)
|
NEXT (round 4 if asked)
|
||||||
- HF dust piles + mosquito swarms — the seeded RNG is still unrolled, and save/load already
|
- HF dust piles + mosquito swarms — the seeded RNG is still unrolled, and save/load already
|
||||||
|
|||||||
@ -7,10 +7,8 @@
|
|||||||
* fixed-timestep interpolation. One tick of latency, and no extrapolation jitter when
|
* 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).
|
* 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
|
* Identity: contracts v3 makes `BeltItem.id` required, so interpolation is EXACT and
|
||||||
* interpolation is EXACT. When absent we fall back to matching by (belt, type, order
|
* the old order-matching fallback (and its per-frame sort of every item) is gone.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* Pathing: cargo follows the belt's topology shape, so on a corner it enters at the
|
* 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
|
* feeding edge and sweeps the arc rather than teleporting to the back edge and cutting
|
||||||
@ -29,8 +27,13 @@ interface ItemMesh {
|
|||||||
capacity: number;
|
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({
|
return new THREE.MeshStandardMaterial({
|
||||||
color: readable(def.color),
|
color: readable(def.color),
|
||||||
emissive: new THREE.Color(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
|
return def.tier >= 2
|
||||||
? new THREE.OctahedronGeometry(ITEM_SIZE * 0.7)
|
? new THREE.OctahedronGeometry(ITEM_SIZE * 0.7)
|
||||||
: new THREE.BoxGeometry(ITEM_SIZE, ITEM_SIZE * 0.8, ITEM_SIZE);
|
: new THREE.BoxGeometry(ITEM_SIZE, ITEM_SIZE * 0.8, ITEM_SIZE);
|
||||||
@ -50,17 +54,15 @@ export class BeltItemLayer {
|
|||||||
readonly group = new THREE.Group();
|
readonly group = new THREE.Group();
|
||||||
private byItem = new Map<string, ItemMesh>();
|
private byItem = new Map<string, ItemMesh>();
|
||||||
private items = new Map<string, ItemDef>();
|
private items = new Map<string, ItemDef>();
|
||||||
private prev = new Map<string, number>();
|
/** Keyed by BeltItem.id — stable across the belt-to-belt handoff. */
|
||||||
private cur = new Map<string, number>();
|
private prev = new Map<number, number>();
|
||||||
|
private cur = new Map<number, number>();
|
||||||
private lastTick = -1;
|
private lastTick = -1;
|
||||||
private dummy = new THREE.Object3D();
|
private dummy = new THREE.Object3D();
|
||||||
private counts = new Map<string, number>();
|
private counts = new Map<string, number>();
|
||||||
private seq = new Map<string, number>();
|
private written = new Map<string, number>();
|
||||||
private sorted: SimSnapshot['beltItems'][number][] = [];
|
|
||||||
private lp = { x: 0, z: 0 };
|
private lp = { x: 0, z: 0 };
|
||||||
private wp = { 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) {
|
constructor(data: GameData, private topo: BeltTopology) {
|
||||||
this.group.name = 'beltItems';
|
this.group.name = 'beltItems';
|
||||||
@ -71,6 +73,7 @@ export class BeltItemLayer {
|
|||||||
const existing = this.byItem.get(def.id);
|
const existing = this.byItem.get(def.id);
|
||||||
if (existing && needed <= existing.capacity) return existing;
|
if (existing && needed <= existing.capacity) return existing;
|
||||||
const capacity = Math.max(128, 1 << Math.ceil(Math.log2(Math.max(1, needed))));
|
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 geometry = existing?.mesh.geometry ?? itemGeometry(def);
|
||||||
const material = (existing?.mesh.material as THREE.Material) ?? itemMaterial(def);
|
const material = (existing?.mesh.material as THREE.Material) ?? itemMaterial(def);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@ -89,17 +92,7 @@ export class BeltItemLayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sync(snap: SimSnapshot, alpha: number, timeSec: number): void {
|
sync(snap: SimSnapshot, alpha: number, timeSec: number): void {
|
||||||
const src = snap.beltItems;
|
// Roll the tick window: cur becomes prev the moment the sim advances.
|
||||||
// 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));
|
|
||||||
}
|
|
||||||
|
|
||||||
const advanced = snap.tick !== this.lastTick;
|
const advanced = snap.tick !== this.lastTick;
|
||||||
if (advanced) {
|
if (advanced) {
|
||||||
const swap = this.prev;
|
const swap = this.prev;
|
||||||
@ -109,12 +102,12 @@ export class BeltItemLayer {
|
|||||||
this.lastTick = snap.tick;
|
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.counts.clear();
|
||||||
this.seq.clear();
|
for (const bi of snap.beltItems) {
|
||||||
for (const bi of this.sorted) {
|
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);
|
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) {
|
for (const [itemId, count] of this.counts) {
|
||||||
const def = this.items.get(itemId);
|
const def = this.items.get(itemId);
|
||||||
@ -122,15 +115,15 @@ export class BeltItemLayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pass 2 — write instance matrices.
|
// Pass 2 — write instance matrices.
|
||||||
this.seq.clear();
|
this.written.clear();
|
||||||
const written = new Map<string, number>();
|
for (const bi of snap.beltItems) {
|
||||||
for (const bi of this.sorted) {
|
const node = this.topo.get(bi.entity);
|
||||||
const node = this.topo.get(bi.entity)!;
|
if (!node) continue;
|
||||||
const def = this.items.get(bi.item);
|
const def = this.items.get(bi.item);
|
||||||
const im = this.byItem.get(bi.item);
|
const im = this.byItem.get(bi.item);
|
||||||
if (!def || !im) continue;
|
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;
|
const t = from === undefined ? bi.t : from + (bi.t - from) * alpha;
|
||||||
|
|
||||||
localPath(node.shape, t, this.lp);
|
localPath(node.shape, t, this.lp);
|
||||||
@ -143,25 +136,16 @@ export class BeltItemLayer {
|
|||||||
}
|
}
|
||||||
this.dummy.updateMatrix();
|
this.dummy.updateMatrix();
|
||||||
|
|
||||||
const n = written.get(bi.item) ?? 0;
|
const n = this.written.get(bi.item) ?? 0;
|
||||||
if (n < im.capacity) {
|
if (n < im.capacity) {
|
||||||
im.mesh.setMatrixAt(n, this.dummy.matrix);
|
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) {
|
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;
|
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}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -150,6 +150,14 @@ export class CameraRig {
|
|||||||
this.applyFrustum();
|
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 {
|
dispose(): void {
|
||||||
for (const d of this.disposers) d();
|
for (const d of this.disposers) d();
|
||||||
this.disposers = [];
|
this.disposers = [];
|
||||||
|
|||||||
@ -43,6 +43,8 @@ export class DevScene {
|
|||||||
private speed = 2;
|
private speed = 2;
|
||||||
private ok = false;
|
private ok = false;
|
||||||
private nextId = 1;
|
private nextId = 1;
|
||||||
|
/** The mock's own scram latch — see snapshot(). */
|
||||||
|
private scrammed = false;
|
||||||
|
|
||||||
constructor(private data: GameData, private stress = false) {
|
constructor(private data: GameData, private stress = false) {
|
||||||
this.build();
|
this.build();
|
||||||
@ -132,9 +134,17 @@ export class DevScene {
|
|||||||
// so all three states are reachable in a short verification session.
|
// so all three states are reachable in a short verification session.
|
||||||
const heat = Math.min(1, (tick % 300) / 200);
|
const heat = Math.min(1, (tick % 300) / 200);
|
||||||
const brownout = Math.floor(tick / 300) % 4 === 3;
|
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) {
|
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.def === 'demuxer') e.jammed = Math.floor(tick / 450) % 3 === 2 ? 'output full: LUMA BARS' : null;
|
||||||
if (e.recipe) e.progress = (tick % 90) / 90;
|
if (e.recipe) e.progress = (tick % 90) / 90;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,19 +21,6 @@ import { createHeatHaze, type HeatHaze } from './heat';
|
|||||||
/** Heat is throttled above this (contracts: "throttles >0.7, scrams at 1"). */
|
/** Heat is throttled above this (contracts: "throttles >0.7, scrams at 1"). */
|
||||||
const THROTTLE_AT = 0.7;
|
const THROTTLE_AT = 0.7;
|
||||||
const THROTTLE_FLOOR = 0.25;
|
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. */
|
/** Used when LANE-DATA hasn't set `bufferCap` yet — tanks still read as tanks. */
|
||||||
const DEFAULT_BUFFER_CAP = 100;
|
const DEFAULT_BUFFER_CAP = 100;
|
||||||
const HEAT_RED = new THREE.Color(0xff3a12);
|
const HEAT_RED = new THREE.Color(0xff3a12);
|
||||||
@ -65,8 +52,6 @@ interface Rec {
|
|||||||
idleClock: number;
|
idleClock: number;
|
||||||
heat: HeatTarget[];
|
heat: HeatTarget[];
|
||||||
all: 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. */
|
/** Whether dead-dark is currently applied, so restart knows to restore. */
|
||||||
wasScrammed: boolean;
|
wasScrammed: boolean;
|
||||||
}
|
}
|
||||||
@ -124,10 +109,12 @@ export class EntityLayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const heat = THREE.MathUtils.clamp(e.heat ?? 0, 0, 1);
|
const heat = THREE.MathUtils.clamp(e.heat ?? 0, 0, 1);
|
||||||
// Latch with hysteresis, exactly as the sim does — see SCRAM_RELEASE.
|
// v3: scram comes from the sim's own latch. `heat` alone cannot express the
|
||||||
if (heat >= 1) rec.scramLatch = true;
|
// hysteresis window — a machine stays dead while cooling from 1.0 back down —
|
||||||
else if (heat < SCRAM_RELEASE) rec.scramLatch = false;
|
// so deriving it here meant duplicating the sim's restart threshold and silently
|
||||||
const scrammed = rec.scramLatch;
|
// 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".
|
// Throttle ramps in rather than snapping, so "labouring" reads before "dead".
|
||||||
const speed = scrammed
|
const speed = scrammed
|
||||||
? 0
|
? 0
|
||||||
@ -258,7 +245,6 @@ export class EntityLayer {
|
|||||||
idleClock: 0,
|
idleClock: 0,
|
||||||
heat,
|
heat,
|
||||||
all,
|
all,
|
||||||
scramLatch: false,
|
|
||||||
wasScrammed: false,
|
wasScrammed: false,
|
||||||
};
|
};
|
||||||
this.live.set(id, rec);
|
this.live.set(id, rec);
|
||||||
|
|||||||
58
fktry/src/render/era.ts
Normal file
58
fktry/src/render/era.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -1,20 +1,37 @@
|
|||||||
/**
|
/**
|
||||||
* LANE-RENDER — build ghost. Shows the selected machine at the hovered tile:
|
* LANE-RENDER — build ghost, and remove-mode hover. Shows the selected machine at the
|
||||||
* cyan when it fits, red when its footprint collides with something already placed.
|
* 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
|
* 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 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 * 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 { 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 { idleOf, type IdleFn } from './registry';
|
||||||
import { WORLD_HALF } from './camera';
|
import { WORLD_HALF } from './camera';
|
||||||
|
import { HEIGHT_BY_KIND } from './palette';
|
||||||
|
import { RemoveHover } from './remove';
|
||||||
|
|
||||||
const VALID = 0x3fd4ff;
|
const VALID = 0x3fd4ff;
|
||||||
const INVALID = 0xff3b30;
|
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 {
|
function ghostMaterial(color: number): THREE.MeshStandardMaterial {
|
||||||
return new THREE.MeshStandardMaterial({
|
return new THREE.MeshStandardMaterial({
|
||||||
color,
|
color,
|
||||||
@ -38,20 +55,36 @@ export class GhostLayer {
|
|||||||
private def: MachineDef | null = null;
|
private def: MachineDef | null = null;
|
||||||
private pos: Vec2 | null = null;
|
private pos: Vec2 | null = null;
|
||||||
private dir: Dir = 0;
|
private dir: Dir = 0;
|
||||||
private occupied = new Set<number>();
|
private removing = false;
|
||||||
|
private occupied = new Map<number, EntityState>();
|
||||||
private matValid = ghostMaterial(VALID);
|
private matValid = ghostMaterial(VALID);
|
||||||
private matInvalid = ghostMaterial(INVALID);
|
private matInvalid = ghostMaterial(INVALID);
|
||||||
|
readonly remove = new RemoveHover();
|
||||||
|
|
||||||
constructor(private registry: AssetRegistry, private defs: Map<string, MachineDef>) {
|
constructor(private registry: AssetRegistry, private defs: Map<string, MachineDef>) {
|
||||||
this.group.name = 'ghost';
|
this.group.name = 'ghost';
|
||||||
this.group.renderOrder = 10;
|
this.group.renderOrder = 10;
|
||||||
|
this.group.add(this.remove.group);
|
||||||
}
|
}
|
||||||
|
|
||||||
set(defId: string | null, pos: Vec2 | null, dir: Dir): void {
|
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;
|
const def = defId ? this.defs.get(defId) ?? null : null;
|
||||||
this.def = def;
|
this.def = def;
|
||||||
this.pos = def && pos ? pos : null;
|
this.pos = def && pos ? pos : null;
|
||||||
this.dir = dir;
|
this.dir = dir;
|
||||||
|
if (this.obj) this.obj.visible = true;
|
||||||
|
|
||||||
if (!def || !this.pos) {
|
if (!def || !this.pos) {
|
||||||
this.group.visible = false;
|
this.group.visible = false;
|
||||||
@ -101,15 +134,22 @@ export class GhostLayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
update(snap: SimSnapshot, timeSec: number): void {
|
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();
|
this.occupied.clear();
|
||||||
for (const e of snap.entities) {
|
for (const e of snap.entities) {
|
||||||
const d = this.defs.get(e.def);
|
const d = this.defs.get(e.def);
|
||||||
if (!d) continue;
|
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 want = tilesOf(this.pos, this.def, this.dir);
|
||||||
const ok = want.every(
|
const ok = want.every(
|
||||||
(t) =>
|
(t) =>
|
||||||
@ -128,4 +168,22 @@ export class GhostLayer {
|
|||||||
this.obj.rotation.y = yawFor(this.dir);
|
this.obj.rotation.y = yawFor(this.dir);
|
||||||
this.idle?.(timeSec);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import { BeltLayer } from './belts';
|
|||||||
import { BeltItemLayer } from './beltitems';
|
import { BeltItemLayer } from './beltitems';
|
||||||
import { GhostLayer } from './ghost';
|
import { GhostLayer } from './ghost';
|
||||||
import { DevScene, devSceneEnabled, devSceneStress } from './devscene';
|
import { DevScene, devSceneEnabled, devSceneStress } from './devscene';
|
||||||
|
import { Showroom, showroomEnabled } from './showroom';
|
||||||
import { BeltTopology } from './topology';
|
import { BeltTopology } from './topology';
|
||||||
import { WORLD } from './palette';
|
import { WORLD } from './palette';
|
||||||
|
|
||||||
@ -34,7 +35,11 @@ export function createRenderer(): Renderer {
|
|||||||
let cargo: BeltItemLayer;
|
let cargo: BeltItemLayer;
|
||||||
let ghost: GhostLayer;
|
let ghost: GhostLayer;
|
||||||
let dev: DevScene | null = null;
|
let dev: DevScene | null = null;
|
||||||
|
let showroom: Showroom | null = null;
|
||||||
|
let host: HTMLElement;
|
||||||
let defs: Map<string, MachineDef>;
|
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 topo = new BeltTopology();
|
||||||
|
|
||||||
const raycaster = new THREE.Raycaster();
|
const raycaster = new THREE.Raycaster();
|
||||||
@ -50,6 +55,7 @@ export function createRenderer(): Renderer {
|
|||||||
return {
|
return {
|
||||||
async init(container: HTMLElement, data: GameData) {
|
async init(container: HTMLElement, data: GameData) {
|
||||||
defs = new Map(data.machines.map((m) => [m.id, m]));
|
defs = new Map(data.machines.map((m) => [m.id, m]));
|
||||||
|
host = container;
|
||||||
|
|
||||||
gl = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
|
gl = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
|
||||||
gl.setPixelRatio(Math.min(devicePixelRatio, 2));
|
gl.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||||
@ -99,18 +105,37 @@ export function createRenderer(): Renderer {
|
|||||||
ghost = new GhostLayer(registry, defs);
|
ghost = new GhostLayer(registry, defs);
|
||||||
scene.add(belts.group, entities.group, cargo.group, ghost.group);
|
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()) {
|
if (devSceneEnabled()) {
|
||||||
const d = new DevScene(data, devSceneStress());
|
const d = new DevScene(data, devSceneStress());
|
||||||
if (d.usable) {
|
if (d.usable) {
|
||||||
dev = d;
|
dev = d;
|
||||||
console.info('[render] devscene active — mock line until LANE-SIM lands entities');
|
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 = () => {
|
const onResize = () => {
|
||||||
gl.setSize(container.clientWidth, container.clientHeight);
|
gl.setSize(container.clientWidth, container.clientHeight);
|
||||||
rig.resize();
|
rig.resize();
|
||||||
@ -121,12 +146,19 @@ export function createRenderer(): Renderer {
|
|||||||
|
|
||||||
render(snap: SimSnapshot, alpha: number) {
|
render(snap: SimSnapshot, alpha: number) {
|
||||||
if (!gl) return;
|
if (!gl) return;
|
||||||
const t0 = dev ? performance.now() : 0;
|
const t0 = debug ? performance.now() : 0;
|
||||||
const dt = clock.getDelta();
|
const dt = clock.getDelta();
|
||||||
const t = clock.elapsedTime;
|
const t = clock.elapsedTime;
|
||||||
|
|
||||||
// Dev scaffold yields the instant the real sim has anything to show.
|
// The showroom is a standalone page, so it owns the frame outright. The devscene
|
||||||
const view = dev && snap.entities.length === 0 ? dev.snapshot(snap.tick) : snap;
|
// 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);
|
rig.update(dt);
|
||||||
// Topology is shared by belts (which piece to draw) and cargo (which path the
|
// 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.toneMappingExposure = BASE_EXPOSURE * dim * flicker;
|
||||||
|
|
||||||
gl.render(scene, rig.camera);
|
gl.render(scene, rig.camera);
|
||||||
|
showroom?.updateLabels(rig.camera, host);
|
||||||
|
|
||||||
if (dev) {
|
if (debug) {
|
||||||
stats.ms = performance.now() - t0;
|
stats.ms = performance.now() - t0;
|
||||||
stats.frames++;
|
stats.frames++;
|
||||||
stats.avg += (stats.ms - stats.avg) * 0.05; // rolling
|
stats.avg += (stats.ms - stats.avg) * 0.05; // rolling
|
||||||
|
|||||||
@ -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 { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
|
||||||
import type { GameData, MachineDef } from '../contracts';
|
import type { GameData, MachineDef } from '../contracts';
|
||||||
import { ACCENT_BY_KIND, ACCENT_MIN_L, BODY_BY_KIND, HEIGHT_BY_KIND, lightnessOf } from './palette';
|
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 MODEL_DIR = 'assets/models';
|
||||||
const POLL_MS = 10_000;
|
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 —
|
* 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.
|
* 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 g = new THREE.Group();
|
||||||
const fw = Math.max(0.4, def.footprint.x * 0.92);
|
const fw = Math.max(0.4, def.footprint.x * 0.92);
|
||||||
const fd = Math.max(0.4, def.footprint.y * 0.92);
|
const fd = Math.max(0.4, def.footprint.y * 0.92);
|
||||||
const h = HEIGHT_BY_KIND[def.kind] ?? 0.8;
|
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(
|
const body = new THREE.Mesh(
|
||||||
new RoundedBoxGeometry(fw, h, fd, 2, Math.min(0.08, h * 0.25)),
|
new RoundedBoxGeometry(fw, h, fd, 2, Math.min(0.08, h * 0.25)),
|
||||||
bodyMat(body_),
|
chassis,
|
||||||
);
|
);
|
||||||
body.position.y = h / 2;
|
body.position.y = h / 2;
|
||||||
body.castShadow = body.receiveShadow = true;
|
body.castShadow = body.receiveShadow = true;
|
||||||
@ -285,13 +288,21 @@ export class AssetRegistry {
|
|||||||
|
|
||||||
private entries = new Map<string, AssetEntry>();
|
private entries = new Map<string, AssetEntry>();
|
||||||
private defs = new Map<string, MachineDef>();
|
private defs = new Map<string, MachineDef>();
|
||||||
|
private eras = new Map<string, Era>();
|
||||||
private loader = new GLTFLoader();
|
private loader = new GLTFLoader();
|
||||||
private timer: number | null = null;
|
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> {
|
async init(data: GameData): Promise<void> {
|
||||||
|
this.eras = eraIndex(data);
|
||||||
for (const def of data.machines) {
|
for (const def of data.machines) {
|
||||||
this.defs.set(def.asset, def);
|
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();
|
await this.probeAll();
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
@ -307,13 +318,13 @@ export class AssetRegistry {
|
|||||||
return this.entries.get(key) ?? null;
|
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);
|
const prev = this.entries.get(def.asset);
|
||||||
return {
|
return {
|
||||||
key: def.asset,
|
key: def.asset,
|
||||||
version: prev ? prev.version : 0,
|
version: prev ? prev.version : 0,
|
||||||
isGLB: false,
|
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 —
|
// Belts are instanced by belts.ts, which supplies the chevron material itself —
|
||||||
// this material is only a fallback and is normally discarded.
|
// this material is only a fallback and is normally discarded.
|
||||||
instanceable: () =>
|
instanceable: () =>
|
||||||
|
|||||||
107
fktry/src/render/remove.ts
Normal file
107
fktry/src/render/remove.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
227
fktry/src/render/showroom.ts
Normal file
227
fktry/src/render/showroom.ts
Normal 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)};`);
|
||||||
Loading…
Reference in New Issue
Block a user