Rungs 2-4 embodied as hospital-white placeholder bodies (warden / gunship / auditor), the gunship's ghost-mirrored sector + parity tether, the auditor's 0:00 stasis bleach, compliance placards, and the v9 onEvents migration. The inversion is measured, not asserted: fed a worst-case integer-stepped position that the sim moves on 6 of 120 frames, the warden renders 120 distinct positions with zero stalled frames. The auditor renders 1 position and 119 stalled frames, deliberately — it moves only between frames (codex 5). - correction.ts: tick-lerp + damped follow for wardens/gunships, instant relocation + blink for auditors, instanced ghost mirror (cap 512), tether, bleach + 00:00:00:00 timecode, and the frozen-entity set - entities.ts: compliance placard + total idle/mixer/haze freeze (idle clock delta measured at exactly 0.000000 over 30 sealed frames) - beltitems.ts: cargo pinned MID-TILE inside a lock; the freeze path is cheaper than the normal path (0.392 vs 0.465 ms at 2,000 items) - index.ts: onEvents buffered and drained at the top of render(); repaired, the firewall can, and relicFound all retired as snapshot heuristics - notice.ts: the fax-side notice halo, with a one-frame retry because main.ts runs the renderer BEFORE the UI and the panel may not be laid out yet - correction.test.ts: 9 tests, mutation-checked. Pins the cross-lane rect convention against LANE-SIM's own frozenEnt formula over 4,896 cases. Perf at a deliberate worst case (500 entities, 2,000 items, the entire base both mirrored AND frozen): whole frame 2.30 ms median against a 16.6 ms budget; this round's net cost ~0.2 ms. Prod bundle clean of all scaffold. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
516 lines
23 KiB
TypeScript
516 lines
23 KiB
TypeScript
/**
|
|
* 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, MachineDef, Renderer, SimEvent, 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 { Showroom, showroomEnabled } from './showroom';
|
|
import { BeltTopology } from './topology';
|
|
import { SeamLayer, loadSeams } from './seams';
|
|
import { RelicLayer } from './relics';
|
|
import { WildlifeLayer, harassedIn } from './wildlife';
|
|
import { Juice } from './juice';
|
|
import { BreakRoom } from './breakroom';
|
|
import { WORLD } from './palette';
|
|
import { createEnvironment } from './env';
|
|
import { centerOf } from './coords';
|
|
import { CorrectionLayer } from './correction';
|
|
import { NoticePing } from './notice';
|
|
|
|
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;
|
|
/**
|
|
* How much of the new environment (env.ts) the WORLD's own materials take — belts, belt
|
|
* items, placeholders, ghosts, wildlife, the cabinet. Deliberately gentle: those were
|
|
* art-directed across six rounds under no IBL at all, and relighting them wholesale is
|
|
* a regression dressed as a fix. This is a dusting for coherence so a placeholder and a
|
|
* GLB read as standing in the same room. Hot-swapped GLBs opt into a much stronger dose
|
|
* of their own (`GLB_ENV_INTENSITY` in registry.ts) because they are the metals — see
|
|
* the arithmetic at the top of env.ts.
|
|
*
|
|
* Measured while tuning, and worth writing down: `AmbientLight` intensity is nearly
|
|
* inert in this scene. Sweeping it 0.55 -> 1.1 moved a placeholder body by ~7% and the
|
|
* high-metalness GLBs by <1%, because ambient only feeds the diffuse term that
|
|
* metalness has already zeroed. Left at its round-1 value rather than "compensated".
|
|
*/
|
|
const ENV_WORLD_INTENSITY = 0.5;
|
|
|
|
export function createRenderer(): Renderer {
|
|
let gl: THREE.WebGLRenderer;
|
|
let scene: THREE.Scene;
|
|
let rig: CameraRig;
|
|
let registry: AssetRegistry;
|
|
let entities: EntityLayer;
|
|
let belts: BeltLayer;
|
|
let cargo: BeltItemLayer;
|
|
let ghost: GhostLayer;
|
|
let relics: RelicLayer;
|
|
let wildlife: WildlifeLayer;
|
|
let correction: CorrectionLayer;
|
|
let notice: NoticePing;
|
|
let juice: Juice;
|
|
let breakroom: BreakRoom;
|
|
let seamLayer: SeamLayer | null = null;
|
|
let dev: DevScene | null = null;
|
|
let showroom: Showroom | null = null;
|
|
let host: HTMLElement;
|
|
let defs: Map<string, MachineDef>;
|
|
/** Shipment-flash detection: last frame's total shipped count, and the shipper tiles. */
|
|
let lastShipped = -1;
|
|
const shipperCentres: Vec2[] = [];
|
|
const itemColors = new Map<string, number>();
|
|
const lastPerItem = new Map<string, number>();
|
|
/** 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();
|
|
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 };
|
|
/**
|
|
* Round 7 boot timing, so "GLBs no longer block first paint" is a number and not a
|
|
* vibe. Dev-only, and every write sits inside a literal `import.meta.env.DEV` so
|
|
* Rollup folds the branches out of the bundle (round 3 learned that the hard way).
|
|
*
|
|
* `readyAt` is the honest one: main.ts awaits `renderer.init()` before it starts the
|
|
* frame loop, so the moment init returns is the earliest a frame can exist. `firstAt`
|
|
* is the real first `gl.render`, but rAF is throttled hard in a background tab, so
|
|
* read it only on a focused window.
|
|
*/
|
|
const perf = { initMs: 0, readyAt: 0, firstAt: 0 };
|
|
let firstFrameDone = false;
|
|
|
|
/**
|
|
* Which entity the inspector has open, if any.
|
|
*
|
|
* BRIDGE: `Renderer` has no inspect channel — `setGhost`'s mode is only build|remove,
|
|
* and main.ts calls it with 'build' during inspect, so nothing about the inspected
|
|
* entity reaches this lane. The typed selection DOES carry it (`{mode:'inspect',
|
|
* entity}`), and the bus is the designated cross-lane global, so we read it here.
|
|
* Same shape as the '__remove' sentinel bridge: it works, it's documented on both
|
|
* sides, and it wants a real contract hook. Filed in NOTES.
|
|
*/
|
|
function inspectedEntity(): number | null {
|
|
const bus = (window as unknown as { __fktryBus?: { selection?: () => unknown } }).__fktryBus;
|
|
const sel = bus?.selection?.() as { mode?: string; entity?: number } | null | undefined;
|
|
return sel && sel.mode === 'inspect' && typeof sel.entity === 'number' ? sel.entity : null;
|
|
}
|
|
|
|
/** Uplink tile centres, recomputed on demand — machines come and go. */
|
|
function refreshShippers(view: SimSnapshot): void {
|
|
shipperCentres.length = 0;
|
|
for (const e of view.entities) {
|
|
const d = defs.get(e.def);
|
|
if (d?.kind !== 'shipper') continue;
|
|
const c = centerOf(e.pos, d, e.dir);
|
|
shipperCentres.push({ x: c.x, y: c.z });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A rise in `shippedTotal` is a shipment — flash the uplinks in the shipped item's
|
|
* colour. Still snapshot-derived: v9's `shipped` event carries item and count but no
|
|
* position, so the uplink tiles have to come from the snapshot either way, and the
|
|
* delta is already exact. Left alone deliberately rather than migrated for its own sake.
|
|
*/
|
|
function shipmentFlash(view: SimSnapshot): void {
|
|
let total = 0;
|
|
let hottest: string | null = null;
|
|
let best = 0;
|
|
for (const k in view.shippedTotal) {
|
|
const n = view.shippedTotal[k];
|
|
total += n;
|
|
const prev = lastPerItem.get(k) ?? 0;
|
|
if (n - prev > best) { best = n - prev; hottest = k; }
|
|
lastPerItem.set(k, n);
|
|
}
|
|
if (lastShipped < 0) { lastShipped = total; return; } // first frame: prime, don't flash
|
|
if (total > lastShipped) {
|
|
refreshShippers(view);
|
|
const col = (hottest !== null ? itemColors.get(hottest) : undefined) ?? 0xff7a3f;
|
|
for (const c of shipperCentres) juice.shipFlash(c.x, c.y, col);
|
|
}
|
|
lastShipped = total;
|
|
}
|
|
|
|
/**
|
|
* v9 EVENT CHANNEL — granted after three rounds of asking (rounds 5, 6, 7-phase-0).
|
|
*
|
|
* main.ts calls `onEvents(events)` immediately before `render(snap, alpha)` with the
|
|
* same array ScreenFX and AudioFX get. We BUFFER rather than act: at onEvents time the
|
|
* frame's `view` hasn't been resolved yet (showroom/devscene may substitute a different
|
|
* snapshot), and several cues need to look an entity or a relic up in it. Draining at
|
|
* the top of render() keeps every cue and the snapshot it reasons about on the same
|
|
* frame. Bounded — main.ts drains the sim every frame, so this never grows.
|
|
*
|
|
* What this retired: three cues that rounds 6 and 7-phase-0 derived from snapshot
|
|
* deltas (`repaired`, the firewall "can", `relicFound`) and their heuristics with them.
|
|
*/
|
|
const pending: SimEvent[] = [];
|
|
const relicsFound = new Set<number>();
|
|
|
|
function applyEvents(view: SimSnapshot): void {
|
|
relicsFound.clear();
|
|
if (!pending.length) return;
|
|
for (const ev of pending) {
|
|
switch (ev.kind) {
|
|
case 'repaired':
|
|
// EXACT now: the mite's correction lands at the event's own pos, on the frame
|
|
// the sim says it landed. Round 6 inferred both from a mite's `size` crossing.
|
|
juice.tidy(ev.pos.x + 0.5, ev.pos.y + 0.5);
|
|
break;
|
|
case 'wildlife':
|
|
// A parity mite leaving. Sated ones reached the rim carrying their correction —
|
|
// no cue. One canned by a firewall mid-repair reads as filed paperwork. The
|
|
// event gives exact timing and position; `size` is the only thing that still
|
|
// discriminates the two, so it comes from the layer's last-seen state.
|
|
if (ev.on === 'cleared' && ev.wildlife === 'parity-mite') {
|
|
const size = wildlife.sizeOfMite(ev.id);
|
|
if (size !== undefined && size < 1) juice.tidy(ev.pos.x + 0.5, ev.pos.y + 0.5);
|
|
}
|
|
break;
|
|
case 'relicFound':
|
|
relicsFound.add(ev.id); // RelicLayer knows where it is; the event doesn't say
|
|
break;
|
|
case 'notice':
|
|
// Fax first, never silent (v9). The paper cue is on the glass; the world gets a
|
|
// small twitch at the uplinks, which is what The Correction is auditing.
|
|
notice.fire(ev.unit);
|
|
refreshShippers(view);
|
|
for (const c of shipperCentres) juice.notice(c.x, c.y);
|
|
break;
|
|
case 'mirrored': {
|
|
// The clean copy out-shipped you. Flash inside the sector, in the item's colour
|
|
// draining to white — it left as yours and arrived as theirs.
|
|
const col = itemColors.get(ev.item) ?? 0xffffff;
|
|
juice.mirrorFlash(ev.pos.x + 0.5, ev.pos.y + 0.5, col);
|
|
break;
|
|
}
|
|
case 'sealed': {
|
|
if (!ev.on) break; // un-sealing is the player's win; the pry itself is UI's cue
|
|
const e = view.entities.find((x) => x.id === ev.entity);
|
|
const d = e ? defs.get(e.def) : undefined;
|
|
if (e && d) {
|
|
const c = centerOf(e.pos, d, e.dir);
|
|
juice.stamp(c.x, c.z); // the rubber stamp landing
|
|
}
|
|
break;
|
|
}
|
|
case 'stasis':
|
|
correction.onStasis(ev.rect, ev.on, ev.lockHash);
|
|
break;
|
|
default:
|
|
break; // shipped/crafted/jammed/... are other layers' business
|
|
}
|
|
}
|
|
pending.length = 0;
|
|
}
|
|
|
|
/** True when the pointer is over the arcade cabinet. */
|
|
function cabinetHit(clientX: number, clientY: number): boolean {
|
|
if (!breakroom) return false;
|
|
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);
|
|
return raycaster.intersectObjects(breakroom.clickable, false).length > 0;
|
|
}
|
|
|
|
/**
|
|
* The cabinet is a world object, so it gets world-object interaction: pointer cursor on
|
|
* hover, click to open. Listeners live on the CANVAS and stop propagation on a hit, so
|
|
* main.ts's #game handler never sees the click — otherwise clicking the cabinet while
|
|
* holding a machine would also place one on the tile behind it.
|
|
*/
|
|
function installCabinetPointer(container: HTMLElement): void {
|
|
const canvas = gl.domElement;
|
|
canvas.addEventListener('pointermove', (e) => {
|
|
const over = cabinetHit(e.clientX, e.clientY);
|
|
// Only claim the cursor when nothing else is driving it (build/remove modes have
|
|
// their own affordance via the ghost).
|
|
canvas.style.cursor = over ? 'pointer' : '';
|
|
if (over) container.title = 'GLYTCH';
|
|
else if (container.title) container.title = '';
|
|
});
|
|
canvas.addEventListener(
|
|
'pointerdown',
|
|
(e) => {
|
|
if (e.button !== 0 || !cabinetHit(e.clientX, e.clientY)) return;
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
window.open('./match.html', '_blank', 'noopener');
|
|
},
|
|
{ capture: true },
|
|
);
|
|
}
|
|
|
|
return {
|
|
async init(container: HTMLElement, data: GameData) {
|
|
const t0 = performance.now();
|
|
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));
|
|
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(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));
|
|
|
|
// THE ENVIRONMENT (round 7 root-cause fix). Until now `scene.environment` was
|
|
// null, which for a metal is not "no reflections" but "reflects the void" — and
|
|
// 16 of the 27 shipped GLBs are metalness >= 0.5, where diffuse is nearly gone
|
|
// and indirect specular is the whole material. See env.ts for the arithmetic.
|
|
// Built from the real light directions so the reflection agrees with the shading.
|
|
const env = createEnvironment(gl, key.position, fill.position);
|
|
scene.environment = env;
|
|
scene.environmentIntensity = ENV_WORLD_INTENSITY;
|
|
|
|
rig = new CameraRig(container, gl.domElement);
|
|
|
|
registry = new AssetRegistry();
|
|
// Hot-swapped GLBs get their own, much stronger, share of the environment — they
|
|
// are the metals. Must be handed over BEFORE init kicks the first probe off.
|
|
registry.setEnvironment(env);
|
|
// Synchronous: placeholders exist the instant this returns, GLBs hot-swap in.
|
|
registry.init(data);
|
|
|
|
juice = new Juice();
|
|
belts = new BeltLayer(registry, defs, topo);
|
|
entities = new EntityLayer(registry, defs, juice);
|
|
cargo = new BeltItemLayer(data, topo);
|
|
ghost = new GhostLayer(registry, defs, data);
|
|
relics = new RelicLayer(juice);
|
|
wildlife = new WildlifeLayer();
|
|
// THE CORRECTION, rungs 2-4. Added LAST of the world layers so its translucent
|
|
// ghost-mirror and stasis bleach sort over the factory they are overwriting.
|
|
correction = new CorrectionLayer(defs, juice);
|
|
notice = new NoticePing(container);
|
|
scene.add(
|
|
belts.group, entities.group, cargo.group, ghost.group, ghost.aura.group,
|
|
relics.group, wildlife.group, correction.group, juice.group,
|
|
);
|
|
|
|
// THE FLOOR: seam regions from data — era skins the crust, the ore glints.
|
|
seamLayer = new SeamLayer(loadSeams(), data);
|
|
scene.add(seamLayer.mesh);
|
|
|
|
// THE BREAK ROOM: the GLYTCH cabinet, a short walk south-east of the start zone.
|
|
// Position is chosen to land in CLICKABLE screen space at the default framing —
|
|
// at (-5,-5) it rendered underneath `#screen`, the decorative overlay that still
|
|
// takes pointer events, so the cabinet was both invisible and unclickable (see the
|
|
// B8 picking findings in NOTES).
|
|
breakroom = new BreakRoom(6, 12);
|
|
scene.add(breakroom.group);
|
|
installCabinetPointer(container);
|
|
|
|
// Item colours for the shipment flash, and the shipper tiles (recomputed on demand).
|
|
for (const it of data.items) itemColors.set(it.id, new THREE.Color(it.color).getHex());
|
|
|
|
debug = devSceneEnabled() || showroomEnabled();
|
|
|
|
// B8 review finding: the start zone must clear the build bar. The bar sits at
|
|
// screen-bottom, and in this iso rig screen-down = world +x/+z, so biasing the
|
|
// camera target south-east lifts world origin into the upper play area where a
|
|
// fresh game's first machines get placed. (Showroom/devscene set their own frame.)
|
|
if (!showroomEnabled()) rig.frame(3, 6, 15);
|
|
|
|
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');
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
// v9: the Correction layer plus a way to inject SimEvents by hand. The devscene
|
|
// fabricates snapshots, not events, so this is the only way to exercise the
|
|
// onEvents cues (notice / mirrored / sealed / stasis / repaired) before SIM's
|
|
// escalation ladder happens to climb during a verification session.
|
|
correction, juice, wildlife, relics, notice,
|
|
fire: (...e: SimEvent[]) => { for (const x of e) pending.push(x); },
|
|
};
|
|
}
|
|
|
|
const onResize = () => {
|
|
gl.setSize(container.clientWidth, container.clientHeight);
|
|
rig.resize();
|
|
};
|
|
addEventListener('resize', onResize);
|
|
new ResizeObserver(onResize).observe(container);
|
|
|
|
if (import.meta.env.DEV) {
|
|
perf.readyAt = performance.now();
|
|
perf.initMs = perf.readyAt - t0;
|
|
(window as unknown as { __fktryPerf: unknown }).__fktryPerf = perf;
|
|
}
|
|
},
|
|
|
|
render(snap: SimSnapshot, alpha: number) {
|
|
if (!gl) return;
|
|
const t0 = debug ? performance.now() : 0;
|
|
// Clamp the frame delta. A backgrounded tab, an alt-tab, a GC pause or the first
|
|
// frame after load all hand back a delta of SECONDS (measured 63.7s), which
|
|
// integrates particles to infinity and kills every effect whose lifetime is
|
|
// shorter than the hitch — juice simply never appeared. main.ts clamps the sim's
|
|
// accumulator for the same reason; this is the renderer's half.
|
|
const dt = Math.min(0.05, clock.getDelta());
|
|
const t = clock.elapsedTime;
|
|
|
|
// 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);
|
|
}
|
|
|
|
// Events first: every one-shot cue reasons about THIS frame's view, and the stasis
|
|
// rects a `stasis` event opens must exist before CorrectionLayer computes what is
|
|
// frozen — otherwise the freeze would always be one frame late.
|
|
applyEvents(view);
|
|
|
|
rig.update(dt);
|
|
// Topology is shared by belts (which piece to draw) and cargo (which path the
|
|
// items take), so it is rebuilt once here rather than twice downstream.
|
|
topo.rebuild(view, defs);
|
|
// The Correction runs BEFORE the layers that read its `frozen` set. It is also the
|
|
// one layer that takes `alpha`: rungs 2-4 glide, everything the player owns judders
|
|
// (§8 — wrong motion belongs to corruption, not to authority). See correction.ts.
|
|
correction.sync(view, alpha, t, dt, rig.camera);
|
|
belts.sync(view, t);
|
|
entities.sync(view, t, dt, harassedIn(view), correction.frozen);
|
|
cargo.sync(view, alpha, t, correction.frozen);
|
|
ghost.update(view, t, inspectedEntity());
|
|
relics.sync(view.relics, t, relicsFound);
|
|
wildlife.sync(view.wildlife, t);
|
|
seamLayer?.frame(t);
|
|
breakroom.frame(t);
|
|
shipmentFlash(view);
|
|
juice.update(dt, rig.camera);
|
|
|
|
// 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);
|
|
showroom?.updateLabels(rig.camera, host);
|
|
|
|
if (import.meta.env.DEV && !firstFrameDone) {
|
|
firstFrameDone = true;
|
|
perf.firstAt = performance.now();
|
|
}
|
|
|
|
if (debug) {
|
|
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, mode: 'build' | 'remove' = 'build') {
|
|
ghost?.set(def, pos, dir, mode);
|
|
},
|
|
|
|
/**
|
|
* v9. Buffered, not acted on — see `applyEvents`. The array belongs to main.ts and is
|
|
* shared with ScreenFX/AudioFX, so we copy the references out and never retain it.
|
|
*/
|
|
onEvents(events: SimEvent[]) {
|
|
if (!gl) return; // init hasn't run; nothing to cue
|
|
for (const e of events) pending.push(e);
|
|
},
|
|
};
|
|
}
|