145 lines
4.6 KiB
TypeScript
145 lines
4.6 KiB
TypeScript
export const PLAYER_JUMP_VELOCITY = 7.2;
|
|
export const PLAYER_JUMP_COYOTE_MS = 100;
|
|
export const PLAYER_JUMP_BUFFER_MS = 120;
|
|
export const PLAYER_JUMP_LANDING_UNLOCK_MS = 80;
|
|
export const PLAYER_LANDING_PRESENTATION_MS = 180;
|
|
export const PLAYER_AIRBORNE_PRESENTATION_DELAY_MS = 100;
|
|
export const PLAYER_GROUND_PROBE_DISTANCE = 0.22;
|
|
export const PLAYER_GROUND_MIN_NORMAL_Y = 0.55;
|
|
|
|
export type CharacterVerticalMotion = "grounded" | "rising" | "falling" | "landing";
|
|
|
|
export interface PlayerJumpTiming {
|
|
lastGroundedAtMs: number;
|
|
bufferedUntilMs: number;
|
|
jumpedAtMs: number;
|
|
lockedUntilLanding: boolean;
|
|
}
|
|
|
|
export interface CharacterVerticalPresentationTiming {
|
|
airborneStartedAtMs: number;
|
|
airbornePresented: boolean;
|
|
landingUntilMs: number;
|
|
}
|
|
|
|
export interface GroundProbeHit {
|
|
readonly timeOfImpact: number;
|
|
readonly normal: { readonly y: number };
|
|
}
|
|
|
|
export function createPlayerJumpTiming(nowMs = Number.NEGATIVE_INFINITY): PlayerJumpTiming {
|
|
return {
|
|
lastGroundedAtMs: nowMs,
|
|
bufferedUntilMs: Number.NEGATIVE_INFINITY,
|
|
jumpedAtMs: Number.NEGATIVE_INFINITY,
|
|
lockedUntilLanding: false,
|
|
};
|
|
}
|
|
|
|
export function createCharacterVerticalPresentationTiming(): CharacterVerticalPresentationTiming {
|
|
return {
|
|
airborneStartedAtMs: Number.NEGATIVE_INFINITY,
|
|
airbornePresented: false,
|
|
landingUntilMs: Number.NEGATIVE_INFINITY,
|
|
};
|
|
}
|
|
|
|
export function bufferPlayerJump(timing: PlayerJumpTiming, requestedAtMs: number): void {
|
|
if (!Number.isFinite(requestedAtMs)) return;
|
|
timing.bufferedUntilMs = requestedAtMs + PLAYER_JUMP_BUFFER_MS;
|
|
}
|
|
|
|
export function cancelBufferedPlayerJump(timing: PlayerJumpTiming): void {
|
|
timing.bufferedUntilMs = Number.NEGATIVE_INFINITY;
|
|
}
|
|
|
|
export function isWalkableGroundHit(
|
|
hit: GroundProbeHit | null | undefined,
|
|
maximumTimeOfImpact: number,
|
|
): boolean {
|
|
return Boolean(
|
|
hit
|
|
&& Number.isFinite(hit.timeOfImpact)
|
|
&& hit.timeOfImpact >= 0
|
|
&& hit.timeOfImpact <= maximumTimeOfImpact
|
|
&& Number.isFinite(hit.normal.y)
|
|
&& hit.normal.y >= PLAYER_GROUND_MIN_NORMAL_Y,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Updates the timing latch and consumes a buffered jump exactly once. The
|
|
* upward-velocity guard prevents the ground probe from unlocking the latch on
|
|
* the first few frames after launch while the capsule is still near the floor.
|
|
*/
|
|
export function updatePlayerJumpTiming(
|
|
timing: PlayerJumpTiming,
|
|
nowMs: number,
|
|
grounded: boolean,
|
|
verticalVelocity: number,
|
|
): boolean {
|
|
if (!Number.isFinite(nowMs)) return false;
|
|
|
|
if (grounded && verticalVelocity <= 0.5) {
|
|
timing.lastGroundedAtMs = nowMs;
|
|
if (
|
|
timing.lockedUntilLanding
|
|
&& nowMs - timing.jumpedAtMs >= PLAYER_JUMP_LANDING_UNLOCK_MS
|
|
) timing.lockedUntilLanding = false;
|
|
}
|
|
|
|
const buffered = timing.bufferedUntilMs >= nowMs;
|
|
const insideCoyoteWindow = nowMs - timing.lastGroundedAtMs <= PLAYER_JUMP_COYOTE_MS;
|
|
if (timing.lockedUntilLanding || !buffered || !insideCoyoteWindow) return false;
|
|
|
|
timing.lockedUntilLanding = true;
|
|
timing.jumpedAtMs = nowMs;
|
|
timing.bufferedUntilMs = Number.NEGATIVE_INFINITY;
|
|
return true;
|
|
}
|
|
|
|
export function characterVerticalMotion(
|
|
grounded: boolean,
|
|
verticalVelocity: number,
|
|
nowMs: number,
|
|
landingUntilMs: number,
|
|
): CharacterVerticalMotion {
|
|
if (grounded) return nowMs < landingUntilMs ? "landing" : "grounded";
|
|
return verticalVelocity > 0.15 ? "rising" : "falling";
|
|
}
|
|
|
|
/**
|
|
* Keeps tiny terrain gaps from interrupting locomotion with a fall/land pair.
|
|
* An intentional jump is presented immediately; an incidental loss of ground
|
|
* must persist before it becomes visible, and only visible airtime may land.
|
|
*/
|
|
export function updateCharacterVerticalPresentation(
|
|
timing: CharacterVerticalPresentationTiming,
|
|
grounded: boolean,
|
|
verticalVelocity: number,
|
|
nowMs: number,
|
|
intentionalJump: boolean,
|
|
): CharacterVerticalMotion {
|
|
if (!Number.isFinite(nowMs)) {
|
|
return characterVerticalMotion(grounded, verticalVelocity, 0, Number.NEGATIVE_INFINITY);
|
|
}
|
|
|
|
if (!grounded) {
|
|
if (!Number.isFinite(timing.airborneStartedAtMs)) timing.airborneStartedAtMs = nowMs;
|
|
if (
|
|
intentionalJump
|
|
|| nowMs - timing.airborneStartedAtMs >= PLAYER_AIRBORNE_PRESENTATION_DELAY_MS
|
|
) timing.airbornePresented = true;
|
|
return timing.airbornePresented
|
|
? characterVerticalMotion(false, verticalVelocity, nowMs, timing.landingUntilMs)
|
|
: "grounded";
|
|
}
|
|
|
|
if (timing.airbornePresented) {
|
|
timing.landingUntilMs = nowMs + PLAYER_LANDING_PRESENTATION_MS;
|
|
}
|
|
timing.airborneStartedAtMs = Number.NEGATIVE_INFINITY;
|
|
timing.airbornePresented = false;
|
|
return characterVerticalMotion(true, verticalVelocity, nowMs, timing.landingUntilMs);
|
|
}
|