feat: add LevelScoreScene with end-of-level tally and kid HUD portraits
Introduce LevelScoreScene to display a choreographed score breakdown (base score, per-kid results, time bonus) using a frozen snapshot of the win condition. Replace generic icon_kid in the HUD with individual kid portraits that update based on each kid's specific status (aboard vs. ejected). Extract _formatTime to a shared utility used by both PlayScene and LevelScoreScene. Add audio assets for the scoring sequence.
This commit is contained in:
parent
9da3219529
commit
7f4c632cce
|
|
@ -171,6 +171,16 @@ export const KID = {
|
|||
settleDurationMs: 500,
|
||||
};
|
||||
|
||||
// Points awarded on LevelScoreScene's end-of-level tally, in the order
|
||||
// they're revealed: a flat completion bonus, then per-kid, then per second
|
||||
// left on the clock.
|
||||
export const SCORE = {
|
||||
baseScore: 100,
|
||||
perKidSaved: 100,
|
||||
perKidLost: -50,
|
||||
perSecondRemaining: 10,
|
||||
};
|
||||
|
||||
export const CAMERA = {
|
||||
lerpX: 0.1,
|
||||
lerpY: 0.1,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import IntroScene from './scenes/IntroScene.js';
|
|||
import MainMenuScene from './scenes/MainMenuScene.js';
|
||||
import LevelSelectScene from './scenes/LevelSelectScene.js';
|
||||
import PlayScene from './scenes/PlayScene.js';
|
||||
import LevelScoreScene from './scenes/LevelScoreScene.js';
|
||||
import LevelCompleteScene from './scenes/LevelCompleteScene.js';
|
||||
import LevelFailedScene from './scenes/LevelFailedScene.js';
|
||||
|
||||
|
|
@ -53,6 +54,7 @@ window.__PHASER_GAME__ = new Phaser.Game({
|
|||
MainMenuScene,
|
||||
LevelSelectScene,
|
||||
PlayScene,
|
||||
LevelScoreScene,
|
||||
LevelCompleteScene,
|
||||
LevelFailedScene,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,282 @@
|
|||
import Phaser from 'phaser';
|
||||
import { GAME_WIDTH, GAME_HEIGHT, WORLD_SCALE, SCORE } from '../config.js';
|
||||
import { createButton } from '../util/ui.js';
|
||||
import { formatTime } from '../util/formatTime.js';
|
||||
|
||||
const KID_ROW_Y = GAME_HEIGHT * 0.62;
|
||||
const KID_SPACING = 92 * WORLD_SCALE;
|
||||
const KID_SPRITE_SIZE = 48 * WORLD_SCALE;
|
||||
const SCORE_Y = GAME_HEIGHT * 0.36;
|
||||
|
||||
// Plays out entirely over a single frozen snapshot of PlayScene at the
|
||||
// instant the bus crossed the finish line (see PlayScene._onWin) - this
|
||||
// scene owns no physics or camera follow of its own, just the tally
|
||||
// choreography on top of that still frame, before handing off to
|
||||
// LevelCompleteScene for the retry/next-level choice.
|
||||
export default class LevelScoreScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super('LevelScore');
|
||||
}
|
||||
|
||||
init(data) {
|
||||
this.levelId = data.levelId;
|
||||
this.kidsSaved = data.kidsSaved;
|
||||
this.total = data.total;
|
||||
this.kidResults = data.kidResults; // boolean[] per seat - true = made it
|
||||
this.timeRemainingSeconds = data.timeRemainingSeconds;
|
||||
this.freezeKey = data.freezeKey;
|
||||
this.runningScore = 0;
|
||||
}
|
||||
|
||||
create() {
|
||||
this.add.image(0, 0, this.freezeKey).setOrigin(0, 0).setDisplaySize(GAME_WIDTH, GAME_HEIGHT);
|
||||
// fillAlpha (the rectangle's own fill opacity) is separate from the
|
||||
// GameObject alpha component tweened below - it has to start at full (1)
|
||||
// here, with alpha 0 doing the actual hiding, or tweening alpha later
|
||||
// multiplies against a permanently-0 fill and never becomes visible.
|
||||
this.dimOverlay = this.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x000000, 1).setOrigin(0, 0).setAlpha(0);
|
||||
|
||||
this.crowdCheer = this.cache.audio.exists('crowd_cheer') ? this.sound.add('crowd_cheer') : null;
|
||||
|
||||
// Same position/style as PlayScene's HUD timer (see PlayScene._buildHud)
|
||||
// so this reads as that same clock continuing, not a new element.
|
||||
const startX = 24 * WORLD_SCALE;
|
||||
const hudY = 24 * WORLD_SCALE;
|
||||
this.timeText = this.add.text(GAME_WIDTH - startX, hudY, formatTime(this.timeRemainingSeconds), {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: `${32 * WORLD_SCALE}px`,
|
||||
fontStyle: 'bold',
|
||||
color: '#1a1f29',
|
||||
backgroundColor: '#ffffffaa',
|
||||
}).setOrigin(1, 0).setDepth(10);
|
||||
|
||||
this.scoreLabel = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT * 0.2, 'Score:', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: `${32 * WORLD_SCALE}px`,
|
||||
fontStyle: 'bold',
|
||||
color: '#1a1f29',
|
||||
backgroundColor: '#ffffffaa',
|
||||
}).setOrigin(0.5).setScale(0).setDepth(10);
|
||||
|
||||
// Both start alpha 0 - they sit at Phaser's default (0,0) position until
|
||||
// _flyInScore places them off-screen and tweens them in, and origin
|
||||
// (0, 0.5) on solidFinishText in particular would otherwise flash its
|
||||
// glyphs at the top-left corner for the first few sequence steps.
|
||||
this.scoreValueText = this.add.text(0, 0, '0', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: `${64 * WORLD_SCALE}px`,
|
||||
fontStyle: 'bold',
|
||||
color: '#f2c14e',
|
||||
stroke: '#1a1f29',
|
||||
strokeThickness: 6 * WORLD_SCALE,
|
||||
}).setOrigin(0.5, 0.5).setAlpha(0).setDepth(10);
|
||||
|
||||
this.solidFinishText = this.add.text(0, 0, 'Solid Finish', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: `${20 * WORLD_SCALE}px`,
|
||||
fontStyle: 'bold',
|
||||
color: '#2c8f3c',
|
||||
backgroundColor: '#ffffffaa',
|
||||
}).setOrigin(0, 0.5).setAlpha(0).setDepth(10);
|
||||
|
||||
this.kidSprites = [];
|
||||
this.kidLabels = [];
|
||||
|
||||
this.events.once('shutdown', this._cleanup, this);
|
||||
|
||||
this._runSequence();
|
||||
}
|
||||
|
||||
async _runSequence() {
|
||||
await this._wait(400);
|
||||
|
||||
if (this.crowdCheer) this.crowdCheer.play();
|
||||
await this._tween({ targets: this.dimOverlay, alpha: 0.7, duration: 350, ease: 'Sine.easeOut' });
|
||||
|
||||
await this._tween({ targets: this.scoreLabel, scale: 1, duration: 350, ease: 'Back.easeOut' });
|
||||
|
||||
await this._flyInScore();
|
||||
await this._wait(250);
|
||||
await this._tween({ targets: this.solidFinishText, alpha: 0, duration: 300, ease: 'Sine.easeIn' });
|
||||
|
||||
await this._revealKids();
|
||||
await this._wait(300);
|
||||
|
||||
await this._slamKidsIntoScore();
|
||||
await this._wait(300);
|
||||
|
||||
await this._tallyTimeRemaining();
|
||||
await this._wait(400);
|
||||
|
||||
this._showContinueButton();
|
||||
}
|
||||
|
||||
// The score number and its "Solid Finish" bonus label fly in together
|
||||
// from off-screen and land under the "Score:" label - the number centered
|
||||
// directly beneath it, "Solid Finish" placed just to the right of wherever
|
||||
// the number's own width puts its right edge.
|
||||
async _flyInScore() {
|
||||
const startOffset = 560 * WORLD_SCALE;
|
||||
const finishGap = 16 * WORLD_SCALE;
|
||||
|
||||
const landScoreX = GAME_WIDTH / 2;
|
||||
this.scoreValueText.setText(String(SCORE.baseScore)).setPosition(landScoreX + startOffset, SCORE_Y).setAlpha(1);
|
||||
|
||||
const landFinishX = landScoreX + this.scoreValueText.displayWidth / 2 + finishGap;
|
||||
this.solidFinishText.setPosition(landFinishX + startOffset, SCORE_Y).setAlpha(1);
|
||||
|
||||
await Promise.all([
|
||||
this._tween({ targets: this.scoreValueText, x: landScoreX, duration: 500, ease: 'Back.easeOut' }),
|
||||
this._tween({ targets: this.solidFinishText, x: landFinishX, duration: 500, ease: 'Back.easeOut' }),
|
||||
]);
|
||||
|
||||
this.runningScore = SCORE.baseScore;
|
||||
this._punch(this.scoreValueText);
|
||||
this._playSound('score_count');
|
||||
}
|
||||
|
||||
// Reveals each kid one at a time in seat order, showing whichever sprite
|
||||
// matches how they actually ended the level (see Kid._applyTexture for the
|
||||
// same idle/ejected + seat-frame pairing) with its point value alongside.
|
||||
async _revealKids() {
|
||||
const total = this.total;
|
||||
const rowStartX = GAME_WIDTH / 2 - ((total - 1) * KID_SPACING) / 2;
|
||||
|
||||
for (let i = 0; i < total; i++) {
|
||||
const saved = this.kidResults[i];
|
||||
const x = rowStartX + i * KID_SPACING;
|
||||
|
||||
this._playSound('kid_count');
|
||||
|
||||
const textureKey = saved ? 'kid_idle' : 'kid_ejected';
|
||||
const sprite = this.add.image(x, KID_ROW_Y, textureKey)
|
||||
.setDisplaySize(KID_SPRITE_SIZE, KID_SPRITE_SIZE)
|
||||
.setScale(0)
|
||||
.setDepth(10);
|
||||
if (this.textures.get(textureKey).has(i)) sprite.setFrame(i);
|
||||
|
||||
const label = this.add.text(x, KID_ROW_Y + KID_SPRITE_SIZE * 0.7, this._formatDelta(saved ? SCORE.perKidSaved : SCORE.perKidLost), {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: `${18 * WORLD_SCALE}px`,
|
||||
fontStyle: 'bold',
|
||||
color: saved ? '#2c8f3c' : '#c0392b',
|
||||
}).setOrigin(0.5).setAlpha(0).setDepth(10);
|
||||
|
||||
this.kidSprites.push(sprite);
|
||||
this.kidLabels.push(label);
|
||||
|
||||
await this._tween({ targets: sprite, scale: 1, duration: 300, ease: 'Back.easeOut' });
|
||||
this.tweens.add({ targets: label, alpha: 1, duration: 200 });
|
||||
await this._wait(180);
|
||||
}
|
||||
}
|
||||
|
||||
// Each kid's point label flies into the running score total and gets
|
||||
// absorbed - lost kids get the same slam and sound, just a negative
|
||||
// (SCORE.perKidLost) delta instead of a positive one.
|
||||
async _slamKidsIntoScore() {
|
||||
const targetX = this.scoreValueText.x;
|
||||
const targetY = this.scoreValueText.y;
|
||||
|
||||
for (let i = 0; i < this.total; i++) {
|
||||
const saved = this.kidResults[i];
|
||||
const label = this.kidLabels[i];
|
||||
|
||||
await this._tween({
|
||||
targets: label,
|
||||
x: targetX,
|
||||
y: targetY,
|
||||
scale: 0.4,
|
||||
alpha: 0,
|
||||
duration: 260,
|
||||
ease: 'Cubic.easeIn',
|
||||
});
|
||||
label.destroy();
|
||||
|
||||
this.runningScore += saved ? SCORE.perKidSaved : SCORE.perKidLost;
|
||||
this.scoreValueText.setText(String(this.runningScore));
|
||||
this._punch(this.scoreValueText);
|
||||
this._playSound('score_count');
|
||||
this.cameras.main.shake(70, 0.004);
|
||||
|
||||
await this._wait(120);
|
||||
}
|
||||
|
||||
for (const sprite of this.kidSprites) sprite.destroy();
|
||||
this.kidSprites = [];
|
||||
}
|
||||
|
||||
// Counts the clock down to zero, decelerating toward the end (each tick's
|
||||
// delay is re-derived from however many seconds are still left, so it
|
||||
// starts brisk and eases out rather than ticking at one flat rate), adding
|
||||
// SCORE.perSecondRemaining per second shaved off.
|
||||
async _tallyTimeRemaining() {
|
||||
let remaining = this.timeRemainingSeconds;
|
||||
this._punch(this.timeText);
|
||||
|
||||
while (remaining > 0) {
|
||||
const perTick = Phaser.Math.Clamp(Math.round(1200 / remaining), 25, 90);
|
||||
await this._wait(perTick);
|
||||
|
||||
remaining -= 1;
|
||||
this.timeText.setText(formatTime(remaining));
|
||||
this.runningScore += SCORE.perSecondRemaining;
|
||||
this.scoreValueText.setText(String(this.runningScore));
|
||||
this._punch(this.timeText);
|
||||
this._punch(this.scoreValueText);
|
||||
this._playSound('score_count');
|
||||
}
|
||||
}
|
||||
|
||||
_showContinueButton() {
|
||||
const y = GAME_HEIGHT * 0.86;
|
||||
const { bg, text } = createButton(this, GAME_WIDTH / 2, y, 'CONTINUE', () => {
|
||||
this._stopCrowdCheer();
|
||||
this.scene.start('LevelComplete', { levelId: this.levelId, kidsSaved: this.kidsSaved, total: this.total });
|
||||
});
|
||||
|
||||
bg.setDepth(10);
|
||||
text.setDepth(10);
|
||||
bg.disableInteractive();
|
||||
bg.setAlpha(0);
|
||||
text.setAlpha(0);
|
||||
this.tweens.add({
|
||||
targets: [bg, text],
|
||||
alpha: 1,
|
||||
duration: 400,
|
||||
ease: 'Sine.easeOut',
|
||||
onComplete: () => bg.setInteractive({ useHandCursor: true }),
|
||||
});
|
||||
}
|
||||
|
||||
_formatDelta(n) {
|
||||
return n >= 0 ? `+${n}` : `${n}`;
|
||||
}
|
||||
|
||||
_punch(target) {
|
||||
this.tweens.add({ targets: target, scale: 1.25, duration: 90, yoyo: true, ease: 'Quad.easeOut' });
|
||||
}
|
||||
|
||||
_playSound(key) {
|
||||
if (!this.cache.audio.exists(key)) return;
|
||||
this.sound.add(key).play();
|
||||
}
|
||||
|
||||
_stopCrowdCheer() {
|
||||
if (this.crowdCheer && this.crowdCheer.isPlaying) this.crowdCheer.stop();
|
||||
}
|
||||
|
||||
_wait(ms) {
|
||||
return new Promise((resolve) => this.time.delayedCall(ms, resolve));
|
||||
}
|
||||
|
||||
_tween(config) {
|
||||
return new Promise((resolve) => {
|
||||
this.tweens.add({ ...config, onComplete: resolve });
|
||||
});
|
||||
}
|
||||
|
||||
_cleanup() {
|
||||
this._stopCrowdCheer();
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import CameraRig from '../systems/CameraRig.js';
|
|||
import EngineSound from '../systems/EngineSound.js';
|
||||
import { stopMenuMusic } from '../util/music.js';
|
||||
import { playLevelVoiceLine } from '../util/voiceLine.js';
|
||||
import { formatTime } from '../util/formatTime.js';
|
||||
|
||||
export default class PlayScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
|
|
@ -180,7 +181,7 @@ export default class PlayScene extends Phaser.Scene {
|
|||
if (!this._timerStarted || this._timeRemaining <= 0) return;
|
||||
|
||||
this._timeRemaining = Math.max(0, this._timeRemaining - delta / 1000);
|
||||
this.timerText.setText(this._formatTime(this._timeRemaining));
|
||||
this.timerText.setText(formatTime(this._timeRemaining));
|
||||
this.timerText.setColor(this._timeRemaining <= 10 ? '#c0392b' : '#1a1f29');
|
||||
|
||||
if (this._timeRemaining <= 0) {
|
||||
|
|
@ -188,13 +189,6 @@ export default class PlayScene extends Phaser.Scene {
|
|||
}
|
||||
}
|
||||
|
||||
_formatTime(seconds) {
|
||||
const whole = Math.max(0, Math.ceil(seconds));
|
||||
const m = Math.floor(whole / 60);
|
||||
const s = whole % 60;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
_buildGoal() {
|
||||
const goal = this.level.goal;
|
||||
this.goalBody = this.matter.add.rectangle(goal.x, goal.y, goal.width, goal.height, {
|
||||
|
|
@ -211,8 +205,12 @@ export default class PlayScene extends Phaser.Scene {
|
|||
const spacing = 30 * WORLD_SCALE;
|
||||
const iconSize = 24 * WORLD_SCALE;
|
||||
|
||||
// One kid_idle portrait per seat (same seat-indexed frame as Kid.js's own
|
||||
// sprite - see Kid._applyTexture) rather than a generic icon, so each HUD
|
||||
// icon reads as that specific kid, not just a headcount pip.
|
||||
for (let i = 0; i < this.level.kidsAboard; i++) {
|
||||
const icon = this.add.image(startX + i * spacing, y, 'icon_kid').setDisplaySize(iconSize, iconSize).setScrollFactor(0).setDepth(10);
|
||||
const icon = this.add.image(startX + i * spacing, y, 'kid_idle').setDisplaySize(iconSize, iconSize).setScrollFactor(0).setDepth(10);
|
||||
if (this.textures.get('kid_idle').has(i)) icon.setFrame(i);
|
||||
this.hudIcons.push(icon);
|
||||
}
|
||||
|
||||
|
|
@ -223,7 +221,7 @@ export default class PlayScene extends Phaser.Scene {
|
|||
backgroundColor: '#ffffffaa',
|
||||
}).setScrollFactor(0).setDepth(10);
|
||||
|
||||
this.timerText = this.add.text(GAME_WIDTH - startX, y, this._formatTime(this._timeRemaining), {
|
||||
this.timerText = this.add.text(GAME_WIDTH - startX, y, formatTime(this._timeRemaining), {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: `${32 * WORLD_SCALE}px`,
|
||||
fontStyle: 'bold',
|
||||
|
|
@ -241,10 +239,18 @@ export default class PlayScene extends Phaser.Scene {
|
|||
}
|
||||
}
|
||||
|
||||
// Fades each icon toward the aboard/ejected state of the specific kid it
|
||||
// portrays (matching seat index), not just a generic "N used up" count -
|
||||
// this fires in both directions, so a kid caught and re-boarded (see
|
||||
// KidManager) fades its icon back in too.
|
||||
_onKidStatusChanged({ kidsAboard, total }) {
|
||||
const ejectedCount = total - kidsAboard;
|
||||
for (let i = 0; i < this.hudIcons.length; i++) {
|
||||
this.hudIcons[i].setAlpha(i < ejectedCount ? 0.2 : 1);
|
||||
const kid = this.kidManager.kids[i];
|
||||
const targetAlpha = kid.state === 'aboard' ? 1 : 0.2;
|
||||
const icon = this.hudIcons[i];
|
||||
if (icon.alpha !== targetAlpha) {
|
||||
this.tweens.add({ targets: icon, alpha: targetAlpha, duration: 300 });
|
||||
}
|
||||
}
|
||||
this.hudText.setText(`Kids aboard: ${kidsAboard}/${total}`);
|
||||
}
|
||||
|
|
@ -269,11 +275,41 @@ export default class PlayScene extends Phaser.Scene {
|
|||
return (isGoal(bodyA) && isBus(bodyB)) || (isGoal(bodyB) && isBus(bodyA));
|
||||
}
|
||||
|
||||
// Freezes the exact moment of crossing the line - pausing physics before
|
||||
// anything else means the snapshot taken below can't catch so much as one
|
||||
// more tick of drift - then hands off to LevelScoreScene, which plays the
|
||||
// score-tally sequence over that frozen frame before continuing on to
|
||||
// LevelCompleteScene's retry/next-level choices.
|
||||
_onWin() {
|
||||
this._levelEnded = true;
|
||||
this.matter.world.pause();
|
||||
this.cameras.main.stopFollow();
|
||||
if (this.engineSound) this.engineSound.destroy();
|
||||
if (this.voiceSound) this.voiceSound.stop();
|
||||
|
||||
const kidsSaved = this.kidManager.kidsAboardCount;
|
||||
const total = this.kidManager.total;
|
||||
this.scene.start('LevelComplete', { levelId: this.levelId, kidsSaved, total });
|
||||
const kidResults = this.kidManager.kids.map((kid) => kid.state === 'aboard');
|
||||
const timeRemainingSeconds = Math.max(0, Math.ceil(this._timeRemaining));
|
||||
|
||||
// Renderer.snapshot() only resolves after the NEXT frame renders, but
|
||||
// physics is already paused above, so that next frame is pixel-identical
|
||||
// to this one - just guaranteed to have actually been drawn once before
|
||||
// we read it back.
|
||||
this.game.renderer.snapshot((image) => {
|
||||
const freezeKey = 'levelScoreFreeze';
|
||||
if (this.textures.exists(freezeKey)) this.textures.remove(freezeKey);
|
||||
this.textures.addImage(freezeKey, image);
|
||||
|
||||
this.scene.start('LevelScore', {
|
||||
levelId: this.levelId,
|
||||
kidsSaved,
|
||||
total,
|
||||
kidResults,
|
||||
timeRemainingSeconds,
|
||||
freezeKey,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_cleanup() {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ export default class PreloadScene extends Phaser.Scene {
|
|||
|
||||
this.load.audio('main_title_music', 'assets/music/main-title.mp3');
|
||||
this.load.audio('engine_heavy', 'assets/fx/engine-heavy.mp3');
|
||||
this.load.audio('crowd_cheer', 'assets/fx/crowd-cheer.mp3');
|
||||
this.load.audio('kid_count', 'assets/fx/kid-count.mp3');
|
||||
this.load.audio('score_count', 'assets/fx/score-count.mp3');
|
||||
loadKidFallSounds(this.load);
|
||||
|
||||
// Not every level has a voice-over - this just attempts one per level
|
||||
|
|
|
|||
|
|
@ -17,6 +17,5 @@ export const ASSET_MANIFEST = [
|
|||
{ key: 'bg_far', path: 'assets/backgrounds/bg_far.png', width: 256 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x8fc7e8, tileable: true },
|
||||
{ key: 'bg_mid', path: 'assets/backgrounds/bg_mid.png', width: 256 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x6fae7d, tileable: true },
|
||||
{ key: 'bg_near', path: 'assets/backgrounds/bg_near.png', width: 256 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x4c8a5c, tileable: true },
|
||||
{ key: 'icon_kid', path: 'assets/ui/icon_kid.png', width: 24 * WORLD_SCALE, height: 24 * WORLD_SCALE, kind: 'circle', color: 0xf2c14e },
|
||||
{ key: 'favicon', path: 'assets/favicon.png', width: 32, height: 32, kind: 'rect', color: 0x2255aa },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
// Shared mm:ss formatting for the countdown timer - used by both PlayScene's
|
||||
// live HUD and LevelScoreScene's end-of-level tally, which replays the same
|
||||
// clock from wherever PlayScene left it.
|
||||
export function formatTime(seconds) {
|
||||
const whole = Math.max(0, Math.ceil(seconds));
|
||||
const m = Math.floor(whole / 60);
|
||||
const s = whole % 60;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
Loading…
Reference in New Issue