diff --git a/src/audio.ts b/src/audio.ts index f90f69a..30ae601 100644 --- a/src/audio.ts +++ b/src/audio.ts @@ -443,6 +443,13 @@ export class AudioManager { this.tone({ freq: 659, dur: 0.12, type: 'triangle', vol: 0.18, delay: 0.06 }); this.tone({ freq: 880, dur: 0.18, type: 'sine', vol: 0.16, delay: 0.16 }); break; + case 'personalBest': + // Rising triumphant arpeggio with a sparkle on top. + this.tone({ freq: 523, dur: 0.1, type: 'triangle', vol: 0.2 }); + this.tone({ freq: 659, dur: 0.1, type: 'triangle', vol: 0.2, delay: 0.09 }); + this.tone({ freq: 784, dur: 0.14, type: 'triangle', vol: 0.22, delay: 0.18 }); + this.tone({ freq: 1046, dur: 0.26, type: 'sine', vol: 0.16, delay: 0.28 }); + break; } } diff --git a/src/game.ts b/src/game.ts index 27a39e6..1cf1cbd 100644 --- a/src/game.ts +++ b/src/game.ts @@ -1,8 +1,8 @@ import { CFG, VW, VH, TAU, FONT_STACK, DIFFICULTIES, STARS } from './config'; import type { Difficulty } from './config'; import { clamp, easeOutBack, fmtTime } from './util'; -import { Store, getStats, getBest, getBestStars, getDailyBest, getDailyStars, getGhostEnabled, setGhostEnabled, getGhostTrack, saveGhostTrack, getFoundFossils, findFossil, getFoundNotes, findNote, getSkinId, setSkinId } from './store'; -import type { GameStats } from './store'; +import { Store, getStats, getBest, getBestStars, getDailyBest, getDailyStars, getGhostEnabled, setGhostEnabled, getGhostTrack, saveGhostTrack, getFoundFossils, findFossil, getFoundNotes, findNote, getSkinId, setSkinId, getRuns, addRun, topRuns, clearRuns } from './store'; +import type { GameStats, RunRecord } from './store'; import { AudioManager } from './audio'; import { Input } from './input'; import type { GameKey } from './input'; @@ -143,7 +143,17 @@ export class Game implements GameCtx { /** Field-note ids discovered so far (persistent meta-progress). */ notesFound: string[] = getFoundNotes(); /** Menu sub-screen: the main menu or the field-notes codex. */ - menuScreen: 'main' | 'codex' = 'main'; + menuScreen: 'main' | 'codex' | 'hall' = 'main'; + /** Hall of Claws: set when the "Clear hall" button is waiting for a second tap. */ + hallClearArmed = false; + /** Set once per run when the score overtakes the stored best (PB burst fires once). */ + pbAnnounced = false; + /** The best score at the start of the current run (PB baseline; best itself updates on victory). */ + pbBaseline = 0; + /** The run just finished, so the victory panel can flag a Hall of Claws top-10 spot. */ + lastRun: RunRecord | null = null; + /** Rank (1-based) of lastRun inside topRuns(10); null when it didn't place. */ + lastRunRank: number | null = null; private ghost: GhostPlayer | null = null; private ghostRec: GhostRecorder | null = null; /** Cheat queue: apply max hearts once a player exists. */ @@ -361,6 +371,8 @@ export class Game implements GameCtx { this.checkpoint = null; this.results = null; this.victoryT = 0; + this.pbAnnounced = false; + this.pbBaseline = this.best.score; // Ghost race: record this run and replay the stored best alongside it this.ghostRec = new GhostRecorder(); this.ghost = null; @@ -454,8 +466,10 @@ export class Game implements GameCtx { this.cycleSkin(k === 'skinNext' ? 1 : -1); return; } - if (k === 'codex' && this.state === 'menu') { - this.menuScreen = this.menuScreen === 'codex' ? 'main' : 'codex'; + if ((k === 'codex' || k === 'hall') && this.state === 'menu') { + const target = k === 'codex' ? 'codex' : 'hall'; + this.menuScreen = this.menuScreen === target ? 'main' : target; + this.hallClearArmed = false; this.audio.play('ui'); return; } @@ -772,6 +786,21 @@ export class Game implements GameCtx { saveGhostTrack(this.daily ? -1 : this.levelIdx, track); } } + // Hall of Claws: carve this run into the local leaderboard + const hallRec: RunRecord = { + score: this.score, + time: this.elapsed, + level: this.daily ? 'Daily · ' + new Date().toLocaleDateString() : LEVELS[this.levelIdx].name, + difficulty: this.difficulty, + date: Date.now(), + }; + addRun(hallRec); + // Object identity is lost through the localStorage round-trip, so match by content. + const rank = topRuns(10).findIndex( + (r) => r.score === hallRec.score && r.date === hallRec.date && r.level === hallRec.level, + ); + this.lastRun = hallRec; + this.lastRunRank = rank >= 0 ? rank + 1 : null; // Lifetime stats const s = getStats(); s.victories += 1; @@ -842,6 +871,15 @@ export class Game implements GameCtx { this.star100Shown = true; this.addStatus('✦ Perfect run — every crystal!', '#ffe28a'); } + // Hall of Claws: one-time celebration when this run overtakes the run-start best + if (!this.pbAnnounced && this.pbBaseline > 0 && this.score > this.pbBaseline) { + this.pbAnnounced = true; + const p = this.player!; + this.texts.push(new FloatingText(p.x + p.w / 2, p.y - 30, 'NEW PERSONAL BEST!', '#ffd257')); + this.addStatus('Personal best shattered!', '#ffd257'); + this.burst(p.x + p.w / 2, p.y + p.h / 2, this.reducedMotion ? 10 : 36, ['#ffd257', '#fff', '#7ec8f2'], 'rect', 180); + this.audio.play('personalBest'); + } if (this.player!.state === 'victory' && this.state === 'playing') { this.state = 'victory'; } @@ -891,6 +929,7 @@ export class Game implements GameCtx { if (!this.level) this.buildLevel(); if (this.state === 'menu') { if (this.menuScreen === 'codex') this.renderCodex(ctx); + else if (this.menuScreen === 'hall') this.renderHall(ctx); else this.renderMenu(ctx); ctx.restore(); return; @@ -1659,6 +1698,7 @@ export class Game implements GameCtx { ['Stomps', String(r.stomps)], ...(r.heartsGot > 0 ? [[`Hearts`, '× ' + r.heartsGot] as [string, string]] : []), ...(this.bossSlain ? [['Magma King', 'defeated! +' + CFG.score.boss] as [string, string]] : []), + ...(this.lastRunRank ? [['Hall of Claws', '#' + this.lastRunRank + ' of ' + Math.min(getRuns().length, 10)] 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], @@ -2009,19 +2049,29 @@ export class Game implements GameCtx { ...pills.map((p) => ({ x: p.x, y: 394, w: 90, h: 30, label: p.label, card: true, action: () => this.selectDifficulty(p.d) })), // Tappable skin swatches (drawn above) ...SKINS.map((s, i) => ({ x: 746 + i * 48, y: 394, w: 40, h: 30, label: s.name, card: true, action: () => this.selectSkin(s.id) })), + // Meta row (above the ground strip): ghost, codex, leaderboard { - x: 38, y: 474, w: 150, h: 40, label: 'Ghost: ' + (this.ghostOn ? 'On' : 'Off') + ' · G', + x: 38, y: 432, w: 150, h: 30, label: 'Ghost: ' + (this.ghostOn ? 'On' : 'Off') + ' · G', color: '#8fa8ba', action: () => this.toggleGhost(), }, { - x: 202, y: 474, w: 168, h: 40, label: 'Field Notes · C', + x: 202, y: 432, w: 168, h: 30, label: 'Field Notes · C', color: '#8fa8ba', action: () => { this.menuScreen = 'codex'; this.audio.play('ui'); }, }, + { + x: 384, y: 432, w: 168, h: 30, label: 'Hall of Claws · L', + color: '#8fa8ba', + action: () => { + this.menuScreen = 'hall'; + this.hallClearArmed = false; + this.audio.play('ui'); + }, + }, { x: VW / 2 - 100, y: 470, w: 200, h: 48, label: 'Start Game', action: () => this.startGame() }, { x: 648, y: 474, w: 132, h: 40, label: (this.audio.muted ? 'Sound: Off' : 'Sound: On') + ' · M', @@ -2118,6 +2168,110 @@ export class Game implements GameCtx { for (const b of this.uiButtons) this.drawUIButton(ctx, b); } + /** Hall of Claws: top-10 local runs with score bars, plus a clear action. */ + renderHall(ctx: CanvasRenderingContext2D): void { + // Scenic backdrop (same auto-pan as the menu), dimmed for reading + const camX = (Math.sin(this.time * 0.06) * 0.5 + 0.5) * 900; + this.bg.draw(ctx, camX, this.time); + ctx.fillStyle = 'rgba(8,12,22,0.8)'; + ctx.fillRect(0, 0, VW, VH); + // Header + ctx.textAlign = 'center'; + ctx.font = '800 34px ' + FONT_STACK; + ctx.fillStyle = '#f4ecd9'; + this.drawTracked(ctx, 'HALL OF CLAWS', VW / 2, 50, 3, false); + const total = getRuns().length; + ctx.font = '600 13px ' + FONT_STACK; + ctx.fillStyle = 'rgba(220,210,180,0.75)'; + ctx.fillText(total === 0 ? 'No runs recorded yet' : 'Top 10 of ' + total + ' run' + (total === 1 ? '' : 's') + ' · by score', VW / 2, 72); + + const top = topRuns(10); + const x0 = 70, x1 = 890; + if (top.length === 0) { + ctx.font = '700 16px ' + FONT_STACK; + ctx.fillStyle = 'rgba(255,255,255,0.5)'; + ctx.fillText('The hall awaits its first legend.', VW / 2, 250); + ctx.font = '600 12px ' + FONT_STACK; + ctx.fillStyle = 'rgba(220,210,180,0.5)'; + ctx.fillText('Finish a run and your score is carved in stone.', VW / 2, 274); + } else { + // Column headers + ctx.font = '700 10px ' + FONT_STACK; + ctx.fillStyle = 'rgba(255,255,255,0.4)'; + ctx.textAlign = 'left'; + ctx.fillText('LEVEL', 112, 104); + ctx.fillText('DIFFICULTY', 430, 104); + ctx.textAlign = 'right'; + ctx.fillText('TIME', 600, 104); + ctx.fillText('DATE', 726, 104); + ctx.fillText('SCORE', x1, 104); + // Rows with score-proportional bars + const rowH = 36, y0 = 118; + const maxScore = Math.max(...top.map((r) => r.score), 1); + const medal = ['#ffd257', '#d7dee8', '#e0a878']; + const diffColor: Record = { easy: '#9ff0a8', normal: '#ffd257', hard: '#ff8f6b' }; + top.forEach((r, i) => { + const ry = y0 + i * rowH; + // score bar + const bw = ((x1 - 120 - x0) * r.score) / maxScore; + ctx.fillStyle = i === 0 ? 'rgba(255,210,87,0.14)' : 'rgba(255,210,87,0.06)'; + this.roundRect(ctx, x0, ry + 3, Math.max(bw, 4), rowH - 6, 4); + ctx.fill(); + // rank medal + ctx.textAlign = 'center'; + ctx.font = '800 15px ' + FONT_STACK; + ctx.fillStyle = i < 3 ? medal[i] : 'rgba(255,255,255,0.35)'; + ctx.fillText(String(i + 1), x0 + 24, ry + 24); + // level + ctx.textAlign = 'left'; + ctx.font = '600 13px ' + FONT_STACK; + ctx.fillStyle = i === 0 ? '#ffe28a' : '#dce8f5'; + ctx.fillText(r.level.length > 22 ? r.level.slice(0, 21) + '…' : r.level, 112, ry + 23); + // difficulty + ctx.font = '700 11px ' + FONT_STACK; + ctx.fillStyle = diffColor[r.difficulty] ?? '#dce8f5'; + ctx.fillText(r.difficulty.toUpperCase(), 430, ry + 22); + // time + date + ctx.font = '600 12px ' + FONT_STACK; + ctx.fillStyle = 'rgba(220,232,245,0.75)'; + ctx.textAlign = 'right'; + ctx.fillText(r.time === null ? '—' : fmtTime(r.time), 600, ry + 23); + ctx.fillText(new Date(r.date).toLocaleDateString(), 726, ry + 23); + // score + ctx.font = '800 14px ' + FONT_STACK; + ctx.fillStyle = '#ffe28a'; + ctx.fillText(String(r.score), x1, ry + 23); + }); + } + // Buttons + this.uiButtons = [ + { + x: VW / 2 - 110, y: 490, w: 220, h: 36, label: 'Back to menu · L', + color: '#8fa8ba', + action: () => { + this.menuScreen = 'main'; + this.hallClearArmed = false; + this.audio.play('ui'); + }, + }, + { + x: 740, y: 490, w: 150, h: 36, + label: this.hallClearArmed ? 'Tap again to clear' : 'Clear hall', + color: this.hallClearArmed ? '#e0705a' : '#8fa8ba', + action: () => { + if (this.hallClearArmed) { + clearRuns(); + this.hallClearArmed = false; + } else { + this.hallClearArmed = true; + } + this.audio.play('ui'); + }, + }, + ]; + for (const b of this.uiButtons) this.drawUIButton(ctx, b); + } + /** Word-wrap helper; draws the text line by line, returns the count drawn. */ wrapText(ctx: CanvasRenderingContext2D, text: string, x: number, y: number, maxWidth: number, lineHeight: number): number { const words = text.split(' '); diff --git a/src/input.ts b/src/input.ts index 56ec02f..740d9ce 100644 --- a/src/input.ts +++ b/src/input.ts @@ -1,5 +1,6 @@ export type GameKey = | 'codex' + | 'hall' | 'skinPrev' | 'skinNext' | 'restart' @@ -61,6 +62,7 @@ export class Input { if (e.code === 'KeyV') this.onGameKey?.('reducedMotion'); if (e.code === 'KeyG') this.onGameKey?.('ghost'); if (e.code === 'KeyC') this.onGameKey?.('codex'); + if (e.code === 'KeyL') this.onGameKey?.('hall'); if (e.code === 'BracketLeft') this.onGameKey?.('skinPrev'); if (e.code === 'BracketRight') this.onGameKey?.('skinNext'); if (e.code === 'F2') { diff --git a/src/store.ts b/src/store.ts index bb33547..12d05ee 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,3 +1,4 @@ +import type { Difficulty } from './config'; import type { GhostTrack } from './ghost'; import { MIN_TRACK_POINTS } from './ghost'; import { SKINS } from './sprite'; @@ -112,6 +113,44 @@ export function findNote(id: string): void { Store.set('tinyrex_notes', [...found, id]); } +/** One finished run, kept in the Hall of Claws leaderboard. */ +export interface RunRecord { + score: number; + /** Run time in seconds, or null when untracked. */ + time: number | null; + /** Display name of the level (e.g. "Crystal Valley" or "Daily · Aug 26"). */ + level: string; + difficulty: Difficulty; + /** Epoch ms when the run finished. */ + date: number; +} + +/** Number of runs the Hall of Claws keeps (newest kept, oldest dropped). */ +export const MAX_RUNS = 100; + +/** All recorded runs, newest first; corrupt storage reads as empty. */ +export function getRuns(): RunRecord[] { + const v = Store.get('tinyrex_runs', null); + if (!Array.isArray(v)) return []; + return v.filter((r) => r && typeof r.score === 'number' && typeof r.level === 'string'); +} + +/** Append a finished run to the hall, keeping the newest MAX_RUNS. */ +export function addRun(r: RunRecord): void { + Store.set('tinyrex_runs', [r, ...getRuns()].slice(0, MAX_RUNS)); +} + +/** The top n runs by score (ties broken by shorter time). */ +export function topRuns(n: number): RunRecord[] { + return [...getRuns()] + .sort((a, b) => b.score - a.score || (a.time ?? 1e9) - (b.time ?? 1e9)) + .slice(0, n); +} + +export function clearRuns(): void { + Store.set('tinyrex_runs', []); +} + /** Selected Rex skin id; unknown/corrupt values fall back to Classic. */ export function getSkinId(): string { const v = Store.get('tinyrex_skin', null); diff --git a/tests/leaderboard.test.ts b/tests/leaderboard.test.ts new file mode 100644 index 0000000..9e87dd4 --- /dev/null +++ b/tests/leaderboard.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { Game } from '../src/game'; +import { Store, getRuns, addRun, topRuns, clearRuns, MAX_RUNS } from '../src/store'; +import type { RunRecord } from '../src/store'; +import { LEVELS } from '../src/level-data'; + +function makeGame(): Game { + const canvas = document.createElement('canvas'); + document.body.appendChild(canvas); + return new Game(canvas); +} + +function run(over: Partial = {}): RunRecord { + return { score: 100, time: 60, level: 'Crystal Valley', difficulty: 'normal', date: 1700000000000, ...over }; +} + +describe('Hall of Claws store', () => { + beforeEach(() => localStorage.clear()); + + it('records runs newest-first', () => { + addRun(run({ score: 100 })); + addRun(run({ score: 200 })); + expect(getRuns().map((r) => r.score)).toEqual([200, 100]); + }); + + it('caps the hall at MAX_RUNS and drops the oldest', () => { + for (let i = 1; i <= MAX_RUNS + 5; i++) addRun(run({ score: i, date: i })); + const runs = getRuns(); + expect(runs).toHaveLength(MAX_RUNS); + expect(runs[0].score).toBe(MAX_RUNS + 5); // newest kept + expect(runs[MAX_RUNS - 1].score).toBe(6); // the first five were dropped + }); + + it('topRuns sorts by score descending and limits to n', () => { + for (const s of [30, 90, 60, 10, 75]) addRun(run({ score: s })); + expect(topRuns(3).map((r) => r.score)).toEqual([90, 75, 60]); + expect(topRuns(10)).toHaveLength(5); + }); + + it('topRuns breaks score ties by shorter time', () => { + addRun(run({ score: 500, time: 90 })); + addRun(run({ score: 500, time: 40 })); + addRun(run({ score: 500, time: null })); + expect(topRuns(3).map((r) => r.time)).toEqual([40, 90, null]); + }); + + it('guards against corrupted storage', () => { + localStorage.setItem('tinyrex_runs', 'oops'); + expect(getRuns()).toEqual([]); + addRun(run({ score: 5 })); + expect(getRuns()).toHaveLength(1); + localStorage.setItem('tinyrex_runs', JSON.stringify([{ score: 'nope' }])); + expect(getRuns()).toEqual([]); + }); + + it('clearRuns empties the hall', () => { + addRun(run()); + addRun(run({ score: 2 })); + clearRuns(); + expect(getRuns()).toEqual([]); + }); +}); + +describe('Hall of Claws (Game)', () => { + let game: Game; + + beforeEach(() => { + localStorage.clear(); + game = makeGame(); + }); + + it('carves a run into the hall when a level is beaten', () => { + game.handleKey('primary'); + game.score = 250; + game.elapsed = 42; + game.player!.hearts = 1; + game.onPlayerVictory(); + expect(getRuns()).toHaveLength(1); + const r = getRuns()[0]; + expect(r.score).toBe(game.score); // includes end-of-run bonuses + expect(r.time).toBe(42); + expect(r.level).toBe(LEVELS[0].name); + expect(r.difficulty).toBe(game.difficulty); + expect(game.lastRunRank).toBe(1); + }); + + it('records daily runs under a Daily label', () => { + game.selectDaily(); + game.handleKey('primary'); + game.score = 100; + game.onPlayerVictory(); + expect(getRuns()[0].level).toMatch(/^Daily/); + }); + + it('ranks the finished run against prior scores', () => { + addRun(run({ score: 900 })); + addRun(run({ score: 400 })); + game.handleKey('primary'); + // Final score lands between the two seeded runs: 300 + 400 heart bonus, + // with a long elapsed time so the time bonus is 0. + game.score = 300; + game.elapsed = 241; + game.player!.hearts = 1; + game.onPlayerVictory(); + expect(getRuns()[0].score).toBe(700); + expect(game.lastRunRank).toBe(2); // 900, 700, 400 + }); + + it('fires a one-time personal-best burst when overtaking the stored best', () => { + Store.set('tinyrex_best_0', { score: 300, time: 100 }); + game.loadRecords(); // pick the seeded best up (constructor already loaded records) + const spy = vi.spyOn(game.audio, 'play'); + game.handleKey('primary'); + game.score = 301; + game.update(0.016); + expect(game.pbAnnounced).toBe(true); + expect(spy).toHaveBeenCalledWith('personalBest'); + // Further score gains do not re-fire it + game.score = 999; + game.update(0.016); + expect(spy.mock.calls.filter((c) => c[0] === 'personalBest')).toHaveLength(1); + }); + + it('does not fire the burst without a prior best', () => { + const spy = vi.spyOn(game.audio, 'play'); + game.handleKey('primary'); + game.score = 500; + game.update(0.016); + expect(game.pbAnnounced).toBe(false); + expect(spy).not.toHaveBeenCalledWith('personalBest'); + }); + + it('fires on the victory frame when the final score overtakes the run-start best', () => { + // onPlayerVictory updates this.best BEFORE the update() PB check runs, so the + // baseline must be captured at run start — regression test for the E2E failure. + Store.set('tinyrex_best_0', { score: 100, time: 60 }); + game.loadRecords(); + const spy = vi.spyOn(game.audio, 'play'); + game.handleKey('primary'); + // Stand the player on the ground inside the nest's rect (feet at the nest + // base) and let one frame run the real victory path + game.player!.x = game.level!.goal.x; + game.player!.y = game.level!.goal.y - game.player!.h; + game.update(0.016); + expect(game.state).toBe('victory'); + expect(game.pbAnnounced).toBe(true); + expect(spy).toHaveBeenCalledWith('personalBest'); + }); + + it('resets the personal-best flag on a fresh run', () => { + Store.set('tinyrex_best_0', { score: 300, time: 100 }); + game.loadRecords(); + game.handleKey('primary'); + game.score = 301; + game.update(0.016); + expect(game.pbAnnounced).toBe(true); + game.state = 'victory'; + game.victoryT = 2; + game.handleKey('primary'); // restart → fresh run + expect(game.pbAnnounced).toBe(false); + }); + + it('toggles the hall screen from the menu with the hall key', () => { + expect(game.state).toBe('menu'); + game.handleKey('hall'); + expect(game.menuScreen).toBe('hall'); + game.handleKey('hall'); + expect(game.menuScreen).toBe('main'); + }); + + it('switches codex → hall → main with the hall key', () => { + game.handleKey('codex'); + expect(game.menuScreen).toBe('codex'); + game.handleKey('hall'); + expect(game.menuScreen).toBe('hall'); + game.handleKey('hall'); + expect(game.menuScreen).toBe('main'); + }); + + it('ignores the hall key while playing', () => { + game.handleKey('primary'); + expect(game.state).toBe('playing'); + game.handleKey('hall'); + expect(game.state).toBe('playing'); + expect(game.menuScreen).toBe('main'); + }); + + it('clears the hall with a two-tap confirm', () => { + addRun(run({ score: 100 })); + addRun(run({ score: 200 })); + game.handleKey('hall'); + game.render(); + const find = (label: string) => game.uiButtons.find((b) => b.label === label)!; + expect(find('Clear hall')).toBeDefined(); + find('Clear hall').action(); + expect(getRuns()).toHaveLength(2); // first tap only arms + game.render(); + find('Tap again to clear').action(); + expect(getRuns()).toHaveLength(0); + expect(game.hallClearArmed).toBe(false); + }); +});