Add swimming and diving modes to player locomotion

This commit is contained in:
2026-04-11 22:10:01 +02:00
parent 3504262ada
commit b10ca7e4c2

View File

@@ -23,6 +23,7 @@ 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) {
@@ -121,10 +122,9 @@ export interface StepPlayerLocomotionResult {
function readGroundProbe(
feetPosition: Vec3,
shape: FirstPersonPlayerShape,
volumeState: RuntimePlayerVolumeState,
probeGround: StepPlayerLocomotionOptions["probeGround"]
): PlayerGroundProbeResult {
if (shape.mode === "none" || volumeState.inWater) {
if (shape.mode === "none") {
return {
grounded: false,
distance: null,
@@ -139,14 +139,15 @@ function readGroundProbe(
function resolveLocomotionMode(
shape: FirstPersonPlayerShape,
inWaterVolume: boolean,
grounded: boolean
grounded: boolean,
headSubmerged: boolean
): RuntimeLocomotionMode {
if (shape.mode === "none") {
return "flying";
}
if (inWaterVolume) {
return "swimming";
return headSubmerged ? "diving" : "swimming";
}
if (grounded) {
@@ -238,6 +239,29 @@ function computePlanarSpeedFromDisplacement(
return Math.hypot(displacement.x, displacement.z) / dt;
}
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 alignPlanarMotionToGround(
planarMotion: Vec3,
groundNormal: Vec3 | null
@@ -331,9 +355,19 @@ export function stepPlayerLocomotion(
const currentGroundProbe = readGroundProbe(
options.feetPosition,
activeShape,
currentVolumeState,
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;
// 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.
@@ -346,12 +380,20 @@ export function stepPlayerLocomotion(
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 &&
!currentVolumeState.inWater;
!currentSwimmableWater;
const previousRequestedPlanarSpeed = Math.max(
0,
options.previousLocomotionState?.requestedPlanarSpeed ?? 0
@@ -373,7 +415,7 @@ export function stepPlayerLocomotion(
options.movement.capabilities.jump &&
(jumpJustPressed || jumpBufferRemainingMs > 0) &&
(currentlyGrounded || coyoteTimeRemainingMs > 0) &&
!currentVolumeState.inWater &&
!currentSwimmableWater &&
activeShape.mode !== "none";
const bufferedJumpTriggered =
jumpTriggered && !jumpJustPressed && jumpBufferRemainingMs > 0;
@@ -402,7 +444,7 @@ export function stepPlayerLocomotion(
}
const requestedPlanarSpeed =
activeShape.mode !== "none" && !currentVolumeState.inWater
activeShape.mode !== "none" && !currentSwimmableWater
? jumpTriggered
? jumpRequestedPlanarSpeed
: currentlyGrounded
@@ -417,7 +459,7 @@ export function stepPlayerLocomotion(
);
const preserveAirborneMomentum =
activeShape.mode !== "none" &&
!currentVolumeState.inWater &&
!currentSwimmableWater &&
(jumpTriggered || !currentlyGrounded) &&
planarMotionFromInput.inputMagnitude <= 0;
const planarMotion = preserveAirborneMomentum
@@ -441,9 +483,38 @@ export function stepPlayerLocomotion(
let verticalVelocity = options.verticalVelocity;
let verticalDisplacement = 0;
const waterVerticalInput =
(jumpPressed ? 1 : 0) - (sprintPressed ? 1 : 0);
const swimVerticalSpeed = options.movement.moveSpeed;
if (activeShape.mode === "none" || currentVolumeState.inWater) {
if (activeShape.mode === "none") {
verticalVelocity = 0;
jumpHoldRemainingMs = 0;
} else if (currentSwimmableWater) {
const targetFeetY =
currentVolumeState.waterSurfaceHeight +
SWIM_HEAD_CLEARANCE -
activeShape.eyeHeight;
const currentHeadSubmerged =
options.feetPosition.y + activeShape.eyeHeight <
currentVolumeState.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;
@@ -497,9 +568,23 @@ export function stepPlayerLocomotion(
const groundProbe = readGroundProbe(
resolvedMotion.feetPosition,
activeShape,
nextVolumeState,
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;
const headSubmerged =
nextSwimmableWater &&
resolvedMotion.feetPosition.y + activeShape.eyeHeight <
nextVolumeState.waterSurfaceHeight;
const headBump =
verticalDisplacement > 0 &&
resolvedMotion.collidedAxes.y &&
@@ -509,23 +594,19 @@ export function stepPlayerLocomotion(
!jumpTriggered &&
!ascending &&
activeShape.mode !== "none" &&
!nextVolumeState.inWater &&
!nextSwimmableWater &&
(resolvedMotion.grounded || groundProbe.grounded);
if (
activeShape.mode === "none" ||
nextVolumeState.inWater ||
grounded ||
headBump
) {
if (activeShape.mode === "none" || grounded || headBump) {
verticalVelocity = 0;
jumpHoldRemainingMs = 0;
}
const locomotionMode = resolveLocomotionMode(
activeShape,
nextVolumeState.inWater,
grounded
nextSwimmableWater,
grounded,
headSubmerged
);
const actualPlanarSpeed =
options.dt > 0
@@ -564,7 +645,7 @@ export function stepPlayerLocomotion(
verticalVelocity,
contact: resolveContactState(resolvedMotion, groundProbe, grounded)
},
inWaterVolume: nextVolumeState.inWater,
inWaterVolume: nextSwimmableWater,
inFogVolume: nextVolumeState.inFog,
planarDisplacement: {
x: resolvedMotion.feetPosition.x - options.feetPosition.x,