git commit -m "Fix throttle feel and null-reference shutdown crashes

Throttle/brake now apply a direct, ground-only force to the chassis on
top of spinning the wheel, since friction-mediated propulsion alone
couldn't reach a satisfying top speed. isGrounded() checks the engine's
live collision pairs instead of tallying start/end events, which left a
counter permanently stuck above zero when crossing segment seams and
turned airborne throttle into a rocket thruster.

Kid.destroy() and GForceMonitor.destroy() no longer call
matter.world.removeConstraint()/off(): by the time a scene's 'shutdown'
handler runs, the Matter World plugin has already torn down and nulled
this.matter.world, throwing 'Cannot read properties of null'. Matter's
World#shutdown() clears all bodies/constraints/listeners itself anyway,
so both removals were redundant, not just unsafe.
"
This commit is contained in:
Brian Fertig 2026-08-18 20:55:02 -06:00
parent 34960a2321
commit ffe29468d6
5 changed files with 71 additions and 8 deletions

View File

@ -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,

View File

@ -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,

View File

@ -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();
}
}

View File

@ -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();
}

View File

@ -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.
}
}