- 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>
201 lines
7.9 KiB
TypeScript
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];
|
|
}
|