// LANE D — fader machines (D4): pitch fader, four channel faders, crossfader. // A fader is a sled that slides in a slot. The player pushes it by walking into // it: the sled chases the player's coordinate along the slot axis at a capped // slew rate (so it reads as shoving a heavy part, not a teleporting wall), // clamped and committed to detents. It also exposes setValue() so a demo can // click-drag it. The crossfader is gated: it can't move until its slot voxels // are clear of dust, and the first full slide after clearing repairs the // crossfader node. import * as THREE from 'three'; import type { BlockId } from '../core/blocks'; import { bus } from '../core/events'; import type { KinematicCollider, Vec3 } from '../core/types'; import { MachineBase, playerAABB, type AABB } from './machine'; import { faderCapMaterials } from './util'; /** Max sled travel speed while chasing the player (voxels/sec). Just under * walk speed (4.3), so the player perceptibly drags it rather than it * snapping to them — tuned to feel heavy. */ const DEFAULT_SLEW = 3.4; export interface FaderOpts { id: string; /** Sled-center travel endpoints (value 0 → start, value 1 → end). */ start: Vec3; end: Vec3; axis: 'x' | 'z'; detents?: number; // default 11 capBlock?: BlockId; // visual, default fader_cap (14) capSize?: [number, number, number]; initial?: number; // default 0.5 (center) /** Max sled travel speed in voxels/sec (default DEFAULT_SLEW). */ slewRate?: number; /** If present and returns false, the fader is jammed and cannot move. */ gate?: () => boolean; /** Called once when a full 0↔1 slide completes while the gate is open. */ onFullSlide?: () => void; /** Called whenever the value changes (e.g. pitch fader → platter). */ onChange?: (value: number) => void; } export class Fader extends MachineBase { private value: number; /** Continuous sled position 0..1 — the cap glides here; `value` is the * detent-snapped commit derived from it. */ private visPos: number; /** Slew cap converted to value-units/sec (voxels/sec ÷ travel length). */ private readonly slewPerSec: number; private readonly axis: 'x' | 'z'; private readonly start: Vec3; private readonly end: Vec3; private readonly detents: number; private readonly gate?: () => boolean; private readonly onFullSlide?: () => void; private readonly onChange?: (value: number) => void; private readonly cap: THREE.Mesh; private readonly capHalf: Vec3; private readonly shape: { kind: 'aabb'; min: Vec3; max: Vec3 }; private readonly aabb: AABB = { minX: 0, minY: 0, minZ: 0, maxX: 0, maxY: 0, maxZ: 0 }; private sawLow = false; private sawHigh = false; private fullSlideFired = false; constructor(o: FaderOpts) { super(o.id); this.axis = o.axis; this.start = o.start; this.end = o.end; this.detents = o.detents ?? 11; this.gate = o.gate; this.onFullSlide = o.onFullSlide; this.onChange = o.onChange; this.value = clamp01(o.initial ?? 0.5); const size = o.capSize ?? [4, 2.4, 6]; this.capHalf = [size[0] / 2, size[1] / 2, size[2] / 2]; this.cap = new THREE.Mesh( new THREE.BoxGeometry(size[0], size[1], size[2]), faderCapMaterials(o.axis, o.capBlock ?? 14), ); this.group.add(this.cap); const travel = Math.abs(o.axis === 'x' ? o.end[0] - o.start[0] : o.end[2] - o.start[2]); this.slewPerSec = (o.slewRate ?? DEFAULT_SLEW) / Math.max(travel, 1); this.shape = { kind: 'aabb', min: [0, 0, 0], max: [0, 0, 0] }; const collider: KinematicCollider = { id: `${o.id}_cap`, shape: this.shape, velocityAt: () => [0, 0, 0], // faders move only when pushed; treat as static surface }; this.colliders = [collider]; this.visPos = this.value; this.moveSled(this.visPos); } getValue(): number { return this.value; } /** Set value directly (demo click-drag). Respects the gate and detents. */ setValue(v: number): void { if (this.gate && !this.gate()) return; this.visPos = clamp01(v); this.moveSled(this.visPos); this.commit(); } /** Position the cap mesh + collider at a continuous 0..1 travel position. */ private moveSled(t: number): void { const p = lerpVec(this.start, this.end, t); this.cap.position.set(p[0], p[1], p[2]); this.shape.min[0] = p[0] - this.capHalf[0]; this.shape.max[0] = p[0] + this.capHalf[0]; this.shape.min[1] = p[1] - this.capHalf[1]; this.shape.max[1] = p[1] + this.capHalf[1]; this.shape.min[2] = p[2] - this.capHalf[2]; this.shape.max[2] = p[2] + this.capHalf[2]; } /** Commit the detent nearest the sled; fires events only on change. */ private commit(): void { const snapped = snapDetent(this.visPos, this.detents); if (snapped === this.value) return; this.value = snapped; bus.emit('fader:move', { faderId: this.id, value: snapped }); this.onChange?.(snapped); this.trackFullSlide(); } private trackFullSlide(): void { if (this.fullSlideFired) return; if (this.value <= 0.1) this.sawLow = true; if (this.value >= 0.9) this.sawHigh = true; if (this.sawLow && this.sawHigh) { this.fullSlideFired = true; this.onFullSlide?.(); } } update(dt: number): void { if (!this.player) return; if (this.gate && !this.gate()) return; // Chase the player's coordinate at the slew cap; when they disengage, // settle the sled into the committed detent (a soft clunk home). const goal = this.trackTarget() ?? this.value; if (goal === this.visPos) return; const step = this.slewPerSec * dt; const d = goal - this.visPos; this.visPos = Math.abs(d) <= step ? goal : this.visPos + Math.sign(d) * step; this.moveSled(this.visPos); this.commit(); } /** The 0..1 position the player is pushing toward, or null if disengaged. */ private trackTarget(): number | null { const pb = playerAABB(this.player!, this.aabb); // Player must overlap the sled cross-section (the two non-travel axes), // then the sled follows the player's coordinate along the travel axis. const capMinX = this.shape.min[0], capMaxX = this.shape.max[0]; const capMinY = this.shape.min[1], capMaxY = this.shape.max[1]; const capMinZ = this.shape.min[2], capMaxZ = this.shape.max[2]; const yOverlap = pb.minY <= capMaxY + 0.5 && pb.maxY >= capMinY - 0.5; if (!yOverlap) return null; let coord: number, lo: number, hi: number, crossOverlap: boolean; if (this.axis === 'x') { crossOverlap = pb.minZ <= capMaxZ + 0.6 && pb.maxZ >= capMinZ - 0.6; coord = clampNum(this.player!.position[0], this.start[0], this.end[0]); lo = Math.min(this.start[0], this.end[0]) - 1; hi = Math.max(this.start[0], this.end[0]) + 1; } else { crossOverlap = pb.minX <= capMaxX + 0.6 && pb.maxX >= capMinX - 0.6; coord = clampNum(this.player!.position[2], this.start[2], this.end[2]); lo = Math.min(this.start[2], this.end[2]) - 1; hi = Math.max(this.start[2], this.end[2]) + 1; } if (!crossOverlap) return null; const along = this.axis === 'x' ? this.player!.position[0] : this.player!.position[2]; if (along < lo || along > hi) return null; // Map the player's clamped coord to 0..1 along start→end. const a = this.axis === 'x' ? this.start[0] : this.start[2]; const b = this.axis === 'x' ? this.end[0] : this.end[2]; return (coord - a) / (b - a); } } function clamp01(v: number): number { return v < 0 ? 0 : v > 1 ? 1 : v; } function clampNum(v: number, a: number, b: number): number { const lo = Math.min(a, b), hi = Math.max(a, b); return v < lo ? lo : v > hi ? hi : v; } function snapDetent(v: number, detents: number): number { if (detents <= 1) return v; const step = 1 / (detents - 1); return Math.round(v / step) * step; } function lerpVec(a: Vec3, b: Vec3, t: number): Vec3 { return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; }