- 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>
362 lines
14 KiB
TypeScript
362 lines
14 KiB
TypeScript
// Lane B — the player controller. Implements IPlayerView. Fixed-step physics:
|
|
// input -> horizontal accel -> gravity/jump -> voxel collision (X,Y,Z with
|
|
// auto-step) -> kinematic-platform resolution + surface carry -> events ->
|
|
// camera. update(dt) internally substeps so the per-axis displacement stays
|
|
// small enough that nothing tunnels, even at sprint + record-rim speeds.
|
|
|
|
import type { PerspectiveCamera } from 'three';
|
|
import type {
|
|
IVoxelWorld, IPlayerView, KinematicCollider, Vec3,
|
|
} from '../core/types';
|
|
import { PLAYER, GRAVITY } from '../core/constants';
|
|
import { bus } from '../core/events';
|
|
import { createInputState, InputController, type InputState } from './input';
|
|
import {
|
|
resolveAxis, moveHorizontalWithStep, HALF_W, HEIGHT, STEP_HEIGHT,
|
|
} from './collision';
|
|
|
|
// --- Local feel tuning (NOT in core PLAYER; see HANDOFF.md if you re-tune). ---
|
|
const GROUND_K = 20; // horizontal responsiveness on the ground (snappy)
|
|
const AIR_K = 6; // ~30% of ground control while airborne
|
|
const FLY_K = 12; // responsiveness in fly mode (both planes)
|
|
const FLY_SPEED = 8.0; // vertical fly speed (voxels/sec)
|
|
const FLY_HORIZ_MULT = 1.6;
|
|
const TERMINAL = 40; // fall-speed clamp (voxels/sec)
|
|
const STAND_UP_EPS = 0.06; // feet may sit this far above a platform top and still stand
|
|
const LAND_DOWN_EPS = 0.30; // ...and snap down onto it from within this gap (> max substep)
|
|
const STEP_DISTANCE = 1.8; // grounded travel between footstep events
|
|
const LAND_MIN_SPEED = 3.0; // don't emit landing below this downward speed
|
|
const MAX_SUBSTEPS = 8;
|
|
const MAX_STEP_DISP = 0.2; // target max displacement per substep (voxels)
|
|
const BOB_AMP = 0.055;
|
|
const BOB_FREQ = 1.9;
|
|
const SPRINT_FOV_BOOST = 5; // degrees
|
|
|
|
export interface PlayerOptions {
|
|
getColliders(): KinematicCollider[];
|
|
camera: PerspectiveCamera;
|
|
domElement: HTMLElement;
|
|
spawn: Vec3;
|
|
}
|
|
|
|
export class PlayerController implements IPlayerView {
|
|
// IPlayerView surface.
|
|
onGround = false;
|
|
groundedOn: string | null = null;
|
|
|
|
// Public knobs.
|
|
readonly input: InputState = createInputState();
|
|
viewBob = true;
|
|
|
|
private world: IVoxelWorld;
|
|
private getColliders: () => KinematicCollider[];
|
|
private camera: PerspectiveCamera;
|
|
private inputCtl: InputController | null;
|
|
private baseFov: number;
|
|
|
|
private _pos: [number, number, number];
|
|
private _vel: [number, number, number] = [0, 0, 0];
|
|
private _eye: [number, number, number] = [0, 0, 0];
|
|
private _look: [number, number, number] = [0, 0, -1];
|
|
|
|
// Surface velocity of the platform we're riding; inherited on jump/step-off.
|
|
private carryVel: [number, number, number] = [0, 0, 0];
|
|
private _scratch: [number, number, number] = [0, 0, 0];
|
|
|
|
private flying = false;
|
|
private stepAccum = 0;
|
|
private bobPhase = 0;
|
|
|
|
constructor(world: IVoxelWorld, opts: PlayerOptions) {
|
|
this.world = world;
|
|
this.getColliders = opts.getColliders;
|
|
this.camera = opts.camera;
|
|
this.baseFov = opts.camera.fov;
|
|
this._pos = [opts.spawn[0], opts.spawn[1], opts.spawn[2]];
|
|
this.inputCtl = new InputController(opts.domElement, this.input);
|
|
this.updateCamera(0);
|
|
}
|
|
|
|
// --- IPlayerView ---
|
|
get position(): Vec3 { return this._pos as Vec3; }
|
|
get eye(): Vec3 { return this._eye as Vec3; }
|
|
get lookDir(): Vec3 { return this._look as Vec3; }
|
|
|
|
// --- Public control ---
|
|
teleport(v: Vec3): void {
|
|
this._pos[0] = v[0]; this._pos[1] = v[1]; this._pos[2] = v[2];
|
|
this._vel[0] = this._vel[1] = this._vel[2] = 0;
|
|
this.carryVel[0] = this.carryVel[1] = this.carryVel[2] = 0;
|
|
this.onGround = false;
|
|
this.groundedOn = null;
|
|
}
|
|
|
|
setFlying(b: boolean): void {
|
|
this.flying = b;
|
|
if (b) this._vel[1] = 0;
|
|
}
|
|
|
|
get isFlying(): boolean { return this.flying; }
|
|
|
|
/** Current surface-carry velocity (for HUD/debug). */
|
|
get carryVelocity(): Vec3 { return this.carryVel as Vec3; }
|
|
|
|
dispose(): void { this.inputCtl?.dispose(); this.inputCtl = null; }
|
|
|
|
update(dt: number): void {
|
|
if (this.input.toggleFly) {
|
|
this.input.toggleFly = false;
|
|
this.setFlying(!this.flying);
|
|
}
|
|
|
|
// Substep so no single axis moves more than ~MAX_STEP_DISP per step.
|
|
const speedEst = Math.max(
|
|
Math.abs(this._vel[0]), Math.abs(this._vel[1]), Math.abs(this._vel[2]),
|
|
) + Math.hypot(this.carryVel[0], this.carryVel[2]);
|
|
const n = Math.min(MAX_SUBSTEPS, Math.max(1, Math.ceil((speedEst * dt) / MAX_STEP_DISP)));
|
|
const h = dt / n;
|
|
for (let i = 0; i < n; i++) this.substep(h);
|
|
|
|
this.updateCamera(dt);
|
|
}
|
|
|
|
private substep(h: number): void {
|
|
const inp = this.input;
|
|
const flying = this.flying;
|
|
|
|
// Movement basis from yaw. forwardH = (-sin, 0, -cos); rightH = (cos, 0, -sin).
|
|
const sy = Math.sin(inp.yaw), cy = Math.cos(inp.yaw);
|
|
let dirX = -sy * inp.moveZ + cy * inp.moveX;
|
|
let dirZ = -cy * inp.moveZ - sy * inp.moveX;
|
|
const len = Math.hypot(dirX, dirZ);
|
|
if (len > 1e-5) { dirX /= len; dirZ /= len; } else { dirX = 0; dirZ = 0; }
|
|
|
|
let speed = inp.sprint ? PLAYER.sprintSpeed : PLAYER.walkSpeed;
|
|
if (flying) speed *= FLY_HORIZ_MULT;
|
|
const desVx = dirX * speed, desVz = dirZ * speed;
|
|
|
|
const prevOnGround = this.onGround;
|
|
const prevGroundedOn = this.groundedOn;
|
|
|
|
// Horizontal accel/friction toward the desired velocity (exponential ease).
|
|
const k = flying ? FLY_K : (prevOnGround ? GROUND_K : AIR_K);
|
|
const f = 1 - Math.exp(-k * h);
|
|
this._vel[0] += (desVx - this._vel[0]) * f;
|
|
this._vel[2] += (desVz - this._vel[2]) * f;
|
|
|
|
// Vertical.
|
|
if (flying) {
|
|
const dv = (inp.flyUp ? 1 : 0) - (inp.flyDown ? 1 : 0);
|
|
this._vel[1] += (dv * FLY_SPEED - this._vel[1]) * (1 - Math.exp(-FLY_K * h));
|
|
} else {
|
|
if (inp.jump && prevOnGround) this._vel[1] = PLAYER.jumpVelocity;
|
|
this._vel[1] -= GRAVITY * h;
|
|
if (this._vel[1] < -TERMINAL) this._vel[1] = -TERMINAL;
|
|
}
|
|
|
|
const startX = this._pos[0], startZ = this._pos[2];
|
|
const vyBefore = this._vel[1];
|
|
const dx = this._vel[0] * h, dy = this._vel[1] * h, dz = this._vel[2] * h;
|
|
|
|
this.onGround = false;
|
|
this.groundedOn = null;
|
|
|
|
// Voxel collision: X, Z (with auto-step) then Y.
|
|
const canStep = prevOnGround && !flying && this._vel[1] <= 0.5;
|
|
moveHorizontalWithStep(this.world, this._pos, this._vel, dx, dz, canStep);
|
|
const hitY = resolveAxis(this.world, this._pos, this._vel, 1, dy);
|
|
if (hitY && dy < 0) this.onGround = true;
|
|
|
|
// Kinematic platforms (record platter, fader sled, tonearm...).
|
|
this.resolveColliders(h);
|
|
|
|
// Leaving a ridden platform (jump or walk-off) inherits its surface speed —
|
|
// this is the record-edge launch. Applied once, at the moment of leaving.
|
|
if (prevGroundedOn !== null && this.groundedOn === null) {
|
|
this._vel[0] += this.carryVel[0];
|
|
this._vel[2] += this.carryVel[2];
|
|
this.carryVel[0] = this.carryVel[1] = this.carryVel[2] = 0;
|
|
}
|
|
|
|
// Landing event.
|
|
if (!prevOnGround && this.onGround && vyBefore < -LAND_MIN_SPEED) {
|
|
bus.emit('player:landed', { impactSpeed: -vyBefore });
|
|
}
|
|
|
|
// Footsteps — only on real voxel ground (platforms have no block). Sample
|
|
// the whole footprint (the same cell range resolveAxis grounds against), not
|
|
// just the center column, so ledge/bridge-edge walking still finds the block
|
|
// actually under the foot instead of an adjacent air column.
|
|
if (this.onGround && this.groundedOn === null) {
|
|
this.stepAccum += Math.hypot(this._pos[0] - startX, this._pos[2] - startZ);
|
|
if (this.stepAccum >= STEP_DISTANCE) {
|
|
this.stepAccum -= STEP_DISTANCE;
|
|
const by = Math.floor(this._pos[1] - 0.1);
|
|
const cx0 = Math.floor(this._pos[0] - HALF_W + 1e-4);
|
|
const cx1 = Math.floor(this._pos[0] + HALF_W - 1e-4);
|
|
const cz0 = Math.floor(this._pos[2] - HALF_W + 1e-4);
|
|
const cz1 = Math.floor(this._pos[2] + HALF_W - 1e-4);
|
|
let block = 0;
|
|
for (let x = cx0; x <= cx1 && block === 0; x++)
|
|
for (let z = cz0; z <= cz1; z++) {
|
|
const b = this.world.getBlock(x, by, z);
|
|
if (b !== 0) { block = b; break; }
|
|
}
|
|
if (block !== 0) bus.emit('player:step', { block });
|
|
}
|
|
}
|
|
}
|
|
|
|
private resolveColliders(h: number): void {
|
|
const colliders = this.getColliders();
|
|
const p = this._pos;
|
|
|
|
// Resolve geometry per collider, but ground on exactly ONE — the highest
|
|
// supporting top — and apply its surface carry once, after the loop. This
|
|
// keeps overlapping rideable colliders (e.g. a fader sled crossing a platter
|
|
// rim) from double-carrying or mis-reporting groundedOn/carryVelocity, both
|
|
// of which are IPlayerView fields Lanes D/E consume.
|
|
let groundTop = -Infinity;
|
|
let groundCollider: KinematicCollider | null = null;
|
|
|
|
for (let i = 0; i < colliders.length; i++) {
|
|
const c = colliders[i];
|
|
const s = c.shape;
|
|
|
|
if (s.kind === 'cylinder') {
|
|
const cx = s.center[0], cyc = s.center[1], cz = s.center[2];
|
|
const top = cyc + s.halfHeight, bottom = cyc - s.halfHeight;
|
|
const rx = p[0] - cx, rz = p[2] - cz;
|
|
const dist = Math.hypot(rx, rz);
|
|
|
|
// Stand on top → record as a ground candidate (snapped after the loop).
|
|
// Radius grace of half the foot box: you can stand on the rim with your
|
|
// center slightly past it, matching how AABB feet rest on voxel edges.
|
|
if (
|
|
dist < s.radius + HALF_W * 0.5 && this._vel[1] <= 0.001 &&
|
|
p[1] <= top + STAND_UP_EPS && p[1] >= top - LAND_DOWN_EPS
|
|
) {
|
|
if (top > groundTop) { groundTop = top; groundCollider = c; }
|
|
continue;
|
|
}
|
|
|
|
// Overlap with the disc slab: resolve by MINIMUM penetration.
|
|
if (p[1] < top && p[1] + HEIGHT > bottom && dist < s.radius + HALF_W && dist > 1e-4) {
|
|
const upPen = top - p[1]; // lift feet onto the top
|
|
const radialPen = s.radius + HALF_W - dist; // push out past the wall
|
|
|
|
// Auto-step: a grounded player walking into a low lip (≤ STEP_HEIGHT,
|
|
// e.g. the record's terraced groove tiers) steps up onto it even
|
|
// though the radial push would be smaller — mirrors the voxel ledge
|
|
// auto-step. Grounding may come from voxels (this.onGround survives
|
|
// the voxel pass only on blocks) or from a collider top found
|
|
// earlier in THIS loop (this.onGround is stale-false then).
|
|
const stepUp = upPen <= STEP_HEIGHT + 0.05 &&
|
|
(this.onGround || groundCollider !== null) && this._vel[1] <= 0.001;
|
|
|
|
if (stepUp || upPen <= radialPen) {
|
|
// Pull the center just inside the lip so next tick's stand check
|
|
// holds (voxel auto-step lands you on the ledge the same way).
|
|
const inTo = s.radius - HALF_W * 0.25;
|
|
if (dist > inTo) {
|
|
const k = inTo / dist;
|
|
p[0] = cx + rx * k; p[2] = cz + rz * k;
|
|
}
|
|
if (top > groundTop) { groundTop = top; groundCollider = c; }
|
|
} else {
|
|
// Wall is the nearest surface: radial push-out.
|
|
const nx = rx / dist, nz = rz / dist;
|
|
p[0] += nx * radialPen; p[2] += nz * radialPen;
|
|
const inward = this._vel[0] * nx + this._vel[2] * nz;
|
|
if (inward < 0) { this._vel[0] -= inward * nx; this._vel[2] -= inward * nz; }
|
|
}
|
|
}
|
|
} else {
|
|
// AABB collider: push out along the minimum-penetration axis.
|
|
const pMnX = p[0] - HALF_W, pMxX = p[0] + HALF_W;
|
|
const pMnY = p[1], pMxY = p[1] + HEIGHT;
|
|
const pMnZ = p[2] - HALF_W, pMxZ = p[2] + HALF_W;
|
|
const ox = Math.min(pMxX, s.max[0]) - Math.max(pMnX, s.min[0]);
|
|
const oy = Math.min(pMxY, s.max[1]) - Math.max(pMnY, s.min[1]);
|
|
const oz = Math.min(pMxZ, s.max[2]) - Math.max(pMnZ, s.min[2]);
|
|
if (ox <= 0 || oy <= 0 || oz <= 0) continue;
|
|
|
|
if (oy <= ox && oy <= oz) {
|
|
const cCenterY = (s.min[1] + s.max[1]) * 0.5;
|
|
if ((pMnY + pMxY) * 0.5 > cCenterY) {
|
|
p[1] += oy;
|
|
if (this._vel[1] < 0) this._vel[1] = 0;
|
|
if (s.max[1] > groundTop) { groundTop = s.max[1]; groundCollider = c; }
|
|
} else {
|
|
p[1] -= oy;
|
|
if (this._vel[1] > 0) this._vel[1] = 0;
|
|
}
|
|
} else if (ox <= oz) {
|
|
const cCenterX = (s.min[0] + s.max[0]) * 0.5;
|
|
p[0] += p[0] > cCenterX ? ox : -ox;
|
|
this._vel[0] = 0;
|
|
} else {
|
|
const cCenterZ = (s.min[2] + s.max[2]) * 0.5;
|
|
p[2] += p[2] > cCenterZ ? oz : -oz;
|
|
this._vel[2] = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (groundCollider) {
|
|
p[1] = groundTop;
|
|
if (this._vel[1] < 0) this._vel[1] = 0;
|
|
this.onGround = true;
|
|
this.groundedOn = groundCollider.id;
|
|
this.setCarry(groundCollider, p);
|
|
this.applyCarry(h);
|
|
}
|
|
}
|
|
|
|
private setCarry(c: KinematicCollider, p: number[]): void {
|
|
const v = c.velocityAt(p[0], p[1], p[2]);
|
|
this.carryVel[0] = v[0]; this.carryVel[1] = 0; this.carryVel[2] = v[2];
|
|
}
|
|
|
|
// Translate by the platform's surface velocity, sweeping voxels so the carry
|
|
// can't shove the player through a wall.
|
|
private applyCarry(h: number): void {
|
|
const s = this._scratch; s[0] = 0; s[1] = 0; s[2] = 0;
|
|
resolveAxis(this.world, this._pos, s, 0, this.carryVel[0] * h);
|
|
resolveAxis(this.world, this._pos, s, 2, this.carryVel[2] * h);
|
|
}
|
|
|
|
private updateCamera(dt: number): void {
|
|
const cam = this.camera;
|
|
const inp = this.input;
|
|
|
|
const hSpeed = Math.hypot(this._vel[0], this._vel[2]);
|
|
const targetFov = this.baseFov + (inp.sprint && hSpeed > 0.5 ? SPRINT_FOV_BOOST : 0);
|
|
if (dt > 0) {
|
|
cam.fov += (targetFov - cam.fov) * Math.min(1, 10 * dt);
|
|
cam.updateProjectionMatrix();
|
|
}
|
|
|
|
let bob = 0, roll = 0;
|
|
if (this.viewBob && this.onGround && this.groundedOn === null && hSpeed > 0.5) {
|
|
this.bobPhase += hSpeed * dt * BOB_FREQ;
|
|
const ph = this.bobPhase * Math.PI * 2;
|
|
bob = Math.sin(ph) * BOB_AMP;
|
|
roll = Math.cos(ph) * BOB_AMP * 0.35;
|
|
}
|
|
|
|
this._eye[0] = this._pos[0];
|
|
this._eye[1] = this._pos[1] + PLAYER.eyeHeight + bob;
|
|
this._eye[2] = this._pos[2];
|
|
cam.position.set(this._eye[0], this._eye[1], this._eye[2]);
|
|
cam.rotation.order = 'YXZ';
|
|
cam.rotation.set(inp.pitch, inp.yaw, roll);
|
|
|
|
const cp = Math.cos(inp.pitch), sp = Math.sin(inp.pitch);
|
|
const syaw = Math.sin(inp.yaw), cyaw = Math.cos(inp.yaw);
|
|
this._look[0] = -cp * syaw;
|
|
this._look[1] = sp;
|
|
this._look[2] = -cp * cyaw;
|
|
}
|
|
}
|