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; +}