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