From a89525ecbfdc816d5a4cdaafa98db3ed9a2c972f Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 18 Jul 2026 14:09:20 +1000 Subject: [PATCH] =?UTF-8?q?[render]=20THE=20FLOOR,=20the=20relic,=20the=20?= =?UTF-8?q?juice,=20the=20pipe=20=E2=80=94=20and=20the=20colorspace=20bug?= =?UTF-8?q?=20behind=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review verdict was "the game about place has no place". The root cause was not a missing feature: a raw ShaderMaterial gets none of the renderer's output includes, so THREE.Color's sRGB->linear conversion was never undone and the crust rendered ~6x too dark. Measured: authored #0c0a15 (RGB 12,10,21) came out RGB(1,1,2). One #include and it measures (10,8,18). Every raw shader in this lane has been that dark since round 1. Round 5 orders 1-8: - ground.ts: fbm terrain mottle + neutral rim + the colorspace fix. - seams.ts: 8 seam regions from data/seams.json. Era skins the crust with a wide falloff; the ore glints as sparse twinkling flecks (first pass drew continuous veins and they read as green worms competing with the belts). Fleck fade is short on purpose: SIM extracts on any footprint/rect overlap, so a glint dying inside the boundary would under-report where mining works. - relics.ts: unfound = a breathing static patch, found = fossil on a plinth. Measured +39 contrast vs floor at wandering zoom, -18 at overview zoom — findable by looking, invisible from above. - wildlife.ts: dust-pile glitter heaps + gnat swarms, harassed machines stutter. Two draw calls for the whole ecosystem. - juice.ts: placement scale-pop + puff, demolition burst, shipment flash, all derived from snapshot deltas since render() never sees events. - breakroom.ts: the GLYTCH cabinet, own raycast, opens match.html. - ghost.ts: inspect highlight + cooler aura (closes round 4's placed-and- selected gap). Also fixes juice never appearing: clock.getDelta() returns the real gap and a backgrounded tab hands back SECONDS (measured 63.7s), so particles were born and killed inside one update and the integrator flung them to infinity. render() now clamps dt to 0.05. Default framing lifts world origin clear of the build bar. 500 entities + 2,000 items + 50 piles + 198 gnats + the floor: 4.4ms/frame, 8 draw calls. Co-Authored-By: Claude Opus 4.8 --- fktry/src/render/breakroom.ts | 109 ++++++++++++++++++++ fktry/src/render/devscene.ts | 49 ++++++++- fktry/src/render/entities.ts | 50 ++++++++- fktry/src/render/ghost.ts | 57 ++++++++++- fktry/src/render/ground.ts | 64 ++++++++++-- fktry/src/render/index.ts | 149 ++++++++++++++++++++++++++- fktry/src/render/juice.ts | 122 ++++++++++++++++++++++ fktry/src/render/relics.ts | 138 +++++++++++++++++++++++++ fktry/src/render/seams.ts | 188 ++++++++++++++++++++++++++++++++++ fktry/src/render/wildlife.ts | 110 ++++++++++++++++++++ 10 files changed, 1015 insertions(+), 21 deletions(-) create mode 100644 fktry/src/render/breakroom.ts create mode 100644 fktry/src/render/juice.ts create mode 100644 fktry/src/render/relics.ts create mode 100644 fktry/src/render/seams.ts create mode 100644 fktry/src/render/wildlife.ts diff --git a/fktry/src/render/breakroom.ts b/fktry/src/render/breakroom.ts new file mode 100644 index 0000000..1c250cc --- /dev/null +++ b/fktry/src/render/breakroom.ts @@ -0,0 +1,109 @@ +/** + * LANE-RENDER — the break room. A GLYTCH arcade cabinet near the start zone. + * + * Clicking it opens `match.html` — the original match-the-glitch prototype, which deploy + * copies next to the game (see deploy.sh). The two games finally meet. The cabinet is a + * real object in the world with its own raycast (index.ts owns the pick + the cursor + * affordance), not a UI button, because it should feel like a thing you find on the + * floor, not a menu item. + * + * The marquee spells GLYTCH in glowing blocks — a placeholder until a MODELBEAST cabinet + * lands under the `arcade-cabinet` asset key (the registry would hot-swap it, but the + * break room isn't a MachineDef, so this is hand-built and self-contained). + */ +import * as THREE from 'three'; + +// 5x5 bitmap font for G L Y T C H — one column of glowing pixels per letter row. +const GLYPHS: Record = { + G: [0b01110, 0b10000, 0b10011, 0b10001, 0b01110], + L: [0b10000, 0b10000, 0b10000, 0b10000, 0b11111], + Y: [0b10001, 0b01010, 0b00100, 0b00100, 0b00100], + T: [0b11111, 0b00100, 0b00100, 0b00100, 0b00100], + C: [0b01110, 0b10001, 0b10000, 0b10001, 0b01110], + H: [0b10001, 0b10001, 0b11111, 0b10001, 0b10001], +}; + +export class BreakRoom { + readonly group = new THREE.Group(); + /** The subtree the raycast tests against — click anywhere on the cabinet counts. */ + readonly clickable: THREE.Object3D[] = []; + private marqueeMat: THREE.MeshStandardMaterial; + + constructor(x: number, z: number) { + this.group.name = 'breakroom'; + this.group.position.set(x, 0, z); + // The iso rig looks from (+x,+y,+z), so a face whose normal is +z points 45° away + // from the viewer and the marquee reads edge-on. Turn the whole cabinet to face the + // camera — it's a prop, not a machine, so it doesn't owe the grid an orientation. + this.group.rotation.y = Math.PI / 4; + + const body = new THREE.MeshStandardMaterial({ color: 0x1a1830, roughness: 0.7, metalness: 0.2 }); + // Wide enough to carry its own marquee: GLYTCH at a legible pixel size spans ~1.8 + // units, and a sign wider than its cabinet reads as a floating decal. + const cabinet = new THREE.Mesh(new THREE.BoxGeometry(2.1, 2.6, 1.1), body); + cabinet.position.y = 1.3; + cabinet.castShadow = cabinet.receiveShadow = true; + this.group.add(cabinet); + this.clickable.push(cabinet); + + // Screen: a dark panel with a cyan glow, angled toward the player. + const screen = new THREE.Mesh( + new THREE.PlaneGeometry(1.5, 1.0), + new THREE.MeshStandardMaterial({ + color: 0x0a1420, emissive: 0x1b6cff, emissiveIntensity: 0.8, roughness: 0.4, + }), + ); + screen.position.set(0, 1.45, 0.56); + screen.rotation.x = -0.18; + this.group.add(screen); + this.clickable.push(screen); + + // Marquee: GLYTCH in emissive pixels across the top. + this.marqueeMat = new THREE.MeshStandardMaterial({ + color: 0x3fd4ff, emissive: 0x3fd4ff, emissiveIntensity: 1.6, + }); + const marquee = this.buildMarquee(); + marquee.position.set(0, 2.28, 0.57); + this.group.add(marquee); + + // Control deck. + const deck = new THREE.Mesh(new THREE.BoxGeometry(2.1, 0.15, 0.5), body); + deck.position.set(0, 0.78, 0.55); + deck.rotation.x = -0.3; + this.group.add(deck); + this.clickable.push(deck); + } + + private buildMarquee(): THREE.Object3D { + const g = new THREE.Group(); + const word = 'GLYTCH'; + // Sized so the word spans most of the cabinet's width — at the rig's closest zoom + // (8) a 0.03 pixel was sub-pixel on screen and GLYTCH was an illegible smudge. + const px = 0.05; + const letterW = 5 * px + px; // 5 cols + gap + const totalW = word.length * letterW; + const pixel = new THREE.BoxGeometry(px * 0.9, px * 0.9, px * 0.6); + word.split('').forEach((ch, li) => { + const rows = GLYPHS[ch]; + if (!rows) return; + rows.forEach((bits, ry) => { + for (let cx = 0; cx < 5; cx++) { + if (!(bits & (1 << (4 - cx)))) continue; + const m = new THREE.Mesh(pixel, this.marqueeMat); + m.position.set( + -totalW / 2 + li * letterW + cx * px, + (2 - ry) * px, + 0, + ); + g.add(m); + } + }); + }); + return g; + } + + /** Marquee attract-mode shimmer. */ + frame(timeSec: number): void { + this.marqueeMat.emissiveIntensity = 1.3 + Math.sin(timeSec * 4) * 0.4; + } +} diff --git a/fktry/src/render/devscene.ts b/fktry/src/render/devscene.ts index 61d0100..8df1c34 100644 --- a/fktry/src/render/devscene.ts +++ b/fktry/src/render/devscene.ts @@ -15,7 +15,11 @@ * 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 and corner pathing honestly. */ -import { TICKS_PER_SECOND, type Dir, type EntityState, type GameData, type SimSnapshot } from '../contracts'; +import { + TICKS_PER_SECOND, + type Dir, type EntityState, type GameData, type RelicState, type SimSnapshot, + type WildlifeState, +} from '../contracts'; /** * Dev builds only. The `import.meta.env.DEV` guard is load-bearing, not decoration: @@ -92,6 +96,24 @@ export class DevScene { this.segment(this.belts(tiles), items[r % items.length], 100); // 2,000 items if (machines.length) this.machine(machines[r % machines.length], -5, y, 1); // +20 = 500 } + // The round-5 perf bar: floor + 50 piles + 10 swarms on top of 500 entities. + const ents = this.entities.filter((e) => e.def !== 'belt'); + for (let i = 0; i < 50; i++) { + this.wildlife.push({ + id: 1000 + i, kind: 'dust-pile', + pos: { x: -30 + (i % 10) * 3, y: -26 + Math.floor(i / 10) * 12 }, + size: ((i * 37) % 100) / 100, + }); + } + for (let i = 0; i < 10; i++) { + const host = ents[i % Math.max(1, ents.length)]; + this.wildlife.push({ + id: 2000 + i, kind: 'mosquito-swarm', + pos: host ? { x: host.pos.x, y: host.pos.y } : { x: -5, y: -20 + i * 5 }, + size: 0.5 + (i % 5) * 0.1, + target: host?.id, + }); + } this.ok = true; return; } @@ -125,6 +147,19 @@ export class DevScene { if (this.has('software-decoder')) this.machine('software-decoder', -4, 8, 1); if (this.has('quantizer')) this.machine('quantizer', 0, 8, 1, 'quantize'); if (this.has('mosh-reactor')) this.machine('mosh-reactor', 4, 8, 1, 'mosh'); + // v4/v5 kinds, so inspect-aura and the lab silhouette are reachable in the scaffold. + if (this.has('asic-cooler')) this.machine('asic-cooler', 9, 8, 1); + if (this.has('archaeology-lab')) this.machine('archaeology-lab', 13, 8, 1); + + // A few hazards so wildlife is visible without the stress rig: a growing dust drift + // beside the quantizer (which is what sheds HF dust, §2) and a swarm on the demuxer. + const demuxer = this.entities.find((e) => e.def === 'demuxer'); + this.wildlife.push( + { id: 900, kind: 'dust-pile', pos: { x: 1, y: 11 }, size: 0.35 }, + { id: 901, kind: 'dust-pile', pos: { x: 2, y: 12 }, size: 0.8 }, + { id: 902, kind: 'dust-pile', pos: { x: 0, y: 12 }, size: 0.15 }, + { id: 910, kind: 'mosquito-swarm', pos: { x: -8, y: 0 }, size: 0.7, target: demuxer?.id }, + ); this.ok = true; } @@ -181,9 +216,21 @@ export class DevScene { // the ghost's padlock is for. `unlocked` is writable from the console to check the // other half (see NOTES). research: this.research, + // v3 hardened: the queue is active-first, then upcoming. + commissionQueue: this.commissionQueue, + // v5/v6: hazards and secrets. Mutable so a verification session can grow a pile, + // aim a swarm, or excavate the relic from the console. + wildlife: this.wildlife, + relics: this.relics, }; } /** Mutable so a verification session can unlock techs and watch padlocks clear. */ readonly research = { active: null as string | null, progress: {}, unlocked: [] as string[] }; + /** v3-hardened field — present so fixtures match the contract, not just the renderer. */ + readonly commissionQueue: string[] = []; + /** v5 hazards. `?devscene=stress` fills these to the perf bar (50 piles + 10 swarms). */ + readonly wildlife: WildlifeState[] = []; + /** v6 secret: one buried optical fossil, unfound. Flip `found` to see the monument. */ + readonly relics: RelicState[] = [{ id: 1, kind: 'optical-fossil', pos: { x: 9, y: -7 }, found: false }]; } diff --git a/fktry/src/render/entities.ts b/fktry/src/render/entities.ts index 7e9c185..af959e9 100644 --- a/fktry/src/render/entities.ts +++ b/fktry/src/render/entities.ts @@ -17,6 +17,10 @@ import { AssetRegistry, clipsOf, fillOf, idleOf, type FillFn, type IdleFn } from import { centerOf, yawFor } from './coords'; import { HEIGHT_BY_KIND, JAM_AMBER } from './palette'; import { createHeatHaze, type HeatHaze } from './heat'; +import type { Juice } from './juice'; + +/** Placement thunk: how long the scale-pop takes, in seconds. */ +const POP_TIME = 0.22; /** Heat is throttled above this (contracts: "throttles >0.7, scrams at 1"). */ const THROTTLE_AT = 0.7; @@ -54,6 +58,8 @@ interface Rec { all: HeatTarget[]; /** Whether dead-dark is currently applied, so restart knows to restore. */ wasScrammed: boolean; + /** Seconds into the placement scale-pop; >= POP_TIME means settled. */ + pop: number; } const JAM_GEO = new THREE.SphereGeometry(0.09, 8, 6); @@ -62,6 +68,8 @@ export class EntityLayer { readonly group = new THREE.Group(); private live = new Map(); private seen = new Set(); + /** False until the first sync completes — stops a save-load firing juice for everything. */ + private primed = false; /** One shared material: every alarm blinks together, like a real klaxon. */ private jamMat = new THREE.MeshStandardMaterial({ color: JAM_AMBER, @@ -69,11 +77,15 @@ export class EntityLayer { emissiveIntensity: 1.5, }); - constructor(private registry: AssetRegistry, private defs: Map) { + constructor( + private registry: AssetRegistry, + private defs: Map, + private juice?: Juice, + ) { this.group.name = 'entities'; } - sync(snap: SimSnapshot, timeSec: number, dt: number): void { + sync(snap: SimSnapshot, timeSec: number, dt: number, harassed?: Set): void { this.seen.clear(); let anyAlarm = false; @@ -102,7 +114,16 @@ export class EntityLayer { this.drop(e.id, rec); // asset hot-swapped (or def changed): rebuild rec = undefined; } - if (!rec) rec = this.build(e.id, e.def, def, entry.version); + if (!rec) { + rec = this.build(e.id, e.def, def, entry.version); + // A machine that was not here last frame was just placed. First frame primes the + // set without firing, so loading a save doesn't confetti the whole factory. + if (this.primed) { + const c = centerOf(e.pos, def, e.dir); + this.juice?.puff(c.x, c.z); + rec.pop = 0; // start the scale-pop + } + } if (rec.x !== e.pos.x || rec.y !== e.pos.y || rec.dir !== e.dir) { centerOf(e.pos, def, e.dir, rec.obj.position); @@ -136,11 +157,31 @@ export class EntityLayer { const alarm = !!e.jammed || scrammed; rec.jam.visible = alarm; if (alarm) anyAlarm = true; + + // Placement thunk: overshoot then settle. Reads as weight landing on the floor. + if (rec.pop < POP_TIME) { + rec.pop += dt; + const k = Math.min(1, rec.pop / POP_TIME); + const s = k < 1 ? 1 + Math.sin(k * Math.PI) * 0.22 : 1; + rec.obj.scale.setScalar(k < 0.12 ? k / 0.12 : s); + } else if (rec.obj.scale.x !== 1) { + rec.obj.scale.setScalar(1); + } + + // Harassed by a swarm: the machine stutters. Wrongness of motion is the tell (§8). + if (harassed?.has(e.id)) { + rec.obj.position.x += Math.sin(timeSec * 47 + e.id) * 0.012; + rec.obj.position.z += Math.cos(timeSec * 53 + e.id) * 0.012; + } } for (const [id, rec] of this.live) { - if (!this.seen.has(id)) this.drop(id, rec); + if (!this.seen.has(id)) { + this.juice?.burst(rec.obj.position.x, rec.obj.position.z); + this.drop(id, rec); + } } + this.primed = true; if (anyAlarm) this.jamMat.emissiveIntensity = Math.sin(timeSec * 7) > 0 ? 2.2 : 0.15; } @@ -250,6 +291,7 @@ export class EntityLayer { heat, all, wasScrammed: false, + pop: POP_TIME, // settled by default; a real placement rewinds it to 0 }; this.live.set(id, rec); return rec; diff --git a/fktry/src/render/ghost.ts b/fktry/src/render/ghost.ts index ecb960b..05da3f7 100644 --- a/fktry/src/render/ghost.ts +++ b/fktry/src/render/ghost.ts @@ -90,6 +90,10 @@ export class GhostLayer { private lockedBy = new Map(); /** Skip the per-frame occupancy walk entirely when no machine in data can cool. */ private anyCoolers = false; + /** True while the inspector owns the aura, so hover logic leaves it alone. */ + private inspectAura = false; + /** Selection ring drawn under the inspected machine. */ + private selectRing: THREE.Mesh; constructor( private registry: AssetRegistry, @@ -102,6 +106,19 @@ export class GhostLayer { this.padlock.obj.visible = false; this.group.add(this.padlock.obj); + // Selection ring: a flat outline under the inspected machine. Lives outside `group` + // so it survives the ghost being hidden (inspect has no hovered tile). + this.selectRing = new THREE.Mesh( + new THREE.RingGeometry(0.62, 0.72, 4, 1, Math.PI / 4), + new THREE.MeshBasicMaterial({ + color: VALID, transparent: true, opacity: 0.4, depthWrite: false, side: THREE.DoubleSide, + }), + ); + this.selectRing.rotation.x = -Math.PI / 2; + this.selectRing.visible = false; + this.selectRing.renderOrder = 9; + this.aura.group.add(this.selectRing); + const machines = new Set(data.machines.map((m) => m.id)); for (const t of data.tech) { for (const u of t.unlocks) { @@ -196,9 +213,13 @@ export class GhostLayer { return snap.research?.unlocked.includes(tech.id) ? null : tech; } - update(snap: SimSnapshot, timeSec: number): void { + update(snap: SimSnapshot, timeSec: number, inspected?: number | null): void { + // The inspected machine is highlighted whether or not the pointer is anywhere near + // it — the inspector is open until it's closed. + this.updateInspect(snap, timeSec, inspected ?? null); + if (!this.pos) { - this.aura.hide(); + if (!this.inspectAura) this.aura.hide(); this.remove.hide(); this.group.visible = false; return; @@ -258,11 +279,43 @@ export class GhostLayer { } } + /** + * v5: the inspected machine gets a selection ring, and — if it cools — its coverage + * rectangle. This is the "placed-and-selected" case round 4 couldn't reach: the + * inspected entity now arrives through the typed selection. + */ + private updateInspect(snap: SimSnapshot, timeSec: number, inspected: number | null): void { + this.inspectAura = false; + if (inspected === null) { + this.selectRing.visible = false; + return; + } + const e = snap.entities.find((x) => x.id === inspected); + const def = e ? this.defs.get(e.def) : undefined; + if (!e || !def) { + this.selectRing.visible = false; + return; + } + const f = rotatedFootprint(def, e.dir); + const c = centerOf(e.pos, def, e.dir); + this.selectRing.visible = true; + this.selectRing.position.set(c.x, 0.04, c.z); + this.selectRing.scale.set(f.x + 0.5, f.y + 0.5, 1); + (this.selectRing.material as THREE.MeshBasicMaterial).opacity = + 0.35 + Math.sin(timeSec * 3) * 0.12; + + if (def.coolRadius) { + this.aura.show(c.x, c.z, f.x, f.y, def.coolRadius, timeSec); + this.inspectAura = true; // claim the aura so hover logic doesn't hide it + } + } + /** * Ring the cooler being placed, or the one under the cursor. Placing wins: while you're * holding a cooler the question is where its coverage lands, not what's already there. */ private updateAura(timeSec: number): void { + if (this.inspectAura) return; // the inspector owns the aura this frame if (!this.anyCoolers || !this.pos) { this.aura.hide(); return; diff --git a/fktry/src/render/ground.ts b/fktry/src/render/ground.ts index 36fd6a6..ebeddbf 100644 --- a/fktry/src/render/ground.ts +++ b/fktry/src/render/ground.ts @@ -1,15 +1,30 @@ /** - * LANE-RENDER — Stream-Crust ground plane. + * LANE-RENDER — Stream-Crust ground plane. THE FLOOR (round 5). * - * 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. + * Round 1 shipped a grid over flat crust. That read as "the void with lines on it", + * which the review named: the game about place had no place. This makes the plane read + * as terrain without ever competing with belts and machines — it's the stage, not the + * set. Three barely-there layers: + * + * - fbm tone variation: large, slow value-noise mottling so the crust isn't a flat + * fill. Amplitude is tiny on purpose; you feel it more than see it. + * - the grid, unchanged (it's the placement affordance and must stay legible). + * - a neutral rim: the crust darkens and dissolves into the void at the world edge. + * + * ERA HINTING lives in seams.ts, not here. The orders asked for strata whispers toward + * the world edges, and I first wrote exactly that — a directional guess (sepia north, + * phosphor east...). Then DATA landed `data/seams.json`, whose doc says "RENDER: `era` + * skins the ground", and its layout has reel in BOTH the north-west and north-east and + * disc both north-centre and south-west. A directional guess would have contradicted the + * data on screen. So era is skinned per seam region, with a wide soft falloff so it still + * reads as a stratum whisper across the ground — and the richest seams sit near the rim + * anyway, which is where the orders wanted the hinting to show up. */ 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 PLANE = WORLD_HALF * 5; // extends past the world; detail only drawn inside ±WORLD_HALF const VERT = /* glsl */ ` varying vec2 vWorld; @@ -28,21 +43,52 @@ const FRAG = /* glsl */ ` uniform float uHalf; varying vec2 vWorld; + // value noise + 3-octave fbm — cheap terrain mottle, no texture fetch. + float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } + float vnoise(vec2 p){ + vec2 i = floor(p), f = fract(p); + f = f*f*(3.0-2.0*f); + float a = hash(i), b = hash(i+vec2(1,0)), c = hash(i+vec2(0,1)), d = hash(i+vec2(1,1)); + return mix(mix(a,b,f.x), mix(c,d,f.x), f.y); + } + float fbm(vec2 p){ + float s = 0.0, a = 0.5; + for (int i=0;i<3;i++){ s += a*vnoise(p); p*=2.03; a*=0.5; } + return s; + } + // Antialiased grid: distance to the nearest cell border, in pixels. - float gridMask(vec2 coord, float width) { + 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; + void main(){ + // --- terrain tone: fbm mottle, tiny amplitude --- + float m = fbm(vWorld * 0.09); + vec3 col = uBase * (0.86 + m * 0.28); // ±~14% brightness swells + col += (sin(vWorld.y * 26.0) * 0.5 + 0.5) * 0.006; // fine crust grain (kept from R1) + + // --- the crust cools toward the rim: older, deeper, less lit --- + vec2 n = clamp(vWorld / uHalf, -1.0, 1.0); + float rim = smoothstep(0.55, 1.0, max(abs(n.x), abs(n.y))); + col *= 1.0 - rim * 0.35 * (0.6 + m * 0.8); // fbm-broken so it reads as sediment + + // --- grid (unchanged — the placement affordance) --- 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 + // --- world-edge dissolve into the void --- 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); + + // COLOR MANAGEMENT — load-bearing, not boilerplate. THREE.Color.setHex() converts + // sRGB -> the linear working space, but a raw ShaderMaterial gets none of the output + // includes, so without this the linear value goes to the framebuffer untouched and + // the floor renders ~6x too dark: #0c0a15 measured as RGB(1,1,2). Every built-in + // material does this via the same chunk. + #include } `; diff --git a/fktry/src/render/index.ts b/fktry/src/render/index.ts index ba3d784..15b888b 100644 --- a/fktry/src/render/index.ts +++ b/fktry/src/render/index.ts @@ -17,7 +17,13 @@ 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 { centerOf } from './coords'; const KEY_INTENSITY = 1.9; const FILL_INTENSITY = 0.7; @@ -34,10 +40,20 @@ export function createRenderer(): Renderer { let belts: BeltLayer; let cargo: BeltItemLayer; let ghost: GhostLayer; + let relics: RelicLayer; + let wildlife: WildlifeLayer; + 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(); @@ -52,6 +68,90 @@ export function createRenderer(): Renderer { * RATE — stays honest even when the tab is backgrounded and rAF is throttled. */ const stats = { ms: 0, avg: 0, frames: 0 }; + /** + * 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; + } + + /** + * A rise in `shippedTotal` is a shipment — flash the uplinks in the shipped item's + * colour. Derived from the snapshot because render() never sees events. + */ + 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) { + 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 }); + } + const col = (hottest !== null ? itemColors.get(hottest) : undefined) ?? 0xff7a3f; + for (const c of shipperCentres) juice.shipFlash(c.x, c.y, col); + } + lastShipped = total; + } + + /** 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) { defs = new Map(data.machines.map((m) => [m.id, m])); @@ -99,14 +199,42 @@ export function createRenderer(): Renderer { registry = new AssetRegistry(); await registry.init(data); + juice = new Juice(); belts = new BeltLayer(registry, defs, topo); - entities = new EntityLayer(registry, defs); + entities = new EntityLayer(registry, defs, juice); cargo = new BeltItemLayer(data, topo); ghost = new GhostLayer(registry, defs, data); - scene.add(belts.group, entities.group, cargo.group, ghost.group, ghost.aura.group); + relics = new RelicLayer(); + wildlife = new WildlifeLayer(); + scene.add( + belts.group, entities.group, cargo.group, ghost.group, ghost.aura.group, + relics.group, wildlife.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); @@ -147,7 +275,12 @@ export function createRenderer(): Renderer { render(snap: SimSnapshot, alpha: number) { if (!gl) return; const t0 = debug ? performance.now() : 0; - const dt = clock.getDelta(); + // 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 @@ -165,9 +298,15 @@ export function createRenderer(): Renderer { // items take), so it is rebuilt once here rather than twice downstream. topo.rebuild(view, defs); belts.sync(view, t); - entities.sync(view, t, dt); + entities.sync(view, t, dt, harassedIn(view)); cargo.sync(view, alpha, t); - ghost.update(view, t); + ghost.update(view, t, inspectedEntity()); + relics.sync(view.relics, t); + 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 diff --git a/fktry/src/render/juice.ts b/fktry/src/render/juice.ts new file mode 100644 index 0000000..3df72f1 --- /dev/null +++ b/fktry/src/render/juice.ts @@ -0,0 +1,122 @@ +/** + * LANE-RENDER — juice. The difference between placing a machine and a machine appearing. + * + * The renderer never sees events (render() gets only the snapshot), so every cue here is + * derived from frame-to-frame deltas: an entity that wasn't there last frame was placed; + * one that vanished was demolished; a rise in `shippedTotal` is a shipment. EntityLayer + * owns the meshes so it does the scale-pop itself and calls in for the puff/burst; the + * shipment flash is driven from index.ts. + * + * One InstancedMesh particle pool for the whole game — cheap, instanced, everywhere. + */ +import * as THREE from 'three'; + +const MAX = 512; +const GRAVITY = -6; + +interface P { + x: number; y: number; z: number; + vx: number; vy: number; vz: number; + life: number; ttl: number; + size: number; + r: number; g: number; b: number; +} + +export class Juice { + readonly group = new THREE.Group(); + private mesh: THREE.InstancedMesh; + private pool: P[] = []; + private live: P[] = []; + private dummy = new THREE.Object3D(); + private color = new THREE.Color(); + + constructor() { + this.group.name = 'juice'; + const geo = new THREE.PlaneGeometry(1, 1); + const mat = new THREE.MeshBasicMaterial({ + transparent: true, depthWrite: false, side: THREE.DoubleSide, + blending: THREE.AdditiveBlending, vertexColors: true, + }); + this.mesh = new THREE.InstancedMesh(geo, mat, MAX); + this.mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); + this.mesh.frustumCulled = false; + this.mesh.count = 0; + // per-instance colour + this.mesh.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(MAX * 3), 3); + this.group.add(this.mesh); + for (let i = 0; i < MAX; i++) { + this.pool.push({ x: 0, y: 0, z: 0, vx: 0, vy: 0, vz: 0, life: 0, ttl: 1, size: 0.1, r: 1, g: 1, b: 1 }); + } + } + + private emit( + x: number, z: number, n: number, spread: number, up: number, + size: number, ttl: number, col: number, + ): void { + this.color.setHex(col); + for (let i = 0; i < n; i++) { + const p = this.pool.pop(); + if (!p) break; // pool exhausted — drop the rest rather than allocate + const a = Math.random() * Math.PI * 2; + const s = Math.random() * spread; + p.x = x; p.y = 0.2; p.z = z; + p.vx = Math.cos(a) * s; + p.vz = Math.sin(a) * s; + p.vy = up * (0.6 + Math.random() * 0.8); + p.life = 0; + p.ttl = ttl * (0.7 + Math.random() * 0.6); + p.size = size * (0.7 + Math.random() * 0.6); + p.r = this.color.r; p.g = this.color.g; p.b = this.color.b; + this.live.push(p); + } + } + + /** Placement: a soft ring of dust kicked outward. Pairs with EntityLayer's scale-pop. */ + puff(cx: number, cz: number): void { + this.emit(cx, cz, 14, 2.2, 1.5, 0.18, 0.5, 0xbfb3a0); + } + + /** Demolition: a sharper, brighter burst. */ + burst(cx: number, cz: number): void { + this.emit(cx, cz, 22, 4.0, 3.0, 0.22, 0.55, 0xff6a3a); + } + + /** Shipment: a quick upward flash of the shipped item's colour at the uplink. */ + shipFlash(cx: number, cz: number, col: number): void { + this.emit(cx, cz, 10, 1.0, 5.0, 0.2, 0.45, col); + } + + update(dt: number, camera: THREE.Camera): void { + // integrate + recycle + for (let i = this.live.length - 1; i >= 0; i--) { + const p = this.live[i]; + p.life += dt; + if (p.life >= p.ttl) { + this.live[i] = this.live[this.live.length - 1]; + this.live.pop(); + this.pool.push(p); + continue; + } + p.vy += GRAVITY * dt; + p.x += p.vx * dt; p.y += p.vy * dt; p.z += p.vz * dt; + if (p.y < 0.05) { p.y = 0.05; p.vy *= -0.3; p.vx *= 0.6; p.vz *= 0.6; } + } + + // write instances, billboarded to the camera, fading over life + const q = camera.quaternion; + for (let i = 0; i < this.live.length; i++) { + const p = this.live[i]; + const k = 1 - p.life / p.ttl; + const s = p.size * (0.4 + k * 0.6); + this.dummy.position.set(p.x, p.y, p.z); + this.dummy.quaternion.copy(q); + this.dummy.scale.setScalar(s); + this.dummy.updateMatrix(); + this.mesh.setMatrixAt(i, this.dummy.matrix); + this.mesh.setColorAt(i, this.color.setRGB(p.r * k, p.g * k, p.b * k)); + } + this.mesh.count = this.live.length; + this.mesh.instanceMatrix.needsUpdate = true; + if (this.mesh.instanceColor) this.mesh.instanceColor.needsUpdate = true; + } +} diff --git a/fktry/src/render/relics.ts b/fktry/src/render/relics.ts new file mode 100644 index 0000000..7243957 --- /dev/null +++ b/fktry/src/render/relics.ts @@ -0,0 +1,138 @@ +/** + * LANE-RENDER — the relic. `snapshot.relics` (v6): optical fossils buried in the crust. + * + * The brief is a Mario hidden pipe: findable by a player who wanders and looks, invisible + * to one who doesn't. So an UNFOUND relic is only a faint static-patch decal that breathes + * — no marker, no minimap ping, no arrow. Its discoverability budget is curiosity. The + * restraint is the feature: if you can see it from across the map it isn't hidden. + * + * FOUND (excavated) → the patch becomes a small monument: a fossil slab on a plinth, + * lifting out of the ground, lit. §1: the optical soundtrack fossils *sing* when + * excavated — the singing is LANE-SCREEN's audio band; this is the thing it sings from. + */ +import * as THREE from 'three'; +import type { RelicState } from '../contracts'; +import { disposeObject } from './coords'; + +const SHIMMER_VERT = /* glsl */ ` + varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } +`; + +const SHIMMER_FRAG = /* glsl */ ` + precision highp float; + uniform float uTime; + varying vec2 vUv; + + float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7)))*43758.5453); } + + void main() { + vec2 p = vUv * 2.0 - 1.0; + float rr = dot(p, p); + if (rr > 1.0) discard; + // static crawl: a hidden VHS dropout, quantised so it flickers rather than smears. + vec2 g = floor(vUv * 22.0) + floor(vec2(uTime * 9.0)); + float s = hash(g); + // breathe the whole patch so a still eye catches motion, but faintly. + float breathe = 0.5 + 0.5 * sin(uTime * 1.3); + float edge = 1.0 - smoothstep(0.4, 1.0, rr); + float a = s * edge * (0.05 + 0.06 * breathe); // deliberately tiny + gl_FragColor = vec4(vec3(0.7, 0.75, 0.85) * s, a); + } +`; + +interface Rec { + found: boolean; + obj: THREE.Object3D; +} + +const PLANE = new THREE.PlaneGeometry(1, 1); + +export class RelicLayer { + readonly group = new THREE.Group(); + private live = new Map(); + private seen = new Set(); + private shimmerUniforms = { uTime: { value: 0 } }; + private shimmerMat: THREE.ShaderMaterial; + + constructor() { + this.group.name = 'relics'; + this.shimmerMat = new THREE.ShaderMaterial({ + uniforms: this.shimmerUniforms, + vertexShader: SHIMMER_VERT, + fragmentShader: SHIMMER_FRAG, + transparent: true, + depthWrite: false, + }); + } + + sync(relics: ReadonlyArray | undefined, timeSec: number): void { + this.shimmerUniforms.uTime.value = timeSec; + this.seen.clear(); + + for (const r of relics ?? []) { + this.seen.add(r.id); + let rec = this.live.get(r.id); + if (rec && rec.found !== r.found) { + this.drop(r.id, rec); // excavated: swap shimmer → monument + rec = undefined; + } + if (!rec) { + const obj = r.found ? this.buildMonument() : this.buildShimmer(); + obj.position.set(r.pos.x + 0.5, 0, r.pos.y + 0.5); + this.group.add(obj); + rec = { found: r.found, obj }; + this.live.set(r.id, rec); + } + if (r.found) { + // gentle lift + rotate so the excavated fossil reads as an exhibit + rec.obj.rotation.y = timeSec * 0.5; + rec.obj.position.y = 0.05 + Math.sin(timeSec * 1.5) * 0.03; + } + } + + for (const [id, rec] of this.live) if (!this.seen.has(id)) this.drop(id, rec); + } + + private buildShimmer(): THREE.Object3D { + const m = new THREE.Mesh(PLANE, this.shimmerMat); + m.rotation.x = -Math.PI / 2; + m.scale.set(2.4, 2.4, 1); + m.position.y = 0.02; + m.renderOrder = 2; + return m; + } + + private buildMonument(): THREE.Object3D { + const g = new THREE.Group(); + const plinth = new THREE.Mesh( + new THREE.CylinderGeometry(0.5, 0.6, 0.4, 12), + new THREE.MeshStandardMaterial({ color: 0x14121d, roughness: 0.9, emissive: 0x1a2740, emissiveIntensity: 0.4 }), + ); + plinth.position.y = 0.2; + plinth.castShadow = plinth.receiveShadow = true; + // The fossil: a wavy photographic strip that "sings" — an emissive slab, gamut-lit. + const strip = new THREE.Mesh( + new THREE.BoxGeometry(0.18, 0.8, 0.5), + new THREE.MeshStandardMaterial({ + color: 0xffe0b0, emissive: 0xffcaa0, emissiveIntensity: 1.3, + roughness: 0.3, metalness: 0.4, + }), + ); + strip.position.y = 0.8; + strip.castShadow = true; + g.add(plinth, strip); + g.userData.owns = true; + return g; + } + + private drop(id: number, rec: Rec): void { + this.group.remove(rec.obj); + if (rec.obj.userData.owns) disposeObject(rec.obj); // monuments own their geo/mats + // shimmers share PLANE + shimmerMat — nothing to dispose + this.live.delete(id); + } +} diff --git a/fktry/src/render/seams.ts b/fktry/src/render/seams.ts new file mode 100644 index 0000000..b97dfca --- /dev/null +++ b/fktry/src/render/seams.ts @@ -0,0 +1,188 @@ +/** + * LANE-RENDER — mineral seams: the glinting patches that tell you where to mine. + * + * Data is `data/seams.json` (LANE-DATA). Its doc sets the division of labour: + * SIM — an extractor extracts only while its footprint overlaps a seam rect. + * RENDER — `era` skins the ground; `resource` is the stratum's codex payload. + * So this layer does two jobs at once: it skins each region with its era (§1 strata, + * with a wide soft falloff so it reads as a whisper across the crust, not a decal), and + * inside the rect it lays the mineral itself — obsidian darkening with the resource's + * colour flickering through fracture veins (§2: "thin glowing rainbow circuit veins"). + * + * The tell is deliberately quiet. There is NO label and no icon: a seam is a patch of + * ground that glints. A player who looks discovers that extractors want to sit on them; + * a player who doesn't just sees texture. `richness` drives how strongly it glints, so + * the rich remote seams are the ones that reward wandering. + */ +import * as THREE from 'three'; +import type { GameData } from '../contracts'; +import seamsFile from '../../data/seams.json'; + +export interface SeamRect { x: number; y: number; w: number; h: number } +export interface SeamDef { + id: string; + era: string; + resource: string; + rect: SeamRect; + richness: number; +} + +/** §1 strata palettes — the era whisper laid over the crust around each seam. */ +const ERA_TINT: Record = { + reel: 0x3a2a16, // celluloid shale, silver-nitrate brown + broadcast: 0x14301c, // phosphor dunes + disc: 0x241634, // rainbow-on-black gloss + stream: 0x1e2430, // flat plastic crust + fortress: 0x2c3038, +}; + +/** How far past the rect the era whisper bleeds, in tiles. */ +const ERA_BLEED = 7; + +const VERT = /* glsl */ ` + attribute vec3 aEra; + attribute vec3 aOre; + attribute vec2 aHalf; // rect half-extent in tiles (for the inside/outside test) + attribute float aRich; + varying vec3 vEra; + varying vec3 vOre; + varying vec2 vHalf; + varying float vRich; + varying vec2 vLocal; // tiles from the rect centre + varying vec2 vWorld; + void main() { + vEra = aEra; vOre = aOre; vHalf = aHalf; vRich = aRich; + vec4 wp = modelMatrix * instanceMatrix * vec4(position, 1.0); + vWorld = wp.xz; + // instanceMatrix scales a unit quad to (rect + bleed); recover tiles-from-centre. + vLocal = position.xz * (aHalf + vec2(${ERA_BLEED}.0)) * 2.0; + gl_Position = projectionMatrix * viewMatrix * wp; + } +`; + +const FRAG = /* glsl */ ` + precision highp float; + uniform float uTime; + varying vec3 vEra; + varying vec3 vOre; + varying vec2 vHalf; + varying float vRich; + varying vec2 vLocal; + varying vec2 vWorld; + + float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7)))*43758.5453); } + float vnoise(vec2 p){ + vec2 i = floor(p), f = fract(p); + f = f*f*(3.0-2.0*f); + return mix(mix(hash(i), hash(i+vec2(1,0)), f.x), + mix(hash(i+vec2(0,1)), hash(i+vec2(1,1)), f.x), f.y); + } + + void main(){ + vec2 d = abs(vLocal) - vHalf; // >0 outside the rect, in tiles + + // --- era whisper: strongest at the rect, faded out over ERA_BLEED tiles --- + float outside = max(max(d.x, d.y), 0.0); + float era = 1.0 - smoothstep(0.0, ${ERA_BLEED}.0, outside); + // fbm-break the edge so a rect never reads as a rectangle + era *= 0.55 + 0.45 * vnoise(vWorld * 0.35); + + // --- the mineral itself: only inside the rect --- + // Soft-edged so a seam never reads as a rectangle you could have drawn in CSS, but + // the fade is SHORT: SIM extracts on any footprint/rect overlap, so a glint that died + // two tiles inside the boundary would under-report where mining actually works. + float body = (1.0 - smoothstep(-0.8, 0.6, max(d.x, d.y))) + * (0.6 + 0.4 * vnoise(vWorld * 0.55)); + + // GLINT, not veins. The first pass drew continuous sinusoidal lines and they read as + // green worms crawling over the floor — organic, loud, competing with the belts. + // Mineral glitter is sparse and it TWINKLES: quantise to a fleck grid, keep the top + // few percent, and let each fleck sparkle on its own clock. + vec2 g = vWorld * 7.0; + vec2 cell = floor(g); + float pick = hash(cell); + // Shape a round fleck inside its cell instead of filling the cell — a full cell reads + // as confetti at this zoom, a point reads as a mineral fleck. + vec2 sub = fract(g) - 0.5 - (vec2(hash(cell + 3.7), hash(cell + 9.1)) - 0.5) * 0.5; + float dot_ = 1.0 - smoothstep(0.10, 0.30, length(sub)); + float fleck = smoothstep(0.90 - vRich * 0.10, 1.0, pick) * dot_; + float twinkle = 0.35 + 0.65 * pow(max(0.0, sin(uTime * 2.2 + pick * 43.0)), 3.0); + float glint = fleck * twinkle * body; + + vec3 col = vEra * era * 0.75; // stratum tint on the crust + col = mix(col, vOre, min(1.0, glint * 1.6)); // ore colour only in the flecks + float a = era * 0.22 + body * 0.09 + glint * (0.35 + vRich * 0.4); + if (a < 0.004) discard; + gl_FragColor = vec4(col, a); + + // sRGB out — a raw ShaderMaterial gets none of the output includes (see ground.ts). + #include + } +`; + +export class SeamLayer { + readonly mesh: THREE.InstancedMesh; + private uniforms = { uTime: { value: 0 } }; + private dummy = new THREE.Object3D(); + + constructor(seams: SeamDef[], data: GameData) { + const geo = new THREE.PlaneGeometry(1, 1); + geo.rotateX(-Math.PI / 2); // lie flat on the ground + + const n = Math.max(1, seams.length); + const era = new Float32Array(n * 3); + const ore = new Float32Array(n * 3); + const half = new Float32Array(n * 2); + const rich = new Float32Array(n); + const c = new THREE.Color(); + + const mat = new THREE.ShaderMaterial({ + uniforms: this.uniforms, + vertexShader: VERT, + fragmentShader: FRAG, + transparent: true, + depthWrite: false, + }); + this.mesh = new THREE.InstancedMesh(geo, mat, n); + this.mesh.name = 'seams'; + this.mesh.renderOrder = -1; // above the ground plane, below everything else + this.mesh.frustumCulled = false; + + seams.forEach((s, i) => { + const { x, y, w, h } = s.rect; + // Quad covers the rect plus the era bleed on every side. + this.dummy.position.set(x + w / 2, 0.012, y + h / 2); + this.dummy.scale.set(w + ERA_BLEED * 2, 1, h + ERA_BLEED * 2); + this.dummy.updateMatrix(); + this.mesh.setMatrixAt(i, this.dummy.matrix); + + c.setHex(ERA_TINT[s.era] ?? ERA_TINT.stream); + era.set([c.r, c.g, c.b], i * 3); + const item = data.items.find((it) => it.id === s.resource); + c.set(item?.color ?? '#8899aa'); + ore.set([c.r, c.g, c.b], i * 3); + half.set([w / 2, h / 2], i * 2); + rich[i] = s.richness ?? 0.5; + }); + this.mesh.count = seams.length; + this.mesh.instanceMatrix.needsUpdate = true; + geo.setAttribute('aEra', new THREE.InstancedBufferAttribute(era, 3)); + geo.setAttribute('aOre', new THREE.InstancedBufferAttribute(ore, 3)); + geo.setAttribute('aHalf', new THREE.InstancedBufferAttribute(half, 2)); + geo.setAttribute('aRich', new THREE.InstancedBufferAttribute(rich, 1)); + } + + frame(timeSec: number): void { + this.uniforms.uTime.value = timeSec; + } +} + +/** The seams DATA authored. Statically imported so it works in a production build too. */ +export function loadSeams(): SeamDef[] { + return ((seamsFile as { seams?: SeamDef[] }).seams ?? []) as SeamDef[]; +} + +/** The SE corner DATA keeps seam-free so the relic always has somewhere to land. */ +export function relicReserve(): SeamRect | null { + return (seamsFile as { relicReserve?: SeamRect }).relicReserve ?? null; +} diff --git a/fktry/src/render/wildlife.ts b/fktry/src/render/wildlife.ts new file mode 100644 index 0000000..95e3dd7 --- /dev/null +++ b/fktry/src/render/wildlife.ts @@ -0,0 +1,110 @@ +/** + * LANE-RENDER — wildlife: dust piles and mosquito swarms (§2/§5, M2's living hazards). + * + * Piles are HF-dust glitter heaps (codex: quantizer sweepings) — they grow with `size`. + * Swarms are pixel-gnat clouds that cluster over the machine they're degrading (`target`); + * the machine's own flicker is EntityLayer's job, driven off the same `target` ids. + * + * Both are instanced from day one: the perf bar is 50 piles + 10 swarms, and a swarm is + * itself a cloud of gnats. One InstancedMesh for every pile, one for every gnat across + * every swarm — two draw calls for the whole ecosystem. + */ +import * as THREE from 'three'; +import type { SimSnapshot, WildlifeState } from '../contracts'; + +const GNATS_PER_SWARM = 24; + +/** HF dust: pale, faintly iridescent glitter. Emissive so heaps sparkle at iso distance. */ +function pileMaterial(): THREE.MeshStandardMaterial { + return new THREE.MeshStandardMaterial({ + color: 0xe8e0ff, emissive: 0xb0a0ff, emissiveIntensity: 0.5, + roughness: 0.3, metalness: 0.6, flatShading: true, + }); +} + +export class WildlifeLayer { + readonly group = new THREE.Group(); + private piles: THREE.InstancedMesh; + private gnats: THREE.InstancedMesh; + private pileCap = 64; + private gnatCap = 64 * GNATS_PER_SWARM; + private dummy = new THREE.Object3D(); + /** Entity ids currently harassed by a swarm — EntityLayer reads this to flicker them. */ + readonly harassed = new Set(); + + constructor() { + this.group.name = 'wildlife'; + // A low, faceted heap; instance scale grows it with `size`. + const pileGeo = new THREE.IcosahedronGeometry(0.5, 0); + pileGeo.scale(1, 0.5, 1); + this.piles = new THREE.InstancedMesh(pileGeo, pileMaterial(), this.pileCap); + this.piles.instanceMatrix.setUsage(THREE.DynamicDrawUsage); + this.piles.castShadow = true; + this.piles.frustumCulled = false; + this.piles.count = 0; + + const gnatGeo = new THREE.PlaneGeometry(0.08, 0.08); + const gnatMat = new THREE.MeshBasicMaterial({ + color: 0x101014, transparent: true, opacity: 0.85, side: THREE.DoubleSide, + }); + this.gnats = new THREE.InstancedMesh(gnatGeo, gnatMat, this.gnatCap); + this.gnats.instanceMatrix.setUsage(THREE.DynamicDrawUsage); + this.gnats.frustumCulled = false; + this.gnats.count = 0; + + this.group.add(this.piles, this.gnats); + } + + sync(wildlife: ReadonlyArray | undefined, timeSec: number): void { + this.harassed.clear(); + const list = wildlife ?? []; + let pileN = 0; + let gnatN = 0; + + for (const w of list) { + if (w.kind === 'dust-pile') { + if (pileN >= this.pileCap) continue; + const s = 0.4 + THREE.MathUtils.clamp(w.size, 0, 1) * 1.3; + this.dummy.position.set(w.pos.x + 0.5, 0.05 * s, w.pos.y + 0.5); + this.dummy.rotation.set(0, w.id * 1.7, 0); // desync facets between heaps + this.dummy.scale.set(s, s, s); + this.dummy.updateMatrix(); + this.piles.setMatrixAt(pileN++, this.dummy.matrix); + } else { + if (w.target !== undefined) this.harassed.add(w.target); + const cx = w.pos.x + 0.5; + const cz = w.pos.y + 0.5; + const density = Math.round(GNATS_PER_SWARM * (0.4 + w.size * 0.6)); + for (let i = 0; i < density && gnatN < this.gnatCap; i++) { + // Each gnat orbits the swarm centre on its own little chaotic path. + const ph = w.id * 3.1 + i * 0.7; + const rad = 0.5 + 0.5 * Math.sin(timeSec * 3 + ph * 1.3); + const a = timeSec * (1.5 + (i % 3) * 0.4) + ph; + this.dummy.position.set( + cx + Math.cos(a) * rad, + 0.5 + 0.4 * Math.sin(timeSec * 4 + ph) + w.size * 0.3, + cz + Math.sin(a * 1.1) * rad, + ); + this.dummy.rotation.set(0, -a, 0); // face roughly along travel + this.dummy.scale.setScalar(1); + this.dummy.updateMatrix(); + this.gnats.setMatrixAt(gnatN++, this.dummy.matrix); + } + } + } + + this.piles.count = pileN; + this.piles.instanceMatrix.needsUpdate = true; + this.gnats.count = gnatN; + this.gnats.instanceMatrix.needsUpdate = true; + } +} + +/** Convenience: which entities a snapshot's swarms are harassing (for EntityLayer). */ +export function harassedIn(snap: SimSnapshot): Set { + const out = new Set(); + for (const w of snap.wildlife ?? []) { + if (w.kind === 'mosquito-swarm' && w.target !== undefined) out.add(w.target); + } + return out; +}