[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.
|
||||
- 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
|
||||
|
||||
@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 = [];
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
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:
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
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