TURNCRAFT/src/player/PlayerController.ts
jing 49cab1f69b Concept-art pass: terraced records, spindle towers, warm lit room
- 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>
2026-07-13 21:48:00 +10:00

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;
}
}