Lanes A (engine), B (player), C (worldgen), D (machines/quest), E (audio/fx/ui) as landed, each with HANDOFF.md + update docs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
183 lines
7.2 KiB
TypeScript
183 lines
7.2 KiB
TypeScript
// 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;
|
||
}
|
||
}
|
||
}
|