import { PlayerMotionModel, DEFAULT_GRAVITY } from './PlayerMotionModel'; import { JUMP_HEIGHT_STANDARD, JUMP_HEIGHT_CHARGED, JUMP_HEIGHT_YELLOW, JUMP_PREPARE_DELAY_MS, JUMP_CHARGE_THRESHOLD_MS, PlayerColorState, } from '../common/Constants'; import { JoystickAngleClass } from '../ui/InputModel'; /** * Jump controller — orchestrates the jump lifecycle on top of * `PlayerMotionModel` (task 4.2). * * Lifecycle (ms timestamps supplied by caller so Jest can stay deterministic): * * pressJump(ts) * ├─ not grounded? ignore (req 2.4) * ├─ enter `charging` state, start timer * └─ emit `jump_prepare_start` * * releaseJump(ts, direction) * ├─ ts - pressTs >= JUMP_CHARGE_THRESHOLD_MS → charged high-jump (req 2.3) * ├─ else → standard jump (req 2.2) * ├─ +150ms crouch delay before launch (req 2.8) * └─ parabolic_right / parabolic_left → horizontal impulse too (req 2.5) * * Yellow-state uses a taller vertical impulse (req 2.2). */ export type JumpPhase = 'idle' | 'charging' | 'crouching' | 'launched'; export interface IJumpDispatchResult { phase: JumpPhase; height: number; horizontalImpulse: number; reason?: string; } /** How much horizontal velocity a parabolic jump imparts (px/s). */ export const PARABOLIC_HORIZONTAL_SPEED = 180; /** * Converts `verticalTravel (px)` to the initial velocity needed to reach it. * Using `v = sqrt(2 * g * h)` under constant gravity. */ export function heightToImpulse(heightPx: number, gravity: number = DEFAULT_GRAVITY): number { return Math.sqrt(2 * gravity * heightPx); } export class JumpController { private phase: JumpPhase = 'idle'; private pressTs = 0; private crouchEndsAt = 0; private pendingImpulse: { vy: number; vx: number } | null = null; constructor( private readonly motion: PlayerMotionModel, private readonly prepareDelayMs: number = JUMP_PREPARE_DELAY_MS, private readonly chargeThresholdMs: number = JUMP_CHARGE_THRESHOLD_MS ) {} /** Called each frame with `now` from `TimeMgr.realTime * 1000`. */ public tick(nowMs: number): void { if (this.phase === 'crouching' && nowMs >= this.crouchEndsAt) { if (this.pendingImpulse) { this.motion.applyJumpImpulse(this.pendingImpulse.vy); this.motion.applyHorizontalImpulse(this.pendingImpulse.vx); this.pendingImpulse = null; } this.phase = 'launched'; } // Once the motion model reports grounded again, reset to idle. if (this.phase === 'launched' && this.motion.isGrounded) { this.phase = 'idle'; } } /** Called on `jumpPressed` UI event. */ public pressJump(nowMs: number): IJumpDispatchResult { if (!this.motion.isGrounded) { return { phase: this.phase, height: 0, horizontalImpulse: 0, reason: 'airborne' }; } if (this.phase !== 'idle') { return { phase: this.phase, height: 0, horizontalImpulse: 0, reason: 'already_in_jump' }; } this.phase = 'charging'; this.pressTs = nowMs; return { phase: this.phase, height: 0, horizontalImpulse: 0 }; } /** Called on `jumpReleased` UI event. */ public releaseJump( nowMs: number, joystickClass: JoystickAngleClass, colorState: PlayerColorState = PlayerColorState.Red ): IJumpDispatchResult { if (this.phase !== 'charging') { return { phase: this.phase, height: 0, horizontalImpulse: 0, reason: 'not_charging' }; } const heldMs = nowMs - this.pressTs; const charged = heldMs >= this.chargeThresholdMs; let height = charged ? JUMP_HEIGHT_CHARGED : colorState === PlayerColorState.Yellow ? JUMP_HEIGHT_YELLOW : JUMP_HEIGHT_STANDARD; let vx = 0; if (joystickClass === 'parabolic_right') { vx = PARABOLIC_HORIZONTAL_SPEED; } else if (joystickClass === 'parabolic_left') { vx = -PARABOLIC_HORIZONTAL_SPEED; } this.phase = 'crouching'; this.crouchEndsAt = nowMs + this.prepareDelayMs; const vy = heightToImpulse(height); this.pendingImpulse = { vy, vx }; return { phase: this.phase, height, horizontalImpulse: vx }; } /** Cancel any pending jump (used on pause / scene unload). */ public cancel(): void { this.phase = 'idle'; this.pendingImpulse = null; } /** Expose the current jump phase for HUD feedback (disabled button, etc.). */ public getPhase(): JumpPhase { return this.phase; } /** * Whether the UI should render the jump button as enabled. Disabled when * airborne or mid-cycle (req 2.4). */ public isButtonEnabled(): boolean { return this.motion.isGrounded && this.phase === 'idle'; } }