From 17fe6efd1026b69117589cd491dd273c60a13d47 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Thu, 20 Aug 2026 11:03:20 -0600 Subject: [PATCH] feat: add level time limits with countdown timer - Add `timeLimit` configuration to levels 1-4 - Implement countdown timer that starts when throttle is pressed - Display remaining time in HUD, turning red when under 10s - Add 'time-up' failure condition and message in LevelFailedScene --- src/data/levels/level01.js | 1 + src/data/levels/level02.js | 1 + src/data/levels/level03.js | 1 + src/data/levels/level04.js | 1 + src/scenes/LevelFailedScene.js | 1 + src/scenes/PlayScene.js | 35 ++++++++++++++++++++++++++++++++++ 6 files changed, 40 insertions(+) diff --git a/src/data/levels/level01.js b/src/data/levels/level01.js index 1579add..16f36e9 100644 --- a/src/data/levels/level01.js +++ b/src/data/levels/level01.js @@ -4,6 +4,7 @@ export default { name: "GoFundMe", description: "Get the Money!", kidsAboard: 4, + timeLimit: 60, startPosition: { x: 200, y: 700 }, startAngle: 0, terrain: [ diff --git a/src/data/levels/level02.js b/src/data/levels/level02.js index d6ec729..c4fbf4c 100644 --- a/src/data/levels/level02.js +++ b/src/data/levels/level02.js @@ -15,6 +15,7 @@ export default { name: 'Big Air', description: 'Clear the gap and land level, or the kids pay for it.', kidsAboard: 3, + timeLimit: 20, startPosition: { x: 100 * WORLD_SCALE, y: 350 * WORLD_SCALE }, startAngle: 0, terrain: [{ points: runway }, { points: landing }], diff --git a/src/data/levels/level03.js b/src/data/levels/level03.js index e19bdd2..ef7bb79 100644 --- a/src/data/levels/level03.js +++ b/src/data/levels/level03.js @@ -32,6 +32,7 @@ export default { name: 'Tight Squeeze', description: 'A steep valley and a long run of sharp bumps - lean early, lean often.', kidsAboard: 4, + timeLimit: 20, startPosition: { x: 100 * WORLD_SCALE, y: 350 * WORLD_SCALE }, startAngle: 0, terrain: [{ points: buildPoints() }], diff --git a/src/data/levels/level04.js b/src/data/levels/level04.js index 76dc3ab..5638466 100644 --- a/src/data/levels/level04.js +++ b/src/data/levels/level04.js @@ -4,6 +4,7 @@ export default { name: "Mega Drop", description: "", kidsAboard: 5, + timeLimit: 76, startPosition: { x: 200, y: 700 }, startAngle: 0, terrain: [ diff --git a/src/scenes/LevelFailedScene.js b/src/scenes/LevelFailedScene.js index 2c2fbae..f446208 100644 --- a/src/scenes/LevelFailedScene.js +++ b/src/scenes/LevelFailedScene.js @@ -5,6 +5,7 @@ import { createButton } from '../util/ui.js'; const REASON_TEXT = { 'all-kids-lost': 'Every kid got thrown off the bus!', 'bus-fell': 'The bus fell into the gap!', + 'time-up': 'Ran out of time before reaching the finish line!', }; export default class LevelFailedScene extends Phaser.Scene { diff --git a/src/scenes/PlayScene.js b/src/scenes/PlayScene.js index 3cb67d9..d250be0 100644 --- a/src/scenes/PlayScene.js +++ b/src/scenes/PlayScene.js @@ -21,6 +21,8 @@ export default class PlayScene extends Phaser.Scene { this.levelId = data.levelId; this.level = getLevelById(this.levelId); this._levelEnded = false; + this._timerStarted = false; + this._timeRemaining = this.level.timeLimit; } create() { @@ -62,11 +64,14 @@ export default class PlayScene extends Phaser.Scene { if (this._levelEnded) return; const intent = this.inputController.getIntent(); + if (!this._timerStarted && intent.throttle > 0) this._timerStarted = true; + this.bus.applyIntent(intent); this.engineSound.update(); this.finishLine.update(time); this.cameraRig.update(); this._updateParallax(); + this._updateTimer(delta); this._checkBusFell(); this._checkAllKidsLost(); @@ -168,6 +173,28 @@ export default class PlayScene extends Phaser.Scene { } } + // Counts down only once the player has actually hit the gas (throttle > + // 0) for the first time - sitting idle at the start line costs nothing, + // matching "the clock is your speedrun, not your reaction time" framing. + _updateTimer(delta) { + 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.setColor(this._timeRemaining <= 10 ? '#c0392b' : '#1a1f29'); + + if (this._timeRemaining <= 0) { + this.events.emit('level-failed', { reason: 'time-up' }); + } + } + + _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, { @@ -196,6 +223,14 @@ 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), { + fontFamily: 'monospace', + fontSize: `${32 * WORLD_SCALE}px`, + fontStyle: 'bold', + color: '#1a1f29', + backgroundColor: '#ffffffaa', + }).setOrigin(1, 0).setScrollFactor(0).setDepth(10); + if (DEBUG) { this.debugText = this.add.text(startX, y + 52 * WORLD_SCALE, 'g-force: 0.00', { fontFamily: 'monospace',