TURNCRAFT/src/fx/bunnies.ts
type-two 33232fdeba SIDE B: the crate worlds — three dive-in pockets, stamps, golden slipmat
Three records in the under-table crate are pressed with whole scenes. E beside
one dives you into its pocket above the booth walls; complete its sound-first
quest to press a STAMP; all three raise the golden slipmat + a permanent bonus
percussion stem. Dust bunnies move in under the table.

- Worlds (Lane W): ACID WAREHOUSE / DUB CHAMBER / DISCO LOFT — sealed themed
  pockets, portal rings, exit pads; deterministic, zero light leaks (incl. the
  flythrough path), meshes unchanged at 469, build ~760 ms.
- Machines (Lane M): portal dive/exit/safety (membership-based escape catch),
  tune-the-303 hold-sweep pedestals, carry-the-charge wobbling spring bridge,
  beat-Simon dance floor with glow tiles; stamps store + relay {t:'stamp'}
  (default-closed, persisted, join snapshot, fuzzed); golden slipmat; stamps
  survive the reset ritual.
- Ambience (Lane S): dust bunnies (wander/flee, zero steady-state allocs),
  vinyl-warp portal iris, HUD stamp tray, stamp confetti + golden shimmer.
- Audio (Lane E2): per-world grooves (acid 303 w/ fader-driven bands, dub
  feedback-echo + spring, disco pentatonic tiles) over a boothGain
  duck-and-handback that can never restore stale state; portal/stamp/golden
  SFX; bonus stem.

Post-integration review (2 seam finders, 10 findings, all fixed + verified):
stamp events carry origin (local/remote/replay) so consumers celebrate
proportionately instead of wall-clock guessing — kills phantom join
celebrations, first-person confetti for peers' wins, and audio/visual grace
mismatches; reconnect re-uploads locally-won stamps the server missed;
relay saveState survives disk errors; pocket escape via mined floor counts as
a real exit at any altitude; exit-pad speed gate + dwell stops mid-quest
yank-outs; dive cooldown stops exit/re-dive ping-pong; locked acid bands
need deliberate holds (tap-spam dead); disco foot band excludes jump apex.

Verified: whole-tree typecheck + build clean; per-lane live verification
(27/27 worldgen assertions, relay fuzz, 18-phase audio matrix, fps unchanged);
integrated smoke: dive/exit/safety/stamps/golden + all origin paths live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 16:22:11 +10:00

324 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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: 812 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();
}
}