import Phaser from 'phaser'; import { GAME_WIDTH, GAME_HEIGHT, DEBUG, WORLD_SCALE, COMPARTMENT_DEBUG } 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'; import { formatTime } from '../util/formatTime.js'; import { createPill } from '../util/pill.js'; import buildCompartmentDebug from '../util/compartmentDebug.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); // Troubleshooting overlay: renders the invisible compartment fixtures // (floor + walls) as simple rectangles tracking the bus, plus per-kid // state readout. Off by default - toggle COMPARTMENT_DEBUG in config.js. this.compartmentDebug = COMPARTMENT_DEBUG ? buildCompartmentDebug(this, this.bus, this.kidManager) : null; 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(formatTime(this._timeRemaining)); this.timerText.setColor(this._timeRemaining <= 10 ? '#c0392b' : '#1a1f29'); if (this._timeRemaining <= 0) { this.events.emit('level-failed', { reason: 'time-up' }); } } _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; // 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, 'kid_idle').setDisplaySize(iconSize, iconSize).setScrollFactor(0).setDepth(10); if (this.textures.get('kid_idle').has(i)) icon.setFrame(i); this.hudIcons.push(icon); } this.hudText = createPill(this, startX, y + 24 * WORLD_SCALE, `Kids aboard: ${this.level.kidsAboard}/${this.level.kidsAboard}`, { fontFamily: 'monospace', fontSize: `${14 * WORLD_SCALE}px`, color: '#1a1f29', }, 0, 0).setScrollFactor(0).setDepth(10); this.timerText = createPill(this, GAME_WIDTH - startX, y, formatTime(this._timeRemaining), { fontFamily: 'monospace', fontSize: `${32 * WORLD_SCALE}px`, fontStyle: 'bold', color: '#1a1f29', }, 1, 0).setScrollFactor(0).setDepth(10); if (DEBUG) { this.debugText = createPill(this, startX, y + 52 * WORLD_SCALE, 'g-force: 0.00', { fontFamily: 'monospace', fontSize: `${14 * WORLD_SCALE}px`, color: '#1a1f29', }, 0, 0).setScrollFactor(0).setDepth(10); } } // 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 }) { for (let i = 0; i < this.hudIcons.length; i++) { 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}`); } _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)); } // 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 returning the player // to the level select map. _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; 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() { 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.compartmentDebug) this.compartmentDebug.destroy(); 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(); } }