first commmit
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user