// LANE D — platter machine ×2 (D2), the signature ride. // A rotating kinematic cylinder carrying a translucent record. Standing on it // adds the surface velocity ω×r to the player (Lane B reads velocityAt). Runs // at "shrunk-time" game-RPM (constants.PLATTER) with a torque-y Technics // spin-up/brake. import * as THREE from 'three'; import { PLATTER, Y_PLINTH_TOP, Y_RECORD_TOP } from '../core/constants'; import { bus } from '../core/events'; import type { KinematicCollider, Vec3 } from '../core/types'; import { MachineBase } from './machine'; import { blockMaterial, cylinderY, recordTopTexture } from './util'; import { BLOCK_BY_NAME } from '../core/blocks'; /** game-RPM (revolutions per minute) → angular speed (rad/s). */ const RPM_TO_OMEGA = Math.PI / 30; // 2π / 60 // Exponential approach time constant; smaller = snappier lurch. Tuned so the // platter reaches ~90% of target in PLATTER.spinUpSeconds. const TAU = PLATTER.spinUpSeconds * 0.43; export type Speed = 33 | 45; export class Platter extends MachineBase { readonly deck: 'A' | 'B'; private readonly cx: number; private readonly cz: number; private omega = 0; // current signed angular speed (rad/s), - = CW from above private targetOmega = 0; private playing = false; private speed: Speed = 33; private pitch = 1; // 0.5..1.5 multiplier from the pitch fader unlocked45 = true; // 45 is available for rim-launch traversal from the start private readonly spin = new THREE.Group(); // everything that rotates about the spindle private readonly collider: KinematicCollider; private readonly _vel: Vec3 = [0, 0, 0]; // velocityAt scratch (reused, no alloc) constructor(deck: 'A' | 'B', spindleX: number, spindleZ: number) { super(`platter${deck}`); this.deck = deck; this.cx = spindleX; this.cz = spindleZ; // ---- Collider: one +Y cylinder spanning plinth top → vinyl surface. ---- const halfH = (Y_RECORD_TOP - Y_PLINTH_TOP) / 2; // 2.5 const cy = Y_PLINTH_TOP + halfH; // 82.5 this.collider = { id: this.id, shape: { kind: 'cylinder', center: [this.cx, cy, this.cz], radius: PLATTER.radius, halfHeight: halfH }, // Surface velocity = ω × r (rigid rotation about +Y). Zero when stopped. // Writes into a reused scratch (no per-call alloc in the ride hot path). // Contract: consume the value before the next velocityAt call on THIS // collider — Lane B reads it immediately each tick (CONTRACTS §4). velocityAt: (x: number, _y: number, z: number): Vec3 => { const v = this._vel; if (this.omega === 0) { v[0] = v[1] = v[2] = 0; return v; } v[0] = this.omega * (z - this.cz); v[1] = 0; v[2] = -this.omega * (x - this.cx); return v; }, }; this.colliders = [this.collider]; this.buildVisuals(deck); this.spin.position.set(this.cx, 0, this.cz); this.group.add(this.spin); } private buildVisuals(deck: 'A' | 'B'): void { const r = PLATTER.radius; const rr = PLATTER.recordRadius; // Platter: brushed dark alu disc just under the record. const platterMat = blockMaterial(3, { scale: 0.62, roughness: 0.4 }); // brushed_alu, darkened const platter = cylinderY(r, 1.6, platterMat, 0, Y_PLINTH_TOP + 0.8, 0); this.spin.add(platter); // Strobe-dot ring: small emissive red studs around the platter rim. const dotMat = blockMaterial(28, { emissive: 0.9 }); // strobe_dot const dotCount = 36; for (let i = 0; i < dotCount; i++) { const a = (i / dotCount) * Math.PI * 2; const d = new THREE.Mesh(new THREE.BoxGeometry(1.1, 0.5, 0.7), dotMat); d.position.set(Math.cos(a) * (r - 0.9), Y_PLINTH_TOP + 1.6, Math.sin(a) * (r - 0.9)); d.rotation.y = -a; this.spin.add(d); } // Slipmat. const slipMat = blockMaterial(7); // slipmat_felt this.spin.add(cylinderY(rr + 1.5, 0.5, slipMat, 0, Y_PLINTH_TOP + 1.9, 0)); // Record body: blue translucent (deck A) / black (deck B). const vinylId = deck === 'A' ? 9 : 8; // vinyl_blue / vinyl_black const vinylDef = deck === 'A' ? BLOCK_BY_NAME.get('vinyl_blue')! : BLOCK_BY_NAME.get('vinyl_black')!; const sideMat = blockMaterial(vinylId, { opacity: deck === 'A' ? 0.5 : 1 }); const recordBody = cylinderY(rr, 1.4, sideMat, 0, Y_RECORD_TOP - 0.7, 0); this.spin.add(recordBody); // Record top face: grooves + printed cream label (canvas texture). const label = BLOCK_BY_NAME.get('label_cream')!.tint; const topTex = recordTopTexture(vinylDef.tint, label, 'TC-00' + (deck === 'A' ? '1' : '2') + ' · TURNCRAFT'); const topMat = new THREE.MeshStandardMaterial({ map: topTex, roughness: 0.5, metalness: 0.05, transparent: deck === 'A', opacity: deck === 'A' ? 0.94 : 1, }); const top = new THREE.Mesh(new THREE.CircleGeometry(rr, 64), topMat); top.rotation.x = -Math.PI / 2; // face +Y top.position.y = Y_RECORD_TOP + 0.01; this.spin.add(top); // Chrome spindle pin (does not rotate meaningfully; ride onto it to climb). const chrome = blockMaterial(11); // chrome const pin = cylinderY(0.8, 8, chrome, 0, Y_RECORD_TOP + 3.5, 0, 16); this.spin.add(pin); } // ---- Control surface (called by buttons / faders / win wiring) ---- togglePlay(): void { this.setPlaying(!this.playing); } setPlaying(on: boolean): void { if (this.playing === on) return; this.playing = on; this.recomputeTarget(); this.emitState(); } setSpeed(s: Speed): void { if (s === 45 && !this.unlocked45) return; if (this.speed === s) return; this.speed = s; this.recomputeTarget(); this.emitState(); } /** Pitch multiplier 0.5..1.5 from the deck's pitch fader value 0..1. */ setPitchFromFader(value01: number): void { const p = 0.5 + value01; // 0..1 -> 0.5..1.5 if (Math.abs(p - this.pitch) < 1e-4) return; this.pitch = p; this.recomputeTarget(); this.emitState(); } isPlaying(): boolean { return this.playing; } currentRpm(): number { return this.playing ? this.baseRpm() * this.pitch : 0; } private baseRpm(): number { return this.speed === 33 ? PLATTER.rpm33 : PLATTER.rpm45; } private recomputeTarget(): void { // Records spin clockwise viewed from above = negative rotation about +Y. this.targetOmega = this.playing ? -RPM_TO_OMEGA * this.baseRpm() * this.pitch : 0; } private lastEmitPlaying = false; private lastEmitRpm = -1; private emitState(): void { // Emit on any meaningful play/speed/pitch change, but never a byte-identical // duplicate (e.g. flicking 33/45 while stopped keeps {playing:false, rpm:0}). const rpm = this.currentRpm(); if (this.playing === this.lastEmitPlaying && rpm === this.lastEmitRpm) return; this.lastEmitPlaying = this.playing; this.lastEmitRpm = rpm; bus.emit('platter:state', { deck: this.deck, playing: this.playing, rpm }); } update(dt: number): void { if (this.omega !== this.targetOmega) { // Exponential approach — the Technics spin-up/brake lurch. const k = 1 - Math.exp(-dt / TAU); this.omega += (this.targetOmega - this.omega) * k; if (Math.abs(this.omega - this.targetOmega) < 1e-4) this.omega = this.targetOmega; } if (this.omega !== 0) { this.spin.rotation.y += this.omega * dt; } } }