diff --git a/src/config.js b/src/config.js index 9f29147..3abc2ee 100644 --- a/src/config.js +++ b/src/config.js @@ -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, diff --git a/src/main.js b/src/main.js index d04ca2c..0b5b662 100644 --- a/src/main.js +++ b/src/main.js @@ -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, ], diff --git a/src/scenes/LevelScoreScene.js b/src/scenes/LevelScoreScene.js new file mode 100644 index 0000000..6fb3d1a --- /dev/null +++ b/src/scenes/LevelScoreScene.js @@ -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(); + } +} diff --git a/src/scenes/PlayScene.js b/src/scenes/PlayScene.js index d250be0..1ec875c 100644 --- a/src/scenes/PlayScene.js +++ b/src/scenes/PlayScene.js @@ -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() { diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index d28c58c..9123cd7 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -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 diff --git a/src/util/assetManifest.js b/src/util/assetManifest.js index b17307a..12670ba 100644 --- a/src/util/assetManifest.js +++ b/src/util/assetManifest.js @@ -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 }, ]; diff --git a/src/util/formatTime.js b/src/util/formatTime.js new file mode 100644 index 0000000..1d759fe --- /dev/null +++ b/src/util/formatTime.js @@ -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')}`; +}