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>
132 lines
4.0 KiB
TypeScript
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);
|
|
}
|
|
}
|