import type { Vec3 } from "../core/vector"; import type { RuntimeLocomotionContactState, RuntimeLocomotionMode, RuntimeLocomotionState, RuntimePlayerVolumeState } from "./navigation-controller"; import type { PlayerStartActionInputState } from "./player-input-bindings"; import { cloneFirstPersonPlayerShape, createCrouchedFirstPersonPlayerShape, type FirstPersonPlayerShape, type PlayerGroundProbeResult, type ResolvedPlayerMotion } from "./player-collision"; import type { RuntimePlayerMovement } from "./runtime-scene-build"; const ACTION_ACTIVE_THRESHOLD = 0.5; const GRAVITY = 22; const GROUND_PROBE_DISTANCE = 0.12; const VERTICAL_ASCENT_EPSILON = 1e-4; const IDLE_SPEED_EPSILON = 0.05; const VARIABLE_JUMP_HOLD_GRAVITY_FACTOR = 0.45; const VARIABLE_JUMP_RELEASE_VELOCITY_FACTOR = 0.45; const SWIM_HEAD_CLEARANCE = 0.04; function clampPlanarSpeed(speed: number, maxSpeed: number): number { if (maxSpeed <= 0) { return speed; } return Math.min(speed, maxSpeed); } function clampUnitInterval(value: number): number { return Math.max(0, Math.min(1, value)); } function createEmptyLocomotionContactState(): RuntimeLocomotionContactState { return { collisionCount: 0, collidedAxes: { x: false, y: false, z: false }, groundNormal: null, groundDistance: null, slopeDegrees: null }; } export function createIdleRuntimeLocomotionState( locomotionMode: RuntimeLocomotionMode = "flying" ): RuntimeLocomotionState { return { locomotionMode, airborneKind: locomotionMode === "airborne" ? "falling" : null, gait: "idle", grounded: locomotionMode === "grounded", crouched: false, sprinting: false, inputMagnitude: 0, requestedPlanarSpeed: 0, planarSpeed: 0, verticalVelocity: 0, contact: createEmptyLocomotionContactState() }; } export interface StepPlayerLocomotionOptions { dt: number; feetPosition: Vec3; movementYawRadians: number; airDirectionYawRadians?: number; standingShape: FirstPersonPlayerShape; verticalVelocity: number; previousLocomotionState?: RuntimeLocomotionState; previousPlanarDisplacement: Vec3; jumpBufferRemainingMs: number; coyoteTimeRemainingMs: number; jumpHoldRemainingMs: number; crouched: boolean; wasJumpPressed: boolean; input: PlayerStartActionInputState; movement: RuntimePlayerMovement; resolveMotion( feetPosition: Vec3, motion: Vec3, shape: FirstPersonPlayerShape ): ResolvedPlayerMotion | null; resolveVolumeState(feetPosition: Vec3): RuntimePlayerVolumeState; probeGround( feetPosition: Vec3, shape: FirstPersonPlayerShape, maxDistance: number ): PlayerGroundProbeResult; canOccupyShape( feetPosition: Vec3, shape: FirstPersonPlayerShape ): boolean; } export interface StepPlayerLocomotionResult { feetPosition: Vec3; activeShape: FirstPersonPlayerShape; verticalVelocity: number; jumpBufferRemainingMs: number; coyoteTimeRemainingMs: number; jumpHoldRemainingMs: number; crouched: boolean; jumpPressed: boolean; jumpStarted: boolean; headBump: boolean; locomotionState: RuntimeLocomotionState; inWaterVolume: boolean; inFogVolume: boolean; planarDisplacement: Vec3; } function readGroundProbe( feetPosition: Vec3, shape: FirstPersonPlayerShape, probeGround: StepPlayerLocomotionOptions["probeGround"] ): PlayerGroundProbeResult { if (shape.mode === "none") { return { grounded: false, distance: null, normal: null, slopeDegrees: null }; } return probeGround(feetPosition, shape, GROUND_PROBE_DISTANCE); } function resolveLocomotionMode( shape: FirstPersonPlayerShape, inWaterVolume: boolean, grounded: boolean, headSubmerged: boolean ): RuntimeLocomotionMode { if (shape.mode === "none") { return "flying"; } if (inWaterVolume) { return headSubmerged ? "diving" : "swimming"; } if (grounded) { return "grounded"; } return "airborne"; } function resolveAirborneKind( locomotionMode: RuntimeLocomotionMode, verticalVelocity: number ): RuntimeLocomotionState["airborneKind"] { if (locomotionMode !== "airborne") { return null; } return verticalVelocity > 0 ? "jumping" : "falling"; } function resolveGait( crouched: boolean, sprinting: boolean, planarSpeed: number ): RuntimeLocomotionState["gait"] { if (crouched) { return "crouch"; } if (planarSpeed <= IDLE_SPEED_EPSILON) { return "idle"; } return sprinting ? "sprint" : "walk"; } function computePlanarMotion( movementYawRadians: number, input: PlayerStartActionInputState, requestedPlanarSpeed: number, dt: number ): { motion: Vec3; inputMagnitude: number } { const directionResult = computePlanarInputDirection( movementYawRadians, input ); if ( directionResult.direction === null || requestedPlanarSpeed <= 0 || dt <= 0 ) { return { motion: { x: 0, y: 0, z: 0 }, inputMagnitude: directionResult.inputMagnitude }; } const planarDistance = requestedPlanarSpeed * dt; return { motion: { x: directionResult.direction.x * planarDistance, y: 0, z: directionResult.direction.z * planarDistance }, inputMagnitude: directionResult.inputMagnitude }; } function computePlanarInputDirection( movementYawRadians: number, input: PlayerStartActionInputState ): { direction: Vec3 | null; inputMagnitude: number } { const inputX = input.moveRight - input.moveLeft; const inputZ = input.moveForward - input.moveBackward; const rawMagnitude = Math.hypot(inputX, inputZ); const inputMagnitude = clampUnitInterval(rawMagnitude); if (rawMagnitude <= 0) { return { direction: null, inputMagnitude }; } const normalizedInputX = inputX / rawMagnitude; const normalizedInputZ = inputZ / rawMagnitude; const forwardX = Math.sin(movementYawRadians); const forwardZ = Math.cos(movementYawRadians); const rightX = -Math.cos(movementYawRadians); const rightZ = Math.sin(movementYawRadians); const directionX = forwardX * normalizedInputZ + rightX * normalizedInputX; const directionZ = forwardZ * normalizedInputZ + rightZ * normalizedInputX; const directionMagnitude = Math.hypot(directionX, directionZ); if (directionMagnitude <= 0) { return { direction: null, inputMagnitude }; } return { direction: { x: directionX / directionMagnitude, y: 0, z: directionZ / directionMagnitude }, inputMagnitude }; } function computePlanarSpeedFromDisplacement( displacement: Vec3, dt: number ): number { if (dt <= 0) { return 0; } return Math.hypot(displacement.x, displacement.z) / dt; } function clearPlanarMovementInput( input: PlayerStartActionInputState ): PlayerStartActionInputState { return { ...input, moveForward: 0, moveBackward: 0, moveLeft: 0, moveRight: 0 }; } function computeDirectionalAirMotion(options: { directionYawRadians: number; input: PlayerStartActionInputState; previousPlanarDisplacement: Vec3; dt: number; }): { motion: Vec3; inputMagnitude: number } { const directionResult = computePlanarInputDirection( options.directionYawRadians, options.input ); const planarSpeed = computePlanarSpeedFromDisplacement( options.previousPlanarDisplacement, options.dt ); if ( directionResult.direction === null || planarSpeed <= 0 || options.dt <= 0 ) { return { motion: { x: 0, y: 0, z: 0 }, inputMagnitude: directionResult.inputMagnitude }; } const planarDistance = planarSpeed * options.dt; return { motion: { x: directionResult.direction.x * planarDistance, y: 0, z: directionResult.direction.z * planarDistance }, inputMagnitude: directionResult.inputMagnitude }; } function isWaterLocomotionMode( locomotionMode: RuntimeLocomotionMode | null | undefined ): boolean { return locomotionMode === "swimming" || locomotionMode === "diving"; } function isShallowWater(options: { inWater: boolean; waterSurfaceHeight: number | null; feetPosition: Vec3; shape: FirstPersonPlayerShape; groundProbe: PlayerGroundProbeResult; }): boolean { if ( !options.inWater || options.waterSurfaceHeight === null || options.groundProbe.grounded !== true || options.groundProbe.distance === null ) { return false; } const waterDepth = options.waterSurfaceHeight - (options.feetPosition.y - options.groundProbe.distance); return waterDepth <= options.shape.eyeHeight + SWIM_HEAD_CLEARANCE; } function shouldPreserveAirborneWaterCrossing(options: { inWater: boolean; waterSurfaceHeight: number | null; feetPosition: Vec3; shape: FirstPersonPlayerShape; verticalVelocity: number; previousLocomotionState?: RuntimeLocomotionState; }): boolean { if ( !options.inWater || options.waterSurfaceHeight === null || options.verticalVelocity <= VERTICAL_ASCENT_EPSILON || isWaterLocomotionMode(options.previousLocomotionState?.locomotionMode) ) { return false; } const headHeight = options.feetPosition.y + options.shape.eyeHeight; return headHeight >= options.waterSurfaceHeight; } function alignPlanarMotionToGround( planarMotion: Vec3, groundNormal: Vec3 | null ): Vec3 { if (groundNormal === null || groundNormal.y <= VERTICAL_ASCENT_EPSILON) { return planarMotion; } return { x: planarMotion.x, y: -( groundNormal.x * planarMotion.x + groundNormal.z * planarMotion.z ) / groundNormal.y, z: planarMotion.z }; } function resolveContactState( resolvedMotion: ResolvedPlayerMotion, groundProbe: PlayerGroundProbeResult, grounded: boolean ): RuntimeLocomotionContactState { const groundNormal = groundProbe.normal ?? resolvedMotion.groundCollisionNormal; const groundDistance = groundProbe.distance ?? (grounded ? 0 : null); return { collisionCount: resolvedMotion.collisionCount, collidedAxes: { x: resolvedMotion.collidedAxes.x, y: resolvedMotion.collidedAxes.y, z: resolvedMotion.collidedAxes.z }, groundNormal, groundDistance, slopeDegrees: groundProbe.slopeDegrees ?? (groundNormal === null ? null : (Math.acos(Math.max(-1, Math.min(1, groundNormal.y))) * 180) / Math.PI) }; } export function stepPlayerLocomotion( options: StepPlayerLocomotionOptions ): StepPlayerLocomotionResult | null { const currentVolumeState = options.resolveVolumeState(options.feetPosition); const dtMs = Math.max(0, options.dt * 1000); const jumpPressed = options.input.jump > ACTION_ACTIVE_THRESHOLD; const sprintPressed = options.input.sprint > ACTION_ACTIVE_THRESHOLD; const crouchPressed = options.input.crouch > ACTION_ACTIVE_THRESHOLD; const jumpJustPressed = jumpPressed && !options.wasJumpPressed; const canCrouch = options.movement.capabilities.crouch && options.standingShape.mode !== "none"; let jumpBufferRemainingMs = Math.max( 0, options.jumpBufferRemainingMs - dtMs ); let coyoteTimeRemainingMs = Math.max( 0, options.coyoteTimeRemainingMs - dtMs ); let jumpHoldRemainingMs = Math.max(0, options.jumpHoldRemainingMs - dtMs); let crouched = options.crouched && canCrouch; if (canCrouch) { if (crouchPressed) { crouched = true; } else if (crouched) { crouched = !options.canOccupyShape( options.feetPosition, options.standingShape ); } } else { crouched = false; } const activeShape = crouched ? createCrouchedFirstPersonPlayerShape(options.standingShape) : cloneFirstPersonPlayerShape(options.standingShape); const currentGroundProbe = readGroundProbe( options.feetPosition, activeShape, options.probeGround ); const currentShallowWater = isShallowWater({ inWater: currentVolumeState.inWater, waterSurfaceHeight: currentVolumeState.waterSurfaceHeight, feetPosition: options.feetPosition, shape: activeShape, groundProbe: currentGroundProbe }); const currentSwimmableWater = currentVolumeState.inWater && currentVolumeState.waterSurfaceHeight !== null && !currentShallowWater && !shouldPreserveAirborneWaterCrossing({ inWater: currentVolumeState.inWater, waterSurfaceHeight: currentVolumeState.waterSurfaceHeight, feetPosition: options.feetPosition, shape: activeShape, verticalVelocity: options.verticalVelocity, previousLocomotionState: options.previousLocomotionState }); // The probe can still see nearby floor on the frame after takeoff. While a // jump is still carrying positive upward velocity, don't let that probe pull // the controller back into grounded/stick-to-ground logic. const ascendingFromPreviousFrame = options.verticalVelocity > VERTICAL_ASCENT_EPSILON; const canUseImmediateGrounding = options.previousLocomotionState === undefined || options.previousLocomotionState.grounded || Math.abs(options.verticalVelocity) <= VERTICAL_ASCENT_EPSILON; const currentlyGrounded = currentGroundProbe.grounded && !ascendingFromPreviousFrame && canUseImmediateGrounding; if (currentlyGrounded) { coyoteTimeRemainingMs = options.movement.jump.coyoteTimeMs; } if ( options.movement.capabilities.jump && jumpJustPressed && !currentSwimmableWater ) { jumpBufferRemainingMs = options.movement.jump.bufferMs; } const sprinting = options.movement.capabilities.sprint && sprintPressed && !crouched && currentlyGrounded && !currentSwimmableWater; const previousRequestedPlanarSpeed = Math.max( 0, options.previousLocomotionState?.requestedPlanarSpeed ?? 0 ); const groundedRequestedPlanarSpeed = clampPlanarSpeed( options.movement.moveSpeed * (crouched ? options.movement.crouch.speedMultiplier : sprinting ? options.movement.sprint.speedMultiplier : 1), options.movement.maxSpeed ); const airborneRequestedPlanarSpeed = clampPlanarSpeed( Math.max(options.movement.moveSpeed, previousRequestedPlanarSpeed), options.movement.maxSpeed ); const jumpTriggered = options.movement.capabilities.jump && (jumpJustPressed || jumpBufferRemainingMs > 0) && (currentlyGrounded || coyoteTimeRemainingMs > 0) && !currentSwimmableWater && activeShape.mode !== "none"; const bufferedJumpTriggered = jumpTriggered && !jumpJustPressed && jumpBufferRemainingMs > 0; const preservePreviousJumpSpeed = !currentlyGrounded || bufferedJumpTriggered; const jumpRequestedPlanarSpeedBase = clampPlanarSpeed( Math.max( options.movement.moveSpeed, groundedRequestedPlanarSpeed, preservePreviousJumpSpeed ? previousRequestedPlanarSpeed : 0 ), options.movement.maxSpeed ); const jumpRequestedPlanarSpeed = bufferedJumpTriggered && options.movement.jump.bunnyHop ? clampPlanarSpeed( jumpRequestedPlanarSpeedBase * (1 + options.movement.jump.bunnyHopBoost), options.movement.maxSpeed ) : jumpRequestedPlanarSpeedBase; if (jumpTriggered) { jumpBufferRemainingMs = 0; coyoteTimeRemainingMs = 0; } const airMovementAllowed = currentlyGrounded || jumpTriggered || currentSwimmableWater || (options.verticalVelocity > VERTICAL_ASCENT_EPSILON ? options.movement.jump.moveWhileJumping : options.movement.jump.moveWhileFalling); const planarInput = airMovementAllowed ? options.input : clearPlanarMovementInput(options.input); const directionalAirControlActive = !currentlyGrounded && !jumpTriggered && !currentSwimmableWater && airMovementAllowed && options.movement.jump.directionOnly; const previousPlanarSpeed = computePlanarSpeedFromDisplacement( options.previousPlanarDisplacement, options.dt ); const requestedPlanarSpeed = activeShape.mode !== "none" && !currentSwimmableWater ? jumpTriggered ? jumpRequestedPlanarSpeed : currentlyGrounded ? groundedRequestedPlanarSpeed : airborneRequestedPlanarSpeed : groundedRequestedPlanarSpeed; const planarMotionFromInput = directionalAirControlActive ? computeDirectionalAirMotion({ directionYawRadians: options.airDirectionYawRadians ?? options.movementYawRadians, input: planarInput, previousPlanarDisplacement: options.previousPlanarDisplacement, dt: options.dt }) : computePlanarMotion( options.movementYawRadians, planarInput, requestedPlanarSpeed, options.dt ); const preserveAirborneMomentum = activeShape.mode !== "none" && !currentSwimmableWater && (jumpTriggered || !currentlyGrounded) && planarMotionFromInput.inputMagnitude <= 0; const planarMotion = preserveAirborneMomentum ? { motion: { x: options.previousPlanarDisplacement.x, y: 0, z: options.previousPlanarDisplacement.z }, inputMagnitude: 0 } : planarMotionFromInput; const groundedPlanarMotion = currentlyGrounded && !jumpTriggered ? alignPlanarMotionToGround( planarMotion.motion, currentGroundProbe.normal ) : planarMotion.motion; let verticalVelocity = options.verticalVelocity; let verticalDisplacement = 0; const waterVerticalInput = (jumpPressed ? 1 : 0) - (sprintPressed ? 1 : 0); const swimVerticalSpeed = options.movement.moveSpeed; if (activeShape.mode === "none") { verticalVelocity = 0; jumpHoldRemainingMs = 0; } else if (currentSwimmableWater) { const waterSurfaceHeight = currentVolumeState.waterSurfaceHeight ?? options.feetPosition.y + activeShape.eyeHeight; const targetFeetY = waterSurfaceHeight + SWIM_HEAD_CLEARANCE - activeShape.eyeHeight; const currentHeadSubmerged = options.feetPosition.y + activeShape.eyeHeight < waterSurfaceHeight; if (waterVerticalInput !== 0) { verticalVelocity = waterVerticalInput * swimVerticalSpeed; verticalDisplacement = verticalVelocity * options.dt; } else if (!currentHeadSubmerged) { const targetDelta = targetFeetY - options.feetPosition.y; verticalDisplacement = Math.max( -swimVerticalSpeed * options.dt, Math.min(swimVerticalSpeed * options.dt, targetDelta) ); verticalVelocity = options.dt > 0 ? verticalDisplacement / options.dt : 0; } else { verticalVelocity = 0; verticalDisplacement = 0; } jumpHoldRemainingMs = 0; } else if (jumpTriggered) { verticalVelocity = options.movement.jump.speed; verticalDisplacement = verticalVelocity * options.dt; jumpHoldRemainingMs = options.movement.jump.variableHeight ? options.movement.jump.maxHoldMs : 0; } else if (currentlyGrounded) { verticalVelocity = 0; verticalDisplacement = 0; jumpHoldRemainingMs = 0; } else { if ( options.movement.jump.variableHeight && !jumpPressed && jumpHoldRemainingMs > 0 && verticalVelocity > VERTICAL_ASCENT_EPSILON ) { verticalVelocity *= VARIABLE_JUMP_RELEASE_VELOCITY_FACTOR; jumpHoldRemainingMs = 0; } const variableJumpActive = options.movement.jump.variableHeight && jumpPressed && jumpHoldRemainingMs > 0 && verticalVelocity > VERTICAL_ASCENT_EPSILON; const gravityScale = variableJumpActive ? VARIABLE_JUMP_HOLD_GRAVITY_FACTOR : 1; verticalVelocity -= GRAVITY * gravityScale * options.dt; verticalDisplacement = verticalVelocity * options.dt; } const resolvedMotion = options.resolveMotion( options.feetPosition, { x: groundedPlanarMotion.x, y: verticalDisplacement + groundedPlanarMotion.y, z: groundedPlanarMotion.z }, activeShape ); if (resolvedMotion === null) { return null; } const nextVolumeState = options.resolveVolumeState(resolvedMotion.feetPosition); const groundProbe = readGroundProbe( resolvedMotion.feetPosition, activeShape, options.probeGround ); const nextShallowWater = isShallowWater({ inWater: nextVolumeState.inWater, waterSurfaceHeight: nextVolumeState.waterSurfaceHeight, feetPosition: resolvedMotion.feetPosition, shape: activeShape, groundProbe }); const nextSwimmableWater = nextVolumeState.inWater && nextVolumeState.waterSurfaceHeight !== null && !nextShallowWater && !shouldPreserveAirborneWaterCrossing({ inWater: nextVolumeState.inWater, waterSurfaceHeight: nextVolumeState.waterSurfaceHeight, feetPosition: resolvedMotion.feetPosition, shape: activeShape, verticalVelocity, previousLocomotionState: options.previousLocomotionState }); const nextWaterSurfaceHeight = nextVolumeState.waterSurfaceHeight ?? resolvedMotion.feetPosition.y + activeShape.eyeHeight; const headSubmerged = nextSwimmableWater && resolvedMotion.feetPosition.y + activeShape.eyeHeight < nextWaterSurfaceHeight; const headBump = verticalDisplacement > 0 && resolvedMotion.collidedAxes.y && resolvedMotion.feetPosition.y <= options.feetPosition.y + 1e-4; const ascending = verticalVelocity > VERTICAL_ASCENT_EPSILON && !headBump; const grounded = !jumpTriggered && !ascending && activeShape.mode !== "none" && !nextSwimmableWater && groundProbe.grounded; if (activeShape.mode === "none" || grounded || headBump) { verticalVelocity = 0; jumpHoldRemainingMs = 0; } const locomotionMode = resolveLocomotionMode( activeShape, nextSwimmableWater, grounded, headSubmerged ); const actualPlanarSpeed = options.dt > 0 ? Math.hypot( resolvedMotion.feetPosition.x - options.feetPosition.x, resolvedMotion.feetPosition.z - options.feetPosition.z ) / options.dt : 0; return { feetPosition: resolvedMotion.feetPosition, activeShape, verticalVelocity, jumpBufferRemainingMs, coyoteTimeRemainingMs, jumpHoldRemainingMs, crouched, jumpPressed, jumpStarted: jumpTriggered, headBump, locomotionState: { locomotionMode, airborneKind: resolveAirborneKind(locomotionMode, verticalVelocity), gait: resolveGait(crouched, sprinting, actualPlanarSpeed), grounded, crouched, sprinting, inputMagnitude: planarMotion.inputMagnitude, requestedPlanarSpeed: preserveAirborneMomentum ? previousPlanarSpeed : (directionalAirControlActive ? previousPlanarSpeed : requestedPlanarSpeed) * planarMotion.inputMagnitude, planarSpeed: actualPlanarSpeed, verticalVelocity, contact: resolveContactState(resolvedMotion, groundProbe, grounded) }, inWaterVolume: nextSwimmableWater, inFogVolume: nextVolumeState.inFog, planarDisplacement: { x: resolvedMotion.feetPosition.x - options.feetPosition.x, y: 0, z: resolvedMotion.feetPosition.z - options.feetPosition.z } }; }