// TURNCRAFT — Lane S (SIDE B). Dust bunnies: 4 fist-sized fluff-balls that // drift around the under-table PCB floor (y≈3), flee when the player gets // close, and settle again. Purely cosmetic — no colliders, no game effect. // // Consumes only core contracts (IVoxelWorld / IPlayerView / WORLD_DEFS) — the // world + player views are injected by the integrator via // `FxSystem.attachAmbience`. All per-tick math is scalar on preallocated // per-bunny records: zero steady-state allocations. The only allocation a // bunny ever causes after construction is the `machine:interact // {action:'bunny_flee'}` payload, rate-limited to at most 1/s globally // (Lane E2 maps it to a squeak). import * as THREE from 'three'; import { bus } from '../core/events'; import { LAYOUT } from '../core/constants'; import { WORLD_DEFS, WORLD_KEYS } from '../core/worlds'; import type { IVoxelWorld, IPlayerView } from '../core/types'; // ── keep-out zones (bunnies never wander in; fleeing steers around) ───────── // The three portal rings (WORLD_DEFS[k].portalStand ± 6) and the two quest // spots in the fuse room. The parts-bin / fuse-box centres mirror the // arithmetic in worldgen/anchors.ts (UNDER.partsBin / UNDER.fuseBox) the same // way fx/layout.ts mirrors the VU towers — derived from LAYOUT, never imported // (worldgen is another lane's directory). interface Zone { x: number; z: number; r2: number; } const M = LAYOUT.mixer; const KEEP_OUT: Zone[] = [ ...WORLD_KEYS.map((k) => { const s = WORLD_DEFS[k].portalStand; return { x: s[0] + 0.5, z: s[2] + 0.5, r2: 6 * 6 }; }), { x: M.minX + 15.5, z: M.minZ + 10.5, r2: 6 * 6 }, // parts bin tray (stylus + fuse pickups) { x: M.minX + 24.5, z: M.maxZ - 7.5, r2: 6 * 6 }, // fuse box wall spot (reset ritual ground) ]; // Home points on the under-table floor: two in the record-crate area (where // SIDE B pulls players), one in the mixer PCB room, one under deck A. Each // bunny is leashed to its home so it stays findable. const RIM = LAYOUT.rimThickness; const HOMES: [number, number][] = [ [RIM + 18, RIM + 9], // crate floor, in front of the discs [WORLD_DEFS.disco.portalStand[0] + 8, RIM + 8], // crate floor, gap past the last discs [(M.minX + M.maxX) / 2 + 6, (M.minZ + M.maxZ) / 2 + 8],// mixer PCB room [LAYOUT.deckA.spindleX, LAYOUT.deckA.spindleZ], // deck A PCB room ]; const BUNNY_COUNT = 4; const LEASH = 14; // max wander radius from home (voxels) const SPOOK_DIST2 = 5 * 5; // player within 5 → flee const FLEE_DIST = 8; // voxels of flight const EMIT_COOLDOWN = 1.0; // machine:interact {bunny_flee} at most 1/s (global) const enum Mode { Wander, Flee, Settle } interface Bunny { mesh: THREE.Mesh; x: number; y: number; z: number; ground: number; // y of the standing surface (top face) under the bunny heading: number; // radians, horizontal speed: number; mode: Mode; timer: number; // mode-specific countdown (wander re-aim / settle) fleeLeft: number; // voxels of flight remaining phase: number; // bob/breathe animation phase spin: number; // idle tumble rate (rad/s) turnSign: number; // alternate deflection direction on blocked moves homeX: number; homeZ: number; } /** One fluff-ball: 8–12 soft grey quads jittered into a ball, one draw call. */ function makeFluffGeometry(rng: () => number): THREE.BufferGeometry { const quads = 8 + Math.floor(rng() * 5); // 8..12 const pos = new Float32Array(quads * 4 * 3); const col = new Float32Array(quads * 4 * 3); const uv = new Float32Array(quads * 4 * 2); const idx = new Uint16Array(quads * 6); const a = new THREE.Vector3(), b = new THREE.Vector3(), n = new THREE.Vector3(); for (let q = 0; q < quads; q++) { // random centre inside the ball + random plane orientation const cx = (rng() * 2 - 1) * 0.34; const cy = (rng() * 2 - 1) * 0.26; const cz = (rng() * 2 - 1) * 0.34; n.set(rng() * 2 - 1, rng() * 2 - 1, rng() * 2 - 1).normalize(); a.set(rng() * 2 - 1, rng() * 2 - 1, rng() * 2 - 1).cross(n).normalize(); b.crossVectors(n, a); const s = 0.26 + rng() * 0.16; // quad half-size → puffs 0.5..0.85 across // dust tone (matches the `dust` block tint 120,116,110) with per-quad jitter const shade = 0.30 + rng() * 0.17; const r = shade * 1.02, g = shade * 0.99, bcol = shade * 0.94; for (let corner = 0; corner < 4; corner++) { const su = corner === 1 || corner === 2 ? s : -s; const sv = corner >= 2 ? s : -s; const i3 = (q * 4 + corner) * 3; pos[i3] = cx + a.x * su + b.x * sv; pos[i3 + 1] = cy + a.y * su + b.y * sv; pos[i3 + 2] = cz + a.z * su + b.z * sv; col[i3] = r; col[i3 + 1] = g; col[i3 + 2] = bcol; const i2 = (q * 4 + corner) * 2; uv[i2] = corner === 1 || corner === 2 ? 1 : 0; uv[i2 + 1] = corner >= 2 ? 1 : 0; } const v0 = q * 4, ii = q * 6; idx[ii] = v0; idx[ii + 1] = v0 + 1; idx[ii + 2] = v0 + 2; idx[ii + 3] = v0; idx[ii + 4] = v0 + 2; idx[ii + 5] = v0 + 3; } const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.BufferAttribute(pos, 3)); geo.setAttribute('color', new THREE.BufferAttribute(col, 3)); geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2)); geo.setIndex(new THREE.BufferAttribute(idx, 1)); return geo; } export class DustBunnies { readonly group: THREE.Group; private world: IVoxelWorld; private player: IPlayerView; private bunnies: Bunny[] = []; private material: THREE.MeshBasicMaterial; private emitCooldown = 0; constructor(world: IVoxelWorld, player: IPlayerView, tex: THREE.Texture) { this.world = world; this.player = player; this.group = new THREE.Group(); // soft round alpha from the shared glow canvas, normal blending so the // fluff reads as matte dust, not light this.material = new THREE.MeshBasicMaterial({ map: tex, transparent: true, depthWrite: false, vertexColors: true, side: THREE.DoubleSide, }); // deterministic-ish per-session fluff (visual only; Math.random is fine // for FX per CONTRACTS — worldgen determinism doesn't apply here) for (let i = 0; i < BUNNY_COUNT; i++) { const [hx, hz] = HOMES[i % HOMES.length]; const mesh = new THREE.Mesh(makeFluffGeometry(Math.random), this.material); mesh.frustumCulled = true; // tiny sphere, fine to cull this.group.add(mesh); const bunny: Bunny = { mesh, x: hx + 0.5, y: 0, z: hz + 0.5, ground: 3, heading: Math.random() * Math.PI * 2, speed: 0.5, mode: Mode.Wander, timer: 1 + Math.random() * 3, fleeLeft: 0, phase: Math.random() * Math.PI * 2, spin: 0.5 + Math.random() * 0.7, turnSign: i & 1 ? 1 : -1, homeX: hx + 0.5, homeZ: hz + 0.5, }; bunny.ground = this.groundTopAt(bunny.x, bunny.z); bunny.y = bunny.ground + 0.42; this.bunnies.push(bunny); } } /** Spawn-only: standing surface at a known-flat home point (top face of the * highest floor block in the under-table band). */ private groundTopAt(x: number, z: number): number { const xi = Math.floor(x), zi = Math.floor(z); for (let y = 5; y >= 0; y--) { if (this.world.isSolid(xi, y, zi)) return y + 1; } return 2; // world floor slab top (should not happen inside the booth) } /** Standing surface at (x,z) reachable from ground level `from` with at most * a 1-voxel step: a valid surface g has solid under it and air at body * level. Overhangs (e.g. the crate's edge-standing records) are NEVER * ground — that's what keeps bunnies from climbing into geometry. Returns * -1 when nothing within ±1 works (a wall or cliff). */ private reachableGround(x: number, z: number, from: number): number { const xi = Math.floor(x), zi = Math.floor(z); // same level first, then step down, then step up if (this.world.isSolid(xi, from - 1, zi) && !this.world.isSolid(xi, from, zi)) return from; if (this.world.isSolid(xi, from - 2, zi) && !this.world.isSolid(xi, from - 1, zi)) return from - 1; if (this.world.isSolid(xi, from, zi) && !this.world.isSolid(xi, from + 1, zi)) return from + 1; return -1; } /** Can a bunny occupy (x,z) coming from ground level `from`? Checks walls * (isSolid lookahead via reachableGround), keep-out zones and the * under-table bounds. Pure scalar math, no allocation. */ private passable(x: number, z: number, from: number): boolean { // hard bounds: stay inside the rim, under the table if (x < RIM + 2 || x > this.world.sizeX - RIM - 2) return false; if (z < RIM + 2 || z > LAYOUT.backWallZ - 2) return false; if (this.reachableGround(x, z, from) < 0) return false; for (let i = 0; i < KEEP_OUT.length; i++) { const zo = KEEP_OUT[i]; const dx = x - zo.x, dz = z - zo.z; if (dx * dx + dz * dz < zo.r2) return false; } return true; } update(dt: number): void { if (this.emitCooldown > 0) this.emitCooldown -= dt; const pp = this.player.position; for (let i = 0; i < this.bunnies.length; i++) { this.step(this.bunnies[i], pp[0], pp[1], pp[2], dt); } } private step(b: Bunny, px: number, py: number, pz: number, dt: number): void { // escape hatch: if someone builds a block into a bunny, pop it on top const bxi = Math.floor(b.x), bzi = Math.floor(b.z); if (this.world.isSolid(bxi, b.ground, bzi)) { for (let g = b.ground + 1; g <= b.ground + 3; g++) { if (!this.world.isSolid(bxi, g, bzi)) { b.ground = g; break; } } } const dx = b.x - px, dz = b.z - pz; const d2 = dx * dx + dz * dz; const nearY = Math.abs(py - b.y) < 4; // only spooked by a player down here // ── spook / re-spook ── if (nearY && d2 < SPOOK_DIST2) { if (b.mode !== Mode.Flee) { b.mode = Mode.Flee; b.fleeLeft = FLEE_DIST; b.heading = Math.atan2(dz, dx); // straight away from the player if (this.emitCooldown <= 0) { this.emitCooldown = EMIT_COOLDOWN; bus.emit('machine:interact', { machineId: 'bunny', action: 'bunny_flee' }); } } else if (b.fleeLeft < FLEE_DIST * 0.5) { b.fleeLeft = FLEE_DIST; // still being chased — keep running } } // ── move ── if (b.mode === Mode.Flee) { // ease out: fast off the mark, slowing as the 8 voxels run out const k = b.fleeLeft / FLEE_DIST; b.speed = 0.6 + 6.4 * Math.pow(k, 0.7); const stepLen = b.speed * dt; if (!this.tryMove(b, stepLen, true)) { // cornered — give up early and settle b.fleeLeft = 0; } b.fleeLeft -= stepLen; if (b.fleeLeft <= 0) { b.mode = Mode.Settle; b.timer = 0.8 + Math.random() * 0.8; } } else if (b.mode === Mode.Settle) { b.timer -= dt; b.speed = 0; if (b.timer <= 0) { b.mode = Mode.Wander; b.timer = 1 + Math.random() * 3; } } else { // wander: slow drift, heading random-walks; re-aim on a timer b.timer -= dt; if (b.timer <= 0) { b.timer = 2 + Math.random() * 4; b.heading = Math.random() * Math.PI * 2; b.speed = Math.random() < 0.25 ? 0 : 0.3 + Math.random() * 0.5; // sometimes just sit } b.heading += (Math.random() - 0.5) * 1.4 * dt; // leash: beyond it, ease the heading back toward home const hx = b.homeX - b.x, hz = b.homeZ - b.z; if (hx * hx + hz * hz > LEASH * LEASH) { const want = Math.atan2(hz, hx); let delta = want - b.heading; while (delta > Math.PI) delta -= Math.PI * 2; while (delta < -Math.PI) delta += Math.PI * 2; b.heading += delta * Math.min(1, 2.5 * dt); } if (b.speed > 0) this.tryMove(b, b.speed * dt, false); } // ── settle onto the floor + dress the mesh (no allocation) ── b.phase += dt * (b.mode === Mode.Flee ? 9 : 2.2); const bob = b.mode === Mode.Settle ? Math.sin(b.phase * 6) * 0.015 // trembling : Math.sin(b.phase) * (b.mode === Mode.Flee ? 0.14 : 0.06); b.y = b.ground + 0.42 + Math.abs(bob); const m = b.mesh; m.position.set(b.x, b.y, b.z); m.rotation.y += dt * (b.mode === Mode.Flee ? 7 : b.spin); m.rotation.z = Math.sin(b.phase * 0.7) * 0.12; const breathe = 1 + Math.sin(b.phase * 1.3) * 0.05; m.scale.setScalar(breathe); } /** Advance along the heading with an isSolid lookahead; on a blocked cell, * deflect (flee tries harder before giving up). Returns false if stuck. */ private tryMove(b: Bunny, stepLen: number, fleeing: boolean): boolean { const look = Math.max(stepLen, 0.35); // look at least a third of a voxel ahead const tries = fleeing ? 5 : 2; for (let t = 0; t < tries; t++) { // deflections: 0, ±45°, ±90° (sign alternates per bunny for variety) const deflect = t === 0 ? 0 : b.turnSign * Math.ceil(t / 2) * (Math.PI / 4) * (t & 1 ? 1 : -1); const h = b.heading + deflect; const cx = Math.cos(h), cz = Math.sin(h); if (this.passable(b.x + cx * look, b.z + cz * look, b.ground)) { if (deflect !== 0) b.heading = h; b.x += cx * stepLen; b.z += cz * stepLen; // ride 1-voxel bumps (copper traces); keep last ground under overhangs const g = this.reachableGround(b.x, b.z, b.ground); if (g >= 0) b.ground = g; return true; } } // blocked: turn away for next tick b.turnSign = -b.turnSign; b.heading += b.turnSign * (Math.PI / 2 + Math.random() * (Math.PI / 2)); return false; } dispose(): void { for (const b of this.bunnies) b.mesh.geometry.dispose(); this.material.dispose(); this.group.removeFromParent(); } }