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
This commit is contained in:
Brian Fertig 2026-08-20 11:03:20 -06:00
parent 0c80e4774e
commit 17fe6efd10
6 changed files with 40 additions and 0 deletions

View File

@ -4,6 +4,7 @@ export default {
name: "GoFundMe", name: "GoFundMe",
description: "Get the Money!", description: "Get the Money!",
kidsAboard: 4, kidsAboard: 4,
timeLimit: 60,
startPosition: { x: 200, y: 700 }, startPosition: { x: 200, y: 700 },
startAngle: 0, startAngle: 0,
terrain: [ terrain: [

View File

@ -15,6 +15,7 @@ export default {
name: 'Big Air', name: 'Big Air',
description: 'Clear the gap and land level, or the kids pay for it.', description: 'Clear the gap and land level, or the kids pay for it.',
kidsAboard: 3, kidsAboard: 3,
timeLimit: 20,
startPosition: { x: 100 * WORLD_SCALE, y: 350 * WORLD_SCALE }, startPosition: { x: 100 * WORLD_SCALE, y: 350 * WORLD_SCALE },
startAngle: 0, startAngle: 0,
terrain: [{ points: runway }, { points: landing }], terrain: [{ points: runway }, { points: landing }],

View File

@ -32,6 +32,7 @@ export default {
name: 'Tight Squeeze', name: 'Tight Squeeze',
description: 'A steep valley and a long run of sharp bumps - lean early, lean often.', description: 'A steep valley and a long run of sharp bumps - lean early, lean often.',
kidsAboard: 4, kidsAboard: 4,
timeLimit: 20,
startPosition: { x: 100 * WORLD_SCALE, y: 350 * WORLD_SCALE }, startPosition: { x: 100 * WORLD_SCALE, y: 350 * WORLD_SCALE },
startAngle: 0, startAngle: 0,
terrain: [{ points: buildPoints() }], terrain: [{ points: buildPoints() }],

View File

@ -4,6 +4,7 @@ export default {
name: "Mega Drop", name: "Mega Drop",
description: "", description: "",
kidsAboard: 5, kidsAboard: 5,
timeLimit: 76,
startPosition: { x: 200, y: 700 }, startPosition: { x: 200, y: 700 },
startAngle: 0, startAngle: 0,
terrain: [ terrain: [

View File

@ -5,6 +5,7 @@ import { createButton } from '../util/ui.js';
const REASON_TEXT = { const REASON_TEXT = {
'all-kids-lost': 'Every kid got thrown off the bus!', 'all-kids-lost': 'Every kid got thrown off the bus!',
'bus-fell': 'The bus fell into the gap!', '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 { export default class LevelFailedScene extends Phaser.Scene {

View File

@ -21,6 +21,8 @@ export default class PlayScene extends Phaser.Scene {
this.levelId = data.levelId; this.levelId = data.levelId;
this.level = getLevelById(this.levelId); this.level = getLevelById(this.levelId);
this._levelEnded = false; this._levelEnded = false;
this._timerStarted = false;
this._timeRemaining = this.level.timeLimit;
} }
create() { create() {
@ -62,11 +64,14 @@ export default class PlayScene extends Phaser.Scene {
if (this._levelEnded) return; if (this._levelEnded) return;
const intent = this.inputController.getIntent(); const intent = this.inputController.getIntent();
if (!this._timerStarted && intent.throttle > 0) this._timerStarted = true;
this.bus.applyIntent(intent); this.bus.applyIntent(intent);
this.engineSound.update(); this.engineSound.update();
this.finishLine.update(time); this.finishLine.update(time);
this.cameraRig.update(); this.cameraRig.update();
this._updateParallax(); this._updateParallax();
this._updateTimer(delta);
this._checkBusFell(); this._checkBusFell();
this._checkAllKidsLost(); 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() { _buildGoal() {
const goal = this.level.goal; const goal = this.level.goal;
this.goalBody = this.matter.add.rectangle(goal.x, goal.y, goal.width, goal.height, { 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', backgroundColor: '#ffffffaa',
}).setScrollFactor(0).setDepth(10); }).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) { if (DEBUG) {
this.debugText = this.add.text(startX, y + 52 * WORLD_SCALE, 'g-force: 0.00', { this.debugText = this.add.text(startX, y + 52 * WORLD_SCALE, 'g-force: 0.00', {
fontFamily: 'monospace', fontFamily: 'monospace',