Files
webeditor3d/src/runtime-three/player-locomotion.ts
Victor Giers 0d176a0129 auto-git:
[change] src/runtime-three/player-locomotion.ts
2026-04-25 04:31:05 +02:00

831 lines
23 KiB
TypeScript

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