From 0cb9054407564a4078d750e5acee84f0d8874870 Mon Sep 17 00:00:00 2001 From: Chris Malpass Date: Wed, 26 Aug 2026 19:04:06 -0400 Subject: [PATCH] Add Level 4: Molten Nest with the Magma King boss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/boss.ts: MagmaKing state machine — arena patrol, telegraphed 3-glob magma spreads, telegraphed charge that staggers at the arena walls, and a 3-crystal-orb stun mechanic; stompable only while staggered/stunned, three hits defeat him. - Level 4 "Molten Nest" in level-data.ts: lava-pit approach, walled boss arena with orb perches (both walls have walk-under gaps), and a nest gate that latches open on defeat. - Magma glob projectiles, ember particles, boss roar + orb SFX. - HUD: boss HP bar with orb pips; victory ceremony gains a "THE MAGMA KING FALLS!" title, molten confetti, and a Magma King results line; menu gains the fifth level card. - Tests: boss state-machine suite, LEVEL_4 integrity checks, Game integration (defeat -> gate latch -> victory); 174 passing, typecheck/build/lint clean, headless E2E verified (zero page errors). --- src/audio.ts | 13 + src/boss.ts | 544 +++++++++++++++++++++++++++++++++++++++ src/config.ts | 6 + src/ctx.ts | 5 + src/game.ts | 106 +++++++- src/level-data.ts | 75 ++++++ src/level.ts | 16 +- src/particles.ts | 12 +- src/projectile.ts | 22 +- src/sprite.ts | 32 +++ tests/boss.test.ts | 211 +++++++++++++++ tests/game.test.ts | 47 +++- tests/level-data.test.ts | 100 ++++++- tests/mock-ctx.ts | 5 + 14 files changed, 1168 insertions(+), 26 deletions(-) create mode 100644 src/boss.ts create mode 100644 tests/boss.test.ts diff --git a/src/audio.ts b/src/audio.ts index 940bc60..11a075e 100644 --- a/src/audio.ts +++ b/src/audio.ts @@ -319,6 +319,19 @@ export class AudioManager { this.tone({ freq: f, dur: 0.09, type: 'sine', vol: 0.16, delay: i * 0.05 }), ); break; + case 'boss': + // Low magma roar: descending saw growl over a heavy thump. + this.tone({ freq: 110, to: 55, dur: 0.55, type: 'sawtooth', vol: 0.26 }); + this.tone({ freq: 220, to: 90, dur: 0.5, type: 'square', vol: 0.1, delay: 0.05 }); + this.noiseBurst({ dur: 0.4, vol: 0.24, freq: 240 }); + break; + case 'orb': + // Crystal shatter: bright descending glassy arpeggio. + [1568, 1244, 988, 784].forEach((f, i) => + this.tone({ freq: f, dur: 0.09, type: 'sine', vol: 0.18, delay: i * 0.045 }), + ); + this.noiseBurst({ dur: 0.08, vol: 0.08, freq: 2400 }); + break; } } diff --git a/src/boss.ts b/src/boss.ts new file mode 100644 index 0000000..2491541 --- /dev/null +++ b/src/boss.ts @@ -0,0 +1,544 @@ +import { CFG } from './config'; +import type { Level } from './level'; +import type { Player } from './player'; +import { overlap } from './util'; + +/** Placement + patrol bounds for the Magma King (x is the left edge). */ +export interface BossDef { + x: number; + y: number; + minX: number; + maxX: number; +} + +export interface BossOrb { + x: number; + y: number; + alive: boolean; + respawnT: number; +} + +export type BossState = + | 'walk' + | 'telegraph' + | 'chargeWarn' + | 'charge' + | 'stagger' + | 'stunned' + | 'dying'; + +const SHOOT_CD = 4.6; +const CHARGE_CD = 7.0; +const TELEGRAPH_T = 0.75; +const CHARGE_WARN_T = 0.85; +const STAGGER_T = 2.2; +const STUN_T = 3.2; +const DIE_T = 1.6; +const ORB_RESPAWN = 10; + +const MAGMA = ['#ff9d3f', '#ff6b35', '#ffd257', '#8a3b1e']; + +/** + * Magma King — the end-of-game boss for "Molten Nest". + * + * Pattern loop: patrols the arena, telegraphs and fires a 3-glob magma spread, + * and periodically telegraphs a full-speed charge that ends in a stagger. + * The player can only damage him while staggered or stunned; shattering all + * three crystal orbs (stomp) stuns him. Three stomp hits kill him. + */ +export class MagmaKing { + readonly w = 120; + readonly h = 104; + x: number; + y: number; + minX: number; + maxX: number; + hp = 3; + readonly maxHp = 3; + state: BossState = 'walk'; + stateT = 0; + dir: 1 | -1 = 1; + dead = false; + /** 1 right after a stomp, eases to 0 (squash). */ + squash = 0; + orbs: BossOrb[]; + shootT = SHOOT_CD; + chargeT = CHARGE_CD; + chargeDir: 1 | -1 = 1; + + private readonly spawnX: number; + private readonly spawnY: number; + private readonly level: Level; + private readonly speedMult: number; + + get rect(): { x: number; y: number; w: number; h: number } { + return { x: this.x, y: this.y, w: this.w, h: this.h }; + } + + get vulnerable(): boolean { + return this.state === 'stagger' || this.state === 'stunned'; + } + + constructor( + def: BossDef, + level: Level, + speedMult: number, + orbs: ReadonlyArray<{ x: number; y: number }>, + ) { + this.x = def.x; + this.y = def.y; + this.minX = def.minX; + this.maxX = def.maxX; + this.spawnX = def.x; + this.spawnY = def.y; + this.level = level; + this.speedMult = speedMult; + this.orbs = orbs.map((o) => ({ x: o.x, y: o.y, alive: true, respawnT: 0 })); + } + + update(dt: number, _t: number, player: Player): void { + if (this.dead) return; + this.squash = Math.max(0, this.squash - dt * 2.5); + + // Orbs respawn on a timer + for (const o of this.orbs) { + if (o.alive) continue; + o.respawnT -= dt; + if (o.respawnT <= 0) { + o.alive = true; + this.level.game.audio.play('orb'); + this.level.game.burst(o.x, o.y, 8, ['#8fd8ff', '#fff'], 'dot', 120); + } + } + + const g = this.level.game; + const playerActive = !player.dead && player.state !== 'victory'; + + switch (this.state) { + case 'dying': { + this.stateT += dt; + if (Math.random() < 0.35) { + g.burst( + this.x + this.w / 2 + (Math.random() - 0.5) * this.w, + this.y + 24, 6, MAGMA, 'ember', 170, + ); + } + if (this.stateT >= DIE_T) { + this.dead = true; + g.onBossDefeated(); + } + return; + } + + case 'telegraph': { + this.stateT += dt; + if (this.stateT >= TELEGRAPH_T) { + this.fireAt(player); + this.state = 'walk'; + this.stateT = 0; + } + break; + } + + case 'chargeWarn': { + this.stateT += dt; + if (this.stateT >= CHARGE_WARN_T) { + const pcx = player.x + player.w / 2; + this.chargeDir = pcx < this.x + this.w / 2 ? -1 : 1; + this.state = 'charge'; + this.stateT = 0; + g.audio.play('boss'); + } + break; + } + + case 'charge': { + this.x += this.chargeDir * CFG.boss.chargeSpeed * this.speedMult * dt; + if (Math.random() < 0.5) { + g.burst( + this.x + (this.chargeDir === 1 ? 8 : this.w - 8), + this.y + this.h - 6, 3, ['#ff9d3f', '#8a5a3b'], 'dust', 70, + ); + } + if (this.x <= this.minX || this.x >= this.maxX) { + this.x = Math.min(Math.max(this.x, this.minX), this.maxX); + this.state = 'stagger'; + this.stateT = 0; + this.shootT = SHOOT_CD; + this.chargeT = CHARGE_CD; + g.addShake(7); + g.burst( + this.x + (this.chargeDir === 1 ? this.w : 0), + this.y + this.h - 20, 18, MAGMA, 'chunk', 260, + ); + g.audio.play('rock'); + } + break; + } + + case 'stagger': + case 'stunned': { + this.stateT += dt; + const dur = this.state === 'stagger' ? STAGGER_T : STUN_T; + if (this.stateT >= dur) { + this.state = 'walk'; + this.stateT = 0; + } + break; + } + + case 'walk': { + this.x += this.dir * 55 * this.speedMult * dt; + if (this.x <= this.minX) { + this.x = this.minX; + this.dir = 1; + } else if (this.x >= this.maxX) { + this.x = this.maxX; + this.dir = -1; + } + if (playerActive) { + this.shootT -= dt; + this.chargeT -= dt; + if (this.shootT <= 0) { + this.shootT = SHOOT_CD; + this.state = 'telegraph'; + this.stateT = 0; + g.burst(this.x + this.w / 2, this.y + 40, 6, MAGMA, 'ember', 130); + } else if (this.chargeT <= 0) { + this.chargeT = CHARGE_CD; + this.state = 'chargeWarn'; + this.stateT = 0; + g.addShake(2.5); + } + } + break; + } + } + + // --- Crystal orbs: stomp to shatter --- + for (const o of this.orbs) { + if (!o.alive) continue; + const orbRect = { x: o.x - 13, y: o.y - 13, w: 26, h: 26 }; + const fallingOnOrb = + player.vy > 40 && + player.x + player.w > orbRect.x && + player.x < orbRect.x + orbRect.w && + player.y + player.h >= orbRect.y && + player.y + player.h <= o.y + 16; + if (!fallingOnOrb) continue; + o.alive = false; + o.respawnT = ORB_RESPAWN; + player.vy = -CFG.player.stompBounce * 0.75; + g.addScore(CFG.score.orb, o.x, o.y); + g.addShake(2); + g.audio.play('orb'); + g.burst(o.x, o.y, 14, ['#8fd8ff', '#fff', '#c9ecff'], 'chunk', 220); + if (this.orbs.every((x) => !x.alive)) { + this.state = 'stunned'; + this.stateT = 0; + g.audio.play('boss'); + g.addShake(4); + g.addStatus('Magma King is stunned! Stomp him!', '#8fd8ff'); + } + } + + // --- Player interaction: stomp when vulnerable, hurt otherwise --- + if ( + !player.dead && + player.state !== 'victory' && + overlap(player.rect, this.rect) + ) { + const fallingOnTop = + player.vy > 40 && player.y + player.h <= this.y + 18; + if (fallingOnTop) { + if (this.vulnerable) { + this.hp -= 1; + this.squash = 1; + player.vy = -CFG.player.stompBounce; + g.stomps += 1; + g.addScore(CFG.score.stomp, this.x + this.w / 2, this.y - 8); + g.addShake(5); + g.audio.play('stomp'); + g.burst( + this.x + this.w / 2, this.y + 20, 16, MAGMA, 'chunk', 240, + ); + if (this.hp <= 0) { + this.state = 'dying'; + this.stateT = 0; + g.addShake(8); + g.audio.play('boss'); + } + } else { + // Clank: no damage, small deflection bounce + player.vy = -260; + g.addShake(1.5); + g.audio.play('rock'); + g.burst(player.x + player.w / 2, this.y + 8, 5, ['#ffd257', '#fff'], 'dot', 120); + } + } else if (player.invulnT <= 0) { + player.damage(this, 'enemy'); + } + } + } + + fireAt(player: Player): void { + const g = this.level.game; + const bx = this.x + this.w / 2; + const by = this.y + 34; + const px = player.x + player.w / 2; + const py = player.y + player.h / 2; + const base = Math.atan2(py - by, px - bx); + for (const off of [-0.22, 0, 0.22]) { + const a = base + off; + this.level.spawnProjectile( + bx, by, + Math.cos(a) * CFG.boss.shootSpeed, + Math.sin(a) * CFG.boss.shootSpeed - 130, + 'magma', + ); + } + g.audio.play('spit'); + g.burst(bx, by, 8, MAGMA, 'ember', 180); + } + + reset(): void { + this.x = this.spawnX; + this.y = this.spawnY; + this.hp = this.maxHp; + this.state = 'walk'; + this.stateT = 0; + this.dead = false; + this.squash = 0; + this.dir = 1; + this.shootT = SHOOT_CD; + this.chargeT = CHARGE_CD; + for (const o of this.orbs) { + o.alive = true; + o.respawnT = 0; + } + } + + draw(ctx: CanvasRenderingContext2D, t: number): void { + // Orbs (floating crystals on the perches) + for (let i = 0; i < this.orbs.length; i++) { + const o = this.orbs[i]; + if (!o.alive) continue; + const bob = Math.sin(t * 3 + i * 2.1) * 3; + const oy = o.y + bob; + ctx.save(); + const glow = ctx.createRadialGradient(o.x, oy, 2, o.x, oy, 20); + glow.addColorStop(0, 'rgba(143,216,255,0.85)'); + glow.addColorStop(1, 'rgba(143,216,255,0)'); + ctx.fillStyle = glow; + ctx.beginPath(); + ctx.arc(o.x, oy, 20, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#e8f8ff'; + ctx.beginPath(); + ctx.moveTo(o.x, oy - 11); + ctx.lineTo(o.x + 8, oy); + ctx.lineTo(o.x, oy + 11); + ctx.lineTo(o.x - 8, oy); + ctx.closePath(); + ctx.fill(); + ctx.fillStyle = '#8fd8ff'; + ctx.beginPath(); + ctx.moveTo(o.x, oy - 11); + ctx.lineTo(o.x + 8, oy); + ctx.lineTo(o.x, oy + 3); + ctx.closePath(); + ctx.fill(); + ctx.restore(); + } + + if (this.dead) return; + + // Squash & stretch around the feet + const sqx = 1 + 0.14 * this.squash; + const sqy = 1 - 0.22 * this.squash; + const sinking = this.state === 'dying' ? Math.min(26, this.stateT * this.stateT * 40) : 0; + + ctx.save(); + ctx.translate(this.x + this.w / 2, this.y + this.h + sinking); + ctx.scale(sqx, sqy); + if (this.state === 'dying') { + ctx.globalAlpha = Math.max(0.25, 1 - this.stateT / 1.6); + } + ctx.translate(-this.w / 2, -this.h); + + // Shadow + ctx.fillStyle = 'rgba(20,10,5,0.35)'; + ctx.beginPath(); + ctx.ellipse(this.w / 2, this.h - 2, 56, 9, 0, 0, Math.PI * 2); + ctx.fill(); + + const crouch = this.state === 'chargeWarn' ? 6 : 0; + const bodyTop = 24 + crouch; + + // Rocky shell body + ctx.fillStyle = '#4a2e26'; + this.rr(ctx, 6, bodyTop, this.w - 12, this.h - bodyTop, 26); + ctx.fill(); + // Belly + ctx.fillStyle = '#33211b'; + this.rr(ctx, 26, bodyTop + 34, this.w - 52, this.h - bodyTop - 44, 16); + ctx.fill(); + // Molten crest (the "magma head") + const molten = ctx.createLinearGradient(0, 12, 0, bodyTop + 46); + molten.addColorStop(0, '#ff6b35'); + molten.addColorStop(1, '#c93a1e'); + ctx.fillStyle = molten; + this.rr(ctx, 14, 14 + crouch, this.w - 28, 44, 18); + ctx.fill(); + // Bubbles on the crest + ctx.fillStyle = 'rgba(255,210,87,0.9)'; + for (let i = 0; i < 3; i++) { + const bub = 2.5 + 1.8 * Math.abs(Math.sin(t * 2.4 + i * 2)); + ctx.beginPath(); + ctx.arc(38 + i * 22, 24 + crouch + 8 + Math.sin(t * 3 + i) * 2, bub, 0, Math.PI * 2); + ctx.fill(); + } + // Magma cracks on the shell + ctx.strokeStyle = 'rgba(255,107,53,0.8)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(20, bodyTop + 50); + ctx.lineTo(34, bodyTop + 62); + ctx.lineTo(28, bodyTop + 76); + ctx.moveTo(98, bodyTop + 46); + ctx.lineTo(86, bodyTop + 60); + ctx.lineTo(94, bodyTop + 74); + ctx.stroke(); + + // Horns + ctx.fillStyle = '#2e1c16'; + for (let i = 0; i < 3; i++) { + const hx = 30 + i * 30; + ctx.beginPath(); + ctx.moveTo(hx - 8, 18 + crouch); + ctx.lineTo(hx, -2 - i * 4 + crouch); + ctx.lineTo(hx + 8, 18 + crouch); + ctx.closePath(); + ctx.fill(); + } + + // Eyes (angry gold; X when stunned; sleepy when dying) + const eyeY = 34 + crouch; + const look = this.dir === 1 ? 2 : -2; + for (const ex of [44, 76]) { + if (this.state === 'stunned') { + ctx.strokeStyle = '#ffd257'; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.moveTo(ex - 5, eyeY - 5); + ctx.lineTo(ex + 5, eyeY + 5); + ctx.moveTo(ex + 5, eyeY - 5); + ctx.lineTo(ex - 5, eyeY + 5); + ctx.stroke(); + } else { + ctx.fillStyle = '#ffd257'; + ctx.beginPath(); + ctx.arc(ex, eyeY, 7, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#241209'; + ctx.beginPath(); + ctx.arc(ex + look, eyeY, 3, 0, Math.PI * 2); + ctx.fill(); + // angry brow + ctx.strokeStyle = '#241209'; + ctx.lineWidth = 3; + ctx.beginPath(); + if (this.state === 'dying') { + ctx.moveTo(ex - 6, eyeY - 7); + ctx.lineTo(ex + 6, eyeY - 7); + } else { + ctx.moveTo(ex - (this.dir === 1 ? 8 : -8), eyeY - 10); + ctx.lineTo(ex + (this.dir === 1 ? 6 : -6), eyeY - 5); + } + ctx.stroke(); + } + } + + // Jaw + ctx.fillStyle = '#2e1c16'; + this.rr(ctx, 30, 52 + crouch, 60, 16, 8); + ctx.fill(); + ctx.fillStyle = '#f4ecd9'; + for (let i = 0; i < 4; i++) { + const tx = 38 + i * 14; + ctx.beginPath(); + ctx.moveTo(tx, 52 + crouch); + ctx.lineTo(tx + 5, 52 + crouch); + ctx.lineTo(tx + 2.5, 58 + crouch); + ctx.closePath(); + ctx.fill(); + } + + // Telegraph: muzzle glow building before the shot + if (this.state === 'telegraph') { + const p = this.stateT / 0.75; + ctx.fillStyle = `rgba(255,157,63,${0.35 + 0.5 * p})`; + ctx.beginPath(); + ctx.arc(60, 56 + crouch, 6 + 10 * p, 0, Math.PI * 2); + ctx.fill(); + } + + // Charge warn: pulsing red tint + "!" overhead + if (this.state === 'chargeWarn') { + const p = this.stateT / 0.85; + ctx.fillStyle = `rgba(255,60,60,${0.12 + 0.18 * Math.abs(Math.sin(p * 12))})`; + this.rr(ctx, 6, bodyTop, this.w - 12, this.h - bodyTop, 26); + ctx.fill(); + ctx.fillStyle = '#ff5c5c'; + ctx.font = '900 26px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('!', this.w / 2, -8 + Math.sin(t * 20) * 3); + } + + // Charge: speed streaks + if (this.state === 'charge') { + ctx.strokeStyle = 'rgba(255,210,87,0.7)'; + ctx.lineWidth = 3; + for (let i = 0; i < 3; i++) { + const sy = 30 + i * 26; + ctx.beginPath(); + ctx.moveTo(this.chargeDir === 1 ? -8 : this.w + 8, sy); + ctx.lineTo(this.chargeDir === 1 ? -30 : this.w + 30, sy); + ctx.stroke(); + } + } + + // Dizzy stars while stagger/stunned + if (this.state === 'stagger' || this.state === 'stunned') { + ctx.fillStyle = '#ffd257'; + for (let i = 0; i < 3; i++) { + const a = t * 4 + (i * Math.PI * 2) / 3; + const sx = this.w / 2 + Math.cos(a) * 30; + const sy = 6 + Math.sin(a) * 7; + ctx.beginPath(); + ctx.arc(sx, sy, 3.4, 0, Math.PI * 2); + ctx.fill(); + } + } + + ctx.restore(); + } + + private rr( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + r: number, + ): void { + const rr = Math.min(r, w / 2, h / 2); + ctx.beginPath(); + ctx.moveTo(x + rr, y); + ctx.arcTo(x + w, y, x + w, y + h, rr); + ctx.arcTo(x + w, y + h, x, y + h, rr); + ctx.arcTo(x, y + h, x, y, rr); + ctx.arcTo(x, y, x + w, y, rr); + ctx.closePath(); + } +} diff --git a/src/config.ts b/src/config.ts index 31840ca..7e1fd1c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -36,6 +36,10 @@ export const CFG = { heartFull: 200, /** Points a hidden fossil is worth (re-collectable each run). */ fossil: 150, + /** Points for shattering one of the Magma King's crystal orbs. */ + orb: 150, + /** Points for defeating the Magma King. */ + boss: 1500, timeBonusBase: 2400, timeBonusPerSec: 10, }, @@ -46,6 +50,8 @@ export const CFG = { spring: { vel: 900 }, /** Spitter enemy: firing range, cooldown, and projectile tuning. */ 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 }, } as const; export const VW = CFG.view.w; diff --git a/src/ctx.ts b/src/ctx.ts index 57a193f..1eff441 100644 --- a/src/ctx.ts +++ b/src/ctx.ts @@ -40,6 +40,11 @@ export interface GameCtx { addStatus(msg: string, color?: string): void; onPlayerDeath(): void; onPlayerVictory(): void; + /** + * The Magma King collapsed: bonus score, fanfare, and the nest gate + * latches open. Fired exactly once per boss death. + */ + onBossDefeated(): void; setCheckpoint(cp: Checkpoint): void; /** * A crystal was collected: score (including any combo bonus), sparkles diff --git a/src/game.ts b/src/game.ts index 53fbfb1..c7dab90 100644 --- a/src/game.ts +++ b/src/game.ts @@ -72,6 +72,8 @@ const PAUSE_TIPS = [ 'A falling rock telegraphs its landing with a shadow.', 'Calm mode (V) tames particles for a mellow run.', 'Lava flicks you clear — jump up out of it.', + 'Shatter all 3 orbs to stun the Magma King — then stomp!', + 'The Magma King is only vulnerable after his charge slams.', ]; /** @@ -122,6 +124,8 @@ export class Game implements GameCtx { rainbow = false; /** Cosmetic skin id (see SKINS); Mint unlocks at 3 fossils. */ skin: string = getSkinId(); + /** True once the Magma King falls this run (Molten Nest victory flourish). */ + bossSlain = false; /** Ghost race: replay the stored best run alongside the player. */ ghostOn: boolean = getGhostEnabled(); /** Fossil ids discovered so far (persistent meta-progress). */ @@ -323,6 +327,7 @@ export class Game implements GameCtx { this.heartsGot = 0; this.star80Shown = false; this.star100Shown = false; + this.bossSlain = false; this.deaths = 0; this.elapsed = 0; this.time = 0; @@ -602,10 +607,10 @@ export class Game implements GameCtx { y, vx: Math.cos(a) * s, vy: Math.sin(a) * s - (type === 'dust' ? 30 : 60), - life: 0.35 + Math.random() * 0.4, - size: type === 'chunk' ? 5 : type === 'dust' ? 4 : 3, + life: type === 'ember' ? 0.6 + Math.random() * 0.5 : 0.35 + Math.random() * 0.4, + size: type === 'chunk' ? 5 : type === 'dust' ? 4 : type === 'ember' ? 2.6 : 3, color: colors[Math.floor(Math.random() * colors.length)], - grav: type === 'dust' ? 60 : type === 'chunk' ? 500 : 260, + grav: type === 'dust' ? 60 : type === 'chunk' ? 500 : type === 'ember' ? 340 : 260, type, rot: Math.random() * TAU, vrot: (Math.random() - 0.5) * 10, @@ -631,8 +636,12 @@ export class Game implements GameCtx { this.uiButtons = []; // no menu buttons linger during the celebration this.audio.play('victory'); this.addShake(4); - // Confetti from above the nest - for (let i = 0; i < (this.reducedMotion ? 30 : 90); i++) { + // Confetti from above the nest (molten palette when the boss fell) + const palette = this.bossSlain + ? ['#ffd257', '#ff6b35', '#ff9d3f', '#7ec8f2', '#fff'] + : ['#ffd257', '#7ec8f2', '#ff8fa3', '#9ff0a8', '#fff']; + const confettiN = (this.reducedMotion ? 30 : 90) + (this.bossSlain ? 50 : 0); + for (let i = 0; i < confettiN; i++) { const x = this.level!.goal.x - 120 + Math.random() * 240; this.particles.push(new Particle({ x, @@ -641,7 +650,7 @@ export class Game implements GameCtx { vy: 60 + Math.random() * 120, life: 1.6 + Math.random() * 1.2, size: 5, - color: ['#ffd257', '#7ec8f2', '#ff8fa3', '#9ff0a8', '#fff'][i % 5], + color: palette[i % palette.length], grav: 120, type: 'rect', rot: Math.random() * TAU, @@ -708,6 +717,26 @@ export class Game implements GameCtx { }; } + onBossDefeated(): void { + if (this.bossSlain) return; // fired once per boss death + this.bossSlain = true; + const boss = this.level?.boss; + const bx = boss ? boss.x + boss.w / 2 : VW / 2; + const by = boss ? boss.y + boss.h / 2 : 240; + this.addScore(CFG.score.boss, bx, by); + this.addShake(10); + // Big molten eruption from the boss + for (let i = 0; i < (this.reducedMotion ? 24 : 60); i++) { + const s = 120 + Math.random() * 320; + this.burst(bx + (Math.random() - 0.5) * 100, by, 1, ['#ff6b35', '#ffd257', '#ff9d3f'], 'ember', s); + } + this.audio.play('boss'); + this.audio.stopMusic(); + this.addStatus('THE MAGMA KING FALLS! The nest is open.', '#ffd257'); + // Latch the nest gate open (Molten Nest only has a door). + for (const d of this.level?.doors ?? []) d.latched = true; + } + update(dt: number): void { this.input.pollGamepad(); this.audio.update(dt); @@ -833,6 +862,12 @@ export class Game implements GameCtx { else Sprite.drawPtero(ctx, e); } + // Magma King (Molten Nest boss arena) — drawn after enemies, before the player + const boss = this.level!.boss; + if (boss) { + if (boss.x + boss.w > camX - 200 && boss.x < camX + VW + 200) boss.draw(ctx, this.time); + } + // Springs & pressure plates (under the player) for (const s of this.level!.springs) { if (s.x + s.w > camX - 40 && s.x < camX + VW + 40) s.draw(ctx, this.time); @@ -1149,6 +1184,49 @@ export class Game implements GameCtx { ctx.restore(); // 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) + const boss = this.player ? this.level?.boss : null; + if (boss && !boss.dead) { + const bw = 240, bx = VW / 2 - bw / 2, by = 44; + ctx.fillStyle = 'rgba(30,16,10,0.62)'; + this.roundRect(ctx, bx - 16, by - 16, bw + 32, 56, 12); + ctx.fill(); + ctx.font = '900 11px ' + FONT_STACK; + ctx.textAlign = 'center'; + ctx.fillStyle = boss.state === 'stunned' || boss.state === 'stagger' ? '#8fd8ff' : '#ff9d3f'; + ctx.fillText( + boss.state === 'stunned' || boss.state === 'stagger' ? 'MAGMA KING — STOMP!' : 'MAGMA KING', + VW / 2, by - 3, + ); + // HP bar + ctx.fillStyle = 'rgba(0,0,0,0.45)'; + this.roundRect(ctx, bx, by + 4, bw, 10, 5); + ctx.fill(); + const hpFrac = boss.hp / boss.maxHp; + if (hpFrac > 0) { + const hg = ctx.createLinearGradient(bx, 0, bx + bw, 0); + hg.addColorStop(0, '#ff6b35'); + hg.addColorStop(1, '#ffd257'); + ctx.fillStyle = hg; + this.roundRect(ctx, bx, by + 4, Math.max(10, bw * hpFrac), 10, 5); + ctx.fill(); + } + // Orb pips (shattered = hollow) + for (let i = 0; i < boss.orbs.length; i++) { + const o = boss.orbs[i]; + const ox = VW / 2 + (i - (boss.orbs.length - 1) / 2) * 22; + ctx.beginPath(); + ctx.arc(ox, by + 28, 5, 0, TAU); + if (o.alive) { + ctx.fillStyle = '#8fd8ff'; + ctx.fill(); + } else { + ctx.strokeStyle = 'rgba(255,255,255,0.35)'; + ctx.lineWidth = 1.5; + ctx.stroke(); + } + } + } // Combo chip while a crystal chain is alive if (this.combo > 1) { const label = 'COMBO ×' + this.combo; @@ -1366,16 +1444,18 @@ export class Game implements GameCtx { const k = this.reducedMotion ? clamp(t / 0.4, 0, 1) : easeOutBack(clamp(t / 0.7, 0, 1)); + const bossWin = this.bossSlain; + const title = bossWin ? 'THE MAGMA KING FALLS!' : 'You made it home!'; ctx.save(); ctx.translate(VW / 2, VH * 0.3); ctx.scale(k, k); ctx.textAlign = 'center'; - ctx.font = '800 50px ' + FONT_STACK; + ctx.font = (bossWin ? '800 38px ' : '800 50px ') + FONT_STACK; ctx.lineWidth = 8; ctx.strokeStyle = 'rgba(25,55,30,0.55)'; - ctx.strokeText('You made it home!', 0, 0); - ctx.fillStyle = '#9ff0a8'; - ctx.fillText('You made it home!', 0, 0); + ctx.strokeText(title, 0, 0); + ctx.fillStyle = bossWin ? '#ffd257' : '#9ff0a8'; + ctx.fillText(title, 0, 0); ctx.restore(); if (t > 0.55) { ctx.globalAlpha = clamp((t - 0.55) / 0.4, 0, 0.8); @@ -1459,13 +1539,15 @@ export class Game implements GameCtx { ['Crystals', r.crystals + ' / ' + r.totalCrystals + (r.crystals === r.totalCrystals ? ' ✦ all!' : '')], ['Stomps', String(r.stomps)], ...(r.heartsGot > 0 ? [[`Hearts`, '× ' + r.heartsGot] as [string, string]] : []), + ...(this.bossSlain ? [['Magma King', 'defeated! +' + CFG.score.boss] as [string, string]] : []), ['Time', fmtTime(r.time) + (r.isBestTime ? ' (best!)' : ' best ' + (this.best.time === null ? '—' : fmtTime(this.best.time)))], ['Health bonus', '+' + r.heartBonus], ['Time bonus', '+' + r.timeBonus], ]; lines.forEach((ln, i) => { const rowIn = clamp((t - (VICTORY_PANEL_T + 0.15 + i * 0.08)) / 0.25, 0, 1); - const y = py + 180 + i * 20; + // 16px rows: a 7th line (Magma King) still clears the TOTAL row + const y = py + 180 + i * 16; ctx.textAlign = 'left'; ctx.globalAlpha = ease * rowIn; ctx.fillStyle = 'rgba(220,232,245,0.8)'; @@ -1641,7 +1723,7 @@ export class Game implements GameCtx { const cardGap = 14; const cardW = Math.floor((680 - cardGap * (cardCount - 1)) / cardCount); const cardX0 = VW / 2 - (cardW * cardCount + cardGap * (cardCount - 1)) / 2; - const cardAccents = ['#9ff0a8', '#ff9d7a', '#8fd8ff']; + const cardAccents = ['#9ff0a8', '#ff9d7a', '#8fd8ff', '#ff7a5c']; for (let i = 0; i < cardCount; i++) { const isDaily = i === LEVELS.length; const cx0 = cardX0 + i * (cardW + cardGap); diff --git a/src/level-data.ts b/src/level-data.ts index c27125a..0eca51b 100644 --- a/src/level-data.ts +++ b/src/level-data.ts @@ -76,6 +76,10 @@ export interface LevelDef { plates?: { x: number; y: number; door: number }[]; /** Sliding gates: {x, y, w, h}; y is the top, bottom meets the ground. */ doors?: { x: number; y: number; w: number; h: number }[]; + /** Boss placement + patrol bounds (x is the left edge of the boss). */ + boss?: { x: number; y: number; minX: number; maxX: number }; + /** Stompable crystal orbs that stun the boss (boss arenas only). */ + orbs?: Point[]; } export type LevelTheme = 'meadow' | 'volcanic' | 'frost'; @@ -480,10 +484,81 @@ const LEVEL_3: LevelDef = { ], }; +// Molten Nest — the endgame: a short lava gauntlet into a walled boss arena. +// The Magma King guards the nest gate; defeating him latches the door open. +const LEVEL_4: LevelDef = { + width: 3950, + startX: 120, + startY: 414, + startGroundY: 460, + platforms: [ + // ---- Approach: lava pits + a high fossil ledge ---- + { x: 0, y: 460, w: 900, h: 120, type: 'ground' }, + { x: 1020, y: 460, w: 500, h: 120, type: 'ground' }, + { x: 1650, y: 460, w: 530, h: 120, type: 'ground' }, + { x: 1300, y: 330, w: 120, h: 24, type: 'stone' }, // high ledge (fossil) + // ---- Boss arena (walled) ---- + { x: 2244, y: 150, w: 40, h: 190, type: 'stone' }, // left wall (gap under = entry) + { x: 2244, y: 460, w: 1100, h: 120, type: 'ground' }, // arena floor (runs under left wall) + { x: 3344, y: 150, w: 40, h: 190, type: 'stone' }, // right wall (gap under = exit) + // Orb perches + { x: 2380, y: 320, w: 110, h: 24, type: 'stone' }, + { x: 3150, y: 320, w: 110, h: 24, type: 'stone' }, + { x: 2720, y: 250, w: 120, h: 24, type: 'stone' }, + // ---- Exit: nest gate + goal ---- + { x: 3384, y: 460, w: 566, h: 120, type: 'ground' }, + ], + crystals: [ + { x: 560, y: 420 }, { x: 700, y: 420 }, { x: 840, y: 420 }, + { x: 1180, y: 420 }, { x: 1330, y: 290 }, { x: 1390, y: 290 }, + { x: 1780, y: 420 }, { x: 2020, y: 420 }, + { x: 2400, y: 420 }, { x: 3240, y: 420 }, // arena floor + { x: 2736, y: 212 }, { x: 2824, y: 212 }, // high perch + { x: 3480, y: 420 }, + ], + hazards: [ + { type: 'lava', x: 900, y: 520, w: 120 }, + { type: 'lava', x: 1520, y: 520, w: 130 }, + { type: 'lava', x: 2180, y: 520, w: 64 }, + ], + checkpoints: [ + { x: 2100, y: 460 }, // just before the arena + ], + enemies: [ + { type: 'beetle', x: 1150, y: 432, minX: 1040, maxX: 1480 }, + ], + hearts: [ + { x: 3560, y: 428 }, // in front of the nest gate + ], + fossils: [ + { x: 1352, y: 296 }, // high ledge in the approach + { x: 3320, y: 428 }, // arena nook between boss patrol and right wall + { x: 3720, y: 428 }, // behind the gate, near the nest + ], + boss: { x: 2760, y: 356, minX: 2320, maxX: 3160 }, + orbs: [ + { x: 2435, y: 286 }, + { x: 3205, y: 286 }, + { x: 2780, y: 216 }, + ], + doors: [ + { x: 3600, y: 300, w: 36, h: 160 }, // nest gate — latches open on boss defeat + ], + goal: { x: 3780, y: 460 }, + decor: [ + { type: 'sign', x: 120 }, + { type: 'rock', x: 300 }, { type: 'crystalrock', x: 460, s: 0.9 }, + { type: 'rock', x: 2000 }, { type: 'crystalrock', x: 2060, s: 1.0 }, + { type: 'crystalrock', x: 2600, s: 1.1 }, + { type: 'crystalrock', x: 3700, s: 1.2 }, { type: 'rock', x: 3850 }, + ], +}; + export const LEVELS: LevelInfo[] = [ { id: 0, name: 'Crystal Valley', subtitle: 'CRYSTAL VALLEY', theme: 'meadow', def: LEVEL_1 }, { id: 1, name: 'Volcanic Depths', subtitle: 'VOLCANIC DEPTHS', theme: 'volcanic', def: LEVEL_2 }, { id: 2, name: 'Frostpeak Pass', subtitle: 'FROSTPEAK PASS', theme: 'frost', def: LEVEL_3 }, + { id: 3, name: 'Molten Nest', subtitle: 'MOLTEN NEST', theme: 'volcanic', def: LEVEL_4 }, ]; /** Backward-compatible handle for the original level (Crystal Valley). */ diff --git a/src/level.ts b/src/level.ts index fda9979..4c94bd8 100644 --- a/src/level.ts +++ b/src/level.ts @@ -13,7 +13,9 @@ import { SpringPad } from './spring'; import { PressurePlate } from './plate'; import { Door } from './door'; import { Projectile } from './projectile'; +import type { ProjectileKind } from './projectile'; import { Fossil } from './fossil'; +import { MagmaKing } from './boss'; export class Level { width: number; @@ -35,6 +37,8 @@ export class Level { doors: Door[]; projectiles: Projectile[]; fossils: Fossil[]; + /** The Magma King (Molten Nest only). */ + boss: MagmaKing | null; readonly game: GameCtx; constructor(d: LevelDef, game: GameCtx, enemySpeed = 1, levelIdx = 0) { @@ -70,6 +74,9 @@ export class Level { this.startGroundY = d.startGroundY; this.totalCrystals = this.crystals.length; this.projectiles = []; + this.boss = d.boss + ? new MagmaKing(d.boss, this, enemySpeed, d.orbs ?? []) + : null; } solidAt(x: number, y: number): boolean { @@ -90,8 +97,11 @@ export class Level { } /** A spitter fires a glob (SFX + muzzle burst handled by the enemy). */ - spawnProjectile(x: number, y: number, vx: number, vy: number): void { - this.projectiles.push(new Projectile(x, y, vx, vy)); + spawnProjectile( + x: number, y: number, vx: number, vy: number, + kind: ProjectileKind = 'goo', + ): void { + this.projectiles.push(new Projectile(x, y, vx, vy, kind)); } popProjectile(x: number, y: number): void { @@ -106,6 +116,7 @@ export class Level { for (const e of this.enemies) e.update(dt, player); for (const hz of this.hazards) hz.update(dt, player); for (const cp of this.checkpoints) cp.update(dt); + 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); } @@ -119,6 +130,7 @@ export class Level { for (const h of this.hearts) h.collected = false; for (const f of this.fossils) f.collected = false; for (const e of this.enemies) e.reset(); + this.boss?.reset(); // Restore each hazard's own interval (the original hard-coded 2.2, // clobbering hazards configured with a different one). for (const hz of this.hazards) { diff --git a/src/particles.ts b/src/particles.ts index 8cd4edc..adb3a4f 100644 --- a/src/particles.ts +++ b/src/particles.ts @@ -1,7 +1,7 @@ import { clamp } from './util'; import { TAU, FONT_STACK } from './config'; -export type ParticleType = 'dot' | 'dust' | 'chunk' | 'rect' | 'ring'; +export type ParticleType = 'dot' | 'dust' | 'chunk' | 'rect' | 'ring' | 'ember'; export interface ParticleOpts { x: number; @@ -74,6 +74,16 @@ export class Particle { ctx.beginPath(); ctx.arc(this.x, this.y, this.size * (1.6 - a * 1.2), 0, TAU); ctx.stroke(); + } else if (this.type === 'ember') { + // Glowing spark: soft halo + bright core, flickering as it fades. + ctx.globalAlpha = a * 0.35; + ctx.beginPath(); + ctx.arc(this.x, this.y, this.size * 2.2 * a + 1, 0, TAU); + ctx.fill(); + ctx.globalAlpha = a; + ctx.beginPath(); + ctx.arc(this.x, this.y, this.size * a + 0.5, 0, TAU); + ctx.fill(); } else { ctx.beginPath(); ctx.arc(this.x, this.y, this.size * a + 0.5, 0, TAU); diff --git a/src/projectile.ts b/src/projectile.ts index f0db722..c14f21f 100644 --- a/src/projectile.ts +++ b/src/projectile.ts @@ -2,9 +2,11 @@ import { CFG } from './config'; import { overlap } from './util'; import type { Level } from './level'; import type { Player } from './player'; -import { drawGlob } from './sprite'; +import { drawGlob, drawMagmaGlob } from './sprite'; -/** A glob of spitter goo: arcing, bounces once on the ground, fades out. */ +export type ProjectileKind = 'goo' | 'magma'; + +/** A glob of spitter goo (or boss magma): arcs, bounces once, fades out. */ export class Projectile { x: number; y: number; @@ -17,12 +19,17 @@ export class Projectile { age = 0; /** extra life granted after a bounce. */ life = 2.4; + kind: ProjectileKind; - constructor(x: number, y: number, vx: number, vy: number) { + constructor( + x: number, y: number, vx: number, vy: number, + kind: ProjectileKind = 'goo', + ) { this.x = x; this.y = y; this.vx = vx; this.vy = vy; + this.kind = kind; } get rect(): { x: number; y: number; w: number; h: number } { @@ -35,6 +42,8 @@ export class Projectile { this.x += this.vx * dt; this.y += this.vy * dt; // pop against solid ground + const popColors = + this.kind === 'magma' ? ['#ff9d3f', '#ffd257'] : ['#8fe07a', '#c9f0a0']; for (const p of level.platforms) { if (!p.solid() || !overlap(this.rect, p)) continue; if (this.vy > 0 && this.bounces > 0) { @@ -43,10 +52,10 @@ export class Projectile { this.vy = -Math.abs(this.vy) * 0.42; this.vx *= 0.72; this.life = Math.max(this.life, 0.9); - level.game.burst(this.x, this.y + this.r, 6, ['#8fe07a', '#c9f0a0'], 'dot', 90); + level.game.burst(this.x, this.y + this.r, 6, popColors, 'dot', 90); } else { this.dead = true; - level.game.burst(this.x, this.y, 7, ['#8fe07a'], 'dot', 80); + level.game.burst(this.x, this.y, 7, [popColors[0]], 'dot', 80); } break; } @@ -60,6 +69,7 @@ export class Projectile { } draw(ctx: CanvasRenderingContext2D, t: number): void { - drawGlob(ctx, this.x, this.y, this.r, t); + if (this.kind === 'magma') drawMagmaGlob(ctx, this.x, this.y, this.r, t); + else drawGlob(ctx, this.x, this.y, this.r, t); } } diff --git a/src/sprite.ts b/src/sprite.ts index a8a1b9f..47a2dcb 100644 --- a/src/sprite.ts +++ b/src/sprite.ts @@ -483,3 +483,35 @@ export function drawGlob(ctx: CanvasRenderingContext2D, x: number, y: number, r: ctx.fill(); ctx.restore(); } + +/** Magma King's glob: a molten ember with a glowing core and trailing sparks. */ +export function drawMagmaGlob(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, t: number): void { + ctx.save(); + ctx.translate(x, y); + const wob = 1 + Math.sin(t * 18) * 0.12; + // Outer glow + ctx.fillStyle = 'rgba(255,107,53,0.28)'; + ctx.beginPath(); + ctx.ellipse(0, 0, r * 1.7 * wob, r * 1.7 / wob, 0, 0, TAU); + ctx.fill(); + // Molten body + const g = ctx.createRadialGradient(-r * 0.25, -r * 0.3, 1, 0, 0, r * wob); + g.addColorStop(0, '#ffd257'); + g.addColorStop(0.55, '#ff6b35'); + g.addColorStop(1, '#c93a1e'); + ctx.fillStyle = g; + ctx.beginPath(); + ctx.ellipse(0, 0, r * wob, r / wob, 0, 0, TAU); + ctx.fill(); + // Hot core + ctx.fillStyle = 'rgba(255,240,200,0.85)'; + ctx.beginPath(); + ctx.arc(-r * 0.18, -r * 0.2, r * 0.3, 0, TAU); + ctx.fill(); + // Spark + ctx.fillStyle = 'rgba(255,210,87,0.9)'; + ctx.beginPath(); + ctx.arc(r * 0.4, r * 0.35, r * 0.16, 0, TAU); + ctx.fill(); + ctx.restore(); +} diff --git a/tests/boss.test.ts b/tests/boss.test.ts new file mode 100644 index 0000000..56fd241 --- /dev/null +++ b/tests/boss.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect, vi } from 'vitest'; +import { CFG } from '../src/config'; +import { LEVELS } from '../src/level-data'; +import { Level } from '../src/level'; +import { Player } from '../src/player'; +import { MagmaKing, type BossState } from '../src/boss'; +import { makeCtx, type MockCtx } from './mock-ctx'; + +const DT = 1 / 60; + +interface Rig { + ctx: MockCtx; + level: Level; + boss: MagmaKing; +} + +/** A Molten Nest level with a fresh recording ctx. */ +function makeRig(speedMult = 1): Rig { + const ctx = makeCtx(); + const level = new Level(LEVELS[3].def, ctx, speedMult, 3); + return { ctx, level, boss: level.boss as MagmaKing }; +} + +/** A plain player the boss can target; place freely. */ +function dummy(x: number, y: number, vy = 0): Player { + const p = new Player(x, y, makeCtx()); + p.vy = vy; + return p; +} + +/** A falling player whose feet sit just inside the boss's stomp band. */ +function stomper(boss: MagmaKing): Player { + return dummy(boss.x + 40, boss.y - 42, 300); +} + +describe('MagmaKing construction', () => { + it('spawns in the Molten Nest arena with full HP and three live orbs', () => { + const { boss } = makeRig(); + expect(boss).toBeInstanceOf(MagmaKing); + expect(boss.x).toBe(2760); + expect(boss.y).toBe(356); + expect(boss.hp).toBe(boss.maxHp); + expect(boss.maxHp).toBe(3); + expect(boss.rect).toEqual({ x: 2760, y: 356, w: 120, h: 104 }); + expect(boss.state).toBe('walk'); + expect(boss.orbs).toHaveLength(3); + expect(boss.orbs.every((o) => o.alive && o.respawnT === 0)).toBe(true); + }); + + it('is absent from the other levels', () => { + for (const l of LEVELS.slice(0, 3)) { + expect(new Level(l.def, makeCtx()).boss, l.name).toBeNull(); + } + }); + + it('is only vulnerable while staggered or stunned', () => { + const { boss } = makeRig(); + for (const s of ['walk', 'telegraph', 'chargeWarn', 'charge'] as const) { + boss.state = s; + expect(boss.vulnerable, s).toBe(false); + } + boss.state = 'stagger'; + expect(boss.vulnerable).toBe(true); + boss.state = 'stunned'; + expect(boss.vulnerable).toBe(true); + }); +}); + +describe('MagmaKing behaviour', () => { + it('patrols inside its arena bounds', () => { + const { boss } = makeRig(); + const start = boss.x; + for (let i = 0; i < 120; i++) boss.update(DT, 0, dummy(2000, 414)); + expect(boss.state).toBe('walk'); + expect(boss.x).not.toBe(start); + expect(boss.x).toBeGreaterThanOrEqual(boss.minX); + expect(boss.x).toBeLessThanOrEqual(boss.maxX); + }); + + it('telegraphs, then fires a three-glob magma spread', () => { + const { ctx, level, boss } = makeRig(); + let sawTelegraph = false; + for (let i = 0; i < 400; i++) { + boss.update(DT, 0, dummy(2000, 414)); + if (boss.state === 'telegraph') sawTelegraph = true; + } + expect(sawTelegraph).toBe(true); + const magma = level.projectiles.filter((p) => p.kind === 'magma'); + expect(magma).toHaveLength(3); + expect(ctx.audio.played).toContain('spit'); + }); + + it('charges toward the player and staggers at the arena bound', () => { + const { ctx, boss } = makeRig(); + boss.state = 'chargeWarn'; + const p = dummy(2000, 414); // left of the arena → charge left + for (let i = 0; i < 200; i++) { + boss.update(DT, 0, p); + if ((boss.state as BossState) === 'stagger') break; // TS keeps the stale narrowing + } + expect(boss.state).toBe('stagger'); + expect(boss.x).toBe(boss.minX); + expect(ctx.shakes.length).toBeGreaterThan(0); + expect(ctx.audio.played).toContain('rock'); + }); + + it('takes damage and bounces the player when stomped while vulnerable', () => { + const { ctx, boss } = makeRig(); + boss.state = 'stagger'; + const p = stomper(boss); + boss.update(DT, 0, p); + expect(boss.hp).toBe(2); + expect(p.vy).toBe(-CFG.player.stompBounce); + expect(ctx.stomps).toBe(1); + expect(ctx.scores).toContain(CFG.score.stomp); + expect(ctx.audio.played).toContain('stomp'); + }); + + it('clanks without damage when stomped while not vulnerable', () => { + const { ctx, boss } = makeRig(); + boss.state = 'walk'; + const before = boss.hp; + const p = stomper(boss); + boss.update(DT, 0, p); + expect(boss.hp).toBe(before); + expect(p.vy).toBe(-260); + expect(ctx.audio.played).toContain('rock'); + }); + + it('damages the player on side contact, unless invulnerable', () => { + const { boss } = makeRig(); + boss.state = 'walk'; + const p = dummy(boss.x + 40, boss.y + 30); + const spy = vi.spyOn(p, 'damage'); + boss.update(DT, 0, p); + expect(spy).toHaveBeenCalledWith(expect.anything(), 'enemy'); + + const p2 = dummy(boss.x + 40, boss.y + 30); + p2.invulnT = 1; + const spy2 = vi.spyOn(p2, 'damage'); + boss.update(DT, 0, p2); + expect(spy2).not.toHaveBeenCalled(); + }); + + it('shatters an orb when stomped: score, bounce, and a respawn timer', () => { + const { ctx, boss } = makeRig(); + const o = boss.orbs[0]; // (2435, 286) + const p = dummy(2420, 240, 300); + boss.update(DT, 0, p); + expect(o.alive).toBe(false); + expect(o.respawnT).toBeGreaterThan(0); + expect(p.vy).toBe(-CFG.player.stompBounce * 0.75); + expect(ctx.scores).toContain(CFG.score.orb); + expect(ctx.audio.played).toContain('orb'); + expect(boss.state).not.toBe('stunned'); // two orbs remain + }); + + it('stuns when the last orb shatters, then recovers after the stun window', () => { + const { ctx, boss } = makeRig(); + boss.orbs[0].alive = false; + boss.orbs[0].respawnT = 999; + boss.orbs[1].alive = false; + boss.orbs[1].respawnT = 999; + const p = dummy(2750, 170, 300); // onto orb 2 at (2780, 216) + boss.update(DT, 0, p); + expect(boss.orbs[2].alive).toBe(false); + expect(boss.state).toBe('stunned'); + expect(ctx.statuses.some((s) => s.includes('stunned'))).toBe(true); + for (let i = 0; i < 200; i++) boss.update(DT, 0, p); + expect(boss.state).toBe('walk'); + }); + + it('respawns shattered orbs after their timer elapses', () => { + const { boss } = makeRig(); + boss.orbs[0].alive = false; + boss.orbs[0].respawnT = 0.5; + for (let i = 0; i < 60; i++) boss.update(DT, 0, dummy(2000, 414)); + expect(boss.orbs[0].alive).toBe(true); + }); + + it('dies on the third stomp and notifies the game exactly once', () => { + const { ctx, boss } = makeRig(); + boss.state = 'stagger'; + boss.hp = 1; + const p = stomper(boss); + boss.update(DT, 0, p); + expect(boss.state).toBe('dying'); + for (let i = 0; i < 120; i++) boss.update(DT, 0, p); + expect(boss.dead).toBe(true); + expect(ctx.bossDefeats).toBe(1); + boss.update(DT, 0, p); + expect(ctx.bossDefeats).toBe(1); + }); + + it('reset() restores spawn position, HP, orbs, and state', () => { + const { boss } = makeRig(); + boss.x = boss.minX; + boss.hp = 1; + boss.state = 'stagger'; + boss.orbs[0].alive = false; + boss.orbs[0].respawnT = 999; + boss.dead = true; + boss.reset(); + expect(boss.x).toBe(2760); + expect(boss.y).toBe(356); + expect(boss.hp).toBe(3); + expect(boss.state).toBe('walk'); + expect(boss.dead).toBe(false); + expect(boss.orbs.every((o) => o.alive && o.respawnT === 0)).toBe(true); + }); +}); diff --git a/tests/game.test.ts b/tests/game.test.ts index b432881..1959c13 100644 --- a/tests/game.test.ts +++ b/tests/game.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest'; +import { CFG } from '../src/config'; import { Game } from '../src/game'; import { LEVEL_DATA, LEVELS } from '../src/level-data'; import { Store, getGhostEnabled, getFoundFossils, getSkinId, type GameStats } from '../src/store'; @@ -329,7 +330,7 @@ describe('Fossil discoveries', () => { expect(game.totalFossils()).toBe( LEVELS.reduce((n, l) => n + (l.def.fossils?.length ?? 0), 0), ); - expect(game.totalFossils()).toBe(9); + expect(game.totalFossils()).toBe(12); }); it('awards score on pickup and persists the first discovery', () => { @@ -458,3 +459,47 @@ describe('Rex skins', () => { expect(game.skin).toBe('ember'); }); }); + +describe('Magma King (Molten Nest)', () => { + let game: Game; + const DT = 1 / 60; + + beforeEach(() => { + localStorage.clear(); + game = makeGame(); + }); + + it('spawns the Magma King with three orbs when Molten Nest starts', () => { + game.selectLevel(3); + game.startGame(); + const boss = game.level!.boss!; + expect(boss).not.toBeNull(); + expect(boss.hp).toBe(boss.maxHp); + expect(boss.orbs).toHaveLength(3); + }); + + it('defeating the boss awards the boss score, latches the gate, and opens the victory', () => { + game.selectLevel(3); + game.startGame(); + const boss = game.level!.boss!; + const door = game.level!.doors[0]; + const p = game.player!; + // Drop onto the boss's head while it is staggered. + boss.state = 'stagger'; + boss.hp = 1; + p.x = boss.x + 40; + p.y = boss.y - 42; + p.vy = 300; + for (let i = 0; i < 300 && !boss.dead; i++) game.update(DT); + expect(boss.dead).toBe(true); + expect(game.bossSlain).toBe(true); + expect(game.score).toBeGreaterThanOrEqual(CFG.score.boss); + expect(door.latched).toBe(true); + // Run to the nest for the victory ceremony. + p.x = game.level!.goal.x; + p.y = game.level!.goal.y - p.h; + p.vy = 0; + for (let i = 0; i < 30 && game.state !== 'victory'; i++) game.update(DT); + expect(game.state).toBe('victory'); + }); +}); diff --git a/tests/level-data.test.ts b/tests/level-data.test.ts index bf7304c..64ddcdf 100644 --- a/tests/level-data.test.ts +++ b/tests/level-data.test.ts @@ -67,14 +67,17 @@ describe('LEVEL_DATA — Crystal Valley integrity', () => { }); describe('LEVELS registry', () => { - it('exposes all three levels with distinct themes', () => { - expect(LEVELS).toHaveLength(3); + it('exposes all four levels with distinct themes', () => { + expect(LEVELS).toHaveLength(4); expect(LEVELS[0].id).toBe(0); expect(LEVELS[0].theme).toBe('meadow'); expect(LEVELS[1].id).toBe(1); expect(LEVELS[1].theme).toBe('volcanic'); expect(LEVELS[2].id).toBe(2); expect(LEVELS[2].theme).toBe('frost'); + expect(LEVELS[3].id).toBe(3); + expect(LEVELS[3].name).toBe('Molten Nest'); + expect(LEVELS[3].theme).toBe('volcanic'); expect(LEVELS[0].def).toBe(LEVEL_DATA); }); }); @@ -288,12 +291,101 @@ describe('LEVEL_3 — Frostpeak Pass integrity', () => { }); }); +describe('LEVEL_4 — Molten Nest integrity', () => { + const L4 = LEVELS[3].def; + + it('has the expected top-level shape', () => { + expect(L4.width).toBe(3950); + expect(L4.startX).toBe(120); + expect(L4.startY).toBe(414); + expect(L4.startGroundY).toBe(460); + expect(L4.goal).toEqual({ x: 3780, y: 460 }); + }); + + it('defines a boss arena with three crystal orbs', () => { + expect(L4.boss).toBeTruthy(); + expect(L4.boss!.x).toBeGreaterThan(L4.boss!.minX); + expect(L4.boss!.x + 120).toBeLessThan(L4.boss!.maxX + 120); // boss w=120 + expect(L4.orbs).toHaveLength(3); + for (const o of L4.orbs ?? []) { + expect(o.x).toBeGreaterThan(0); + expect(o.x).toBeLessThan(L4.width); + expect(o.y).toBeGreaterThan(150); + expect(o.y).toBeLessThan(460); + } + }); + + it('keeps the boss patrol and spawn on the arena floor', () => { + const arena = L4.platforms.find( + (p) => p.type === 'ground' && p.x >= 2200 && p.x < 2400, + ); + expect(arena, 'arena floor').toBeTruthy(); + expect(L4.boss!.y + 104).toBe(460); // boss h=104 stands on the ground line + expect(L4.boss!.minX).toBeGreaterThanOrEqual(arena!.x); + expect(L4.boss!.maxX + 120).toBeLessThanOrEqual(arena!.x + arena!.w); + }); + + it('gives both arena walls a walk-under gap at ground level', () => { + const walls = L4.platforms.filter( + (p) => p.type === 'stone' && p.w === 40 && p.x >= 2200, + ); + expect(walls).toHaveLength(2); + for (const w of walls) { + // Wall bottom must sit above the ground line so the player can pass under. + expect(w.y + w.h, 'wall at x=' + w.x).toBeLessThan(460); + } + }); + + it('places the nest gate on the exit floor ahead of the goal', () => { + expect(L4.doors).toHaveLength(1); + const d = L4.doors![0]; + expect(d.y + d.h).toBe(460); + expect(d.x).toBeLessThan(L4.goal.x); + const exit = L4.platforms.find( + (p) => p.type === 'ground' && d.x >= p.x && d.x + d.w <= p.x + p.w, + ); + expect(exit, 'gate must sit on solid ground').toBeTruthy(); + }); + + it('keeps every ground gap inside a running jump (~235px)', () => { + const grounds = L4.platforms + .filter((p) => p.type === 'ground') + .sort((a, b) => a.x - b.x); + for (let i = 0; i < grounds.length - 1; i++) { + const width = grounds[i + 1].x - (grounds[i].x + grounds[i].w); + expect(width, 'gap ' + (grounds[i].x + grounds[i].w) + '..' + grounds[i + 1].x).toBeLessThanOrEqual(235); + } + }); + + it('keeps lava pools inside ground gaps, never under solid ground', () => { + const grounds = L4.platforms.filter((p) => p.type === 'ground'); + for (const hz of L4.hazards) { + if (hz.type !== 'lava') continue; + const under = grounds.some((p) => hz.x + 40 < p.x + p.w && hz.x + hz.w - 40 > p.x); + expect(under, 'lava at x=' + hz.x).toBe(false); + } + }); + + it('places the checkpoint on ground and the beetle inside its ground segment', () => { + const grounds = L4.platforms.filter((p) => p.type === 'ground'); + for (const cp of L4.checkpoints) { + expect(grounds.some((p) => cp.x >= p.x && cp.x <= p.x + p.w), 'checkpoint').toBe(true); + } + for (const e of L4.enemies) { + expect( + grounds.some((p) => (e.minX ?? 0) >= p.x && (e.maxX ?? 0) <= p.x + p.w), + 'enemy ' + e.type, + ).toBe(true); + } + }); +}); + describe('Hidden fossils (all hand-built levels)', () => { - it('places exactly three fossils per level (nine total)', () => { + it('places exactly three fossils per level (twelve total)', () => { for (const l of LEVELS) { expect(l.def.fossils, l.name).toHaveLength(3); } - expect(LEVELS.reduce((n, l) => n + (l.def.fossils?.length ?? 0), 0)).toBe(9); + expect(LEVELS.reduce((n, l) => n + (l.def.fossils?.length ?? 0), 0)).toBe(12); }); it('keeps every fossil inside the level bounds, above the ground line', () => { diff --git a/tests/mock-ctx.ts b/tests/mock-ctx.ts index 10abdba..e1155c7 100644 --- a/tests/mock-ctx.ts +++ b/tests/mock-ctx.ts @@ -11,6 +11,7 @@ export interface MockCtx extends GameCtx { deaths: number; victories: number; checkpoints: number; + bossDefeats: number; audio: { play: (name: string, opts?: SfxOptions) => void; muted: boolean; @@ -43,6 +44,7 @@ export function makeCtx(): MockCtx { deaths: 0, victories: 0, checkpoints: 0, + bossDefeats: 0, burst: (x, y, n, colors, type, speed) => { ctx.bursts.push({ x, y, n, colors, type, speed }); }, @@ -62,6 +64,9 @@ export function makeCtx(): MockCtx { onPlayerVictory: () => { ctx.victories += 1; }, + onBossDefeated: () => { + ctx.bossDefeats += 1; + }, setCheckpoint: () => { ctx.checkpoints += 1; },