explodorama / files / src / ui / scores.ts
scores.ts
bb3823d2 2026-10-31 Pavel Safronov | 1 | import { storageKey, countEarlyRun } from '../channel' |
0580e85e 2026-10-24 Pavel Safronov | 2 | import { addEntry, BOARD_SIZE, type ScoreEntry } from '../sim/scores' |
| 3 | import { POWER_UP_ICONS, spriteUrl } from '../render/sprites' | |
| 4 | import { openDialog } from './dialog' | |
| 5 | import { deathLine, PASS_KIND, POWER_UP_TEXT } from './info' | |
| 6 | import { LAST_LEVEL } from '../sim/levelgen' | |
| 7 | ||
| 8 | /** | |
| 9 | * The high-score board in this browser (user request, 2026-10-24): stored in `localStorage`, | |
| 10 | * shown from the menu. The entries (sim/scores.ts) are built for a global board later. | |
| 11 | */ | |
bb3823d2 2026-10-31 Pavel Safronov | 12 | const SCORES_KEY = storageKey('scores') |
0580e85e 2026-10-24 Pavel Safronov | 13 | |
| 14 | export function loadScores(): ScoreEntry[] { | |
| 15 | try { | |
| 16 | const all = JSON.parse(localStorage.getItem(SCORES_KEY) ?? '[]') as ScoreEntry[] | |
| 17 | return Array.isArray(all) ? all.filter((e) => e.format === 1) : [] | |
| 18 | } catch { | |
| 19 | return [] | |
| 20 | } | |
| 21 | } | |
| 22 | ||
| 23 | /** Put a finished run on the board; its rank (1-based), or 0 if it didn't make it. */ | |
| 24 | export function recordScore(entry: ScoreEntry): number { | |
bb3823d2 2026-10-31 Pavel Safronov | 25 | // A run finished on the next copy is counted where the released one can see it too (channel.ts). |
| 26 | if (entry.channel === 'next') countEarlyRun() | |
0580e85e 2026-10-24 Pavel Safronov | 27 | const { board, rank } = addEntry(loadScores(), entry) |
| 28 | try { | |
| 29 | localStorage.setItem(SCORES_KEY, JSON.stringify(board)) | |
| 30 | } catch { | |
| 31 | // No storage (or full): the board just isn't kept. | |
| 32 | } | |
| 33 | return rank | |
| 34 | } | |
| 35 | ||
| 36 | function entryHtml(e: ScoreEntry, rank: number): string { | |
| 37 | const result = e.won ? `★ Cleared all ${LAST_LEVEL} levels` : `Level ${e.level}` | |
| 38 | const how = e.won ? 'Won!' : deathLine(e.level, e.death) | |
| 39 | const powers = e.powers | |
| 40 | .map((p) => `<img src="${spriteUrl(POWER_UP_ICONS[PASS_KIND[p]])}" alt="${POWER_UP_TEXT[PASS_KIND[p]].name}" title="${POWER_UP_TEXT[PASS_KIND[p]].name}">`) | |
| 41 | .join('') | |
| 42 | const date = new Date(e.date).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }) | |
| 43 | return ( | |
| 44 | // A Developer run counts, with a red border (user request, 2026-10-24). | |
| 45 | `<li class="score${e.options.dev ? ' developer' : ''}">` + | |
| 46 | `<div class="score-head"><b>#${rank}</b><span>${result}</span><span>${e.coins} ${e.coins === 1 ? 'coin' : 'coins'}</span></div>` + | |
| 47 | `<p>${e.kills} ${e.kills === 1 ? 'enemy' : 'enemies'} · ${e.turns} ${e.turns === 1 ? 'turn' : 'turns'} · ${e.livesLost} ${e.livesLost === 1 ? 'life' : 'lives'} lost</p>` + | |
| 48 | `<p>${how}</p>` + | |
| 49 | `<p class="score-powers">${powers}<span>Bombs ${e.stats.capacity} · Blast ${e.stats.fire} · Fuse ${e.stats.fuse}</span></p>` + | |
bb3823d2 2026-10-31 Pavel Safronov | 50 | `<p class="score-meta">${date} · Seed ${e.seed}${e.options.dev ? ' · <b>Developer</b>' : ''}${e.channel === 'next' ? ' · <b>Early adopter</b>' : ''}</p>` + |
0580e85e 2026-10-24 Pavel Safronov | 51 | `</li>` |
| 52 | ) | |
| 53 | } | |
| 54 | ||
| 55 | export function openScores(): void { | |
| 56 | const board = loadScores() | |
| 57 | openDialog({ | |
| 58 | title: 'High scores', | |
| 59 | body: board.length | |
| 60 | ? `<p class="scores-intro">Ranked by how far the run got, then coins. The best ${BOARD_SIZE} are kept, in this browser.</p>` + | |
| 61 | `<ol class="scores">${board.map((e, i) => entryHtml(e, i + 1)).join('')}</ol>` | |
| 62 | : `<p>No finished runs yet. A run goes on the board when it ends: at game over or after the last level. Immortal runs don’t count.</p>`, | |
| 63 | buttons: [{ label: 'Close', kind: 'plain' }], | |
| 64 | }) | |
| 65 | } |