diff --git a/src/config.js b/src/config.js index aea6131..a0ca313 100644 --- a/src/config.js +++ b/src/config.js @@ -75,6 +75,19 @@ export const BUS = { brakeTorque: 0.4, maxBrakeAngularVelocity: 3.5, + // Measured via headless testing: spinning the wheel faster alone barely + // moved the chassis - past a point the wheel just slips (friction force + // is capped by frictionCoefficient * normalForce regardless of how fast + // the wheel spins beyond matching ground speed), so throttle applies a + // direct force to the chassis itself, along its current facing/tilt, on + // top of the wheel spin. This is the standard fix in arcade 2D driving + // games for exactly this problem. Expressed as target acceleration + // (Bus.js computes force = chassis mass * this) rather than a raw force, + // so it stays correct if chassisDensity/size ever changes - tune these + // to change top speed feel. + driveAcceleration: 0.011, + brakeAcceleration: 0.007, + // Bumped up to stay responsive at the new higher speed - sluggish lean // control would feel wrong on a bus that's now moving quickly. maxLeanAngularVelocity: 0.13, diff --git a/src/entities/Bus.js b/src/entities/Bus.js index ebb27a5..2cc9e8b 100644 --- a/src/entities/Bus.js +++ b/src/entities/Bus.js @@ -47,6 +47,32 @@ export default class Bus { this.seatOffsets = this._buildSeatOffsets(); } + // Drive/brake apply a direct force to the chassis (see applyIntent) on top + // of spinning the wheel, since friction-mediated propulsion alone couldn't + // reach a satisfying top speed. That force MUST only apply while a wheel + // is actually touching the ground - applying it in the air turned + // throttle into a rocket thruster (holding it after a jump just kept + // climbing forever instead of arcing back down under gravity). + // + // This checks the engine's live active collision pairs each call rather + // than tallying collisionstart/collisionend events - terrain is a chain of + // many small adjacent segments, and the wheel rolling across a seam can + // start touching the next segment fractionally before it stops touching + // the last one, which left a start/end counter permanently stuck above + // zero (confirmed via headless testing - the counter climbed to 5 and + // never came back down, so the bus never stopped thinking it was grounded). + isGrounded() { + const wheels = [this.wheelRear.body, this.wheelFront.body]; + const pairs = this.scene.matter.world.engine.pairs.list; + for (const pair of pairs) { + if (!pair.isActive) continue; + const isWheel = wheels.includes(pair.bodyA) || wheels.includes(pair.bodyB); + const isTerrain = pair.bodyA.label === 'terrain' || pair.bodyB.label === 'terrain'; + if (isWheel && isTerrain) return true; + } + return false; + } + // A single point-to-point constraint is radially symmetric - it can only // resist the wheel drifting away in general, not sideways specifically. // Two constraints anchored on either side of the true axle position (both @@ -96,6 +122,8 @@ export default class Bus { // intent: { throttle: -1|0|1, leanLeft: bool, leanRight: bool } applyIntent(intent) { const wheelBody = this.wheelRear.body; + const chassisBody = this.chassis.body; + const grounded = intent.throttle !== 0 && this.isGrounded(); if (intent.throttle > 0) { const next = Phaser.Math.Clamp( @@ -104,6 +132,12 @@ export default class Bus { BUS.maxWheelAngularVelocity ); this.wheelRear.setAngularVelocity(next); + + if (grounded) { + const dir = { x: Math.cos(chassisBody.angle), y: Math.sin(chassisBody.angle) }; + const force = chassisBody.mass * BUS.driveAcceleration; + this.chassis.applyForce({ x: dir.x * force, y: dir.y * force }); + } } else if (intent.throttle < 0) { const next = Phaser.Math.Clamp( wheelBody.angularVelocity - BUS.brakeTorque, @@ -111,9 +145,13 @@ export default class Bus { BUS.maxBrakeAngularVelocity ); this.wheelRear.setAngularVelocity(next); - } - const chassisBody = this.chassis.body; + if (grounded) { + const dir = { x: Math.cos(chassisBody.angle), y: Math.sin(chassisBody.angle) }; + const force = chassisBody.mass * BUS.brakeAcceleration; + this.chassis.applyForce({ x: -dir.x * force, y: -dir.y * force }); + } + } if (intent.leanLeft && !intent.leanRight) { const next = Phaser.Math.Clamp( chassisBody.angularVelocity - BUS.leanTorqueStep, diff --git a/src/entities/Kid.js b/src/entities/Kid.js index e932680..4bc2af6 100644 --- a/src/entities/Kid.js +++ b/src/entities/Kid.js @@ -60,10 +60,12 @@ export default class Kid { } destroy() { - if (this.seatConstraint) { - this.scene.matter.world.removeConstraint(this.seatConstraint, true); - this.seatConstraint = null; - } + // No removeConstraint() here (unlike eject(), which runs mid-gameplay + // and needs it): destroy() only ever runs from PlayScene's shutdown + // cleanup, by which point Phaser's Matter World plugin may have already + // torn down (nulled) this.scene.matter.world, and World#shutdown() + // already clears every constraint/body itself anyway. + this.seatConstraint = null; this.image.destroy(); } } diff --git a/src/scenes/PlayScene.js b/src/scenes/PlayScene.js index e4c171c..4f6ed35 100644 --- a/src/scenes/PlayScene.js +++ b/src/scenes/PlayScene.js @@ -155,7 +155,13 @@ export default class PlayScene extends Phaser.Scene { _cleanup() { this.events.off('kid-ejected', this._onKidEjected, this); this.events.off('level-failed', this._onLevelFailed, this); - this.matter.world.off('collisionstart', this._onCollisionStart, 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(); } diff --git a/src/systems/GForceMonitor.js b/src/systems/GForceMonitor.js index 279014b..56b31fd 100644 --- a/src/systems/GForceMonitor.js +++ b/src/systems/GForceMonitor.js @@ -46,6 +46,10 @@ export default class GForceMonitor { } destroy() { - this.scene.matter.world.off('afterupdate', this._onAfterUpdate); + // Deliberately empty: Matter World's own shutdown already removes every + // listener registered on it (including this one), and by the time a + // scene's 'shutdown' handler runs, this.scene.matter.world may already + // be null - calling .off() on it here threw "Cannot read properties of + // null". Kept as a no-op so callers don't need to know that. } }