Add player locomotion logic for runtime-three

This commit is contained in:
2026-04-11 18:33:58 +02:00
parent a9e169370e
commit 9b75fc4500

View File

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