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.
This commit is contained in:
Brian Fertig 2026-08-22 09:17:33 -06:00
parent d6630dce3a
commit b81ffac547
4 changed files with 93 additions and 22 deletions

View File

@ -124,6 +124,13 @@ Per-campaign notes:
badge to be a different display size than ~58-71 units, adjust badge to be a different display size than ~58-71 units, adjust
`BADGE_FRAC_OF_FRAME` / the per-state `BADGE_FILE_FRAC` entries in `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). `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 - **`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 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 ink is ~60% of the width, full height); the scene displays it at 0.18x the

View File

@ -233,8 +233,9 @@ export default class LevelScoreScene extends Phaser.Scene {
const y = GAME_HEIGHT * 0.86; const y = GAME_HEIGHT * 0.86;
const { bg, text } = createButton(this, GAME_WIDTH / 2, y, 'CONTINUE', () => { const { bg, text } = createButton(this, GAME_WIDTH / 2, y, 'CONTINUE', () => {
this._stopCrowdCheer(); this._stopCrowdCheer();
// Record the result and head straight back to the map. // Record the result (including this run's final score, so the level
markLevelComplete(this.levelId, this.kidsSaved, this.total); // 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.stop('LevelScore');
this.scene.start('LevelSelect'); this.scene.start('LevelSelect');
}); });

View File

@ -2,7 +2,7 @@ import Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js'; import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE } from '../config.js';
import { CAMPAIGNS } from '../data/levels/index.js'; import { CAMPAIGNS } from '../data/levels/index.js';
import { autoLayoutPoints, sampleSpline } from '../data/levels/positioning.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 { playMenuMusic } from '../util/music.js';
import { nodeDisplaySize, scalePos, BADGE_HIT_FRAC, BADGE_HIT_RADIUS_MULT } from '../util/mapArt.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 = { const PILL_STYLE = {
fontFamily: 'monospace', fontFamily: 'monospace',
// 13 (not 14) so the longest tag - "10/10 kids saved" on a cleared node - // 13 (not 14) so the level-name tag under the current node stays compact
// fits comfortably under ~100-unit-spaced nodes once a campaign grows past // even for campaigns with many levels; the best-score pills use their own
// a handful of levels. // SCORE_PILL_STYLE below.
fontSize: `${13 * WORLD_SCALE}px`, fontSize: `${13 * WORLD_SCALE}px`,
color: '#1a1f29', color: '#1a1f29',
backgroundColor: '#ffffffaa', backgroundColor: '#ffffffaa',
padding: { x: 9 * WORLD_SCALE, y: 5 * WORLD_SCALE }, 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 { export default class LevelSelectScene extends Phaser.Scene {
constructor() { constructor() {
super('LevelSelect'); super('LevelSelect');
@ -200,12 +213,11 @@ export default class LevelSelectScene extends Phaser.Scene {
} }
this._groups.nodes.push(node); this._groups.nodes.push(node);
const result = getLevelResult(level.id); if (state === 'done') {
let label = ''; const best = getBestScore(level.id);
if (state === 'done') label = result ? `${result.kidsSaved}/${result.kidsTotal} kids saved` : 'cleared!'; if (best != null) this._scoreRollup(p, nh, best, 80 + i * 70);
else if (state === 'current') label = level.name; } else if (state === 'current') {
if (label) { this._groups.tags.push(this.add.text(p.x, p.y + nh * 0.68, level.name, PILL_STYLE).setOrigin(0.5).setDepth(DEPTH.tag));
this._groups.tags.push(this.add.text(p.x, p.y + nh * 0.68, label, 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) { _startPulse(index, delay = 0) {
if (this._pulseTween) this._pulseTween.stop(); if (this._pulseTween) this._pulseTween.stop();
const node = this._groups.nodes[index]; const node = this._groups.nodes[index];

View File

@ -9,16 +9,7 @@ function readAll() {
} }
} }
export function isLevelComplete(levelId) { function writeAll(all) {
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 };
}
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(all)); localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
} catch (e) { } 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) { export function getLevelResult(levelId) {
return readAll()[levelId] || null; 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;
}