import Phaser from 'phaser'; import { GAME_WIDTH, GAME_HEIGHT, DEBUG, WORLD_SCALE } from '../config.js'; import { getLevelById } from '../data/levels/index.js'; import Bus from '../entities/Bus.js'; import Terrain from '../entities/Terrain.js'; import FinishLine from '../entities/FinishLine.js'; import InputController from '../systems/InputController.js'; import GForceMonitor from '../systems/GForceMonitor.js'; import KidManager from '../systems/KidManager.js'; import CameraRig from '../systems/CameraRig.js'; import EngineSound from '../systems/EngineSound.js'; import { stopMenuMusic } from '../util/music.js'; import { playLevelVoiceLine } from '../util/voiceLine.js'; export default class PlayScene extends Phaser.Scene { constructor() { super('Play'); } init(data) { this.levelId = data.levelId; this.level = getLevelById(this.levelId); this._levelEnded = false; this._timerStarted = false; this._timeRemaining = this.level.timeLimit; } create() { stopMenuMusic(this); this.cameras.main.setBackgroundColor('#8fc7e8'); this._buildParallax(); this.terrain = new Terrain(this, this.level); this.finishLine = new FinishLine(this, this.level); this.bus = new Bus(this, this.level.startPosition.x, this.level.startPosition.y, this.level.startAngle || 0); this.kidManager = new KidManager(this, this.bus, this.level.kidsAboard); this.inputController = new InputController(this); this.engineSound = new EngineSound(this, this.bus); this.voiceSound = playLevelVoiceLine(this, this.levelId); this.gForceMonitor = new GForceMonitor(this, this.bus.chassis.body); this.gForceMonitor.resetBaseline(); this.cameraRig = new CameraRig(this, this.bus.chassis, this.level.cameraBounds); this._buildGoal(); this._buildHud(); this.events.on('kid-status-changed', this._onKidStatusChanged, this); this.events.on('level-failed', this._onLevelFailed, this); this.matter.world.on('collisionstart', this._onCollisionStart, this); this.events.once('shutdown', this._cleanup, this); } update(time, delta) { 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(); if (DEBUG && this.debugText) { const g = this.gForceMonitor.lastGForce || 0; this.debugText.setText(`g-force: ${g.toFixed(2)}`); } } _buildParallax() { const bounds = this.level.cameraBounds; const width = bounds.width + 1000 * WORLD_SCALE; // Far layer fills the full viewport height at ONE uniform scale (same // factor on both axes, so its native aspect ratio is preserved - no // stretch), fixed to the screen (scroll factor 0). _updateParallax() // manually scrubs its tilePositionX each frame based on progress from // start to goal, so the single image pans start-to-end exactly once // over the course of the level. const farNative = this._nativeTextureSize('bg_far'); const farScale = GAME_HEIGHT / farNative.height; this.bgFar = this.add.tileSprite(0, 0, GAME_WIDTH, GAME_HEIGHT, 'bg_far').setOrigin(0, 0); this.bgFar.setScrollFactor(0, 0); this.bgFar.tileScaleX = farScale; this.bgFar.tileScaleY = farScale; this._bgFarPanRange = Math.max(0, farNative.width - GAME_WIDTH / farScale); // Mid/near are ground-level layers stacked from the bottom of the // screen upward, each at its own uniform (non-stretching) scale, tiled // across the level via the normal scrollFactor-driven parallax. Repeat // count isn't pinned to an exact number - it falls out of each layer's // target height, and near's is shorter than mid's, so it naturally // repeats more often across the same span. this._addGroundLayer('bg_mid', bounds.x, width, GAME_HEIGHT * 0.6, 0.45); this._addGroundLayer('bg_near', bounds.x, width, GAME_HEIGHT * 0.35, 0.75); } _nativeTextureSize(key) { const src = this.textures.get(key).getSourceImage(); return { width: src.width, height: src.height }; } // Adds a bottom-anchored parallax layer scaled uniformly (same factor on // both axes, so its native aspect ratio is preserved - no stretch), // tiled horizontally across `displayWidth` however many times that scale // naturally allows. _addGroundLayer(key, x, displayWidth, targetHeight, scrollFactorX) { const native = this._nativeTextureSize(key); const scale = targetHeight / native.height; const y = GAME_HEIGHT - targetHeight; const layer = this.add.tileSprite(x, y, displayWidth, targetHeight, key).setOrigin(0, 0); layer.tileScaleX = scale; layer.tileScaleY = scale; layer.setScrollFactor(scrollFactorX, 0); return layer; } // Drives bg_far's pan independent of camera scroll: 0 at the bus's start // position, 1 at the goal, clamped so overshoot/backing up doesn't run // the image past its own ends. _updateParallax() { if (!this.bgFar) return; const startX = this.level.startPosition.x; const endX = this.level.goal.x; const progress = Phaser.Math.Clamp((this.bus.chassis.x - startX) / Math.max(1, endX - startX), 0, 1); this.bgFar.tilePositionX = progress * this._bgFarPanRange; } // A bus that's fallen past the bottom of the level's own camera bounds // is somewhere the camera can never scroll down far enough to show again // (that's the literal definition of "off screen" here) - treated the // same as losing every kid, since there's no way to recover either. _checkBusFell() { if (this._isBelowCameraBounds(this.bus.chassis.y)) { this.events.emit('level-failed', { reason: 'bus-fell' }); } } _isBelowCameraBounds(y) { const bounds = this.level.cameraBounds; return y > bounds.y + bounds.height; } // 0 kids aboard isn't itself a fail condition - KidManager just tracks // who's still seated. A kid popped loose by a jump is still catchable // (falls down through the compartment's open top and re-boards - see // KidManager) right up until it's either settled outside the bus or lost // down a gap, so the level only fails once EVERY kid has reached one of // those two unrecoverable states, not the instant the last one leaves. _checkAllKidsLost() { if (this.kidManager.kidsAboardCount > 0) return; const allUnrecoverable = this.kidManager.kids.every((kid) => { return this._isBelowCameraBounds(kid.image.y) || kid.isSettled(this.time.now); }); if (allUnrecoverable) { this.events.emit('level-failed', { reason: 'all-kids-lost' }); } } // 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, { isStatic: true, isSensor: true, label: 'goal', }); } _buildHud() { this.hudIcons = []; const startX = 24 * WORLD_SCALE; const y = 24 * WORLD_SCALE; const spacing = 30 * WORLD_SCALE; const iconSize = 24 * WORLD_SCALE; 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); this.hudIcons.push(icon); } this.hudText = this.add.text(startX, y + 24 * WORLD_SCALE, `Kids aboard: ${this.level.kidsAboard}/${this.level.kidsAboard}`, { fontFamily: 'monospace', fontSize: `${14 * WORLD_SCALE}px`, color: '#1a1f29', 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', fontSize: `${14 * WORLD_SCALE}px`, color: '#1a1f29', backgroundColor: '#ffffffaa', }).setScrollFactor(0).setDepth(10); } } _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); } this.hudText.setText(`Kids aboard: ${kidsAboard}/${total}`); } _onLevelFailed({ reason }) { if (this._levelEnded) return; this._levelEnded = true; this.scene.start('LevelFailed', { levelId: this.levelId, reason }); } _onCollisionStart(event, bodyA, bodyB) { if (this._levelEnded) return; if (this._isGoalHit(bodyA, bodyB)) { this._onWin(); } } _isGoalHit(bodyA, bodyB) { const busBodies = [this.bus.chassis.body, this.bus.wheelRear.body, this.bus.wheelFront.body]; const isGoal = (b) => b.label === 'goal'; const isBus = (b) => busBodies.includes(b); return (isGoal(bodyA) && isBus(bodyB)) || (isGoal(bodyB) && isBus(bodyA)); } _onWin() { this._levelEnded = true; const kidsSaved = this.kidManager.kidsAboardCount; const total = this.kidManager.total; this.scene.start('LevelComplete', { levelId: this.levelId, kidsSaved, total }); } _cleanup() { this.events.off('kid-status-changed', this._onKidStatusChanged, this); this.events.off('level-failed', this._onLevelFailed, this); // No matter.world.off() here: Phaser's Matter World plugin registers its // own 'shutdown' listener before this scene's create() ever runs, so it // tears down (and nulls out) this.matter.world before this handler // fires - calling .off() on it threw "Cannot read properties of null". // Matter.World#shutdown() already calls removeAllListeners() on itself // internally (verified against the vendored source), so this was always // redundant, not just now-unsafe. if (this.gForceMonitor) this.gForceMonitor.destroy(); if (this.kidManager) this.kidManager.destroy(); if (this.engineSound) this.engineSound.destroy(); if (this.voiceSound) this.voiceSound.stop(); if (this.bus) this.bus.destroy(); } }