From 1d2a779c73b654d6406772d2076cb3e5bbd3b15d Mon Sep 17 00:00:00 2001 From: Chris Malpass Date: Wed, 26 Aug 2026 21:01:24 -0400 Subject: [PATCH] Adaptive soundtrack: urgent drums, crystal shimmer, flawless fanfare Music now reacts to the run: - Urgent drum layer (kick/hat patterns per theme) crossfades in when hearts drop to 2 or fewer, a hazard looms within 420px ahead, or the Magma King is in the arena. - Shimmer pad layer (lead +12 semitones with sparkle overtones) sparkles over crystal-dense stretches (4+ uncollected within 280px). - Layers are always scheduled; only gains move, so transitions are click-free and stop/victory fade them out. - Victory fanfare gets a brighter "flawless run" variant (8-note arpeggio + bell shimmer) when the run took no damage; the game tracks hits via a new GameCtx.onPlayerHit hook. - Pure trigger logic lives in src/adaptive.ts (adaptiveFlags) with 9 unit tests; 7 more game-level tests cover hit tracking and fanfare routing. Validated: typecheck, 209/209 tests, build (131.67 kB / 40.43 kB gzip), lint, and headless E2E (gain crossfades measured live, zero page errors). --- src/adaptive.ts | 34 +++++++++++++ src/audio.ts | 108 ++++++++++++++++++++++++++++++++++++++--- src/ctx.ts | 7 +++ src/game.ts | 40 ++++++++++++++- src/player.ts | 1 + tests/adaptive.test.ts | 70 ++++++++++++++++++++++++++ tests/game.test.ts | 76 ++++++++++++++++++++++++++++- tests/mock-ctx.ts | 5 ++ 8 files changed, 332 insertions(+), 9 deletions(-) create mode 100644 src/adaptive.ts create mode 100644 tests/adaptive.test.ts diff --git a/src/adaptive.ts b/src/adaptive.ts new file mode 100644 index 0000000..f98d2b9 --- /dev/null +++ b/src/adaptive.ts @@ -0,0 +1,34 @@ +/** + * Adaptive-soundtrack trigger logic, kept pure so it is unit-testable. + * The audio manager fades its "urgent" and "shimmer" layers based on + * what these flags say about the current run. + */ +export interface AdaptiveInput { + hearts: number; + playerX: number; + bossAlive: boolean; + hazards: { type: string; x: number; w: number }[]; + crystals: { x: number; collected: boolean }[]; +} + +/** How far ahead of Rex (px) a hazard counts as "coming up". */ +export const DANGER_AHEAD = 420; +/** Uncollected crystals within this radius (px) light the shimmer layer. */ +export const SHIMMER_RADIUS = 280; +/** ...and only when at least this many of them are nearby. */ +export const SHIMMER_MIN = 4; + +export function adaptiveFlags(i: AdaptiveInput): { urgent: boolean; shimmer: boolean } { + const danger = i.hazards.some( + (h) => + (h.type === 'spikes' || h.type === 'lava' || h.type === 'rocks') && + h.x + h.w >= i.playerX - 40 && + h.x <= i.playerX + DANGER_AHEAD, + ); + const urgent = i.hearts <= 2 || danger || i.bossAlive; + const near = i.crystals.reduce( + (n, c) => n + (c.collected || Math.abs(c.x - i.playerX) > SHIMMER_RADIUS ? 0 : 1), + 0, + ); + return { urgent, shimmer: near >= SHIMMER_MIN }; +} diff --git a/src/audio.ts b/src/audio.ts index 2438c78..292d77b 100644 --- a/src/audio.ts +++ b/src/audio.ts @@ -37,6 +37,10 @@ export class AudioManager { private musicNextT = 0; // AudioContext time of the next step (0 = unscheduled) private musicStep = 0; + /* --- Adaptive layers: always scheduled, crossfaded by setAdaptive --- */ + private urgentGain: GainNode | null = null; // drums that tense the run up + private shimmerGain: GainNode | null = null; // high pad that sparkles over crystal fields + constructor() { this.muted = Store.get('tinyrex_muted', false); } @@ -58,6 +62,18 @@ export class AudioManager { clearInterval(this.musicTimer); this.musicTimer = null; } + this.setAdaptive(false, false); // let the layers settle when music stops + } + + /** + * Crossfade the adaptive layers. The layers are scheduled continuously + * while music plays, so only the gains move — no audible clicks. + */ + setAdaptive(urgent: boolean, shimmer: boolean): void { + if (!this.ctx) return; + const t = this.ctx.currentTime; + if (this.urgentGain) this.urgentGain.gain.setTargetAtTime(urgent ? 0.9 : 0, t, 0.35); + if (this.shimmerGain) this.shimmerGain.gain.setTargetAtTime(shimmer ? 0.85 : 0, t, 0.5); } private scheduleMusic(): void { @@ -68,6 +84,7 @@ export class AudioManager { if (this.musicNextT === 0) this.musicNextT = this.ctx.currentTime + 0.12; const lead = MUSIC[theme].lead; const bass = MUSIC[theme].bass; + const drums = MUSIC[theme].drums; while (this.musicNextT < this.ctx.currentTime + 0.15) { if (!this.muted) { const idx = this.musicStep % lead.length; @@ -75,13 +92,31 @@ export class AudioManager { if (ln > 0) this.musicNote(midiToFreq(ln), this.musicNextT, step * 0.9, 'square', 0.045); const bn = bass[idx % bass.length]; if (bn > 0) this.musicNote(midiToFreq(bn), this.musicNextT, step * 0.9, 'triangle', 0.06); + // Urgent layer: per-theme drum pattern (1 = kick, 2 = hat). + const dn = drums[idx]; + if (dn === 1) this.kick(this.musicNextT); + else if (dn === 2) this.hat(this.musicNextT); + // Shimmer layer: soft octave-up echo of the lead, plus a sparkle ping. + if (ln > 0) { + this.musicNote(midiToFreq(ln + 12), this.musicNextT, step * 1.7, 'sine', 0.05, this.shimmerGain); + if (idx % 8 === 4) { + this.musicNote(midiToFreq(ln + 24), this.musicNextT + step * 0.5, 0.4, 'sine', 0.06, this.shimmerGain); + } + } } this.musicStep++; this.musicNextT += step; } } - private musicNote(freq: number, t0: number, dur: number, type: OscillatorType, vol: number): void { + private musicNote( + freq: number, + t0: number, + dur: number, + type: OscillatorType, + vol: number, + out?: GainNode | null, + ): void { const osc = this.ctx!.createOscillator(); const g = this.ctx!.createGain(); osc.type = type; @@ -89,11 +124,44 @@ export class AudioManager { g.gain.setValueAtTime(vol, t0); g.gain.exponentialRampToValueAtTime(0.001, t0 + dur); osc.connect(g); - g.connect(this.master!); + g.connect(out ?? this.master!); osc.start(t0); osc.stop(t0 + dur + 0.02); } + /* Urgent-layer drums: a low kick thump and a short hi-hat tick. */ + private kick(t0: number): void { + const osc = this.ctx!.createOscillator(); + const g = this.ctx!.createGain(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(130, t0); + osc.frequency.exponentialRampToValueAtTime(42, t0 + 0.12); + g.gain.setValueAtTime(0.5, t0); + g.gain.exponentialRampToValueAtTime(0.001, t0 + 0.13); + osc.connect(g); + g.connect(this.urgentGain!); + osc.start(t0); + osc.stop(t0 + 0.15); + } + + private hat(t0: number): void { + const len = Math.max(1, Math.floor(this.ctx!.sampleRate * 0.045)); + const buf = this.ctx!.createBuffer(1, len, this.ctx!.sampleRate); + const d = buf.getChannelData(0); + for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / len); + const src = this.ctx!.createBufferSource(); + src.buffer = buf; + const filt = this.ctx!.createBiquadFilter(); + filt.type = 'highpass'; + filt.frequency.value = 6000; + const g = this.ctx!.createGain(); + g.gain.value = 0.3; + src.connect(filt); + filt.connect(g); + g.connect(this.urgentGain!); + src.start(t0); + } + unlock(): void { if (this.ctx) { if (this.ctx.state === 'suspended') this.ctx.resume().catch(() => undefined); @@ -109,6 +177,12 @@ export class AudioManager { this.master = this.ctx.createGain(); this.master.gain.value = this.muted ? 0 : 0.5; this.master.connect(this.ctx.destination); + this.urgentGain = this.ctx.createGain(); + this.urgentGain.gain.value = 0; + this.urgentGain.connect(this.master); + this.shimmerGain = this.ctx.createGain(); + this.shimmerGain.gain.value = 0; + this.shimmerGain.connect(this.master); this.ensureAmbient(); } catch { this.ctx = null; @@ -277,11 +351,27 @@ export class AudioManager { case 'rockfall': this.noiseBurst({ dur: 0.12, vol: 0.12, freq: 500 }); break; - case 'victory': - [523, 659, 784, 1046, 784, 1046].forEach((f, i) => - this.tone({ freq: f, dur: i === 5 ? 0.4 : 0.16, type: 'triangle', vol: 0.22, delay: i * 0.13 }), + case 'victory': { + // A flawless run (no damage, no deaths) earns the longer, brighter fanfare. + const seq = opts?.flawless + ? [523, 659, 784, 1046, 1318, 1568, 1318, 2093] + : [523, 659, 784, 1046, 784, 1046]; + seq.forEach((f, i) => + this.tone({ + freq: f, + dur: i === seq.length - 1 ? 0.5 : 0.16, + type: 'triangle', + vol: 0.22, + delay: i * (opts?.flawless ? 0.12 : 0.13), + }), ); + if (opts?.flawless) { + // High bell shimmer over the final notes + this.tone({ freq: 2637, dur: 0.4, type: 'sine', vol: 0.12, delay: 0.72 }); + this.tone({ freq: 2093, dur: 0.4, type: 'sine', vol: 0.1, delay: 0.84 }); + } break; + } case 'ui': this.tone({ freq: 660, to: 880, dur: 0.07, type: 'square', vol: 0.08 }); break; @@ -357,23 +447,27 @@ export class AudioManager { } } -/* Chiptune melodies per theme (MIDI note numbers, 0 = rest). 32 steps = 4 bars of 8ths. */ +/* Chiptune melodies per theme (MIDI note numbers, 0 = rest). 32 steps = 4 bars of 8ths. + * drums: 0 = rest, 1 = kick, 2 = hat — the adaptive "urgent" layer. */ const midiToFreq = (n: number): number => 440 * Math.pow(2, (n - 69) / 12); -const MUSIC: Record = { +const MUSIC: Record = { meadow: { bpm: 108, lead: [69, 72, 76, 81, 76, 72, 69, 76, 66, 69, 73, 78, 81, 78, 73, 69, 69, 72, 76, 73, 72, 69, 66, 62, 64, 66, 69, 73, 72, 69, 64, 0], bass: [45, 0, 57, 0, 45, 0, 57, 0, 42, 0, 54, 0, 42, 0, 54, 0, 38, 0, 50, 0, 38, 0, 50, 0, 40, 0, 52, 0, 40, 0, 52, 0], + drums: [1, 0, 0, 2, 0, 0, 1, 2, 1, 0, 0, 2, 0, 0, 1, 2, 1, 0, 0, 2, 0, 0, 1, 2, 1, 0, 0, 2, 0, 0, 1, 2], }, volcanic: { bpm: 92, lead: [57, 60, 64, 67, 64, 60, 57, 55, 54, 57, 60, 64, 60, 57, 54, 52, 55, 57, 60, 62, 60, 57, 55, 52, 52, 54, 57, 60, 57, 54, 52, 0], bass: [33, 0, 45, 0, 33, 0, 45, 0, 30, 0, 42, 0, 30, 0, 42, 0, 31, 0, 43, 0, 31, 0, 43, 0, 40, 0, 52, 0, 40, 0, 52, 0], + drums: [1, 0, 2, 1, 0, 2, 1, 2, 1, 0, 2, 1, 0, 2, 1, 2, 1, 0, 2, 1, 0, 2, 1, 2, 1, 0, 2, 1, 0, 2, 1, 2], }, frost: { bpm: 100, lead: [72, 76, 79, 84, 79, 76, 72, 71, 69, 72, 76, 79, 76, 72, 69, 67, 69, 72, 76, 74, 72, 69, 67, 64, 66, 69, 72, 76, 72, 69, 66, 0], bass: [48, 0, 60, 0, 48, 0, 60, 0, 45, 0, 57, 0, 45, 0, 57, 0, 43, 0, 55, 0, 43, 0, 55, 0, 41, 0, 53, 0, 41, 0, 53, 0], + drums: [1, 0, 0, 0, 2, 0, 0, 2, 1, 0, 0, 0, 2, 0, 0, 2, 1, 0, 0, 0, 2, 0, 0, 2, 1, 0, 0, 0, 2, 0, 0, 2], }, }; diff --git a/src/ctx.ts b/src/ctx.ts index 1eff441..9dd9f0b 100644 --- a/src/ctx.ts +++ b/src/ctx.ts @@ -11,6 +11,8 @@ export interface SfxOptions { starIndex?: number; /** Pressure plate just pressed (true) or released (false). */ pressed?: boolean; + /** Victory fanfare variant: true for a flawless run (no damage, no deaths). */ + flawless?: boolean; } /** Minimal SFX surface that entities need from the audio manager. */ @@ -40,6 +42,11 @@ export interface GameCtx { addStatus(msg: string, color?: string): void; onPlayerDeath(): void; onPlayerVictory(): void; + /** + * The player lost a heart (god mode and bubble saves do not count). + * Feeds the flawless-run tracking for the victory fanfare. + */ + onPlayerHit(): void; /** * The Magma King collapsed: bonus score, fanfare, and the nest gate * latches open. Fired exactly once per boss death. diff --git a/src/game.ts b/src/game.ts index 64d7c5b..925d5a0 100644 --- a/src/game.ts +++ b/src/game.ts @@ -18,6 +18,7 @@ import { generateDailyLevel, dailySeed, dailyLabel, rexCode } from './daily'; import { GhostRecorder, GhostPlayer } from './ghost'; import { drawDecor } from './decor'; import { Sprite, SKINS, skinUnlocked } from './sprite'; +import { adaptiveFlags } from './adaptive'; import { drawPowerUpIcon, POWERUP_COLORS } from './powerup'; import type { PowerUpType } from './powerup'; import type { GameCtx } from './ctx'; @@ -108,6 +109,10 @@ export class Game implements GameCtx { private star80Shown = false; private star100Shown = false; deaths = 0; + /** Hearts lost this run (god mode and bubble saves don't count). 0 = flawless. */ + hits = 0; + private lastUrgent = false; + private lastShimmer = false; particles: Particle[] = []; texts: FloatingText[] = []; status = { msg: '', color: '#fff', t: 0 }; @@ -331,6 +336,9 @@ export class Game implements GameCtx { this.star100Shown = false; this.bossSlain = false; this.deaths = 0; + this.hits = 0; + this.lastUrgent = false; + this.lastShimmer = false; this.elapsed = 0; this.time = 0; this.particles = []; @@ -620,8 +628,36 @@ export class Game implements GameCtx { } } + /** The player lost a heart: counts against a flawless run. */ + onPlayerHit(): void { + this.hits += 1; + } + + /** + * Crossfade the adaptive music layers: drums tense up when hearts run low, + * hazards loom ahead, or the boss is in the arena; the shimmer pad sparkles + * over crystal-dense stretches. Called every frame while playing; the + * audio manager only moves the gains when a flag actually changed. + */ + private updateAdaptive(): void { + const p = this.player!; + const lvl = this.level!; + const flags = adaptiveFlags({ + hearts: p.hearts, + playerX: p.x, + bossAlive: lvl.boss !== null && !lvl.boss.dead, + hazards: lvl.hazards, + crystals: lvl.crystals, + }); + if (flags.urgent === this.lastUrgent && flags.shimmer === this.lastShimmer) return; + this.lastUrgent = flags.urgent; + this.lastShimmer = flags.shimmer; + this.audio.setAdaptive(flags.urgent, flags.shimmer); + } + onPlayerDeath(): void { this.deaths += 1; + this.hits += 1; const s = getStats(); s.deaths += 1; Store.set('tinyrex_stats', s); @@ -636,7 +672,8 @@ export class Game implements GameCtx { this.victoryT = 0; this.starChime = 0; this.uiButtons = []; // no menu buttons linger during the celebration - this.audio.play('victory'); + this.audio.play('victory', { flawless: this.hits === 0 }); + this.audio.setAdaptive(false, false); // let the run's tension settle this.addShake(4); // Confetti from above the nest (molten palette when the boss fell) const palette = this.bossSlain @@ -750,6 +787,7 @@ export class Game implements GameCtx { this.level!.update(dt, this.time, this.player!); this.player!.update(dt, this.time, this.input, this.level!); this.camera.update(dt, this.player!, this.level!.width, this); + this.updateAdaptive(); // track crystal count this.crystalsGot = this.level!.crystals.filter((c) => c.collected).length; // Combo expires once the window elapses without another pickup diff --git a/src/player.ts b/src/player.ts index 3f7d4a9..a2c9e33 100644 --- a/src/player.ts +++ b/src/player.ts @@ -465,6 +465,7 @@ export class Player { return; } this.hearts -= 1; + this.game.onPlayerHit(); this.invulnT = CFG.player.invulnTime; this.hurtT = 0.5; this.game.audio.play('hurt'); diff --git a/tests/adaptive.test.ts b/tests/adaptive.test.ts new file mode 100644 index 0000000..3ea47a5 --- /dev/null +++ b/tests/adaptive.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { adaptiveFlags, DANGER_AHEAD, SHIMMER_MIN, SHIMMER_RADIUS } from '../src/adaptive'; + +const base = { + hearts: 3, + playerX: 1000, + bossAlive: false, + hazards: [] as { type: string; x: number; w: number }[], + crystals: [] as { x: number; collected: boolean }[], +}; + +describe('adaptiveFlags', () => { + it('is calm at the start of a clean run', () => { + expect(adaptiveFlags(base)).toEqual({ urgent: false, shimmer: false }); + }); + + it('goes urgent at two or fewer hearts', () => { + expect(adaptiveFlags({ ...base, hearts: 2 }).urgent).toBe(true); + expect(adaptiveFlags({ ...base, hearts: 1 }).urgent).toBe(true); + expect(adaptiveFlags({ ...base, hearts: 3 }).urgent).toBe(false); + }); + + it('goes urgent when a hazard looms ahead', () => { + const ahead = { type: 'spikes', x: 1300, w: 80 }; + expect(adaptiveFlags({ ...base, hazards: [ahead] }).urgent).toBe(true); + // Just inside the window edge + const atEdge = { type: 'lava', x: base.playerX + DANGER_AHEAD - 5, w: 40 }; + expect(adaptiveFlags({ ...base, hazards: [atEdge] }).urgent).toBe(true); + }); + + it('ignores hazards far away or behind', () => { + const far = { type: 'spikes', x: base.playerX + DANGER_AHEAD + 60, w: 80 }; + const behind = { type: 'lava', x: base.playerX - 800, w: 80 }; + expect(adaptiveFlags({ ...base, hazards: [far, behind] }).urgent).toBe(false); + }); + + it('a hazard slightly behind still counts (40px tail)', () => { + const tail = { type: 'rocks', x: base.playerX - 60, w: 40 }; // right edge at playerX-20 + expect(adaptiveFlags({ ...base, hazards: [tail] }).urgent).toBe(true); + const past = { type: 'rocks', x: base.playerX - 120, w: 40 }; // right edge at playerX-80 + expect(adaptiveFlags({ ...base, hazards: [past] }).urgent).toBe(false); + }); + + it('goes urgent while the boss is alive', () => { + expect(adaptiveFlags({ ...base, bossAlive: true }).urgent).toBe(true); + }); + + it('shimmers over crystal-dense stretches', () => { + const crystals = Array.from({ length: SHIMMER_MIN }, (_, i) => ({ x: base.playerX + i * 50, collected: false })); + expect(adaptiveFlags({ ...base, crystals }).shimmer).toBe(true); + const sparse = crystals.slice(0, SHIMMER_MIN - 1); + expect(adaptiveFlags({ ...base, crystals: sparse }).shimmer).toBe(false); + }); + + it('only counts uncollected crystals within the radius', () => { + const inRadius = { x: base.playerX + SHIMMER_RADIUS - 10, collected: false }; + const outOfRadius = { x: base.playerX + SHIMMER_RADIUS + 10, collected: false }; + const collected = { x: base.playerX, collected: true }; + const few = Array.from({ length: SHIMMER_MIN - 1 }, (_, i) => ({ x: base.playerX - 200 + i * 30, collected: false })); + // inRadius + few reaches the minimum; outOfRadius and collected do not count + expect(adaptiveFlags({ ...base, crystals: [...few, inRadius] }).shimmer).toBe(true); + expect(adaptiveFlags({ ...base, crystals: [...few, outOfRadius, collected] }).shimmer).toBe(false); + }); + + it('layers can be active together', () => { + const crystals = Array.from({ length: SHIMMER_MIN }, (_, i) => ({ x: base.playerX + 40 + i * 60, collected: false })); + const flags = adaptiveFlags({ ...base, hearts: 1, crystals }); + expect(flags).toEqual({ urgent: true, shimmer: true }); + }); +}); diff --git a/tests/game.test.ts b/tests/game.test.ts index 1959c13..40f1edd 100644 --- a/tests/game.test.ts +++ b/tests/game.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { CFG } from '../src/config'; import { Game } from '../src/game'; import { LEVEL_DATA, LEVELS } from '../src/level-data'; @@ -503,3 +503,77 @@ describe('Magma King (Molten Nest)', () => { expect(game.state).toBe('victory'); }); }); + +describe('Adaptive soundtrack', () => { + let game: Game; + + beforeEach(() => { + localStorage.clear(); + game = makeGame(); + }); + + it('tracks hits for the flawless counter (damage and deaths)', () => { + game.handleKey('primary'); + expect(game.hits).toBe(0); + game.onPlayerHit(); + expect(game.hits).toBe(1); + game.onPlayerHit(); + game.onPlayerDeath(); + expect(game.hits).toBe(3); + expect(game.deaths).toBe(1); + }); + + it('restart clears the hit counter', () => { + game.handleKey('primary'); + game.onPlayerHit(); + game.handleKey('restart'); + expect(game.hits).toBe(0); + }); + + it('plays the flawless fanfare variant when the run was clean', () => { + const spy = vi.spyOn(game.audio, 'play'); + game.handleKey('primary'); + game.onPlayerVictory(); + const victoryCalls = spy.mock.calls.filter((c) => c[0] === 'victory'); + expect(victoryCalls.length).toBe(1); + expect(victoryCalls[0][1]).toEqual({ flawless: true }); + }); + + it('falls back to the plain fanfare after taking a hit', () => { + const spy = vi.spyOn(game.audio, 'play'); + game.handleKey('primary'); + game.onPlayerHit(); + game.onPlayerVictory(); + const victoryCalls = spy.mock.calls.filter((c) => c[0] === 'victory'); + expect(victoryCalls[0][1]).toEqual({ flawless: false }); + }); + + it('crossfades the urgent layer when hearts run low', () => { + game.handleKey('primary'); + const spy = vi.spyOn(game.audio, 'setAdaptive'); + game.player!.hearts = 1; + game.update(0.016); + expect(spy.mock.calls.some((c) => c[0] === true)).toBe(true); + }); + + it('crossfades the urgent layer when a hazard looms ahead', () => { + game.handleKey('primary'); + const spy = vi.spyOn(game.audio, 'setAdaptive'); + const hz = game.level!.hazards[0]; + game.player!.x = hz.x - 320; + game.player!.y = 414; + game.player!.vy = 0; + game.update(0.016); + expect(spy.mock.calls.some((c) => c[0] === true)).toBe(true); + }); + + it('stays calm at a clean start and settles the layers on victory', () => { + game.handleKey('primary'); + const spy = vi.spyOn(game.audio, 'setAdaptive'); + game.update(0.016); + // At the spawn point with full hearts, nothing has triggered + expect(spy.mock.calls.some((c) => c[0] === true)).toBe(false); + game.onPlayerVictory(); + expect(spy.mock.calls[spy.mock.calls.length - 1]).toEqual([false, false]); + }); +}); diff --git a/tests/mock-ctx.ts b/tests/mock-ctx.ts index e1155c7..bc316d8 100644 --- a/tests/mock-ctx.ts +++ b/tests/mock-ctx.ts @@ -10,6 +10,7 @@ export interface MockCtx extends GameCtx { shakes: number[]; deaths: number; victories: number; + hits: number; checkpoints: number; bossDefeats: number; audio: { @@ -43,6 +44,7 @@ export function makeCtx(): MockCtx { shakes: [], deaths: 0, victories: 0, + hits: 0, checkpoints: 0, bossDefeats: 0, burst: (x, y, n, colors, type, speed) => { @@ -64,6 +66,9 @@ export function makeCtx(): MockCtx { onPlayerVictory: () => { ctx.victories += 1; }, + onPlayerHit: () => { + ctx.hits += 1; + }, onBossDefeated: () => { ctx.bossDefeats += 1; },