diff --git a/src/audio.ts b/src/audio.ts index 11a075e..2438c78 100644 --- a/src/audio.ts +++ b/src/audio.ts @@ -332,6 +332,17 @@ export class AudioManager { ); this.noiseBurst({ dur: 0.08, vol: 0.08, freq: 2400 }); break; + case 'powerup': + // Bright rising four-note pickup chime. + [523, 784, 1046, 1568].forEach((f, i) => + this.tone({ freq: f, dur: 0.1, type: 'triangle', vol: 0.18, delay: i * 0.05 }), + ); + break; + case 'bubblePop': + // Soap-bubble pop: soft hiss plus a falling blip. + this.noiseBurst({ dur: 0.08, vol: 0.16, freq: 1600 }); + this.tone({ freq: 900, to: 300, dur: 0.16, type: 'sine', vol: 0.2 }); + break; } } diff --git a/src/config.ts b/src/config.ts index 7e1fd1c..e9f4334 100644 --- a/src/config.ts +++ b/src/config.ts @@ -52,6 +52,16 @@ export const CFG = { spitter: { range: 520, band: 150, fireCd: 1.8, projSpeed: 1.06, projGravity: 700 }, /** Magma King: charge speed (px/s) and magma-glob fire speed. */ boss: { chargeSpeed: 640, shootSpeed: 430 }, + /** Enemy-kill power-up capsules: Magnet, Double Jump, Bubble shield. */ + powerup: { + dropChance: 0.25, // share of stomped enemies that drop a capsule + magnetDur: 8, // seconds of crystal attraction + magnetRange: 150, // pickup radius of the magnet (px) + magnetSpeed: 520, // how fast attracted crystals home in (px/s) + doubleJumpDur: 15, // seconds of extra air jumps + doubleJumpVel: 600, // air-jump impulse (just under the ground jump) + expireT: 10, // seconds a dropped capsule waits before evaporating + }, } as const; export const VW = CFG.view.w; diff --git a/src/game.ts b/src/game.ts index c7dab90..64d7c5b 100644 --- a/src/game.ts +++ b/src/game.ts @@ -18,6 +18,8 @@ import { generateDailyLevel, dailySeed, dailyLabel, rexCode } from './daily'; import { GhostRecorder, GhostPlayer } from './ghost'; import { drawDecor } from './decor'; import { Sprite, SKINS, skinUnlocked } from './sprite'; +import { drawPowerUpIcon, POWERUP_COLORS } from './powerup'; +import type { PowerUpType } from './powerup'; import type { GameCtx } from './ctx'; import type { Checkpoint } from './checkpoint'; import type { Platform } from './platform'; @@ -853,6 +855,12 @@ export class Game implements GameCtx { for (const f of this.level!.fossils) { if (!f.collected) f.draw(ctx, this.time); } + // Power-up capsules (enemy drops) + for (const pw of this.level!.powerups) { + if (pw.collected) continue; + if (pw.x + 30 < camX - 40 || pw.x - 30 > camX + VW + 40) continue; + pw.draw(ctx, this.time); + } for (const e of this.level!.enemies) { if (e.dead) continue; if (e.x + e.w < camX - 60 || e.x > camX + VW + 60) continue; @@ -1182,6 +1190,33 @@ export class Game implements GameCtx { } } ctx.restore(); + // Active power-up chips (under the heart panel, left) + if (this.player) { + const p = this.player; + const chips: { type: PowerUpType; frac: number }[] = []; + if (p.magnetT > 0) chips.push({ type: 'magnet', frac: p.magnetT / CFG.powerup.magnetDur }); + if (p.doubleJumpT > 0) chips.push({ type: 'double', frac: p.doubleJumpT / CFG.powerup.doubleJumpDur }); + if (p.bubble) chips.push({ type: 'bubble', frac: 1 }); + let chipX = 22; + for (const chip of chips) { + ctx.fillStyle = 'rgba(20,30,45,0.6)'; + this.roundRect(ctx, chipX, 80, 46, 26, 8); + ctx.fill(); + ctx.strokeStyle = POWERUP_COLORS[chip.type]; + ctx.lineWidth = 1.5; + this.roundRect(ctx, chipX, 80, 46, 26, 8); + ctx.stroke(); + drawPowerUpIcon(ctx, chip.type, chipX + 14, 93); + // Remaining-time bar (full for the one-hit bubble) + ctx.fillStyle = 'rgba(0,0,0,0.45)'; + this.roundRect(ctx, chipX + 27, 87, 14, 4, 2); + ctx.fill(); + ctx.fillStyle = POWERUP_COLORS[chip.type]; + this.roundRect(ctx, chipX + 27, 87, Math.max(2, 14 * chip.frac), 4, 2); + ctx.fill(); + chipX += 54; + } + } // Progress toward the nest (top centre, between the panels) if (this.player && this.level) this.drawProgress(ctx); // Magma King: health bar + orb indicators (top centre, under the track) diff --git a/src/level.ts b/src/level.ts index 4c94bd8..163616c 100644 --- a/src/level.ts +++ b/src/level.ts @@ -16,6 +16,8 @@ import { Projectile } from './projectile'; import type { ProjectileKind } from './projectile'; import { Fossil } from './fossil'; import { MagmaKing } from './boss'; +import { PowerUp } from './powerup'; +import type { PowerUpType } from './powerup'; export class Level { width: number; @@ -39,6 +41,8 @@ export class Level { fossils: Fossil[]; /** The Magma King (Molten Nest only). */ boss: MagmaKing | null; + /** Power-up capsules dropped by stomped enemies. */ + powerups: PowerUp[]; readonly game: GameCtx; constructor(d: LevelDef, game: GameCtx, enemySpeed = 1, levelIdx = 0) { @@ -77,6 +81,12 @@ export class Level { this.boss = d.boss ? new MagmaKing(d.boss, this, enemySpeed, d.orbs ?? []) : null; + this.powerups = []; + } + + /** Spawn a power-up capsule (enemy-kill drop). */ + spawnPowerUp(type: PowerUpType, x: number, y: number): void { + this.powerups.push(new PowerUp(type, x, y)); } solidAt(x: number, y: number): boolean { @@ -119,6 +129,16 @@ export class Level { this.boss?.update(dt, t, player); for (const pr of this.projectiles) pr.update(dt, this, player); this.projectiles = this.projectiles.filter((pr) => !pr.dead); + for (const pw of this.powerups) pw.update(dt); + this.powerups = this.powerups.filter((pw) => { + if (pw.collected) return false; + if (pw.life <= 0) { + // Left behind: evaporate with a small poof + this.game.burst(pw.x, pw.y, 6, ['#ffffff', '#cfe8ff'], 'dot', 90); + return false; + } + return true; + }); } reset(): void { @@ -126,6 +146,7 @@ export class Level { for (const s of this.springs) s.reset(); for (const p of this.plates) p.reset(); this.projectiles = []; + this.powerups = []; for (const c of this.crystals) c.collected = false; for (const h of this.hearts) h.collected = false; for (const f of this.fossils) f.collected = false; diff --git a/src/player.ts b/src/player.ts index 0ad2433..3f7d4a9 100644 --- a/src/player.ts +++ b/src/player.ts @@ -4,6 +4,9 @@ import type { Input } from './input'; import type { Level } from './level'; import type { Platform } from './platform'; import type { GameCtx } from './ctx'; +import type { Crystal } from './crystal'; +import { rollDrop } from './powerup'; +import type { PowerUpType } from './powerup'; export type PlayerState = 'idle' | 'run' | 'jump' | 'fall' | 'hurt' | 'dead' | 'victory'; export type DamageKind = 'spikes' | 'lava' | 'enemy' | 'rock' | 'pit' | 'spit'; @@ -64,6 +67,13 @@ export class Player { rainbow = false; /** Cosmetic skin id (see SKINS in sprite.ts); set by the Game. */ skin = 'classic'; + /** Power-up timers: seconds of remaining effect (0 = inactive). */ + magnetT = 0; + doubleJumpT = 0; + /** One-hit bubble shield from a Bubble capsule. */ + bubble = false; + /** Air jumps left this flight (double-jump power-up). */ + airJumps = 0; private readonly game: GameCtx; constructor(x: number, y: number, game: GameCtx) { @@ -94,6 +104,10 @@ export class Player { this.hurtT = 0; this.walkDustT = 0; this.jumpCutPending = false; + this.magnetT = 0; + this.doubleJumpT = 0; + this.bubble = false; + this.airJumps = 0; } get feet(): number { @@ -140,6 +154,8 @@ export class Player { this.hurtT = Math.max(0, this.hurtT - dt); this.invulnT = Math.max(0, this.invulnT - dt); + this.magnetT = Math.max(0, this.magnetT - dt); + this.doubleJumpT = Math.max(0, this.doubleJumpT - dt); this.landT = Math.max(0, this.landT - dt); // Ease squash & stretch back to neutral this.squashX = lerp(this.squashX, 1, dt * 12); @@ -178,6 +194,25 @@ export class Player { this.game.burst(this.x + this.w / 2, this.feet, 5, ['#e8dcc8'], 'dust', 90); this.game.audio.play('jump'); } + // Double-jump power-up: one extra mid-air jump per flight. Re-reads + // the buffer (a ground jump this frame already consumed it). + if ( + input.jumpBufferT >= 0 && + t - input.jumpBufferT <= P.jumpBuffer && + !this.grounded && + this.coyoteT <= 0 && + this.doubleJumpT > 0 && + this.airJumps < 1 + ) { + input.jumpBufferT = -1; + this.vy = -CFG.powerup.doubleJumpVel; + this.airJumps += 1; + this.squashX = 0.82; + this.squashY = 1.22; + this.jumpCutPending = true; + this.game.burst(this.x + this.w / 2, this.feet, 8, ['#8fd8ff', '#cfe8ff'], 'dot', 120); + this.game.audio.play('jump'); + } // Variable jump height: releasing early cuts the ascent, but only once // (edge-triggered) so stomp bounces and lava flicks keep their full pop. if (this.jumpCutPending) { @@ -249,6 +284,8 @@ export class Player { } } } + // Landing resets the air-jump allotment + if (this.grounded) this.airJumps = 0; // --- Spring pads: launch when falling (or walking) onto one --- for (const s of level.springs) { @@ -294,9 +331,10 @@ export class Player { } } - // --- Crystals --- + // --- Crystals (the Magnet power-up pulls them in first) --- for (const c of level.crystals) { if (c.collected) continue; + if (this.magnetT > 0) this.attract(c, dt); if (overlap(this.rect, c.rect)) { c.collected = true; this.game.collectCrystal(c.x, c.y, c.bonus); @@ -338,11 +376,22 @@ export class Player { this.game.audio.play('stomp'); if (e.type === 'spitter') level.popProjectile(e.x + e.w / 2, e.y + e.h / 2); vibrate(30); + const drop = rollDrop(); + if (drop) level.spawnPowerUp(drop, e.x + e.w / 2, e.y + e.h / 2); } else if (this.invulnT <= 0) { this.damage(e, 'enemy'); } } + // --- Power-up capsules (enemy drops) --- + for (const pw of level.powerups) { + if (!pw.alive) continue; + if (overlap(this.rect, pw.rect)) { + pw.collected = true; + this.applyPowerup(pw.type, pw.x, pw.y); + } + } + // --- Checkpoints --- for (const cp of level.checkpoints) { if (!cp.active && overlap(this.rect, cp.rect)) cp.activate(); @@ -359,6 +408,35 @@ export class Player { } } + /** Magnet power-up: pulls a nearby crystal toward Rex's centre. */ + private attract(c: Crystal, dt: number): void { + const dx = this.x + this.w / 2 - c.x; + const dy = this.y + this.h / 2 - c.y; + const d = Math.hypot(dx, dy); + if (d > CFG.powerup.magnetRange || d < 1) return; + const step = CFG.powerup.magnetSpeed * dt; + c.x += (dx / d) * step; + c.y += (dy / d) * step; + } + + /** Pick up a power-up capsule: applies the timed effect. */ + applyPowerup(type: PowerUpType, x: number, y: number): void { + const P = CFG.powerup; + if (type === 'magnet') { + this.magnetT = P.magnetDur; + this.game.addStatus('Magnet!', '#ff9db0'); + } else if (type === 'double') { + this.doubleJumpT = P.doubleJumpDur; + this.airJumps = 0; + this.game.addStatus('Double Jump!', '#8fd8ff'); + } else { + this.bubble = true; + this.game.addStatus('Bubble Shield!', '#bff4ff'); + } + this.game.audio.play('powerup'); + this.game.burst(x, y, 14, ['#ffffff', '#cfe8ff', '#ffd257'], 'dot', 150); + } + enemyColors(type: string): string[] { if (type === 'beetle') return ['#7a3b2e', '#5c2c22', '#c96f4a']; if (type === 'trike') return ['#7d97ad', '#5d7690', '#c3d3e0']; @@ -376,6 +454,16 @@ export class Player { this.game.addStatus('Invulnerable!', '#8fe3ff'); return; } + if (this.bubble) { + // The bubble absorbs the hit: pop, brief grace, no heart lost. + this.bubble = false; + this.invulnT = CFG.player.invulnTime * 0.5; + this.hurtT = 0.25; + this.game.audio.play('bubblePop'); + this.game.burst(this.x + this.w / 2, this.y + this.h / 2, 16, ['#bff4ff', '#8fe3ff', '#fff'], 'dot', 170); + this.game.addStatus('Bubble popped!', '#bff4ff'); + return; + } this.hearts -= 1; this.invulnT = CFG.player.invulnTime; this.hurtT = 0.5; diff --git a/src/powerup.ts b/src/powerup.ts new file mode 100644 index 0000000..7f2a28f --- /dev/null +++ b/src/powerup.ts @@ -0,0 +1,132 @@ +import { CFG, TAU } from './config'; + +export type PowerUpType = 'magnet' | 'double' | 'bubble'; + +export const POWERUP_COLORS: Record = { + magnet: '#ff6b8a', + double: '#7ac9ff', + bubble: '#8fe3ff', +}; + +/** + * Enemy-kill drop table: nothing most of the time, otherwise one of the + * three capsules. `rng` is injectable so tests can force outcomes. + */ +export function rollDrop(rng: () => number = Math.random): PowerUpType | null { + const r = rng(); + if (r >= CFG.powerup.dropChance) return null; + // Conditional on dropping, the same roll picks the type uniformly. + const pick = r / CFG.powerup.dropChance; + return pick < 1 / 3 ? 'magnet' : pick < 2 / 3 ? 'double' : 'bubble'; +} + +/** Icon glyph shared by the world capsule and the HUD chip. */ +export function drawPowerUpIcon(ctx: CanvasRenderingContext2D, type: PowerUpType, cx: number, cy: number): void { + ctx.save(); + ctx.translate(cx, cy); + if (type === 'magnet') { + // Horseshoe (opening down) with silver tips + ctx.lineCap = 'round'; + ctx.lineWidth = 4.4; + ctx.strokeStyle = POWERUP_COLORS.magnet; + ctx.beginPath(); + ctx.arc(0, -2, 6.5, Math.PI - 0.45, Math.PI * 2 + 0.45); + ctx.stroke(); + ctx.lineWidth = 2.6; + ctx.strokeStyle = '#e8eef4'; + ctx.beginPath(); + ctx.moveTo(-5.8, 1.6); + ctx.lineTo(-5.8, 5.6); + ctx.moveTo(5.8, 1.6); + ctx.lineTo(5.8, 5.6); + ctx.stroke(); + } else if (type === 'double') { + // Two stacked upward chevrons (the lower one ghosted) + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.strokeStyle = POWERUP_COLORS.double; + ctx.lineWidth = 2.6; + ctx.globalAlpha = 0.5; + ctx.beginPath(); + ctx.moveTo(-5.5, 7); + ctx.lineTo(0, 1.5); + ctx.lineTo(5.5, 7); + ctx.stroke(); + ctx.globalAlpha = 1; + ctx.beginPath(); + ctx.moveTo(-5.5, 2.5); + ctx.lineTo(0, -3); + ctx.lineTo(5.5, 2.5); + ctx.stroke(); + } else { + // Bubble ring with a highlight + ctx.lineWidth = 2.4; + ctx.strokeStyle = POWERUP_COLORS.bubble; + ctx.beginPath(); + ctx.arc(0, 0, 7, 0, TAU); + ctx.stroke(); + ctx.fillStyle = 'rgba(255,255,255,0.85)'; + ctx.beginPath(); + ctx.arc(-2.6, -2.8, 1.8, 0, TAU); + ctx.fill(); + } + ctx.restore(); +} + +/** + * A dropped power-up capsule: bobs, glows, and evaporates after a few + * seconds if the player leaves it behind. + */ +export class PowerUp { + type: PowerUpType; + x: number; + y: number; + collected = false; + life: number = CFG.powerup.expireT; + phase = Math.random() * TAU; + + constructor(type: PowerUpType, x: number, y: number) { + this.type = type; + this.x = x; + this.y = y; + } + + get alive(): boolean { + return !this.collected && this.life > 0; + } + + get rect(): { x: number; y: number; w: number; h: number } { + return { x: this.x - 15, y: this.y - 15, w: 30, h: 30 }; + } + + update(dt: number): void { + this.life = Math.max(0, this.life - dt); + this.phase += dt * 3; + } + + draw(ctx: CanvasRenderingContext2D, t: number): void { + const bob = Math.sin(t * 2.8 + this.phase) * 3; + const cx = this.x; + const cy = this.y + bob; + // Blink in the last two seconds so an expiring capsule reads clearly + const blink = this.life < 2 ? (Math.sin(t * 14) > 0 ? 1 : 0.3) : 1; + ctx.save(); + // glow + ctx.globalAlpha = blink * (0.28 + 0.14 * Math.sin(t * 3.4 + this.phase)); + ctx.fillStyle = POWERUP_COLORS[this.type]; + ctx.beginPath(); + ctx.arc(cx, cy, 21, 0, TAU); + ctx.fill(); + // capsule body + ctx.globalAlpha = blink; + ctx.fillStyle = 'rgba(22,34,50,0.88)'; + ctx.beginPath(); + ctx.arc(cx, cy, 13, 0, TAU); + ctx.fill(); + ctx.strokeStyle = POWERUP_COLORS[this.type]; + ctx.lineWidth = 2; + ctx.stroke(); + drawPowerUpIcon(ctx, this.type, cx, cy); + ctx.restore(); + } +} diff --git a/tests/powerup.test.ts b/tests/powerup.test.ts new file mode 100644 index 0000000..2e085fc --- /dev/null +++ b/tests/powerup.test.ts @@ -0,0 +1,250 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { CFG } from '../src/config'; +import { LEVEL_DATA } from '../src/level-data'; +import { Level } from '../src/level'; +import { Player } from '../src/player'; +import { Input } from '../src/input'; +import { Crystal } from '../src/crystal'; +import { Enemy } from '../src/enemy'; +import { PowerUp, rollDrop } from '../src/powerup'; +import { makeCtx, type MockCtx } from './mock-ctx'; + +const DT = 1 / 60; + +interface Rig { + game: MockCtx; + level: Level; + player: Player; + input: Input; +} + +function setup(x = 120, y = 414): Rig { + const game = makeCtx(); + const level = new Level(LEVEL_DATA, game); + const player = new Player(x, y, game); + const input = new Input(); + return { game, level, player, input }; +} + +describe('rollDrop', () => { + it('drops nothing when the roll misses the chance', () => { + expect(rollDrop(() => 0.5)).toBeNull(); // 0.5 >= 0.25 + expect(rollDrop(() => 0.99)).toBeNull(); + }); + + it('covers all three capsules (single roll, chance 0.25)', () => { + expect(rollDrop(() => 0.05)).toBe('magnet'); // pick 0.2 < 1/3 + expect(rollDrop(() => 0.1)).toBe('double'); // pick 0.4 < 2/3 + expect(rollDrop(() => 0.2)).toBe('bubble'); // pick 0.8 >= 2/3 + expect(rollDrop(() => 0.25)).toBeNull(); // exactly at the chance edge + expect(rollDrop(() => 0.9)).toBeNull(); + }); +}); + +describe('PowerUp capsule', () => { + it('starts alive and expires after the wait', () => { + const pw = new PowerUp('magnet', 100, 100); + expect(pw.alive).toBe(true); + for (let i = 0; i < CFG.powerup.expireT * 60 - 1; i++) pw.update(DT); + expect(pw.alive).toBe(true); + pw.update(DT); + expect(pw.life).toBe(0); + expect(pw.alive).toBe(false); + }); + + it('stops being alive once collected', () => { + const pw = new PowerUp('bubble', 0, 0); + pw.collected = true; + expect(pw.alive).toBe(false); + }); + + it('is removed from the level when collected (no poof)', () => { + const { game, level, player } = setup(); + level.spawnPowerUp('magnet', 100, 400); + level.powerups[0].collected = true; + level.update(DT, 0, player); + expect(level.powerups).toHaveLength(0); + expect(game.bursts).toHaveLength(0); + }); + + it('evaporates with a poof when left behind', () => { + const { game, level, player } = setup(); + level.spawnPowerUp('double', 100, 400); + for (let i = 0; i < CFG.powerup.expireT * 60 + 5; i++) level.update(DT, i * DT, player); + expect(level.powerups).toHaveLength(0); + expect(game.bursts).toHaveLength(1); + expect(game.bursts[0].x).toBe(100); + }); + + it('level reset clears all capsules', () => { + const { level } = setup(); + level.spawnPowerUp('magnet', 100, 400); + level.spawnPowerUp('bubble', 200, 400); + expect(level.powerups).toHaveLength(2); + level.reset(); + expect(level.powerups).toHaveLength(0); + }); +}); + +describe('Player power-up effects', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('stomping an enemy rolls a capsule drop', () => { + const { level, player } = setup(200, 346); + const beetle = new Enemy({ type: 'beetle', x: 200, y: 432, minX: 160, maxX: 280 }, level, 1); + level.enemies.push(beetle); + vi.spyOn(Math, 'random').mockReturnValue(0.05); // drop + 'magnet' (pick 0.2) + player.vy = 300; + for (let i = 0; i < 30 && !beetle.dead; i++) { + player.update(DT, 1 + i * DT, new Input(), level); + } + expect(beetle.dead).toBe(true); + expect(level.powerups).toHaveLength(1); + expect(level.powerups[0].type).toBe('magnet'); + expect(level.powerups[0].x).toBeCloseTo(219, 0); // beetle centre + }); + + it('no drop when the roll misses', () => { + const { level, player } = setup(200, 346); + const beetle = new Enemy({ type: 'beetle', x: 200, y: 432, minX: 160, maxX: 280 }, level, 1); + level.enemies.push(beetle); + vi.spyOn(Math, 'random').mockReturnValue(0.9); // 0.9 >= 0.25 -> no drop + player.vy = 300; + for (let i = 0; i < 30 && !beetle.dead; i++) { + player.update(DT, 1 + i * DT, new Input(), level); + } + expect(beetle.dead).toBe(true); + expect(level.powerups).toHaveLength(0); + }); + + it('applyPowerup sets the timers and plays feedback', () => { + const { game, player } = setup(); + player.applyPowerup('magnet', 100, 100); + expect(player.magnetT).toBe(CFG.powerup.magnetDur); + expect(game.statuses).toContain('Magnet!'); + expect(game.audio.played).toContain('powerup'); + player.applyPowerup('double', 100, 100); + expect(player.doubleJumpT).toBe(CFG.powerup.doubleJumpDur); + expect(game.statuses).toContain('Double Jump!'); + player.applyPowerup('bubble', 100, 100); + expect(player.bubble).toBe(true); + expect(game.statuses).toContain('Bubble Shield!'); + }); + + it('timers decay and reset() clears everything', () => { + const { level, player } = setup(); + player.applyPowerup('magnet', 0, 0); + player.applyPowerup('bubble', 0, 0); + for (let i = 0; i < (CFG.powerup.magnetDur + 0.3) * 60; i++) { + player.update(DT, i * DT, new Input(), level); + } + expect(player.magnetT).toBe(0); + player.reset(120, 414, false); + expect(player.magnetT).toBe(0); + expect(player.doubleJumpT).toBe(0); + expect(player.bubble).toBe(false); + expect(player.airJumps).toBe(0); + }); + + it('the magnet pulls nearby crystals toward Rex', () => { + const { level, player } = setup(); + player.update(DT, 0, new Input(), level); // land first + const near = new Crystal(200, 400, false); + const far = new Crystal(120, 120, false); + level.crystals.push(near, far); + player.magnetT = CFG.powerup.magnetDur; + for (let i = 0; i < 3; i++) player.update(DT, i * DT, new Input(), level); + expect(near.x).toBeLessThan(200); // pulled left, toward the player + expect(near.y).toBeGreaterThan(400); // and down, toward the centre + expect(near.collected).toBe(false); // not close enough yet + expect(far.x).toBe(120); // out of range: untouched + expect(far.y).toBe(120); + }); + + it('the magnet stops pulling after it expires', () => { + const { level, player } = setup(); + player.update(DT, 0, new Input(), level); + const c = new Crystal(200, 400, false); + level.crystals.push(c); + player.magnetT = CFG.powerup.magnetDur; + const frames = CFG.powerup.magnetDur * 60 + 60; + for (let i = 0; i < frames; i++) player.update(DT, i * DT, new Input(), level); + expect(player.magnetT).toBe(0); + const x = c.x, y = c.y; + player.update(DT, frames * DT, new Input(), level); + expect(c.x).toBe(x); + expect(c.y).toBe(y); + }); + + it('double jump gives one extra mid-air jump', () => { + const { game, level, player, input } = setup(120, 300); + player.update(DT, 0, input, level); // let gravity start + player.doubleJumpT = CFG.powerup.doubleJumpDur; + input.jumpBufferT = 1; // fresh press + player.update(DT, 1, input, level); + expect(player.airJumps).toBe(1); + // impulse minus the early release cut, plus one gravity step + expect(player.vy).toBeCloseTo( + -CFG.powerup.doubleJumpVel * CFG.player.jumpCut + CFG.player.gravity * DT, + 5, + ); + expect(game.audio.played).toContain('jump'); + }); + + it('the extra jump cannot stack within one flight', () => { + const { level, player, input } = setup(120, 300); + player.update(DT, 0, input, level); + player.doubleJumpT = CFG.powerup.doubleJumpDur; + input.jumpBufferT = 1; + player.update(DT, 1, input, level); + input.jumpBufferT = 1.1; // press again, still airborne + player.update(DT, 1.1, input, level); + expect(player.airJumps).toBe(1); + }); + + it('without the power-up an air jump does nothing', () => { + const { game, level, player, input } = setup(120, 300); + player.update(DT, 0, input, level); + input.jumpBufferT = 1; + player.update(DT, 1, input, level); + expect(player.airJumps).toBe(0); + expect(player.vy).toBeGreaterThan(0); // still falling + expect(game.audio.played).not.toContain('jump'); + }); + + it('landing resets the air-jump allotment', () => { + const { level, player, input } = setup(120, 300); + player.update(DT, 0, input, level); + player.doubleJumpT = CFG.powerup.doubleJumpDur; + input.jumpBufferT = 1; + player.update(DT, 1, input, level); + expect(player.airJumps).toBe(1); + player.y = 415; // drop onto the start ground (top 460) + player.vy = 50; + player.update(DT, 1.1, input, level); + expect(player.grounded).toBe(true); + expect(player.airJumps).toBe(0); + }); + + it('the bubble absorbs one hit without losing a heart', () => { + const { game, player } = setup(); + player.bubble = true; + player.hearts = 3; + player.damage({ x: 0, w: 34 }, 'enemy'); + expect(player.bubble).toBe(false); + expect(player.hearts).toBe(3); + expect(player.invulnT).toBeGreaterThan(0); + expect(game.audio.played).toContain('bubblePop'); + expect(game.statuses).toContain('Bubble popped!'); + }); + + it('without a bubble the hit costs a heart', () => { + const { game, player } = setup(); + player.hearts = 3; + player.damage({ x: 0, w: 34 }, 'enemy'); + expect(player.hearts).toBe(2); + expect(game.audio.played).toContain('hurt'); + }); +});