TURNCRAFT/src/machines/platter.ts
jing 5a39e3a947 TURNCRAFT: contracts, docs, and all five lane deliverables (pre-integration)
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>
2026-07-13 20:50:56 +10:00

183 lines
7.2 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 — 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;
}
}
}