[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>
This commit is contained in:
parent
3a42ab2d9e
commit
883dd4b02b
175
fktry/src/render/beltitems.ts
Normal file
175
fktry/src/render/beltitems.ts
Normal file
@ -0,0 +1,175 @@
|
||||
/**
|
||||
* LANE-RENDER — belt cargo. One InstancedMesh per item type; sim owns the truth,
|
||||
* we own the smoothness.
|
||||
*
|
||||
* Interpolation: main.ts hands us the post-tick snapshot plus `alpha` (fraction into
|
||||
* the next tick), so we render lerp(prevTick, thisTick, alpha) — the classic
|
||||
* 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).
|
||||
*
|
||||
* BeltItem has no stable id (see contracts), so items are matched across ticks by
|
||||
* (belt entity, item type, order along the belt). Items can't overtake each other, so
|
||||
* that ordering is stable — EXCEPT on the tick where the lead item leaves a belt and
|
||||
* the queue behind it shifts index. Those items miss their match for exactly one tick
|
||||
* and render at the sim's `t` instead of interpolated: a bounded error of one tick of
|
||||
* belt travel (beltSpeed/30 ≈ 0.07 tiles), which is imperceptible. A stable
|
||||
* `BeltItem.id` would make this exact — filed as a CONTRACT REQUEST in NOTES.
|
||||
*
|
||||
* Hot path: two allocation-free passes over beltItems (count, then write matrices).
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import type { GameData, ItemDef, MachineDef, SimSnapshot } from '../contracts';
|
||||
import { DIR_VEC } from './coords';
|
||||
import { HEIGHT_BY_KIND, readable } from './palette';
|
||||
|
||||
const ITEM_SIZE = 0.3;
|
||||
const RIDE_Y = HEIGHT_BY_KIND.belt + ITEM_SIZE * 0.5;
|
||||
|
||||
interface ItemMesh {
|
||||
mesh: THREE.InstancedMesh;
|
||||
capacity: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function itemMaterial(def: ItemDef): THREE.MeshStandardMaterial {
|
||||
return new THREE.MeshStandardMaterial({
|
||||
color: readable(def.color),
|
||||
emissive: new THREE.Color(def.color),
|
||||
emissiveIntensity: def.tier >= 2 ? 1.4 : def.tier * 0.35,
|
||||
roughness: 0.5,
|
||||
metalness: 0.2,
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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>();
|
||||
private lastTick = -1;
|
||||
private dummy = new THREE.Object3D();
|
||||
private belts = new Map<number, { x: number; y: number; dir: 0 | 1 | 2 | 3 }>();
|
||||
private counts = new Map<string, number>();
|
||||
private seq = new Map<string, number>();
|
||||
private sorted: SimSnapshot['beltItems'][number][] = [];
|
||||
|
||||
constructor(data: GameData, private defs: Map<string, MachineDef>) {
|
||||
this.group.name = 'beltItems';
|
||||
for (const i of data.items) this.items.set(i.id, i);
|
||||
}
|
||||
|
||||
private ensure(def: ItemDef, needed: number): ItemMesh {
|
||||
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) {
|
||||
this.group.remove(existing.mesh);
|
||||
existing.mesh.dispose();
|
||||
}
|
||||
const mesh = new THREE.InstancedMesh(geometry, material, capacity);
|
||||
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||||
mesh.frustumCulled = false;
|
||||
mesh.castShadow = true;
|
||||
mesh.count = 0;
|
||||
this.group.add(mesh);
|
||||
const im: ItemMesh = { mesh, capacity };
|
||||
this.byItem.set(def.id, im);
|
||||
return im;
|
||||
}
|
||||
|
||||
sync(snap: SimSnapshot, alpha: number, timeSec: number): void {
|
||||
this.belts.clear();
|
||||
for (const e of snap.entities) {
|
||||
const def = this.defs.get(e.def);
|
||||
if (def?.kind === 'belt') this.belts.set(e.id, { x: e.pos.x, y: e.pos.y, dir: e.dir });
|
||||
}
|
||||
|
||||
// Order cargo front-to-back per belt so the ordering keys are stable.
|
||||
this.sorted.length = 0;
|
||||
for (const bi of snap.beltItems) if (this.belts.has(bi.entity)) this.sorted.push(bi);
|
||||
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;
|
||||
this.prev = this.cur;
|
||||
this.cur = swap;
|
||||
this.cur.clear();
|
||||
this.lastTick = snap.tick;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
this.counts.set(bi.item, (this.counts.get(bi.item) ?? 0) + 1);
|
||||
if (advanced) {
|
||||
const sk = `${bi.entity}:${bi.item}`;
|
||||
const idx = this.seq.get(sk) ?? 0;
|
||||
this.seq.set(sk, idx + 1);
|
||||
this.cur.set(`${bi.entity}:${bi.item}:${idx}`, bi.t);
|
||||
}
|
||||
}
|
||||
for (const [itemId, count] of this.counts) {
|
||||
const def = this.items.get(itemId);
|
||||
if (def) this.ensure(def, count);
|
||||
}
|
||||
|
||||
// Pass 2 — write instance matrices.
|
||||
this.seq.clear();
|
||||
const written = new Map<string, number>();
|
||||
for (const bi of this.sorted) {
|
||||
const belt = this.belts.get(bi.entity)!;
|
||||
const def = this.items.get(bi.item);
|
||||
const im = this.byItem.get(bi.item);
|
||||
if (!def || !im) continue;
|
||||
|
||||
const sk = `${bi.entity}:${bi.item}`;
|
||||
const idx = this.seq.get(sk) ?? 0;
|
||||
this.seq.set(sk, idx + 1);
|
||||
const from = this.prev.get(`${bi.entity}:${bi.item}:${idx}`);
|
||||
const t = from === undefined ? bi.t : from + (bi.t - from) * alpha;
|
||||
|
||||
const dir = DIR_VEC[belt.dir];
|
||||
this.dummy.position.set(
|
||||
belt.x + 0.5 + dir.x * (t - 0.5),
|
||||
RIDE_Y,
|
||||
belt.y + 0.5 + dir.y * (t - 0.5),
|
||||
);
|
||||
this.dummy.rotation.set(0, 0, 0);
|
||||
if (def.tier >= 2) {
|
||||
this.dummy.rotation.y = timeSec * 1.6; // products tumble; ore just rides
|
||||
this.dummy.position.y += Math.sin(timeSec * 3 + bi.entity) * 0.02;
|
||||
}
|
||||
this.dummy.updateMatrix();
|
||||
|
||||
const n = written.get(bi.item) ?? 0;
|
||||
if (n < im.capacity) {
|
||||
im.mesh.setMatrixAt(n, this.dummy.matrix);
|
||||
written.set(bi.item, n + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, im] of this.byItem) {
|
||||
im.mesh.count = written.get(id) ?? 0;
|
||||
im.mesh.instanceMatrix.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
147
fktry/src/render/belts.ts
Normal file
147
fktry/src/render/belts.ts
Normal file
@ -0,0 +1,147 @@
|
||||
/**
|
||||
* LANE-RENDER — belts, instanced.
|
||||
*
|
||||
* One InstancedMesh per belt def (so a future fast-belt def just works). The scrolling
|
||||
* chevron is injected into a MeshStandardMaterial rather than written as a raw
|
||||
* ShaderMaterial, so belts keep real lighting, fog and brownout dimming for free.
|
||||
*
|
||||
* Chevrons scroll along local -z; the instance yaw carries them to world direction.
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import type { MachineDef, SimSnapshot } from '../contracts';
|
||||
import { AssetRegistry } from './registry';
|
||||
import { yawFor } from './coords';
|
||||
import { ACCENT_BY_KIND, HEIGHT_BY_KIND } from './palette';
|
||||
|
||||
const CHEVRONS_PER_TILE = 3.0;
|
||||
|
||||
interface BeltMesh {
|
||||
mesh: THREE.InstancedMesh;
|
||||
capacity: number;
|
||||
assetVersion: number;
|
||||
live: Map<number, { x: number; y: number; dir: number }>;
|
||||
}
|
||||
|
||||
export class BeltLayer {
|
||||
readonly group = new THREE.Group();
|
||||
private byDef = new Map<string, BeltMesh>();
|
||||
private uTime = { value: 0 };
|
||||
private dummy = new THREE.Object3D();
|
||||
|
||||
constructor(private registry: AssetRegistry, private defs: Map<string, MachineDef>) {
|
||||
this.group.name = 'belts';
|
||||
}
|
||||
|
||||
private material(def: MachineDef): THREE.Material {
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: 0x2a2833,
|
||||
roughness: 0.9,
|
||||
metalness: 0.1,
|
||||
});
|
||||
const uSpeed = { value: def.beltSpeed ?? 2 };
|
||||
const uAccent = { value: new THREE.Color(ACCENT_BY_KIND.belt) };
|
||||
const topY = (HEIGHT_BY_KIND.belt * 0.92).toFixed(4);
|
||||
mat.onBeforeCompile = (shader) => {
|
||||
shader.uniforms.uTime = this.uTime;
|
||||
shader.uniforms.uSpeed = uSpeed;
|
||||
shader.uniforms.uAccent = uAccent;
|
||||
shader.vertexShader =
|
||||
'varying vec3 vLocal;\n' +
|
||||
shader.vertexShader.replace('#include <begin_vertex>', '#include <begin_vertex>\n vLocal = position;');
|
||||
shader.fragmentShader =
|
||||
'uniform float uTime;\nuniform float uSpeed;\nuniform vec3 uAccent;\nvarying vec3 vLocal;\n' +
|
||||
shader.fragmentShader.replace(
|
||||
'#include <emissivemap_fragment>',
|
||||
`#include <emissivemap_fragment>
|
||||
if (vLocal.y > ${topY}) {
|
||||
float band = fract((vLocal.z + abs(vLocal.x) * 0.55) * ${CHEVRONS_PER_TILE.toFixed(1)}
|
||||
+ uTime * uSpeed * ${CHEVRONS_PER_TILE.toFixed(1)});
|
||||
float arrow = smoothstep(0.40, 0.50, band) - smoothstep(0.50, 0.60, band);
|
||||
totalEmissiveRadiance += uAccent * arrow * 0.85;
|
||||
}`,
|
||||
);
|
||||
};
|
||||
return mat;
|
||||
}
|
||||
|
||||
private ensure(def: MachineDef, needed: number): BeltMesh {
|
||||
const entry = this.registry.get(def.asset);
|
||||
const version = entry?.version ?? 0;
|
||||
let bm = this.byDef.get(def.id);
|
||||
|
||||
if (bm && bm.assetVersion !== version) {
|
||||
this.group.remove(bm.mesh);
|
||||
bm.mesh.dispose();
|
||||
bm = undefined; // GLB landed for belts: rebuild the instanced mesh from it
|
||||
}
|
||||
if (bm && needed <= bm.capacity) return bm;
|
||||
|
||||
const capacity = Math.max(64, 1 << Math.ceil(Math.log2(Math.max(1, needed))));
|
||||
const inst = entry?.instanceable() ?? null;
|
||||
const geometry = inst?.geometry ?? new THREE.BoxGeometry(0.94, HEIGHT_BY_KIND.belt, 0.94);
|
||||
if (!inst) geometry.translate(0, HEIGHT_BY_KIND.belt / 2, 0);
|
||||
// A real GLB brings its own look; the placeholder plate gets the chevron scroll.
|
||||
// (Taking inst.material unconditionally here silently dropped the chevrons — the
|
||||
// registry hands back a plain plate material for the placeholder case.)
|
||||
const material = entry?.isGLB && inst ? inst.material : this.material(def);
|
||||
|
||||
if (bm) {
|
||||
this.group.remove(bm.mesh);
|
||||
bm.mesh.dispose(); // grow: drop the old instanced mesh, keep geo/mat
|
||||
}
|
||||
const mesh = new THREE.InstancedMesh(geometry, material, capacity);
|
||||
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||||
mesh.castShadow = mesh.receiveShadow = true;
|
||||
mesh.frustumCulled = false;
|
||||
mesh.count = 0;
|
||||
this.group.add(mesh);
|
||||
const next: BeltMesh = { mesh, capacity, assetVersion: version, live: new Map() };
|
||||
this.byDef.set(def.id, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
sync(snap: SimSnapshot, timeSec: number): void {
|
||||
this.uTime.value = timeSec;
|
||||
|
||||
// Bucket belt entities by def.
|
||||
const buckets = new Map<string, Array<{ id: number; x: number; y: number; dir: number }>>();
|
||||
for (const e of snap.entities) {
|
||||
const def = this.defs.get(e.def);
|
||||
if (!def || def.kind !== 'belt') continue;
|
||||
let b = buckets.get(def.id);
|
||||
if (!b) buckets.set(def.id, (b = []));
|
||||
b.push({ id: e.id, x: e.pos.x, y: e.pos.y, dir: e.dir });
|
||||
}
|
||||
|
||||
for (const [defId, def] of this.defs) {
|
||||
if (def.kind !== 'belt') continue;
|
||||
const items = buckets.get(defId) ?? [];
|
||||
const bm = this.ensure(def, items.length);
|
||||
|
||||
// Belts are static once placed: only rewrite matrices when the set changes.
|
||||
let dirty = items.length !== bm.live.size;
|
||||
if (!dirty) {
|
||||
for (const it of items) {
|
||||
const prev = bm.live.get(it.id);
|
||||
if (!prev || prev.x !== it.x || prev.y !== it.y || prev.dir !== it.dir) {
|
||||
dirty = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!dirty) continue;
|
||||
|
||||
bm.live.clear();
|
||||
items.forEach((it, i) => {
|
||||
this.dummy.position.set(it.x + 0.5, 0, it.y + 0.5); // 1x1 footprint: tile centre
|
||||
this.dummy.rotation.set(0, yawFor(it.dir as 0 | 1 | 2 | 3), 0);
|
||||
this.dummy.updateMatrix();
|
||||
bm.mesh.setMatrixAt(i, this.dummy.matrix);
|
||||
bm.live.set(it.id, { x: it.x, y: it.y, dir: it.dir });
|
||||
});
|
||||
bm.mesh.count = items.length;
|
||||
bm.mesh.instanceMatrix.needsUpdate = true;
|
||||
bm.mesh.computeBoundingSphere();
|
||||
}
|
||||
}
|
||||
}
|
||||
157
fktry/src/render/camera.ts
Normal file
157
fktry/src/render/camera.ts
Normal file
@ -0,0 +1,157 @@
|
||||
/**
|
||||
* LANE-RENDER — orthographic isometric camera rig.
|
||||
* Pan: middle-drag or WASD. Zoom: wheel (clamped). Resize handled.
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
|
||||
const ISO_DIR = new THREE.Vector3(1, 1.15, 1).normalize();
|
||||
/**
|
||||
* Ortho: this affects near/far clipping, not apparent size. It DOES set how far every
|
||||
* object sits from the camera, so scene fog must be banded around it — see index.ts.
|
||||
*/
|
||||
export const CAM_DIST = 90;
|
||||
const ZOOM_MIN = 8;
|
||||
const ZOOM_MAX = 40;
|
||||
const PAN_KEY_SPEED = 14; // world units/sec at default zoom
|
||||
|
||||
export const WORLD_HALF = 32; // 64x64 world, origin at centre (CONTRACTS.md)
|
||||
|
||||
export class CameraRig {
|
||||
readonly camera: THREE.OrthographicCamera;
|
||||
private target = new THREE.Vector3(0, 0, 0);
|
||||
/** Half-height of the ortho frustum in world units — our zoom level. */
|
||||
private zoomD = 16;
|
||||
private keys = new Set<string>();
|
||||
private dragging = false;
|
||||
private lastX = 0;
|
||||
private lastY = 0;
|
||||
private disposers: Array<() => void> = [];
|
||||
|
||||
constructor(private container: HTMLElement, private dom: HTMLCanvasElement) {
|
||||
this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 400);
|
||||
this.applyFrustum();
|
||||
this.place();
|
||||
this.bind();
|
||||
}
|
||||
|
||||
private bind(): void {
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
this.zoomD = THREE.MathUtils.clamp(this.zoomD * Math.exp(e.deltaY * 0.0012), ZOOM_MIN, ZOOM_MAX);
|
||||
this.applyFrustum();
|
||||
};
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (e.button === 0) return; // left stays free for LANE-UI's click-to-place
|
||||
// main.ts places on ANY pointerdown over #game; keep our pan from also building.
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
this.dragging = true;
|
||||
this.lastX = e.clientX;
|
||||
this.lastY = e.clientY;
|
||||
this.dom.setPointerCapture(e.pointerId);
|
||||
};
|
||||
const onMove = (e: PointerEvent) => {
|
||||
if (!this.dragging) return;
|
||||
const wpp = (this.zoomD * 2) / this.container.clientHeight; // world units per pixel
|
||||
this.panScreen((e.clientX - this.lastX) * wpp, (e.clientY - this.lastY) * wpp);
|
||||
this.lastX = e.clientX;
|
||||
this.lastY = e.clientY;
|
||||
};
|
||||
const onUp = (e: PointerEvent) => {
|
||||
this.dragging = false;
|
||||
if (this.dom.hasPointerCapture(e.pointerId)) this.dom.releasePointerCapture(e.pointerId);
|
||||
};
|
||||
const onCtx = (e: Event) => e.preventDefault(); // right-drag pan without a menu
|
||||
const isTyping = (t: EventTarget | null) => {
|
||||
const el = t as HTMLElement | null;
|
||||
return !!el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable);
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (isTyping(e.target)) return; // don't eat LANE-UI's typing
|
||||
this.keys.add(e.key.toLowerCase());
|
||||
};
|
||||
const onKeyUp = (e: KeyboardEvent) => this.keys.delete(e.key.toLowerCase());
|
||||
const onBlur = () => this.keys.clear();
|
||||
|
||||
this.dom.addEventListener('wheel', onWheel, { passive: false });
|
||||
this.dom.addEventListener('pointerdown', onDown);
|
||||
this.dom.addEventListener('pointermove', onMove);
|
||||
this.dom.addEventListener('pointerup', onUp);
|
||||
this.dom.addEventListener('pointercancel', onUp);
|
||||
this.dom.addEventListener('contextmenu', onCtx);
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
window.addEventListener('keyup', onKeyUp);
|
||||
window.addEventListener('blur', onBlur);
|
||||
|
||||
this.disposers.push(() => {
|
||||
this.dom.removeEventListener('wheel', onWheel);
|
||||
this.dom.removeEventListener('pointerdown', onDown);
|
||||
this.dom.removeEventListener('pointermove', onMove);
|
||||
this.dom.removeEventListener('pointerup', onUp);
|
||||
this.dom.removeEventListener('pointercancel', onUp);
|
||||
this.dom.removeEventListener('contextmenu', onCtx);
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
window.removeEventListener('blur', onBlur);
|
||||
});
|
||||
}
|
||||
|
||||
/** Screen-space drag delta (world units) -> target motion on the ground plane. */
|
||||
private panScreen(dxWorld: number, dyWorld: number): void {
|
||||
const right = new THREE.Vector3().setFromMatrixColumn(this.camera.matrix, 0).setY(0).normalize();
|
||||
const up = new THREE.Vector3().setFromMatrixColumn(this.camera.matrix, 1).setY(0).normalize();
|
||||
this.target.addScaledVector(right, -dxWorld);
|
||||
this.target.addScaledVector(up, dyWorld);
|
||||
this.clampTarget();
|
||||
this.place();
|
||||
}
|
||||
|
||||
update(dtSec: number): void {
|
||||
let kx = 0;
|
||||
let ky = 0;
|
||||
if (this.keys.has('a') || this.keys.has('arrowleft')) kx -= 1;
|
||||
if (this.keys.has('d') || this.keys.has('arrowright')) kx += 1;
|
||||
if (this.keys.has('w') || this.keys.has('arrowup')) ky += 1;
|
||||
if (this.keys.has('s') || this.keys.has('arrowdown')) ky -= 1;
|
||||
if (!kx && !ky) return;
|
||||
// Scale with zoom so keyboard panning feels the same when zoomed in.
|
||||
const step = PAN_KEY_SPEED * dtSec * (this.zoomD / 16);
|
||||
const right = new THREE.Vector3().setFromMatrixColumn(this.camera.matrix, 0).setY(0).normalize();
|
||||
const up = new THREE.Vector3().setFromMatrixColumn(this.camera.matrix, 1).setY(0).normalize();
|
||||
this.target.addScaledVector(right, kx * step);
|
||||
this.target.addScaledVector(up, ky * step);
|
||||
this.clampTarget();
|
||||
this.place();
|
||||
}
|
||||
|
||||
private clampTarget(): void {
|
||||
this.target.x = THREE.MathUtils.clamp(this.target.x, -WORLD_HALF, WORLD_HALF);
|
||||
this.target.z = THREE.MathUtils.clamp(this.target.z, -WORLD_HALF, WORLD_HALF);
|
||||
this.target.y = 0;
|
||||
}
|
||||
|
||||
private place(): void {
|
||||
this.camera.position.copy(this.target).addScaledVector(ISO_DIR, CAM_DIST);
|
||||
this.camera.lookAt(this.target);
|
||||
this.camera.updateMatrixWorld();
|
||||
}
|
||||
|
||||
private applyFrustum(): void {
|
||||
const aspect = Math.max(0.0001, this.container.clientWidth / Math.max(1, this.container.clientHeight));
|
||||
const d = this.zoomD;
|
||||
this.camera.left = -d * aspect;
|
||||
this.camera.right = d * aspect;
|
||||
this.camera.top = d;
|
||||
this.camera.bottom = -d;
|
||||
this.camera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
resize(): void {
|
||||
this.applyFrustum();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const d of this.disposers) d();
|
||||
this.disposers = [];
|
||||
}
|
||||
}
|
||||
58
fktry/src/render/coords.ts
Normal file
58
fktry/src/render/coords.ts
Normal file
@ -0,0 +1,58 @@
|
||||
/**
|
||||
* LANE-RENDER — tile/world coordinate math. The single place the CONTRACTS.md
|
||||
* conventions are encoded: tile grid, origin at world centre, +x east, +y south,
|
||||
* Dir 0..3 = N,E,S,W clockwise, machine `pos` = origin (min) corner.
|
||||
*
|
||||
* Mapping: tile (x, y) -> three.js world (x, 0, y). Tile (x,y) spans [x,x+1]x[y,y+1].
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import type { Dir, MachineDef, Vec2 } from '../contracts';
|
||||
|
||||
export const DIR_VEC: Record<Dir, Vec2> = {
|
||||
0: { x: 0, y: -1 }, // N
|
||||
1: { x: 1, y: 0 }, // E
|
||||
2: { x: 0, y: 1 }, // S
|
||||
3: { x: -1, y: 0 }, // W
|
||||
};
|
||||
|
||||
/** Placeholders are authored facing N (-z). Yaw -90° per dir step: N->E->S->W. */
|
||||
export function yawFor(dir: Dir): number {
|
||||
return (-dir * Math.PI) / 2;
|
||||
}
|
||||
|
||||
/** E/W rotation swaps the footprint's width and depth. */
|
||||
export function rotatedFootprint(def: MachineDef, dir: Dir): Vec2 {
|
||||
return dir === 1 || dir === 3
|
||||
? { x: def.footprint.y, y: def.footprint.x }
|
||||
: { x: def.footprint.x, y: def.footprint.y };
|
||||
}
|
||||
|
||||
/** Centre of a machine's rotated footprint, in world units, on the ground plane. */
|
||||
export function centerOf(pos: Vec2, def: MachineDef, dir: Dir, out = new THREE.Vector3()): THREE.Vector3 {
|
||||
const f = rotatedFootprint(def, dir);
|
||||
return out.set(pos.x + f.x / 2, 0, pos.y + f.y / 2);
|
||||
}
|
||||
|
||||
/** Every tile a machine occupies. Used for ghost occupancy checks. */
|
||||
export function tilesOf(pos: Vec2, def: MachineDef, dir: Dir): Vec2[] {
|
||||
const f = rotatedFootprint(def, dir);
|
||||
const out: Vec2[] = [];
|
||||
for (let dx = 0; dx < f.x; dx++) {
|
||||
for (let dy = 0; dy < f.y; dy++) out.push({ x: pos.x + dx, y: pos.y + dy });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const tileKey = (x: number, y: number): number => (x + 512) * 4096 + (y + 512);
|
||||
|
||||
/** Free all geometry/material owned by a subtree before dropping it. */
|
||||
export function disposeObject(root: THREE.Object3D): void {
|
||||
root.traverse((o) => {
|
||||
const m = o as THREE.Mesh;
|
||||
if (!m.isMesh) return;
|
||||
m.geometry?.dispose();
|
||||
const mat = m.material;
|
||||
if (Array.isArray(mat)) mat.forEach((x) => x.dispose());
|
||||
else mat?.dispose();
|
||||
});
|
||||
}
|
||||
139
fktry/src/render/devscene.ts
Normal file
139
fktry/src/render/devscene.ts
Normal file
@ -0,0 +1,139 @@
|
||||
/**
|
||||
* LANE-RENDER — dev-only mock snapshot. NOT part of the game.
|
||||
*
|
||||
* LANE-SIM is being built in parallel and its stub reports zero entities, so there is
|
||||
* nothing on screen to verify this lane against. This fabricates the M1 line —
|
||||
* extractor -> belts -> demuxer -> quantizer -> mosh reactor -> shipper, with cargo
|
||||
* riding the belts — purely so the renderer can be checked with real eyes.
|
||||
*
|
||||
* Gated behind `?devscene` AND only used while the real sim has no entities: the
|
||||
* moment LANE-SIM produces anything, this yields automatically. It never fabricates
|
||||
* over live sim data.
|
||||
*
|
||||
* Items are advanced along the belt CHAIN (handing off between belt entities) rather
|
||||
* than by wrapping `t` on one belt, because that's what the real sim does — and it's
|
||||
* what exercises the renderer's cross-belt interpolation path honestly.
|
||||
*/
|
||||
import { TICKS_PER_SECOND, type Dir, type EntityState, type GameData, type SimSnapshot } from '../contracts';
|
||||
|
||||
/**
|
||||
* Dev builds only. The `import.meta.env.DEV` guard is load-bearing, not decoration:
|
||||
* without it `?devscene` would fabricate a fake factory in a shipped build and expose
|
||||
* the renderer internals to anyone with a query string. It also lets Vite drop this
|
||||
* whole module from the production bundle.
|
||||
*/
|
||||
export const devSceneEnabled = (): boolean =>
|
||||
import.meta.env.DEV && new URLSearchParams(location.search).has('devscene');
|
||||
|
||||
/** `?devscene=stress` builds the standing-rule load: 500 entities + 2,000 belt items. */
|
||||
export const devSceneStress = (): boolean =>
|
||||
import.meta.env.DEV && new URLSearchParams(location.search).get('devscene') === 'stress';
|
||||
|
||||
interface Segment {
|
||||
ids: number[]; // belt entity ids, in travel order
|
||||
item: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export class DevScene {
|
||||
private entities: EntityState[] = [];
|
||||
private segments: Segment[] = [];
|
||||
private speed = 2;
|
||||
private ok = false;
|
||||
|
||||
constructor(private data: GameData, private stress = false) {
|
||||
this.build();
|
||||
}
|
||||
|
||||
get usable(): boolean {
|
||||
return this.ok;
|
||||
}
|
||||
|
||||
private has(id: string): boolean {
|
||||
return this.data.machines.some((m) => m.id === id);
|
||||
}
|
||||
|
||||
private build(): void {
|
||||
const need = ['seam-extractor', 'belt', 'demuxer', 'quantizer', 'mosh-reactor', 'shipper'];
|
||||
if (!need.every((n) => this.has(n))) return; // data drifted; stay out of the way
|
||||
this.speed = this.data.machines.find((m) => m.id === 'belt')?.beltSpeed ?? 2;
|
||||
|
||||
let id = 1;
|
||||
const machine = (def: string, x: number, y: number, dir: Dir = 1, recipe: string | null = null) => {
|
||||
this.entities.push({
|
||||
id: id++, def, pos: { x, y }, dir, recipe,
|
||||
progress: 0, inputBuf: {}, outputBuf: {}, jammed: null, heat: 0,
|
||||
});
|
||||
};
|
||||
const beltRun = (x0: number, x1: number, item: string, count: number, y = 0) => {
|
||||
const ids: number[] = [];
|
||||
for (let x = x0; x <= x1; x++) {
|
||||
ids.push(id);
|
||||
machine('belt', x, y, 1);
|
||||
}
|
||||
this.segments.push({ ids, item, count });
|
||||
};
|
||||
|
||||
if (this.stress) {
|
||||
// Standing-rule load: ~500 entities and ~2,000 belt items, to prove the
|
||||
// instancing holds up before there's any real factory to slow down.
|
||||
const items = ['mdat-ore', 'luma-bars', 'coefficient-pack', 'melt'];
|
||||
const machines = ['quantizer', 'demuxer', 'mosh-reactor', 'decode-asic'];
|
||||
for (let r = 0; r < 20; r++) {
|
||||
const y = -28 + r * 3;
|
||||
beltRun(-30, -7, items[r % items.length], 100, y); // 24 belts x 20 = 480
|
||||
machine(machines[r % machines.length], -5, y, 1); // +20 machines = 500
|
||||
}
|
||||
this.segments = this.segments.filter((s) => this.data.items.some((i) => i.id === s.item));
|
||||
this.ok = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// A single line along the belt row y=0, running east.
|
||||
machine('seam-extractor', -14, 0, 1, 'extract-mdat');
|
||||
beltRun(-12, -9, 'mdat-ore', 5);
|
||||
machine('demuxer', -8, 0, 1, 'demux-ore');
|
||||
beltRun(-6, -3, 'luma-bars', 4);
|
||||
machine('quantizer', -2, 0, 1, 'quantize');
|
||||
beltRun(0, 3, 'coefficient-pack', 4);
|
||||
machine('mosh-reactor', 4, -1, 1, 'mosh'); // 3x3: centre lands on the belt row
|
||||
beltRun(7, 10, 'melt', 3);
|
||||
machine('shipper', 11, 0, 1);
|
||||
machine('decode-asic', -2, 4, 1);
|
||||
|
||||
// Only claim items the data actually defines.
|
||||
this.segments = this.segments.filter((s) => this.data.items.some((i) => i.id === s.item));
|
||||
this.ok = true;
|
||||
}
|
||||
|
||||
snapshot(tick: number): SimSnapshot {
|
||||
// Exercise the feedback dressing on a slow cycle: jams, then brownout.
|
||||
const jamPhase = Math.floor(tick / 450) % 3 === 2;
|
||||
const brownout = Math.floor(tick / 300) % 4 === 3;
|
||||
for (const e of this.entities) {
|
||||
if (e.def === 'demuxer') e.jammed = jamPhase ? 'output full: LUMA BARS' : null;
|
||||
if (e.recipe) e.progress = ((tick % 90) / 90);
|
||||
}
|
||||
|
||||
const beltItems: SimSnapshot['beltItems'] = this.segments.flatMap((seg) => {
|
||||
const perTick = this.speed / TICKS_PER_SECOND;
|
||||
const span = seg.ids.length;
|
||||
return Array.from({ length: seg.count }, (_, k) => {
|
||||
const p = (tick * perTick + (k * span) / seg.count) % span;
|
||||
const idx = Math.min(span - 1, Math.floor(p));
|
||||
return { item: seg.item, entity: seg.ids[idx], t: p - idx };
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
tick,
|
||||
paused: false,
|
||||
entities: this.entities,
|
||||
beltItems,
|
||||
bandwidth: { gen: 20, draw: brownout ? 26 : 14, stored: brownout ? 0 : 40, brownout },
|
||||
shippedTotal: { melt: Math.floor(tick / 90) },
|
||||
activeCommission: null,
|
||||
commissionProgress: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
113
fktry/src/render/entities.ts
Normal file
113
fktry/src/render/entities.ts
Normal file
@ -0,0 +1,113 @@
|
||||
/**
|
||||
* LANE-RENDER — machine entities (everything except belts, which are instanced).
|
||||
*
|
||||
* Syncs the scene to `snap.entities`: add, remove, re-transform, and rebuild when a
|
||||
* MODELBEAST GLB hot-swaps under an asset key. Sim is truth; we only ever read.
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import type { Dir, MachineDef, SimSnapshot } from '../contracts';
|
||||
import { AssetRegistry, idleOf, type IdleFn } from './registry';
|
||||
import { centerOf, disposeObject, yawFor } from './coords';
|
||||
import { HEIGHT_BY_KIND, JAM_AMBER } from './palette';
|
||||
|
||||
interface Rec {
|
||||
obj: THREE.Object3D;
|
||||
def: string;
|
||||
dir: Dir;
|
||||
x: number;
|
||||
y: number;
|
||||
assetVersion: number;
|
||||
/** Placeholders own their geo/mat and must be disposed. GLB clones share the
|
||||
* template's — disposing one would break every other instance. */
|
||||
owns: boolean;
|
||||
idle: IdleFn | null;
|
||||
jam: THREE.Mesh;
|
||||
phase: number;
|
||||
}
|
||||
|
||||
const JAM_GEO = new THREE.SphereGeometry(0.09, 8, 6);
|
||||
|
||||
export class EntityLayer {
|
||||
readonly group = new THREE.Group();
|
||||
private live = new Map<number, Rec>();
|
||||
private seen = new Set<number>();
|
||||
/** One shared material: every alarm blinks together, like a real klaxon. */
|
||||
private jamMat = new THREE.MeshStandardMaterial({
|
||||
color: JAM_AMBER,
|
||||
emissive: JAM_AMBER,
|
||||
emissiveIntensity: 1.5,
|
||||
});
|
||||
|
||||
constructor(private registry: AssetRegistry, private defs: Map<string, MachineDef>) {
|
||||
this.group.name = 'entities';
|
||||
}
|
||||
|
||||
sync(snap: SimSnapshot, timeSec: number): void {
|
||||
this.seen.clear();
|
||||
let anyJammed = false;
|
||||
|
||||
for (const e of snap.entities) {
|
||||
const def = this.defs.get(e.def);
|
||||
if (!def || def.kind === 'belt') continue; // belts: see belts.ts
|
||||
const entry = this.registry.get(def.asset);
|
||||
if (!entry) continue;
|
||||
this.seen.add(e.id);
|
||||
|
||||
let rec = this.live.get(e.id);
|
||||
if (rec && (rec.def !== e.def || rec.assetVersion !== entry.version)) {
|
||||
this.drop(e.id, rec); // asset hot-swapped (or def changed): rebuild
|
||||
rec = undefined;
|
||||
}
|
||||
if (!rec) {
|
||||
const obj = entry.create();
|
||||
const jam = new THREE.Mesh(JAM_GEO, this.jamMat);
|
||||
jam.position.y = (HEIGHT_BY_KIND[def.kind] ?? 0.8) + 0.28;
|
||||
jam.visible = false;
|
||||
obj.add(jam);
|
||||
this.group.add(obj);
|
||||
rec = {
|
||||
obj,
|
||||
def: e.def,
|
||||
dir: e.dir,
|
||||
x: NaN,
|
||||
y: NaN,
|
||||
assetVersion: entry.version,
|
||||
owns: !entry.isGLB,
|
||||
idle: idleOf(obj),
|
||||
jam,
|
||||
phase: (e.id % 17) * 0.41, // desync idle motion between machines
|
||||
};
|
||||
this.live.set(e.id, rec);
|
||||
}
|
||||
|
||||
if (rec.x !== e.pos.x || rec.y !== e.pos.y || rec.dir !== e.dir) {
|
||||
centerOf(e.pos, def, e.dir, rec.obj.position);
|
||||
rec.obj.rotation.y = yawFor(e.dir);
|
||||
rec.x = e.pos.x;
|
||||
rec.y = e.pos.y;
|
||||
rec.dir = e.dir;
|
||||
}
|
||||
|
||||
rec.jam.visible = !!e.jammed;
|
||||
if (e.jammed) anyJammed = true;
|
||||
rec.idle?.(timeSec + rec.phase);
|
||||
}
|
||||
|
||||
for (const [id, rec] of this.live) {
|
||||
if (!this.seen.has(id)) this.drop(id, rec);
|
||||
}
|
||||
|
||||
if (anyJammed) {
|
||||
this.jamMat.emissiveIntensity = Math.sin(timeSec * 7) > 0 ? 2.2 : 0.15;
|
||||
}
|
||||
}
|
||||
|
||||
private drop(id: number, rec: Rec): void {
|
||||
this.group.remove(rec.obj);
|
||||
// The jam light's geo/mat are shared across all machines — detach before any
|
||||
// disposal walks the subtree and frees them out from under everyone else.
|
||||
rec.obj.remove(rec.jam);
|
||||
if (rec.owns) disposeObject(rec.obj);
|
||||
this.live.delete(id);
|
||||
}
|
||||
}
|
||||
131
fktry/src/render/ghost.ts
Normal file
131
fktry/src/render/ghost.ts
Normal file
@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
78
fktry/src/render/ground.ts
Normal file
78
fktry/src/render/ground.ts
Normal file
@ -0,0 +1,78 @@
|
||||
/**
|
||||
* LANE-RENDER — Stream-Crust ground plane.
|
||||
*
|
||||
* A shader grid rather than GridHelper: resolution-independent antialiased lines,
|
||||
* major lines every 8 tiles, faint scanline grain, and a dissolve at the world edge
|
||||
* so the Bitstream fades into the void instead of ending on a hard rectangle.
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import { WORLD } from './palette';
|
||||
import { WORLD_HALF } from './camera';
|
||||
|
||||
const PLANE = WORLD_HALF * 5; // extends past the world; grid only drawn inside ±WORLD_HALF
|
||||
|
||||
const VERT = /* glsl */ `
|
||||
varying vec2 vWorld;
|
||||
void main() {
|
||||
vec4 wp = modelMatrix * vec4(position, 1.0);
|
||||
vWorld = wp.xz;
|
||||
gl_Position = projectionMatrix * viewMatrix * wp;
|
||||
}
|
||||
`;
|
||||
|
||||
const FRAG = /* glsl */ `
|
||||
precision highp float;
|
||||
uniform vec3 uBase;
|
||||
uniform vec3 uLine;
|
||||
uniform vec3 uMajor;
|
||||
uniform float uHalf;
|
||||
varying vec2 vWorld;
|
||||
|
||||
// Antialiased grid: distance to the nearest cell border, in pixels.
|
||||
float gridMask(vec2 coord, float width) {
|
||||
vec2 g = abs(fract(coord - 0.5) - 0.5) / fwidth(coord);
|
||||
return 1.0 - min(min(g.x, g.y) / width, 1.0);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec3 col = uBase;
|
||||
col = mix(col, uLine, gridMask(vWorld, 1.0) * 0.85);
|
||||
col = mix(col, uMajor, gridMask(vWorld / 8.0, 1.0) * 0.9);
|
||||
col += (sin(vWorld.y * 26.0) * 0.5 + 0.5) * 0.006; // crust grain
|
||||
|
||||
vec2 d = abs(vWorld) / uHalf;
|
||||
float inside = 1.0 - smoothstep(0.82, 1.0, max(d.x, d.y));
|
||||
gl_FragColor = vec4(mix(uBase, col, inside), 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
export function createGround(): THREE.Mesh {
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
uBase: { value: new THREE.Color(WORLD.crust) },
|
||||
uLine: { value: new THREE.Color(WORLD.gridLine) },
|
||||
uMajor: { value: new THREE.Color(WORLD.gridMajor) },
|
||||
uHalf: { value: WORLD_HALF },
|
||||
},
|
||||
vertexShader: VERT,
|
||||
fragmentShader: FRAG,
|
||||
});
|
||||
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(PLANE, PLANE), mat);
|
||||
mesh.rotation.x = -Math.PI / 2; // +y up; tile (x,y) -> world (x, 0, y)
|
||||
mesh.receiveShadow = true;
|
||||
mesh.renderOrder = -1;
|
||||
mesh.name = 'ground';
|
||||
return mesh;
|
||||
}
|
||||
|
||||
/** Screen-space vignette. Cheap, and keeps post-processing out of LANE-SCREEN's way. */
|
||||
export function createVignette(): HTMLDivElement {
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = [
|
||||
'position:absolute',
|
||||
'inset:0',
|
||||
'pointer-events:none',
|
||||
'background:radial-gradient(ellipse at center, rgba(0,0,0,0) 45%, rgba(4,3,9,0.78) 100%)',
|
||||
].join(';');
|
||||
return el;
|
||||
}
|
||||
@ -1,34 +1,171 @@
|
||||
/** LANE-RENDER territory. Stub — replace per lanes/LANE-RENDER.md. */
|
||||
/**
|
||||
* LANE-RENDER — the Bitstream, made visible.
|
||||
*
|
||||
* Isometric factory view. Sim is truth: we render snapshots and never simulate.
|
||||
* Composition: registry (assets + MODELBEAST hot-swap) -> ground / belts / entities /
|
||||
* belt items / ghost layers -> ortho iso camera.
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import type { Dir, GameData, Renderer, SimSnapshot, Vec2 } from '../contracts';
|
||||
import type { Dir, GameData, MachineDef, Renderer, SimSnapshot, Vec2 } from '../contracts';
|
||||
import { AssetRegistry } from './registry';
|
||||
import { CAM_DIST, CameraRig, WORLD_HALF } from './camera';
|
||||
import { createGround, createVignette } from './ground';
|
||||
import { EntityLayer } from './entities';
|
||||
import { BeltLayer } from './belts';
|
||||
import { BeltItemLayer } from './beltitems';
|
||||
import { GhostLayer } from './ghost';
|
||||
import { DevScene, devSceneEnabled, devSceneStress } from './devscene';
|
||||
import { WORLD } from './palette';
|
||||
|
||||
const KEY_INTENSITY = 1.9;
|
||||
const FILL_INTENSITY = 0.7;
|
||||
const AMBIENT_INTENSITY = 1.1;
|
||||
/** ACES rolls mid-tones down hard; this lifts the factory back off a near-black ground. */
|
||||
const BASE_EXPOSURE = 1.35;
|
||||
|
||||
export function createRenderer(): Renderer {
|
||||
let renderer: THREE.WebGLRenderer;
|
||||
let gl: THREE.WebGLRenderer;
|
||||
let scene: THREE.Scene;
|
||||
let camera: THREE.OrthographicCamera;
|
||||
let rig: CameraRig;
|
||||
let registry: AssetRegistry;
|
||||
let entities: EntityLayer;
|
||||
let belts: BeltLayer;
|
||||
let cargo: BeltItemLayer;
|
||||
let ghost: GhostLayer;
|
||||
let dev: DevScene | null = null;
|
||||
let defs: Map<string, MachineDef>;
|
||||
|
||||
const raycaster = new THREE.Raycaster();
|
||||
const groundPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
|
||||
const ndc = new THREE.Vector2();
|
||||
const hit = new THREE.Vector3();
|
||||
const clock = new THREE.Clock();
|
||||
let dim = 1; // smoothed brownout level
|
||||
/** Dev-only frame-cost sampler. Measures CPU ms per frame, which — unlike frame
|
||||
* RATE — stays honest even when the tab is backgrounded and rAF is throttled. */
|
||||
const stats = { ms: 0, avg: 0, frames: 0 };
|
||||
|
||||
return {
|
||||
async init(container: HTMLElement, _data: GameData) {
|
||||
renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setSize(container.clientWidth, container.clientHeight);
|
||||
container.appendChild(renderer.domElement);
|
||||
async init(container: HTMLElement, data: GameData) {
|
||||
defs = new Map(data.machines.map((m) => [m.id, m]));
|
||||
|
||||
gl = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
|
||||
gl.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||
gl.setSize(container.clientWidth, container.clientHeight);
|
||||
gl.shadowMap.enabled = true;
|
||||
gl.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
gl.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
container.appendChild(gl.domElement);
|
||||
container.appendChild(createVignette());
|
||||
|
||||
scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x0a0812);
|
||||
const aspect = container.clientWidth / container.clientHeight;
|
||||
const d = 12;
|
||||
camera = new THREE.OrthographicCamera(-d * aspect, d * aspect, d, -d, 0.1, 200);
|
||||
camera.position.set(20, 24, 20);
|
||||
camera.lookAt(0, 0, 0);
|
||||
scene.add(new THREE.GridHelper(64, 64, 0x2a2440, 0x181425));
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 0.8));
|
||||
addEventListener('resize', () => {
|
||||
renderer.setSize(container.clientWidth, container.clientHeight);
|
||||
});
|
||||
scene.background = new THREE.Color(WORLD.bg);
|
||||
// Fog distances are measured from the CAMERA, which an ortho iso rig parks
|
||||
// CAM_DIST away from everything — so the band starts past the focal point and
|
||||
// only bites at the far reaches. Absolute values here would fog the whole world
|
||||
// into the background.
|
||||
scene.fog = new THREE.Fog(WORLD.bg, CAM_DIST + 12, CAM_DIST + 78);
|
||||
scene.add(createGround());
|
||||
|
||||
const key = new THREE.DirectionalLight(0xbfc8ff, KEY_INTENSITY);
|
||||
key.position.set(18, 30, 12);
|
||||
key.castShadow = true;
|
||||
key.shadow.mapSize.set(2048, 2048);
|
||||
key.shadow.camera.near = 1;
|
||||
key.shadow.camera.far = 90;
|
||||
const s = 26;
|
||||
key.shadow.camera.left = -s;
|
||||
key.shadow.camera.right = s;
|
||||
key.shadow.camera.top = s;
|
||||
key.shadow.camera.bottom = -s;
|
||||
key.shadow.bias = -0.0012;
|
||||
scene.add(key);
|
||||
// Cool bounce from below-ish so machine flanks don't go pure black.
|
||||
const fill = new THREE.DirectionalLight(0x4a3f7a, FILL_INTENSITY);
|
||||
fill.position.set(-14, 8, -16);
|
||||
scene.add(fill);
|
||||
scene.add(new THREE.AmbientLight(0x8f8fbf, AMBIENT_INTENSITY));
|
||||
|
||||
rig = new CameraRig(container, gl.domElement);
|
||||
|
||||
registry = new AssetRegistry();
|
||||
await registry.init(data);
|
||||
|
||||
belts = new BeltLayer(registry, defs);
|
||||
entities = new EntityLayer(registry, defs);
|
||||
cargo = new BeltItemLayer(data, defs);
|
||||
ghost = new GhostLayer(registry, defs);
|
||||
scene.add(belts.group, entities.group, cargo.group, ghost.group);
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const onResize = () => {
|
||||
gl.setSize(container.clientWidth, container.clientHeight);
|
||||
rig.resize();
|
||||
};
|
||||
addEventListener('resize', onResize);
|
||||
new ResizeObserver(onResize).observe(container);
|
||||
},
|
||||
render(_snap: SimSnapshot, _alpha: number) {
|
||||
if (renderer) renderer.render(scene, camera);
|
||||
|
||||
render(snap: SimSnapshot, alpha: number) {
|
||||
if (!gl) return;
|
||||
const t0 = dev ? 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;
|
||||
|
||||
rig.update(dt);
|
||||
belts.sync(view, t);
|
||||
entities.sync(view, t);
|
||||
cargo.sync(view, alpha, t);
|
||||
ghost.update(view, t);
|
||||
|
||||
// Brownout: drop the whole scene ~30% and flicker. Driven through tone-mapping
|
||||
// exposure so emissives dim too — a brownout that left the glow at full would
|
||||
// read as a lighting bug rather than a power failure.
|
||||
const target = view.bandwidth.brownout ? 0.7 : 1;
|
||||
dim += (target - dim) * Math.min(1, dt * 6);
|
||||
const flicker = view.bandwidth.brownout
|
||||
? 0.88 + Math.sin(t * 37) * 0.08 + (Math.sin(t * 13.3) > 0.72 ? -0.22 : 0)
|
||||
: 1;
|
||||
gl.toneMappingExposure = BASE_EXPOSURE * dim * flicker;
|
||||
|
||||
gl.render(scene, rig.camera);
|
||||
|
||||
if (dev) {
|
||||
stats.ms = performance.now() - t0;
|
||||
stats.frames++;
|
||||
stats.avg += (stats.ms - stats.avg) * 0.05; // rolling
|
||||
}
|
||||
},
|
||||
|
||||
pickTile(clientX: number, clientY: number): Vec2 | null {
|
||||
if (!gl) return null;
|
||||
const rect = gl.domElement.getBoundingClientRect();
|
||||
ndc.x = ((clientX - rect.left) / rect.width) * 2 - 1;
|
||||
ndc.y = -((clientY - rect.top) / rect.height) * 2 + 1;
|
||||
raycaster.setFromCamera(ndc, rig.camera);
|
||||
if (!raycaster.ray.intersectPlane(groundPlane, hit)) return null;
|
||||
const x = Math.floor(hit.x);
|
||||
const y = Math.floor(hit.z);
|
||||
if (x < -WORLD_HALF || x >= WORLD_HALF || y < -WORLD_HALF || y >= WORLD_HALF) return null;
|
||||
return { x, y };
|
||||
},
|
||||
|
||||
setGhost(def: string | null, pos: Vec2 | null, dir: Dir) {
|
||||
ghost?.set(def, pos, dir);
|
||||
},
|
||||
pickTile(_x: number, _y: number): Vec2 | null { return null; },
|
||||
setGhost(_def: string | null, _pos: Vec2 | null, _dir: Dir) {},
|
||||
};
|
||||
}
|
||||
|
||||
78
fktry/src/render/palette.ts
Normal file
78
fktry/src/render/palette.ts
Normal file
@ -0,0 +1,78 @@
|
||||
/**
|
||||
* LANE-RENDER — world palette, per the style guide (docs/FKTRY_LORE.md §8).
|
||||
*
|
||||
* Rule the guide gives us: the base world is desaturated industrial neutrals; ALL
|
||||
* saturation belongs to media/artifacts. So machine BODIES live here as grimy
|
||||
* neutrals, and machine ACCENTS are derived from data (the colour of what the
|
||||
* machine makes) in registry.ts — colour = value = literally the resource.
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import type { MachineKind } from '../contracts';
|
||||
|
||||
const hsl = { h: 0, s: 0, l: 0 };
|
||||
|
||||
/**
|
||||
* Perceptual (sRGB) lightness of a data colour. Reasoned in sRGB rather than the
|
||||
* linear working space so the thresholds below mean what they look like.
|
||||
*/
|
||||
export function lightnessOf(hex: string): number {
|
||||
new THREE.Color(hex).getHSL(hsl, THREE.SRGBColorSpace);
|
||||
return hsl.l;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data colours are authored for the codex, not for legibility on a near-black ground:
|
||||
* MDAT ORE is #1a1a22, which renders as an invisible smudge. Lift very dark colours to
|
||||
* a floor, keeping hue and saturation, so every item still reads as itself on a belt.
|
||||
*/
|
||||
export function readable(hex: string, minL = 0.34): THREE.Color {
|
||||
const c = new THREE.Color(hex);
|
||||
c.getHSL(hsl, THREE.SRGBColorSpace);
|
||||
if (hsl.l < minL) c.setHSL(hsl.h, hsl.s, minL, THREE.SRGBColorSpace);
|
||||
return c;
|
||||
}
|
||||
|
||||
/** Below this a derived accent is too dark to be "the impossible element" (§8). */
|
||||
export const ACCENT_MIN_L = 0.3;
|
||||
|
||||
export const WORLD = {
|
||||
bg: 0x0a0812,
|
||||
crust: 0x0c0a15,
|
||||
gridLine: 0x181425,
|
||||
gridMajor: 0x241d38,
|
||||
} as const;
|
||||
|
||||
/** Grimy industrial bodies. Desaturated on purpose — the accent does the talking. */
|
||||
export const BODY_BY_KIND: Record<MachineKind, number> = {
|
||||
extractor: 0x6e6134, // grimy yellow chassis (codex §4 seam extractor)
|
||||
crafter: 0x3b3944,
|
||||
belt: 0x2a2833,
|
||||
splitter: 0x35333f,
|
||||
power: 0x45434f,
|
||||
buffer: 0x3a3f48,
|
||||
shipper: 0x4a4652,
|
||||
};
|
||||
|
||||
/** Fallback accent when a machine runs no recipes (belts, power, shippers). */
|
||||
export const ACCENT_BY_KIND: Record<MachineKind, number> = {
|
||||
extractor: 0x9fe8ff, // CRT monitor head
|
||||
crafter: 0x3fffe0,
|
||||
belt: 0x4a5568,
|
||||
splitter: 0x7fb0ff,
|
||||
power: 0xffb03f, // serene LED face
|
||||
buffer: 0x7fffd4,
|
||||
shipper: 0x3fd4ff, // uplink to THE SCREEN
|
||||
};
|
||||
|
||||
/** Body height in world units. Silhouette differentiation on a flat iso grid. */
|
||||
export const HEIGHT_BY_KIND: Record<MachineKind, number> = {
|
||||
extractor: 1.15,
|
||||
crafter: 0.8,
|
||||
belt: 0.14,
|
||||
splitter: 0.45,
|
||||
power: 0.95,
|
||||
buffer: 0.7,
|
||||
shipper: 1.3,
|
||||
};
|
||||
|
||||
export const JAM_AMBER = 0xffa726;
|
||||
313
fktry/src/render/registry.ts
Normal file
313
fktry/src/render/registry.ts
Normal file
@ -0,0 +1,313 @@
|
||||
/**
|
||||
* LANE-RENDER — asset registry + MODELBEAST hot-swap.
|
||||
*
|
||||
* Every entity mesh keys off `MachineDef.asset` through here. No hard-coded ids.
|
||||
*
|
||||
* Default for every key is a procedural placeholder built to the style guide's
|
||||
* two-material rule: a grimy neutral body + exactly ONE emissive accent, where the
|
||||
* accent colour is DERIVED FROM DATA — the colour of the item the machine makes
|
||||
* (recipes -> outputs -> ItemDef.color). A quantizer glows coefficient-cyan, a mosh
|
||||
* reactor glows melt-orange, and a new machine in data/ lights up with zero code.
|
||||
*
|
||||
* If `public/assets/models/<key>.glb` exists, it replaces the placeholder factory.
|
||||
* We probe on init and re-probe every 10s in dev, so an asset dropped in mid-session
|
||||
* appears without a reload. GLBs are auto-normalised (scaled to footprint, sat on the
|
||||
* ground) so MODELBEAST can export at any scale and it just lands correctly.
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||
import { RoundedBoxGeometry } from 'three/examples/jsm/geometries/RoundedBoxGeometry.js';
|
||||
import type { GameData, MachineDef } from '../contracts';
|
||||
import { ACCENT_BY_KIND, ACCENT_MIN_L, BODY_BY_KIND, HEIGHT_BY_KIND, lightnessOf } from './palette';
|
||||
|
||||
const MODEL_DIR = 'assets/models';
|
||||
const POLL_MS = 10_000;
|
||||
|
||||
/** Per-frame idle animation attached to a placeholder. `t` is seconds (phase-shifted). */
|
||||
export type IdleFn = (t: number) => void;
|
||||
|
||||
export interface AssetEntry {
|
||||
key: string;
|
||||
/** Bumps when a GLB hot-swaps in; layers rebuild meshes whose version moved. */
|
||||
version: number;
|
||||
isGLB: boolean;
|
||||
/** A fresh instance, normalised to the machine footprint, origin at ground centre. */
|
||||
create(): THREE.Object3D;
|
||||
/** Single geo+mat for InstancedMesh use (belts). Null if the asset can't be instanced. */
|
||||
instanceable(): { geometry: THREE.BufferGeometry; material: THREE.Material } | null;
|
||||
}
|
||||
|
||||
export function idleOf(obj: THREE.Object3D): IdleFn | null {
|
||||
return (obj.userData.fktryIdle as IdleFn | undefined) ?? null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- placeholders
|
||||
|
||||
function bodyMat(color: number): THREE.MeshStandardMaterial {
|
||||
return new THREE.MeshStandardMaterial({ color, roughness: 0.85, metalness: 0.15 });
|
||||
}
|
||||
|
||||
function accentMat(color: number): THREE.MeshStandardMaterial {
|
||||
return new THREE.MeshStandardMaterial({
|
||||
color,
|
||||
emissive: color,
|
||||
emissiveIntensity: 1.35,
|
||||
roughness: 0.35,
|
||||
metalness: 0.1,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Procedural placeholder: footprint-sized bevelled box, one emissive accent, one tiny
|
||||
* idle motion. Accent geometry + idle differ per kind so silhouettes read apart on the
|
||||
* iso grid even before real assets land.
|
||||
*/
|
||||
function makePlaceholder(def: MachineDef, accent: number): 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 body = new THREE.Mesh(
|
||||
new RoundedBoxGeometry(fw, h, fd, 2, Math.min(0.08, h * 0.25)),
|
||||
bodyMat(BODY_BY_KIND[def.kind] ?? 0x3b3944),
|
||||
);
|
||||
body.position.y = h / 2;
|
||||
body.castShadow = body.receiveShadow = true;
|
||||
g.add(body);
|
||||
|
||||
const am = accentMat(accent);
|
||||
|
||||
switch (def.kind) {
|
||||
case 'extractor': {
|
||||
// CRT monitor for a head + a drill bit that spins (codex §4).
|
||||
const crt = new THREE.Mesh(new THREE.BoxGeometry(fw * 0.5, h * 0.3, 0.06), am);
|
||||
crt.position.set(0, h * 0.72, -fd / 2 - 0.01);
|
||||
g.add(crt);
|
||||
const drill = new THREE.Mesh(new THREE.ConeGeometry(fw * 0.16, h * 0.5, 6), am.clone());
|
||||
drill.position.set(0, h * 0.22, fd * 0.22);
|
||||
drill.rotation.x = Math.PI;
|
||||
g.add(drill);
|
||||
g.userData.fktryIdle = ((t: number) => {
|
||||
drill.rotation.y = t * 4;
|
||||
(crt.material as THREE.MeshStandardMaterial).emissiveIntensity =
|
||||
1.2 + Math.sin(t * 9) * 0.25; // CRT flicker
|
||||
}) satisfies IdleFn;
|
||||
break;
|
||||
}
|
||||
case 'crafter': {
|
||||
// The jaw: an emissive slab that bobs. Quantizer energy, applied to all crafters.
|
||||
const jaw = new THREE.Mesh(new THREE.BoxGeometry(fw * 0.62, h * 0.14, fd * 0.62), am);
|
||||
jaw.position.y = h + 0.02;
|
||||
g.add(jaw);
|
||||
g.userData.fktryIdle = ((t: number) => {
|
||||
jaw.position.y = h + 0.02 + Math.abs(Math.sin(t * 1.6)) * 0.09;
|
||||
}) satisfies IdleFn;
|
||||
break;
|
||||
}
|
||||
case 'power': {
|
||||
const led = new THREE.Mesh(new THREE.BoxGeometry(fw * 0.7, 0.05, fd * 0.12), am);
|
||||
led.position.y = h + 0.02;
|
||||
g.add(led);
|
||||
g.userData.fktryIdle = ((t: number) => {
|
||||
am.emissiveIntensity = 1.0 + Math.sin(t * 2.2) * 0.5; // serene LED breath
|
||||
}) satisfies IdleFn;
|
||||
break;
|
||||
}
|
||||
case 'shipper': {
|
||||
// Uplink dish, pointed at THE SCREEN.
|
||||
const dish = new THREE.Mesh(new THREE.ConeGeometry(fw * 0.3, h * 0.42, 12, 1, true), am);
|
||||
dish.position.y = h + h * 0.2;
|
||||
g.add(dish);
|
||||
g.userData.fktryIdle = ((t: number) => {
|
||||
dish.rotation.y = t * 1.1;
|
||||
am.emissiveIntensity = 1.2 + Math.sin(t * 6) * 0.35;
|
||||
}) satisfies IdleFn;
|
||||
break;
|
||||
}
|
||||
case 'buffer': {
|
||||
const ring = new THREE.Mesh(new THREE.TorusGeometry(fw * 0.3, 0.035, 8, 20), am);
|
||||
ring.position.y = h + 0.04;
|
||||
ring.rotation.x = Math.PI / 2;
|
||||
g.add(ring);
|
||||
g.userData.fktryIdle = ((t: number) => {
|
||||
ring.rotation.z = t * 0.8;
|
||||
}) satisfies IdleFn;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// splitter / belt / anything new in data/: a simple emissive brow.
|
||||
const brow = new THREE.Mesh(new THREE.BoxGeometry(fw * 0.55, 0.05, fd * 0.2), am);
|
||||
brow.position.y = h + 0.02;
|
||||
g.add(brow);
|
||||
g.userData.fktryIdle = ((t: number) => {
|
||||
am.emissiveIntensity = 1.1 + Math.sin(t * 3) * 0.25;
|
||||
}) satisfies IdleFn;
|
||||
}
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
/** The flat belt plate. Instanced by belts.ts; chevron scroll lives in its material. */
|
||||
export function beltPlateGeometry(): THREE.BufferGeometry {
|
||||
const geo = new THREE.BoxGeometry(0.94, HEIGHT_BY_KIND.belt, 0.94);
|
||||
geo.translate(0, HEIGHT_BY_KIND.belt / 2, 0);
|
||||
return geo;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- GLB handling
|
||||
|
||||
/** Fit an arbitrary GLB into the machine footprint, bottom sat on the ground plane. */
|
||||
function normaliseGLB(src: THREE.Object3D, def: MachineDef): THREE.Object3D {
|
||||
const obj = src.clone(true);
|
||||
const box = new THREE.Box3().setFromObject(obj);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const targetW = Math.max(0.4, def.footprint.x * 0.92);
|
||||
const targetD = Math.max(0.4, def.footprint.y * 0.92);
|
||||
const s = Math.min(
|
||||
size.x > 1e-6 ? targetW / size.x : 1,
|
||||
size.z > 1e-6 ? targetD / size.z : 1,
|
||||
);
|
||||
if (Number.isFinite(s) && s > 0) obj.scale.setScalar(s);
|
||||
|
||||
// Re-measure after scaling, then centre on X/Z and sit the base at y=0.
|
||||
const box2 = new THREE.Box3().setFromObject(obj);
|
||||
const c = box2.getCenter(new THREE.Vector3());
|
||||
obj.position.x -= c.x;
|
||||
obj.position.z -= c.z;
|
||||
obj.position.y -= box2.min.y;
|
||||
obj.traverse((o) => {
|
||||
if ((o as THREE.Mesh).isMesh) o.castShadow = o.receiveShadow = true;
|
||||
});
|
||||
return obj;
|
||||
}
|
||||
|
||||
function firstInstanceable(
|
||||
root: THREE.Object3D,
|
||||
): { geometry: THREE.BufferGeometry; material: THREE.Material } | null {
|
||||
let found: THREE.Mesh | null = null;
|
||||
root.updateWorldMatrix(true, true);
|
||||
root.traverse((o) => {
|
||||
const m = o as THREE.Mesh;
|
||||
if (!found && m.isMesh && m.geometry) found = m;
|
||||
});
|
||||
if (!found) return null;
|
||||
const mesh = found as THREE.Mesh;
|
||||
const geometry = mesh.geometry.clone().applyMatrix4(mesh.matrixWorld);
|
||||
const material = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
|
||||
return { geometry, material };
|
||||
}
|
||||
|
||||
/**
|
||||
* True only if the URL is really a GLB. A dev server's SPA fallback happily answers
|
||||
* 200 + text/html for a missing file, so check the content-type before we trust it.
|
||||
*/
|
||||
async function glbExists(url: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(url, { method: 'HEAD', cache: 'no-store' });
|
||||
if (!res.ok) return false;
|
||||
const ct = res.headers.get('content-type') ?? '';
|
||||
return !ct.includes('text/html');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- registry
|
||||
|
||||
export class AssetRegistry {
|
||||
/** Bumps on any hot-swap. Layers compare against it to know a rebuild is due. */
|
||||
version = 0;
|
||||
|
||||
private entries = new Map<string, AssetEntry>();
|
||||
private defs = new Map<string, MachineDef>();
|
||||
private loader = new GLTFLoader();
|
||||
private timer: number | null = null;
|
||||
|
||||
async init(data: GameData): Promise<void> {
|
||||
for (const def of data.machines) {
|
||||
this.defs.set(def.asset, def);
|
||||
const accent = accentFor(def, data);
|
||||
this.entries.set(def.asset, this.placeholderEntry(def, accent));
|
||||
}
|
||||
await this.probeAll();
|
||||
if (import.meta.env.DEV) {
|
||||
this.timer = setInterval(() => void this.probeAll(), POLL_MS) as unknown as number;
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.timer !== null) clearInterval(this.timer);
|
||||
}
|
||||
|
||||
get(key: string): AssetEntry | null {
|
||||
return this.entries.get(key) ?? null;
|
||||
}
|
||||
|
||||
private placeholderEntry(def: MachineDef, accent: number): AssetEntry {
|
||||
const prev = this.entries.get(def.asset);
|
||||
return {
|
||||
key: def.asset,
|
||||
version: prev ? prev.version : 0,
|
||||
isGLB: false,
|
||||
create: () => makePlaceholder(def, accent),
|
||||
// Belts are instanced by belts.ts, which supplies the chevron material itself —
|
||||
// this material is only a fallback and is normally discarded.
|
||||
instanceable: () =>
|
||||
def.kind === 'belt'
|
||||
? { geometry: beltPlateGeometry(), material: bodyMat(BODY_BY_KIND.belt) }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Probe every key that hasn't got a GLB yet. Cheap: one HEAD each, 10s apart. */
|
||||
private async probeAll(): Promise<void> {
|
||||
const pending = [...this.defs.values()].filter((d) => !this.entries.get(d.asset)?.isGLB);
|
||||
await Promise.all(pending.map((d) => this.tryLoad(d)));
|
||||
}
|
||||
|
||||
private async tryLoad(def: MachineDef): Promise<void> {
|
||||
const url = `${import.meta.env.BASE_URL}${MODEL_DIR}/${def.asset}.glb`;
|
||||
if (!(await glbExists(url))) return;
|
||||
try {
|
||||
const gltf = await this.loader.loadAsync(url);
|
||||
const prev = this.entries.get(def.asset);
|
||||
const version = (prev?.version ?? 0) + 1;
|
||||
const template = gltf.scene;
|
||||
this.entries.set(def.asset, {
|
||||
key: def.asset,
|
||||
version,
|
||||
isGLB: true,
|
||||
create: () => normaliseGLB(template, def),
|
||||
instanceable: () => firstInstanceable(normaliseGLB(template, def)),
|
||||
});
|
||||
this.version++;
|
||||
console.info(`[render] hot-swapped GLB for "${def.asset}"`);
|
||||
} catch (err) {
|
||||
// Corrupt/half-written file (MODELBEAST may still be copying). Keep the
|
||||
// placeholder and let the next poll retry.
|
||||
console.warn(`[render] GLB for "${def.asset}" failed to parse, keeping placeholder`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A machine's accent = the colour of what it makes.
|
||||
*
|
||||
* With one guard: raw products are near-black by design (MDAT ORE is #1a1a22), and an
|
||||
* accent that dark isn't an accent — the seam extractor's CRT head would be a black
|
||||
* panel on a black chassis. Any derived colour below the legibility floor is rejected
|
||||
* in favour of the per-kind accent (which for the extractor is CRT blue, as the codex
|
||||
* describes it). Machines that run no recipes fall back the same way.
|
||||
*/
|
||||
function accentFor(def: MachineDef, data: GameData): number {
|
||||
for (const rid of def.recipes) {
|
||||
const recipe = data.recipes.find((r) => r.id === rid);
|
||||
const outId = recipe && Object.keys(recipe.outputs)[0];
|
||||
const item = outId ? data.items.find((i) => i.id === outId) : undefined;
|
||||
if (item && lightnessOf(item.color) >= ACCENT_MIN_L) {
|
||||
return new THREE.Color(item.color).getHex();
|
||||
}
|
||||
}
|
||||
return ACCENT_BY_KIND[def.kind] ?? 0x3fffe0;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user