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:
parent
34960a2321
commit
ffe29468d6
|
|
@ -75,6 +75,19 @@ export const BUS = {
|
||||||
brakeTorque: 0.4,
|
brakeTorque: 0.4,
|
||||||
maxBrakeAngularVelocity: 3.5,
|
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
|
// Bumped up to stay responsive at the new higher speed - sluggish lean
|
||||||
// control would feel wrong on a bus that's now moving quickly.
|
// control would feel wrong on a bus that's now moving quickly.
|
||||||
maxLeanAngularVelocity: 0.13,
|
maxLeanAngularVelocity: 0.13,
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,32 @@ export default class Bus {
|
||||||
this.seatOffsets = this._buildSeatOffsets();
|
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
|
// A single point-to-point constraint is radially symmetric - it can only
|
||||||
// resist the wheel drifting away in general, not sideways specifically.
|
// resist the wheel drifting away in general, not sideways specifically.
|
||||||
// Two constraints anchored on either side of the true axle position (both
|
// 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 }
|
// intent: { throttle: -1|0|1, leanLeft: bool, leanRight: bool }
|
||||||
applyIntent(intent) {
|
applyIntent(intent) {
|
||||||
const wheelBody = this.wheelRear.body;
|
const wheelBody = this.wheelRear.body;
|
||||||
|
const chassisBody = this.chassis.body;
|
||||||
|
const grounded = intent.throttle !== 0 && this.isGrounded();
|
||||||
|
|
||||||
if (intent.throttle > 0) {
|
if (intent.throttle > 0) {
|
||||||
const next = Phaser.Math.Clamp(
|
const next = Phaser.Math.Clamp(
|
||||||
|
|
@ -104,6 +132,12 @@ export default class Bus {
|
||||||
BUS.maxWheelAngularVelocity
|
BUS.maxWheelAngularVelocity
|
||||||
);
|
);
|
||||||
this.wheelRear.setAngularVelocity(next);
|
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) {
|
} else if (intent.throttle < 0) {
|
||||||
const next = Phaser.Math.Clamp(
|
const next = Phaser.Math.Clamp(
|
||||||
wheelBody.angularVelocity - BUS.brakeTorque,
|
wheelBody.angularVelocity - BUS.brakeTorque,
|
||||||
|
|
@ -111,9 +145,13 @@ export default class Bus {
|
||||||
BUS.maxBrakeAngularVelocity
|
BUS.maxBrakeAngularVelocity
|
||||||
);
|
);
|
||||||
this.wheelRear.setAngularVelocity(next);
|
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) {
|
if (intent.leanLeft && !intent.leanRight) {
|
||||||
const next = Phaser.Math.Clamp(
|
const next = Phaser.Math.Clamp(
|
||||||
chassisBody.angularVelocity - BUS.leanTorqueStep,
|
chassisBody.angularVelocity - BUS.leanTorqueStep,
|
||||||
|
|
|
||||||
|
|
@ -60,10 +60,12 @@ export default class Kid {
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
if (this.seatConstraint) {
|
// No removeConstraint() here (unlike eject(), which runs mid-gameplay
|
||||||
this.scene.matter.world.removeConstraint(this.seatConstraint, true);
|
// and needs it): destroy() only ever runs from PlayScene's shutdown
|
||||||
this.seatConstraint = null;
|
// 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();
|
this.image.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -155,7 +155,13 @@ export default class PlayScene extends Phaser.Scene {
|
||||||
_cleanup() {
|
_cleanup() {
|
||||||
this.events.off('kid-ejected', this._onKidEjected, this);
|
this.events.off('kid-ejected', this._onKidEjected, this);
|
||||||
this.events.off('level-failed', this._onLevelFailed, 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.gForceMonitor) this.gForceMonitor.destroy();
|
||||||
if (this.kidManager) this.kidManager.destroy();
|
if (this.kidManager) this.kidManager.destroy();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,10 @@ export default class GForceMonitor {
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
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.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue