glytch/fktry/src/render/ghost.ts
type-two 883dd4b02b [render] isometric world, instanced belts/cargo, GLB hot-swap registry
Replaces the render stub with the full M1 surface:

- camera: ortho iso rig, wheel zoom, middle/right-drag + WASD pan, resize
- ground: shader tile grid w/ world-edge dissolve + screen-space vignette
- registry: procedural placeholders (two-material rule) + MODELBEAST GLB
  hot-swap, probed on init and every 10s in dev. GLBs auto-normalise to
  the machine footprint, so any export scale lands correctly.
- belts: InstancedMesh per belt def, chevron scroll injected into a
  MeshStandardMaterial so belts keep lighting/fog/brownout
- cargo: InstancedMesh per item type, interpolated across ticks via alpha
- ghost + pickTile with footprint occupancy (cyan valid / red blocked)
- feedback: jam klaxon blink, brownout dims the scene via tone-mapping
  exposure so emissives dim too

Machine accents are derived from data (recipes -> outputs -> ItemDef.color),
with a legibility floor: near-black products like MDAT ORE would otherwise
render the extractor's CRT head as a black panel and its ore as invisible.

Verified in the dev build: 500 entities + 2,000 belt items at 3.2ms/frame
(5 instanced draw calls); a GLB dropped in mid-session swaps without reload.
Dev scaffold is DEV-gated and tree-shaken from the prod bundle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:31:10 +10:00

132 lines
4.0 KiB
TypeScript

/**
* 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.
*
* 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.
*/
import * as THREE from 'three';
import type { Dir, MachineDef, SimSnapshot, Vec2 } from '../contracts';
import { AssetRegistry } from './registry';
import { centerOf, tileKey, tilesOf, yawFor } from './coords';
import { idleOf, type IdleFn } from './registry';
import { WORLD_HALF } from './camera';
const VALID = 0x3fd4ff;
const INVALID = 0xff3b30;
function ghostMaterial(color: number): THREE.MeshStandardMaterial {
return new THREE.MeshStandardMaterial({
color,
emissive: color,
emissiveIntensity: 0.85,
transparent: true,
opacity: 0.42,
depthWrite: false,
roughness: 0.4,
});
}
export class GhostLayer {
readonly group = new THREE.Group();
private obj: THREE.Object3D | null = null;
private objDef: string | null = null;
private objVersion = -1;
/** GLB clones share the template's geometry — only dispose what we built ourselves. */
private owns = false;
private idle: IdleFn | null = null;
private def: MachineDef | null = null;
private pos: Vec2 | null = null;
private dir: Dir = 0;
private occupied = new Set<number>();
private matValid = ghostMaterial(VALID);
private matInvalid = ghostMaterial(INVALID);
constructor(private registry: AssetRegistry, private defs: Map<string, MachineDef>) {
this.group.name = 'ghost';
this.group.renderOrder = 10;
}
set(defId: string | null, pos: Vec2 | null, dir: Dir): void {
const def = defId ? this.defs.get(defId) ?? null : null;
this.def = def;
this.pos = def && pos ? pos : null;
this.dir = dir;
if (!def || !this.pos) {
this.group.visible = false;
return;
}
const entry = this.registry.get(def.asset);
if (!entry) {
this.group.visible = false;
return;
}
if (!this.obj || this.objDef !== def.id || this.objVersion !== entry.version) {
this.rebuild(def);
this.objDef = def.id;
this.objVersion = entry.version;
}
this.group.visible = true;
}
private rebuild(def: MachineDef): void {
this.drop();
const entry = this.registry.get(def.asset)!;
const obj = entry.create();
this.owns = !entry.isGLB;
this.idle = idleOf(obj);
// Replace (never mutate) materials: a hot-swapped GLB's materials are shared
// with every placed copy of that machine.
obj.traverse((o) => {
const m = o as THREE.Mesh;
if (!m.isMesh) return;
m.material = this.matValid;
m.castShadow = m.receiveShadow = false;
});
this.group.add(obj);
this.obj = obj;
}
private drop(): void {
if (!this.obj) return;
this.group.remove(this.obj);
if (this.owns) {
this.obj.traverse((o) => {
const m = o as THREE.Mesh;
if (m.isMesh) m.geometry?.dispose(); // materials here are the shared ghost mats
});
}
this.obj = null;
}
update(snap: SimSnapshot, timeSec: number): void {
if (!this.group.visible || !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));
}
const want = tilesOf(this.pos, this.def, this.dir);
const ok = want.every(
(t) =>
!this.occupied.has(tileKey(t.x, t.y)) &&
t.x >= -WORLD_HALF && t.x < WORLD_HALF &&
t.y >= -WORLD_HALF && t.y < WORLD_HALF,
);
const mat = ok ? this.matValid : this.matInvalid;
this.obj.traverse((o) => {
const m = o as THREE.Mesh;
if (m.isMesh) m.material = mat;
});
centerOf(this.pos, this.def, this.dir, this.obj.position);
this.obj.position.y += 0.02;
this.obj.rotation.y = yawFor(this.dir);
this.idle?.(timeSec);
}
}