fuzzyworld

explodorama / files / src / ui / scores.ts

scores.ts

Blame: the commit that last changed each line · file · history

bb3823d2 2026-10-31 Pavel Safronov1import { storageKey, countEarlyRun } from '../channel'
0580e85e 2026-10-24 Pavel Safronov2import { addEntry, BOARD_SIZE, type ScoreEntry } from '../sim/scores'
3import { POWER_UP_ICONS, spriteUrl } from '../render/sprites'
4import { openDialog } from './dialog'
5import { deathLine, PASS_KIND, POWER_UP_TEXT } from './info'
6import { 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 Safronov12const SCORES_KEY = storageKey('scores')
0580e85e 2026-10-24 Pavel Safronov13
14export 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. */
24export function recordScore(entry: ScoreEntry): number {
bb3823d2 2026-10-31 Pavel Safronov25 // 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 Safronov27 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
36function 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 Safronov50 `<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 Safronov51 `</li>`
52 )
53}
54
55export 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}