From b81ffac54701e608e3e577df56feb369d99bf805 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sat, 22 Aug 2026 09:17:33 -0600 Subject: [PATCH] Show best score under cleared level nodes Cleared nodes on the level select map now display the player's best score in a gold pill that rolls up from 0 on entrance, replacing the previous "kids saved" label. The tally scene records each run's final score alongside kids saved/total via `markLevelComplete`, and new `getBestScore` helper exposes it for display. --- sprites.md | 7 ++++ src/scenes/LevelScoreScene.js | 5 +-- src/scenes/LevelSelectScene.js | 65 ++++++++++++++++++++++++++++------ src/util/progress.js | 38 ++++++++++++++------ 4 files changed, 93 insertions(+), 22 deletions(-) diff --git a/sprites.md b/sprites.md index 4e107c3..15c5e1e 100644 --- a/sprites.md +++ b/sprites.md @@ -124,6 +124,13 @@ Per-campaign notes: badge to be a different display size than ~58-71 units, adjust `BADGE_FRAC_OF_FRAME` / the per-state `BADGE_FILE_FRAC` entries in `src/util/mapArt.js` (the click area follows the art, not these knobs). +- **Cleared nodes show the player's best score** under the badge, as a small + gold pill (the game's `SCORE_PILL_STYLE` in `src/scenes/LevelSelectScene.js`) + with the number rolling up from 0 on entrance. The pill is drawn by the + game, not part of the badge art - the `map_node` badge should stay a plain + cleared marker (no digits baked in). The score itself comes from + `bestScore` in `src/util/progress.js` (localStorage), recorded by the + tally scene after each run. - **`map_finish.png`** - the flag sits just past the last node, so a base/mound that grounds it looks best. Currently ships as a tight 512x512 image (flag ink is ~60% of the width, full height); the scene displays it at 0.18x the diff --git a/src/scenes/LevelScoreScene.js b/src/scenes/LevelScoreScene.js index bd227e8..4bdbe48 100644 --- a/src/scenes/LevelScoreScene.js +++ b/src/scenes/LevelScoreScene.js @@ -233,8 +233,9 @@ export default class LevelScoreScene extends Phaser.Scene { const y = GAME_HEIGHT * 0.86; const { bg, text } = createButton(this, GAME_WIDTH / 2, y, 'CONTINUE', () => { this._stopCrowdCheer(); - // Record the result and head straight back to the map. - markLevelComplete(this.levelId, this.kidsSaved, this.total); + // Record the result (including this run's final score, so the level + // select map can show the player's best) and head back to the map. + markLevelComplete(this.levelId, this.kidsSaved, this.total, this.runningScore); this.scene.stop('LevelScore'); this.scene.start('LevelSelect'); }); diff --git a/src/scenes/LevelSelectScene.js b/src/scenes/LevelSelectScene.js index 8488202..0a1f73a 100644 --- a/src/scenes/LevelSelectScene.js +++ b/src/scenes/LevelSelectScene.js @@ -2,7 +2,7 @@ import Phaser from 'phaser'; import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js'; import { CAMPAIGNS } from '../data/levels/index.js'; import { autoLayoutPoints, sampleSpline } from '../data/levels/positioning.js'; -import { isLevelComplete, getLevelResult } from '../util/progress.js'; +import { isLevelComplete, getBestScore } from '../util/progress.js'; import { playMenuMusic } from '../util/music.js'; import { nodeDisplaySize, scalePos, BADGE_HIT_FRAC, BADGE_HIT_RADIUS_MULT } from '../util/mapArt.js'; @@ -24,15 +24,28 @@ const TITLE_STYLE = { const PILL_STYLE = { fontFamily: 'monospace', - // 13 (not 14) so the longest tag - "10/10 kids saved" on a cleared node - - // fits comfortably under ~100-unit-spaced nodes once a campaign grows past - // a handful of levels. + // 13 (not 14) so the level-name tag under the current node stays compact + // even for campaigns with many levels; the best-score pills use their own + // SCORE_PILL_STYLE below. fontSize: `${13 * WORLD_SCALE}px`, color: '#1a1f29', backgroundColor: '#ffffffaa', padding: { x: 9 * WORLD_SCALE, y: 5 * WORLD_SCALE }, }; +// The "best score" pill that rolls up under cleared nodes - gold, to match +// the gold score numerals in the score tally scene. +const SCORE_PILL_STYLE = { + fontFamily: 'monospace', + fontSize: `${14 * WORLD_SCALE}px`, + fontStyle: 'bold', + color: '#1a1f29', + backgroundColor: '#f2c14e', + stroke: '#ffffff', + strokeThickness: 2 * WORLD_SCALE, + padding: { x: 10 * WORLD_SCALE, y: 5 * WORLD_SCALE }, +}; + export default class LevelSelectScene extends Phaser.Scene { constructor() { super('LevelSelect'); @@ -200,12 +213,11 @@ export default class LevelSelectScene extends Phaser.Scene { } this._groups.nodes.push(node); - const result = getLevelResult(level.id); - let label = ''; - if (state === 'done') label = result ? `${result.kidsSaved}/${result.kidsTotal} kids saved` : 'cleared!'; - else if (state === 'current') label = level.name; - if (label) { - this._groups.tags.push(this.add.text(p.x, p.y + nh * 0.68, label, PILL_STYLE).setOrigin(0.5).setDepth(DEPTH.tag)); + if (state === 'done') { + const best = getBestScore(level.id); + if (best != null) this._scoreRollup(p, nh, best, 80 + i * 70); + } else if (state === 'current') { + this._groups.tags.push(this.add.text(p.x, p.y + nh * 0.68, level.name, PILL_STYLE).setOrigin(0.5).setDepth(DEPTH.tag)); } }); @@ -241,6 +253,39 @@ export default class LevelSelectScene extends Phaser.Scene { } } + // Cleared nodes wear a small gold "best score" pill under the badge (gold + // matches the gold score numerals in the tally scene). On entrance the + // number rolls up from 0 to the player's best - an odometer that reads as + // the map remembering the run. Duration scales with the score's magnitude + // (capped) so big scores get the longer, more satisfying count. + _scoreRollup(p, nh, best, delay) { + const pill = this.add + .text(p.x, p.y + nh * 0.68, '★ 0', SCORE_PILL_STYLE) + .setOrigin(0.5) + .setDepth(DEPTH.tag) + .setAlpha(0) + .setScale(0.4); + this._groups.tags.push(pill); + this.tweens.add({ + targets: pill, + alpha: 1, + scale: 1, + delay, + duration: 220, + ease: 'Back.easeOut', + onComplete: () => { + const counter = { v: 0 }; + this.tweens.add({ + targets: counter, + v: best, + duration: Math.min(1500, 500 + Math.sqrt(best) * 40), + ease: 'Cubic.easeOut', + onUpdate: () => pill.setText(`★ ${Math.round(counter.v).toLocaleString()}`), + }); + }, + }); + } + _startPulse(index, delay = 0) { if (this._pulseTween) this._pulseTween.stop(); const node = this._groups.nodes[index]; diff --git a/src/util/progress.js b/src/util/progress.js index ffc72e4..82c6cb9 100644 --- a/src/util/progress.js +++ b/src/util/progress.js @@ -9,16 +9,7 @@ function readAll() { } } -export function isLevelComplete(levelId) { - return Boolean(readAll()[levelId]); -} - -export function markLevelComplete(levelId, kidsSaved, kidsTotal) { - const all = readAll(); - const existing = all[levelId]; - if (!existing || kidsSaved > existing.kidsSaved) { - all[levelId] = { kidsSaved, kidsTotal }; - } +function writeAll(all) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(all)); } catch (e) { @@ -26,6 +17,33 @@ export function markLevelComplete(levelId, kidsSaved, kidsTotal) { } } +export function isLevelComplete(levelId) { + return Boolean(readAll()[levelId]); +} + +// Records a completed run. kidsSaved/kidsTotal are kept (they're how +// "isLevelComplete" and any legacy saves work), and bestScore is the +// player's highest final score - the level select map shows it under each +// cleared node. A run only "counts" if it improves the best on either +// axis, but whichever axis improved wins; both are kept at their max. +export function markLevelComplete(levelId, kidsSaved, kidsTotal, score = null) { + const all = readAll(); + const existing = all[levelId] || {}; + all[levelId] = { + kidsSaved: Math.max(existing.kidsSaved || 0, kidsSaved), + kidsTotal, + bestScore: Math.max(existing.bestScore || 0, score || 0), + }; + writeAll(all); +} + export function getLevelResult(levelId) { return readAll()[levelId] || null; } + +// The player's best score for a level, or null if none has been recorded +// yet (e.g. a legacy save from before best scores existed). +export function getBestScore(levelId) { + const result = readAll()[levelId]; + return result && typeof result.bestScore === 'number' && result.bestScore > 0 ? result.bestScore : null; +}