TURNCRAFT/src/machines/faders.ts
jing 9c668d340c Headshell Workshop + Glow-Up phase: assembly minigame, art pass, review fixes
- Workshop (WORKSHOP_CARTRIDGE): five-stage cartridge assembly at Deck A —
  seat/square, crimp four tag-wires, torque screws, ride-the-arm counterweight
  balance, needle-drop diagnostic with per-fault audio + scope. Relay-synced
  (per-field co-op merge: held screw + carried wire survive remote state).
- Glow-Up (G1-G5): 32px atlas with per-voxel variants, selective LED bloom
  (quality-gated), screen-print decal system + party flyers, mixer/PCB worldgen
  density pass, record groove-sheen side texture.
- 10 confirmed multi-agent review fixes, incl. co-op screw-stomp soft-lock,
  ride-snap collider-identity (magnet feet / eaten record-fling), workshop SFX
  exact-match map (rca_seated hijack), beam ride colliders to the head,
  double-crimp guard, WIRING INCOMPLETE diagnosis mode, skate-abort timer,
  completedState wiring, per-tick material churn, trackingHeavy platter drag.
- Crossfader playtest fix: slew-limited sled (3.4 v/s) + ribbed grip caps with
  amber index — the sled reads as a heavy handle, not a teleporting wall.
- Demo harnesses: machinesDemo hold-key wiring, playerDemo seesaw phase
  continuity, audioDemo incomplete fault button.
- Workshop sync: exact 1.0 screw endpoint gets its own emit signature.

Verified: typecheck + vite build clean, live solo quest smoke, two-client
co-op relay smoke (simultaneous torque, no rewind, exact convergence).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 21:32:07 +10:00

201 lines
7.9 KiB
TypeScript

// 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];
}