Add player locomotion logic for runtime-three
This commit is contained in:
391
src/runtime-three/player-locomotion.ts
Normal file
391
src/runtime-three/player-locomotion.ts
Normal 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
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user