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:
parent
d6630dce3a
commit
b81ffac547
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue