/** * 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; /** Shipment-flash detection: last frame's total shipped count, and the shipper tiles. */ let lastShipped = -1; const shipperCentres: Vec2[] = []; const itemColors = new Map(); const lastPerItem = new Map(); /** 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(); 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/.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); }, }; }