399 lines
10 KiB
TypeScript
399 lines
10 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 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;
|
|
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;
|
|
jumpStarted: boolean;
|
|
headBump: 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 =
|
|
!jumpTriggered &&
|
|
activeShape.mode !== "none" &&
|
|
!nextVolumeState.inWater &&
|
|
(resolvedMotion.grounded || groundProbe.grounded);
|
|
const headBump =
|
|
verticalDisplacement > 0 &&
|
|
resolvedMotion.collidedAxes.y &&
|
|
resolvedMotion.feetPosition.y <= options.feetPosition.y + 1e-4;
|
|
|
|
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,
|
|
jumpStarted: jumpTriggered,
|
|
headBump,
|
|
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
|
|
}
|
|
};
|
|
} |