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