import { PlayerMotionModel } from '@logic/PlayerMotionModel'; import { JumpController, PARABOLIC_HORIZONTAL_SPEED, heightToImpulse } from '@logic/JumpController'; import { JUMP_HEIGHT_STANDARD, JUMP_HEIGHT_CHARGED, JUMP_HEIGHT_YELLOW, JUMP_PREPARE_DELAY_MS, PlayerColorState, } from '@common/Constants'; function newPair(color: PlayerColorState = PlayerColorState.Red) { const motion = new PlayerMotionModel({ aabb: { x: 0, y: 16, w: 16, h: 32 }, platforms: [{ topY: 0, leftX: -500, rightX: 500 }], initialColorState: color, }); motion.update(0.016); // settle on ground const jump = new JumpController(motion); return { motion, jump }; } describe('JumpController — press / release lifecycle (req 2.2, 2.3, 2.4, 2.8)', () => { it('refuses to press when airborne (req 2.4)', () => { const { motion, jump } = newPair(); motion.applyJumpImpulse(500); // lift off manually const res = jump.pressJump(0); expect(res.reason).toBe('airborne'); expect(jump.isButtonEnabled()).toBe(false); }); it('standard-press + quick release → standard jump height', () => { const { jump } = newPair(); jump.pressJump(0); const res = jump.releaseJump(100, 'horizontal'); expect(res.height).toBe(JUMP_HEIGHT_STANDARD); expect(res.phase).toBe('crouching'); }); it('holding ≥500ms produces the charged jump (req 2.3)', () => { const { jump } = newPair(); jump.pressJump(0); const res = jump.releaseJump(600, 'horizontal'); expect(res.height).toBe(JUMP_HEIGHT_CHARGED); }); it('yellow color state uses 300px baseline jump (req 2.2)', () => { const { jump } = newPair(PlayerColorState.Yellow); jump.pressJump(0); const res = jump.releaseJump(50, 'horizontal', PlayerColorState.Yellow); expect(res.height).toBe(JUMP_HEIGHT_YELLOW); }); }); describe('JumpController — parabolic impulse (req 2.5)', () => { it('parabolic_right imparts +PARABOLIC_HORIZONTAL_SPEED', () => { const { jump } = newPair(); jump.pressJump(0); const res = jump.releaseJump(100, 'parabolic_right'); expect(res.horizontalImpulse).toBe(PARABOLIC_HORIZONTAL_SPEED); }); it('parabolic_left imparts −PARABOLIC_HORIZONTAL_SPEED', () => { const { jump } = newPair(); jump.pressJump(0); const res = jump.releaseJump(100, 'parabolic_left'); expect(res.horizontalImpulse).toBe(-PARABOLIC_HORIZONTAL_SPEED); }); }); describe('JumpController — crouch delay + launch + re-enable (req 2.8, 2.4)', () => { it('does not apply impulse until after 150ms crouch delay', () => { const { motion, jump } = newPair(); jump.pressJump(0); jump.releaseJump(100, 'horizontal'); // Inside the crouch window — still grounded because impulse not applied. jump.tick(200); expect(motion.vy).toBe(0); expect(motion.isGrounded).toBe(true); jump.tick(100 + JUMP_PREPARE_DELAY_MS + 1); expect(motion.vy).toBeGreaterThan(0); expect(motion.isGrounded).toBe(false); }); it('re-enables the jump button once the player lands again', () => { const { motion, jump } = newPair(); jump.pressJump(0); jump.releaseJump(50, 'horizontal'); jump.tick(50 + JUMP_PREPARE_DELAY_MS + 1); // Let gravity bring the player back down. for (let i = 0; i < 120; i++) motion.update(1 / 60); jump.tick(1_000); expect(motion.isGrounded).toBe(true); expect(jump.isButtonEnabled()).toBe(true); }); }); describe('heightToImpulse — physics math', () => { it('computes impulse such that peak equals the requested height', () => { const g = 2500; const h = 250; const v0 = heightToImpulse(h, g); // At apex v=0 ⇒ t = v0/g ⇒ peak = v0 * t - 0.5 * g * t^2 ⇒ v0^2 / (2g) expect((v0 * v0) / (2 * g)).toBeCloseTo(h, 3); }); });