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>
324 lines
14 KiB
TypeScript
324 lines
14 KiB
TypeScript
// 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();
|
||
}
|
||
}
|