// LANE D — tonearm machine ×2 (D3). // A rigid arm pivoting about a rear pedestal. While the deck plays AND the // stylus is fitted, it cues to the outer groove then tracks slowly inward // (~TRACK_SECONDS for a full pass) before auto-returning to rest. The headshell // is the "needle plow": a slow kinematic aabb that pushes the player along. import * as THREE from 'three'; import { PLATTER, Y_RECORD_TOP } from '../core/constants'; import { bus } from '../core/events'; import type { ColliderShape, KinematicCollider, Vec3 } from '../core/types'; import { MachineBase } from './machine'; import { blockMaterial } from './util'; const HEAD_Y = Y_RECORD_TOP + 2.6; // hovers above the vinyl, clearing the mid terrace const R_INNER = 16; // inner groove — stays outside the label // plateau (r 14, top +2) and the tower const R_OUTER = PLATTER.recordRadius - 3; // outer groove const R_REST = PLATTER.radius + 1; // parked just off the platter rim const TRACK_SECONDS = 240; // ~4 min for a full inward pass const CUE_SECONDS = 1.6; // drop-to-outer-groove cue time const RETURN_RATE = 0.6; // rad/s auto-return sweep type State = 'rest' | 'cueing' | 'tracking' | 'returning'; export class Tonearm extends MachineBase { readonly deck: 'A' | 'B'; /** Wired by createMachines to the deck's platter. */ deckPlaying: () => boolean = () => false; private readonly px: number; private readonly pz: number; private readonly L: number; // rigid arm length (pivot → headshell) private readonly baseX: number; // unit baseline dir (pivot → spindle), xz private readonly baseZ: number; private readonly sign: 1 | -1; // which side of the baseline the arm swings private readonly phiRest: number; private readonly phiOuter: number; private state: State = 'rest'; private phi: number; // current sweep angle from baseline private trackT = 0; // 0..1 inward progress while tracking private stylus: boolean; private readonly head: Vec3 = [0, 0, 0]; private readonly prevHead: Vec3 = [0, 0, 0]; private readonly headVel: Vec3 = [0, 0, 0]; private headInit = false; private readonly tube: THREE.Mesh; private readonly headGroup = new THREE.Group(); private readonly stylusMesh: THREE.Mesh; private readonly counterweight: THREE.Mesh; private readonly headShape: { kind: 'aabb'; min: Vec3; max: Vec3 }; // The arm is a chain of small AABBs tiling the pivot→head line (the brief's // "2–3 segment aabb chain"). A single endpoint-bounding box would balloon into // a huge phantom collider over the rideable record when the arm is diagonal. private readonly armSegs: { kind: 'aabb'; min: Vec3; max: Vec3 }[] = []; private static readonly ARM_SEGMENTS = 3; constructor(deck: 'A' | 'B', spindleX: number, spindleZ: number, pivot: Vec3, initialStylus: boolean) { super(`tonearm${deck}`); this.deck = deck; this.px = pivot[0]; this.pz = pivot[2]; this.stylus = initialStylus; const dx = spindleX - this.px; const dz = spindleZ - this.pz; const D = Math.hypot(dx, dz); this.baseX = dx / D; this.baseZ = dz / D; this.L = D - R_INNER; // φ=0 lands the headshell at the inner groove this.phiRest = this.solvePhi(D, R_REST); this.phiOuter = this.solvePhi(D, R_OUTER); // Pick the swing side that carries the headshell toward the front (−z). const testZ = (s: 1 | -1) => this.rotZ(this.baseX, this.baseZ, s * this.phiOuter); this.sign = testZ(1) < testZ(-1) ? 1 : -1; this.phi = this.phiRest; // ---- Colliders (positions filled by first update) ---- this.headShape = { kind: 'aabb', min: [0, 0, 0], max: [0, 0, 0] }; const headCollider: KinematicCollider = { id: `${this.id}_head`, shape: this.headShape as ColliderShape, velocityAt: () => [this.headVel[0], this.headVel[1], this.headVel[2]], }; this.colliders = [headCollider]; for (let i = 0; i < Tonearm.ARM_SEGMENTS; i++) { const seg = { kind: 'aabb' as const, min: [0, 0, 0] as Vec3, max: [0, 0, 0] as Vec3 }; this.armSegs.push(seg); this.colliders.push({ id: `${this.id}_arm${i}`, shape: seg as ColliderShape, velocityAt: () => [this.headVel[0] * 0.4, 0, this.headVel[2] * 0.4], }); } // ---- Visuals ---- const chrome = blockMaterial(11); // chrome const dark = blockMaterial(5); // matte_black counterweight const pedestal = new THREE.Mesh(new THREE.CylinderGeometry(2.2, 2.6, 8, 16), chrome); pedestal.position.set(this.px, HEAD_Y - 3, this.pz); this.group.add(pedestal); this.tube = new THREE.Mesh(new THREE.CylinderGeometry(0.7, 0.7, 1, 12), chrome); this.group.add(this.tube); const cw = new THREE.Mesh(new THREE.CylinderGeometry(2.6, 2.6, 4, 16), dark); cw.rotation.z = Math.PI / 2; // counterweight, positioned each frame with the arm const headBody = new THREE.Mesh(new THREE.BoxGeometry(3, 2.4, 2.4), dark); this.headGroup.add(headBody); this.stylusMesh = new THREE.Mesh(new THREE.ConeGeometry(0.5, 2.2, 10), chrome); this.stylusMesh.position.y = -1.8; this.stylusMesh.visible = this.stylus; this.headGroup.add(this.stylusMesh); this.group.add(this.headGroup); this.counterweight = cw; this.group.add(cw); this.tickGeometry(0); // place everything for frame 0 } /** Angle from the baseline so the headshell sits at radius r from the spindle. */ private solvePhi(D: number, r: number): number { const c = (D * D + this.L * this.L - r * r) / (2 * D * this.L); return Math.acos(Math.max(-1, Math.min(1, c))); } /** z-component of baseline rotated by angle a about +Y (for side selection). */ private rotZ(vx: number, vz: number, a: number): number { return -vx * Math.sin(a) + vz * Math.cos(a); } setStylus(on: boolean): void { this.stylus = on; this.stylusMesh.visible = on; if (on) bus.emit('machine:interact', { machineId: this.id, action: 'stylus_fitted' }); } hasStylus(): boolean { return this.stylus; } private enabled(): boolean { return this.stylus && this.deckPlaying(); } update(dt: number): void { // ---- State machine over the sweep angle φ ---- switch (this.state) { case 'rest': if (this.enabled()) { this.state = 'cueing'; } break; case 'cueing': { // Lerp rest → outer over CUE_SECONDS. const step = (this.phiRest - this.phiOuter) * (dt / CUE_SECONDS); this.phi -= step; if (this.phi <= this.phiOuter) { this.phi = this.phiOuter; this.state = 'tracking'; this.trackT = 0; } if (!this.enabled()) this.state = 'returning'; break; } case 'tracking': this.trackT = Math.min(1, this.trackT + dt / TRACK_SECONDS); this.phi = this.phiOuter * (1 - this.trackT); // → 0 (inner) as trackT → 1 if (!this.enabled() || this.trackT >= 1) this.state = 'returning'; break; case 'returning': this.phi += RETURN_RATE * dt; if (this.phi >= this.phiRest) { this.phi = this.phiRest; this.state = 'rest'; } break; } this.tickGeometry(dt); } /** Recompute headshell world pos, arm transform, colliders, and velocity. */ private tickGeometry(dt: number): void { const a = this.sign * this.phi; // Direction pivot→headshell = baseline rotated by a. const dirX = this.baseX * Math.cos(a) + this.baseZ * Math.sin(a); const dirZ = -this.baseX * Math.sin(a) + this.baseZ * Math.cos(a); const hx = this.px + dirX * this.L; const hz = this.pz + dirZ * this.L; this.head[0] = hx; this.head[1] = HEAD_Y; this.head[2] = hz; // Headshell velocity (finite difference; zero on first frame). if (this.headInit && dt > 0) { this.headVel[0] = (hx - this.prevHead[0]) / dt; this.headVel[1] = 0; this.headVel[2] = (hz - this.prevHead[2]) / dt; } else { this.headVel[0] = this.headVel[1] = this.headVel[2] = 0; } this.prevHead[0] = hx; this.prevHead[1] = HEAD_Y; this.prevHead[2] = hz; this.headInit = true; // Headshell group + stylus. this.headGroup.position.set(hx, HEAD_Y, hz); // Tube: unit cylinder scaled to length L, midpoint pivot↔head. const mx = (this.px + hx) / 2, mz = (this.pz + hz) / 2; this.tube.position.set(mx, HEAD_Y, mz); this.tube.scale.set(1, this.L, 1); const dir = _dir.set(hx - this.px, 0, hz - this.pz).normalize(); this.tube.quaternion.setFromUnitVectors(UP, dir); // Counterweight behind the pivot. this.counterweight.position.set(this.px - dir.x * 6, HEAD_Y, this.pz - dir.z * 6); // Headshell aabb (the plow). setAabb(this.headShape, hx, HEAD_Y, hz, 2, 2.6, 2); // Arm collider chain: tile the pivot→head line in N small AABBs so the box // hugs the diagonal tube instead of enclosing the whole bounding rectangle. const n = Tonearm.ARM_SEGMENTS; for (let i = 0; i < n; i++) { const t0 = i / n, t1 = (i + 1) / n; const x0 = this.px + (hx - this.px) * t0, z0 = this.pz + (hz - this.pz) * t0; const x1 = this.px + (hx - this.px) * t1, z1 = this.pz + (hz - this.pz) * t1; const s = this.armSegs[i]; s.min[0] = Math.min(x0, x1) - 1; s.max[0] = Math.max(x0, x1) + 1; s.min[1] = HEAD_Y - 1.5; s.max[1] = HEAD_Y + 1.5; s.min[2] = Math.min(z0, z1) - 1; s.max[2] = Math.max(z0, z1) + 1; } } } const _dir = new THREE.Vector3(); // scratch, reused each tick (fix #5) const UP = new THREE.Vector3(0, 1, 0); function setAabb(s: { min: Vec3; max: Vec3 }, cx: number, cy: number, cz: number, hx: number, hy: number, hz: number): void { s.min[0] = cx - hx; s.min[1] = cy - hy; s.min[2] = cz - hz; s.max[0] = cx + hx; s.max[1] = cy + hy; s.max[2] = cz + hz; }