From 9b75fc450068d690d4bcff03ccf771cf629a5e1c Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Sat, 11 Apr 2026 18:33:58 +0200 Subject: [PATCH] Add player locomotion logic for runtime-three --- src/runtime-three/player-locomotion.ts | 391 +++++++++++++++++++++++++ 1 file changed, 391 insertions(+) create mode 100644 src/runtime-three/player-locomotion.ts diff --git a/src/runtime-three/player-locomotion.ts b/src/runtime-three/player-locomotion.ts new file mode 100644 index 00000000..55fef85a --- /dev/null +++ b/src/runtime-three/player-locomotion.ts @@ -0,0 +1,391 @@ +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 JUMP_SPEED = 7.2; +const SPRINT_SPEED_MULTIPLIER = 1.65; +const CROUCH_SPEED_MULTIPLIER = 0.45; +const GROUND_PROBE_DISTANCE = 0.12; +const GROUND_STICK_DISTANCE = 0.08; +const IDLE_SPEED_EPSILON = 0.05; + +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; + activeShape: FirstPersonPlayerShape; + standingShape: FirstPersonPlayerShape; + verticalVelocity: 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; + crouched: boolean; + jumpPressed: boolean; + locomotionState: RuntimeLocomotionState; + inWaterVolume: boolean; + inFogVolume: boolean; + planarDisplacement: Vec3; +} + +function readGroundProbe( + feetPosition: Vec3, + shape: FirstPersonPlayerShape, + volumeState: RuntimePlayerVolumeState, + probeGround: StepPlayerLocomotionOptions["probeGround"] +): PlayerGroundProbeResult { + if (shape.mode === "none" || volumeState.inWater) { + return { + grounded: false, + distance: null, + normal: null, + slopeDegrees: null + }; + } + + return probeGround(feetPosition, shape, GROUND_PROBE_DISTANCE); +} + +function resolveLocomotionMode( + shape: FirstPersonPlayerShape, + inWaterVolume: boolean, + grounded: boolean +): RuntimeLocomotionMode { + if (shape.mode === "none") { + return "flying"; + } + + if (inWaterVolume) { + return "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 inputX = input.moveRight - input.moveLeft; + const inputZ = input.moveForward - input.moveBackward; + const rawMagnitude = Math.hypot(inputX, inputZ); + const inputMagnitude = clampUnitInterval(rawMagnitude); + + if (rawMagnitude <= 0 || requestedPlanarSpeed <= 0 || dt <= 0) { + return { + motion: { + x: 0, + y: 0, + z: 0 + }, + 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 planarDistance = requestedPlanarSpeed * dt; + + return { + motion: { + x: + (forwardX * normalizedInputZ + rightX * normalizedInputX) * + planarDistance, + y: 0, + z: + (forwardZ * normalizedInputZ + rightZ * normalizedInputX) * + planarDistance + }, + inputMagnitude + }; +} + +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 jumpPressed = options.input.jump > ACTION_ACTIVE_THRESHOLD; + const sprintPressed = options.input.sprint > ACTION_ACTIVE_THRESHOLD; + const crouchPressed = options.input.crouch > ACTION_ACTIVE_THRESHOLD; + const canCrouch = + options.movement.capabilities.crouch && + options.standingShape.mode !== "none"; + + 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, + currentVolumeState, + options.probeGround + ); + const currentlyGrounded = currentGroundProbe.grounded; + const sprinting = + options.movement.capabilities.sprint && + sprintPressed && + !crouched && + currentlyGrounded && + !currentVolumeState.inWater; + const requestedPlanarSpeed = + options.movement.moveSpeed * + (crouched + ? CROUCH_SPEED_MULTIPLIER + : sprinting + ? SPRINT_SPEED_MULTIPLIER + : 1); + const planarMotion = computePlanarMotion( + options.movementYawRadians, + options.input, + requestedPlanarSpeed, + options.dt + ); + const jumpTriggered = + options.movement.capabilities.jump && + jumpPressed && + !options.wasJumpPressed && + currentlyGrounded && + !currentVolumeState.inWater && + activeShape.mode !== "none"; + + let verticalVelocity = options.verticalVelocity; + let verticalDisplacement = 0; + + if (activeShape.mode === "none" || currentVolumeState.inWater) { + verticalVelocity = 0; + } else if (jumpTriggered) { + verticalVelocity = JUMP_SPEED; + verticalDisplacement = verticalVelocity * options.dt; + } else if (currentlyGrounded) { + verticalVelocity = 0; + verticalDisplacement = options.dt > 0 ? -GROUND_STICK_DISTANCE : 0; + } else { + verticalVelocity -= GRAVITY * options.dt; + verticalDisplacement = verticalVelocity * options.dt; + } + + const resolvedMotion = options.resolveMotion( + options.feetPosition, + { + x: planarMotion.motion.x, + y: verticalDisplacement, + z: planarMotion.motion.z + }, + activeShape + ); + + if (resolvedMotion === null) { + return null; + } + + const nextVolumeState = options.resolveVolumeState(resolvedMotion.feetPosition); + const groundProbe = readGroundProbe( + resolvedMotion.feetPosition, + activeShape, + nextVolumeState, + options.probeGround + ); + const grounded = + activeShape.mode !== "none" && + !nextVolumeState.inWater && + (resolvedMotion.grounded || groundProbe.grounded); + + if (activeShape.mode === "none" || nextVolumeState.inWater || grounded) { + verticalVelocity = 0; + } + + const locomotionMode = resolveLocomotionMode( + activeShape, + nextVolumeState.inWater, + grounded + ); + 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, + crouched, + jumpPressed, + locomotionState: { + locomotionMode, + airborneKind: resolveAirborneKind(locomotionMode, verticalVelocity), + gait: resolveGait(crouched, sprinting, actualPlanarSpeed), + grounded, + crouched, + sprinting, + inputMagnitude: planarMotion.inputMagnitude, + requestedPlanarSpeed: + requestedPlanarSpeed * planarMotion.inputMagnitude, + planarSpeed: actualPlanarSpeed, + verticalVelocity, + contact: resolveContactState(resolvedMotion, groundProbe, grounded) + }, + inWaterVolume: nextVolumeState.inWater, + inFogVolume: nextVolumeState.inFog, + planarDisplacement: { + x: resolvedMotion.feetPosition.x - options.feetPosition.x, + y: 0, + z: resolvedMotion.feetPosition.z - options.feetPosition.z + } + }; +} \ No newline at end of file