296 lines
11 KiB
JavaScript
296 lines
11 KiB
JavaScript
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();
|
|
}
|
|
}
|