Files
2026-06-07 22:10:03 +08:00

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