147 lines
5.2 KiB
TypeScript
147 lines
5.2 KiB
TypeScript
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) {
|
|
console.log('[JumpController] pressJump REJECTED — airborne, phase=', this.phase);
|
|
return { phase: this.phase, height: 0, horizontalImpulse: 0, reason: 'airborne' };
|
|
}
|
|
if (this.phase !== 'idle') {
|
|
console.log('[JumpController] pressJump REJECTED — phase=', this.phase, ', not idle');
|
|
return { phase: this.phase, height: 0, horizontalImpulse: 0, reason: 'already_in_jump' };
|
|
}
|
|
this.phase = 'charging';
|
|
this.pressTs = nowMs;
|
|
console.log('[JumpController] pressJump ACCEPTED — entering charging phase');
|
|
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';
|
|
}
|
|
}
|