- 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>
236 lines
9.7 KiB
TypeScript
236 lines
9.7 KiB
TypeScript
// 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;
|
||
}
|