/** * 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(); private matValid = ghostMaterial(VALID); private matInvalid = ghostMaterial(INVALID); constructor(private registry: AssetRegistry, private defs: Map) { 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); } }