TURNCRAFT/src/machines/tonearm.ts
jing 49cab1f69b Concept-art pass: terraced records, spindle towers, warm lit room
- Records are stepped groove amphitheaters (3 vinyl tiers + 4-tier spindle
  tower, stacked rotating cylinder colliders) - you climb a spinning
  staircase. Tonearm clearances raised to match.
- Player physics: cylinder contacts resolve by minimum penetration (fixes a
  latent radial-slingshot bug), collider lips auto-step like voxel ledges,
  rim-standing radius grace.
- Well seam ring raised (was a 7-deep inescapable trench, now a 1-step
  Technics seam).
- Plywood ceiling with amber lamp panels + warm point lights; brighter warm
  lighting rig; colorful mixer knob caps; rim-crumble debris.
- window.TURNCRAFT_CINE camera override for screenshots/trailers.

Verified live: rim->plateau spiral climb at 33rpm, tower climb 83->87,
seam escape, quest/win chain, 121 fps.

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

236 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
// "23 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;
}