Compare commits

...

3 Commits

Author SHA1 Message Date
Brian Fertig e7cb1d907a fix(terrain): align physics body with drawn terrain line
Correct the rectangle center offset calculation in Terrain to account
for both X and Y components perpendicular to each segment. Previously,
only the Y offset was applied, causing physics bodies to shift sideways
on sloped terrain. This fix resolves the bus visibly riding above or
through the ground on non-flat segments.

Also adds background and logo assets.
2026-08-20 13:16:57 -06:00
Brian Fertig ef47daea6d feat(editor): improve level preview and auto-calculate time limits
- Fix terrain preview aspect ratio to match in-game rendering by using
  consistent X/Y scaling, preventing slopes from appearing distorted
- Calculate timeLimit automatically based on level length and pace
  (TIME_LIMIT_PACE) to ensure consistent difficulty scaling across levels
- Add max-height and vertical scrolling to the level preview panel
- Generate level02 (The Big Jump) from the editor with manual tweak
2026-08-20 12:38:21 -06:00
Brian Fertig 7f4c632cce 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.
2026-08-20 12:22:59 -06:00
15 changed files with 653 additions and 41 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

BIN
assets/backgrounds/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 KiB

View File

@ -174,6 +174,8 @@
}
#preview-scroll {
overflow-x: auto;
overflow-y: auto;
max-height: 640px;
border: 1px solid #232c40;
border-radius: 4px;
background: #0b0e14;

View File

@ -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,

View File

@ -1,25 +1,260 @@
import { generateWave, withRamp } from './terrainHelpers.js';
import { WORLD_SCALE } from '../../config.js';
const RUNWAY_END_X = 1850 * WORLD_SCALE;
const LANDING_START_X = 2200 * WORLD_SCALE;
const END_X = 3900 * WORLD_SCALE;
const runway = generateWave(-200 * WORLD_SCALE, RUNWAY_END_X, 60 * WORLD_SCALE, 420 * WORLD_SCALE, 10 * WORLD_SCALE, 500 * WORLD_SCALE);
withRamp(runway, 1500 * WORLD_SCALE, RUNWAY_END_X, -170 * WORLD_SCALE); // launch ramp climbing up to the jump-off edge
const landing = generateWave(LANDING_START_X, END_X, 80 * WORLD_SCALE, 460 * WORLD_SCALE, 15 * WORLD_SCALE, 400 * WORLD_SCALE);
// Generated by editor.html - review before dropping into src/data/levels/.
export default {
id: 'level02',
name: 'Big Air',
description: 'Clear the gap and land level, or the kids pay for it.',
id: "level02",
name: "The Big Jump",
description: "Make the Jump or the kids pay!",
kidsAboard: 3,
timeLimit: 20,
startPosition: { x: 100 * WORLD_SCALE, y: 350 * WORLD_SCALE },
timeLimit: 41,
startPosition: { x: 200, y: 700 },
startAngle: 0,
terrain: [{ points: runway }, { points: landing }],
terrain: [
{ points: [
{ x: -400, y: 840 },
{ x: -330, y: 840 },
{ x: -260, y: 840 },
{ x: -190, y: 840 },
{ x: -120, y: 840 },
{ x: -50, y: 840 },
{ x: 20, y: 840 },
{ x: 90, y: 840 },
{ x: 160, y: 840 },
{ x: 230, y: 840 },
{ x: 300, y: 840 },
{ x: 370, y: 840 },
{ x: 440, y: 840 },
{ x: 510, y: 840 },
{ x: 580, y: 840 },
{ x: 650, y: 840 },
{ x: 720, y: 840 },
{ x: 790, y: 840 },
{ x: 860, y: 840 },
{ x: 930, y: 840 },
{ x: 1000, y: 840 },
{ x: 1070, y: 838 },
{ x: 1140, y: 834 },
{ x: 1210, y: 830 },
{ x: 1280, y: 822 },
{ x: 1350, y: 812 },
{ x: 1420, y: 802 },
{ x: 1490, y: 790 },
{ x: 1560, y: 776 },
{ x: 1630, y: 764 },
{ x: 1700, y: 750 },
{ x: 1770, y: 736 },
{ x: 1840, y: 724 },
{ x: 1910, y: 710 },
{ x: 1980, y: 698 },
{ x: 2050, y: 688 },
{ x: 2120, y: 678 },
{ x: 2190, y: 670 },
{ x: 2260, y: 666 },
{ x: 2330, y: 662 },
{ x: 2400, y: 660 },
{ x: 2470, y: 658 },
{ x: 2540, y: 654 },
{ x: 2610, y: 650 },
{ x: 2680, y: 642 },
{ x: 2750, y: 632 },
{ x: 2820, y: 622 },
{ x: 2890, y: 610 },
{ x: 2960, y: 596 },
{ x: 3030, y: 584 },
{ x: 3100, y: 570 },
{ x: 3170, y: 556 },
{ x: 3240, y: 544 },
{ x: 3310, y: 530 },
{ x: 3380, y: 518 },
{ x: 3450, y: 508 },
{ x: 3520, y: 498 },
{ x: 3590, y: 490 },
{ x: 3660, y: 486 },
{ x: 3730, y: 482 },
{ x: 3800, y: 480 },
{ x: 3870, y: 464 },
{ x: 3940, y: 448 },
{ x: 4010, y: 432 },
{ x: 4080, y: 416 },
{ x: 4150, y: 400 },
{ x: 4220, y: 384 },
{ x: 4290, y: 368 },
{ x: 4360, y: 352 },
{ x: 4430, y: 336 },
{ x: 4500, y: 320 },
{ x: 4570, y: 304 },
{ x: 4640, y: 288 },
{ x: 4710, y: 272 },
{ x: 4780, y: 256 },
{ x: 4850, y: 240 },
{ x: 4920, y: 224 },
{ x: 4990, y: 208 },
{ x: 5060, y: 192 },
{ x: 5130, y: 176 },
{ x: 5200, y: 160 },
{ x: 5270, y: 144 },
{ x: 5340, y: 126 },
{ x: 5410, y: 104 },
{ x: 5480, y: 80 },
{ x: 5550, y: 52 },
{ x: 5620, y: 22 },
{ x: 5690, y: -8 },
{ x: 5760, y: -40 },
{ x: 5830, y: -70 },
{ x: 5900, y: -100 },
{ x: 5970, y: -130 },
{ x: 6040, y: -160 },
{ x: 6110, y: -192 },
{ x: 6180, y: -222 },
{ x: 6250, y: -252 },
{ x: 6320, y: -282 },
{ x: 6390, y: -312 },
{ x: 6460, y: -344 },
{ x: 6530, y: -374 },
{ x: 6600, y: -404 },
{ x: 6670, y: -434 },
{ x: 6740, y: -466 },
{ x: 6810, y: -498 },
{ x: 6880, y: -530 },
{ x: 6950, y: -566 },
{ x: 7020, y: -602 },
{ x: 7090, y: -638 },
{ x: 7160, y: -674 },
{ x: 7230, y: -712 },
{ x: 7300, y: -742 },
{ x: 7356, y: -742 },
] },
{ points: [
{ x: 7776, y: -612 },
{ x: 7846, y: -600 },
{ x: 7916, y: -572 },
{ x: 7986, y: -534 },
{ x: 8056, y: -490 },
{ x: 8126, y: -450 },
{ x: 8196, y: -418 },
{ x: 8266, y: -404 },
{ x: 8280, y: -404 },
{ x: 8350, y: -370 },
{ x: 8420, y: -336 },
{ x: 8490, y: -302 },
{ x: 8560, y: -268 },
{ x: 8630, y: -236 },
{ x: 8700, y: -202 },
{ x: 8770, y: -168 },
{ x: 8840, y: -134 },
{ x: 8910, y: -100 },
{ x: 8980, y: -68 },
{ x: 9050, y: -34 },
{ x: 9120, y: 0 },
{ x: 9190, y: 34 },
{ x: 9260, y: 68 },
{ x: 9330, y: 100 },
{ x: 9400, y: 134 },
{ x: 9470, y: 168 },
{ x: 9540, y: 202 },
{ x: 9610, y: 236 },
{ x: 9680, y: 268 },
{ x: 9750, y: 296 },
{ x: 9820, y: 322 },
{ x: 9890, y: 342 },
{ x: 9960, y: 354 },
{ x: 10030, y: 358 },
{ x: 10100, y: 354 },
{ x: 10170, y: 342 },
{ x: 10240, y: 322 },
{ x: 10310, y: 296 },
{ x: 10380, y: 268 },
{ x: 10450, y: 240 },
{ x: 10520, y: 216 },
{ x: 10590, y: 196 },
{ x: 10660, y: 184 },
{ x: 10730, y: 178 },
{ x: 10800, y: 184 },
{ x: 10870, y: 196 },
{ x: 10940, y: 216 },
{ x: 11010, y: 240 },
{ x: 11080, y: 268 },
{ x: 11150, y: 312 },
{ x: 11220, y: 350 },
{ x: 11290, y: 382 },
{ x: 11360, y: 402 },
{ x: 11430, y: 408 },
{ x: 11500, y: 402 },
{ x: 11570, y: 382 },
{ x: 11640, y: 350 },
{ x: 11710, y: 312 },
{ x: 11780, y: 268 },
{ x: 11850, y: 226 },
{ x: 11920, y: 186 },
{ x: 11990, y: 156 },
{ x: 12060, y: 136 },
{ x: 12130, y: 130 },
{ x: 12200, y: 136 },
{ x: 12270, y: 156 },
{ x: 12340, y: 186 },
{ x: 12410, y: 226 },
{ x: 12480, y: 268 },
{ x: 12550, y: 328 },
{ x: 12620, y: 382 },
{ x: 12690, y: 426 },
{ x: 12760, y: 452 },
{ x: 12830, y: 462 },
{ x: 12900, y: 452 },
{ x: 12970, y: 426 },
{ x: 13040, y: 382 },
{ x: 13110, y: 328 },
{ x: 13180, y: 268 },
{ x: 13250, y: 208 },
{ x: 13320, y: 154 },
{ x: 13390, y: 112 },
{ x: 13460, y: 84 },
{ x: 13530, y: 76 },
{ x: 13600, y: 84 },
{ x: 13670, y: 112 },
{ x: 13740, y: 154 },
{ x: 13810, y: 208 },
{ x: 13880, y: 268 },
{ x: 13950, y: 326 },
{ x: 14020, y: 376 },
{ x: 14090, y: 418 },
{ x: 14160, y: 448 },
{ x: 14230, y: 470 },
{ x: 14300, y: 488 },
{ x: 14370, y: 504 },
{ x: 14440, y: 520 },
{ x: 14510, y: 536 },
{ x: 14580, y: 552 },
{ x: 14650, y: 568 },
{ x: 14720, y: 584 },
{ x: 14790, y: 600 },
{ x: 14860, y: 616 },
{ x: 14930, y: 632 },
{ x: 15000, y: 648 },
{ x: 15070, y: 664 },
{ x: 15140, y: 680 },
{ x: 15210, y: 696 },
{ x: 15280, y: 712 },
{ x: 15350, y: 712 },
{ x: 15420, y: 712 },
{ x: 15490, y: 712 },
{ x: 15560, y: 712 },
{ x: 15630, y: 712 },
{ x: 15700, y: 712 },
{ x: 15770, y: 712 },
{ x: 15840, y: 712 },
{ x: 15910, y: 712 },
{ x: 15980, y: 712 },
{ x: 16050, y: 712 },
{ x: 16120, y: 712 },
{ x: 16190, y: 712 },
{ x: 16260, y: 712 },
{ x: 16330, y: 712 },
{ x: 16400, y: 712 },
{ x: 16470, y: 712 },
{ x: 16540, y: 712 },
{ x: 16610, y: 712 },
{ x: 16680, y: 712 },
] },
],
obstacles: [],
goal: { x: END_X - 120 * WORLD_SCALE, y: 340 * WORLD_SCALE, width: 140 * WORLD_SCALE, height: 320 * WORLD_SCALE },
cameraBounds: { x: -300 * WORLD_SCALE, y: 0, width: END_X + 700 * WORLD_SCALE, height: 1000 * WORLD_SCALE },
goal: { x: 16440, y: 472, width: 280, height: 640 },
cameraBounds: { x: -600, y: -1542, width: 18080, height: 3182 },
};

View File

@ -28,6 +28,7 @@ export function exportLevelToSource(levelData) {
` name: ${JSON.stringify(levelData.name)},`,
` description: ${JSON.stringify(levelData.description)},`,
` kidsAboard: ${levelData.kidsAboard},`,
` timeLimit: ${levelData.timeLimit},`,
` startPosition: { x: ${levelData.startPosition.x}, y: ${levelData.startPosition.y} },`,
` startAngle: ${levelData.startAngle},`,
` terrain: ${terrainLiteral(levelData.terrain)},`,

View File

@ -26,6 +26,17 @@ const CAMERA_RIGHT_MARGIN = 700;
const CAMERA_VERTICAL_PADDING = 400;
const CAMERA_MIN_HEIGHT = 1000;
// PlayScene reads level.timeLimit directly with no fallback (see
// PlayScene._updateTimer) - a level missing it entirely counts down from
// `undefined`, which never satisfies <= 0, so the level can simply never
// time out and the HUD clock reads "NaN:NaN". Auto-deriving it from the
// level's own start-to-goal distance means every exported level always has
// one, tuned by playtesting to a pace matching level01/03/04's
// hand-authored limits (~380-420 scaled units/sec each) rather than a flat
// number that wouldn't scale with track length.
const TIME_LIMIT_PACE = 400; // scaled world-units of track per second
const TIME_LIMIT_MIN = 15;
function scalePoint(p) {
return { x: Math.round(p.x * WORLD_SCALE), y: Math.round(p.y * WORLD_SCALE) };
}
@ -197,6 +208,10 @@ export function buildLevelData(sections, metadata) {
const endX = Math.round(baseX * WORLD_SCALE);
const groundYAtEnd = Math.round(baseY * WORLD_SCALE);
const spawnX = Math.round(BUS_SPAWN_X * WORLD_SCALE);
const goalX = endX - Math.round(GOAL_END_MARGIN * WORLD_SCALE);
const timeLimit = Math.max(TIME_LIMIT_MIN, Math.round((goalX - spawnX) / TIME_LIMIT_PACE));
let minTerrainY = Infinity;
let maxTerrainY = -Infinity;
for (const segment of terrainSegments) {
@ -218,15 +233,16 @@ export function buildLevelData(sections, metadata) {
name: metadata.name,
description: metadata.description,
kidsAboard: metadata.kidsAboard,
timeLimit,
startPosition: {
x: Math.round(BUS_SPAWN_X * WORLD_SCALE),
x: spawnX,
y: Math.round((INITIAL_GROUND_Y - BUS_SPAWN_DROP) * WORLD_SCALE),
},
startAngle: 0,
terrain: terrainSegments,
obstacles: [],
goal: {
x: endX - Math.round(GOAL_END_MARGIN * WORLD_SCALE),
x: goalX,
y: groundYAtEnd - Math.round(GOAL_HEIGHT_OFFSET * WORLD_SCALE),
width: Math.round(GOAL_WIDTH * WORLD_SCALE),
height: Math.round(GOAL_HEIGHT * WORLD_SCALE),

View File

@ -224,7 +224,7 @@ removeBtn.addEventListener('click', () => {
const PADDING = 40;
const H_PX_PER_UNIT = 0.12;
const CANVAS_HEIGHT = 460;
const MIN_CANVAS_HEIGHT = 260;
function computeBounds(levelData) {
let minX = Infinity;
@ -251,14 +251,20 @@ function drawPreview(levelData) {
const contentWidth = Math.max(1, maxX - minX);
const contentHeight = Math.max(1, maxY - minY);
// One scale factor for both axes - the actual game (Terrain.js) always
// scales X and Y identically (everything just multiplies by WORLD_SCALE),
// so fitting height to a fixed canvas box with its own independently
// derived vertical scale (the old behavior) stretched or squashed every
// incline/hill relative to how it actually plays. Height now just follows
// from content height at the same scale, with a floor so a nearly-flat
// level doesn't render as a sliver.
const width = Math.max(600, Math.round(contentWidth * H_PX_PER_UNIT) + PADDING * 2);
const height = CANVAS_HEIGHT;
const height = Math.max(MIN_CANVAS_HEIGHT, Math.round(contentHeight * H_PX_PER_UNIT) + PADDING * 2);
previewCanvas.width = width;
previewCanvas.height = height;
const vScale = (height - PADDING * 2) / contentHeight;
const screenX = (worldX) => (worldX - minX) * H_PX_PER_UNIT + PADDING;
const screenY = (worldY) => (worldY - minY) * vScale + PADDING;
const screenY = (worldY) => (worldY - minY) * H_PX_PER_UNIT + PADDING;
const ctx = previewCanvas.getContext('2d');
ctx.fillStyle = '#0b0e14';

View File

@ -66,8 +66,19 @@ export default class Terrain {
const length = Math.hypot(dx, dy);
if (length < 1) continue;
// The rectangle's top edge needs to sit exactly on the a-b line, which
// means offsetting its center from the segment's own midpoint by
// TERRAIN_DEPTH/2 in the direction PERPENDICULAR to the segment - both
// an X and a Y component (-sin/cos of the segment's angle), not just
// straight down in world Y. Omitting the X term (as a prior version of
// this did) left every non-flat segment's physics body shifted
// sideways from its drawn line by TERRAIN_DEPTH/2 * sin(angle) - with
// TERRAIN_DEPTH deliberately huge (deep enough nothing ever tunnels
// through the bottom), that shift is large even at modest slopes,
// which is exactly what made the bus visibly ride above/through the
// drawn ground on anything but flat terrain.
const angle = Math.atan2(dy, dx);
const midX = (a.x + b.x) / 2;
const midX = (a.x + b.x) / 2 - (TERRAIN_DEPTH / 2) * Math.sin(angle);
const midY = (a.y + b.y) / 2 + (TERRAIN_DEPTH / 2) * Math.cos(angle);
const body = this.scene.matter.add.rectangle(midX, midY, length, TERRAIN_DEPTH, {

View File

@ -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,
],

View File

@ -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();
}
}

View File

@ -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() {

View File

@ -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

View File

@ -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 },
];

9
src/util/formatTime.js Normal file
View File

@ -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')}`;
}