311 lines
15 KiB
JavaScript
311 lines
15 KiB
JavaScript
import Phaser from 'phaser';
|
|
import { BUS } from '../config.js';
|
|
|
|
// Builds the bus as a chassis + two wheel Matter Image bodies, each wheel
|
|
// held by a "wishbone" - two suspension constraints instead of one - rather
|
|
// than Phaser's built-in Factory.car() - car()'s axle constraints are rigid
|
|
// pins (stiffness 1, length 0, verified against the shipped Phaser 4 build)
|
|
// with no vertical give, which is too harsh for a bus that needs real
|
|
// suspension travel to absorb bumps.
|
|
export default class Bus {
|
|
constructor(scene, x, y, angle = 0) {
|
|
this.scene = scene;
|
|
const matter = scene.matter;
|
|
|
|
this.group = matter.world.nextGroup(true);
|
|
// Dedicated category so the compartment fixtures below can target kids
|
|
// specifically. They're standalone bodies (not part of the chassis),
|
|
// pinned to it via rigid constraints rather than compounded into it -
|
|
// Matter's own compound-body collision detection only ever consults the
|
|
// ROOT body's collisionFilter to decide whether a pair is even
|
|
// considered (verified against the vendored source: per-part filters on
|
|
// a compound are read for shape/response, never for the go/no-go
|
|
// decision), so a compounded fixture couldn't be selectively made to
|
|
// collide with kids while the chassis part stays exempt - only
|
|
// standalone bodies with their own root-level filter can do that.
|
|
this.kidCategory = matter.body.nextCategory();
|
|
// Chassis/wheels' own category, so kids can be exempted from just this
|
|
// bus's structure via mask rather than by sharing `group` with it -
|
|
// sharing a group is what used to also make every kid on this bus
|
|
// mutually exempt from every OTHER kid (group exemption doesn't
|
|
// distinguish which other body), which is why they were passing
|
|
// through each other.
|
|
this.busCategory = matter.body.nextCategory();
|
|
|
|
// The anchor sits suspensionTravel above where the wheel actually rests,
|
|
// so the spring pulling the wheel out to its full constraint length is
|
|
// what lands it exactly on wheelRestOffsetY (anchorY + travel = restY),
|
|
// while still leaving suspensionTravel of compress/droop room.
|
|
const anchorY = BUS.wheelRestOffsetY - BUS.suspensionTravel;
|
|
// The wheel doesn't hang straight down from a single point (that point
|
|
// is split into two, spread by axleSpread), so the constraint's target
|
|
// length is the hypotenuse to the spread anchor, not just the vertical
|
|
// drop - otherwise the wheel would spawn pulled taut at an angle instead
|
|
// of hanging level, snapping into place on the first physics step.
|
|
const constraintLength = Math.hypot(BUS.axleSpread, BUS.suspensionTravel);
|
|
|
|
this.chassis = matter.add.image(x, y, 'bus_chassis', null, {
|
|
shape: { type: 'rectangle', width: BUS.chassisWidth, height: BUS.chassisHeight },
|
|
chamfer: { radius: BUS.chassisHeight * 0.35 },
|
|
density: BUS.chassisDensity,
|
|
friction: BUS.chassisFriction,
|
|
frictionAir: BUS.chassisFrictionAir,
|
|
collisionFilter: { group: this.group, category: this.busCategory },
|
|
});
|
|
this.chassis.setDisplaySize(BUS.chassisWidth, BUS.chassisHeight);
|
|
this.chassis.setAngle(Phaser.Math.RadToDeg(angle));
|
|
// Explicit depth (rather than default 0) so it's guaranteed to draw
|
|
// over the kids riding behind it (see Kid.js's depth) regardless of
|
|
// creation order - kept fully opaque per its real art, not faked with
|
|
// alpha.
|
|
this.chassis.setDepth(2);
|
|
|
|
const wheelY = y + BUS.wheelRestOffsetY;
|
|
this.wheelRear = this._createWheel(x + BUS.rearWheelOffsetX, wheelY);
|
|
this.wheelFront = this._createWheel(x + BUS.frontWheelOffsetX, wheelY);
|
|
|
|
this.wheelRearLinks = this._attachWishbone(this.wheelRear, BUS.rearWheelOffsetX, anchorY, constraintLength);
|
|
this.wheelFrontLinks = this._attachWishbone(this.wheelFront, BUS.frontWheelOffsetX, anchorY, constraintLength);
|
|
|
|
this.seatOffsets = this._buildSeatOffsets();
|
|
this._buildCompartment(x, y);
|
|
|
|
// The wishbone above only constrains each wheel's DISTANCE from its two
|
|
// anchors - it has no notion of which side of them the wheel is on. Two
|
|
// positions satisfy "equidistant from both anchors": the normal one
|
|
// below the anchor line, and one mirrored above it. A hard enough impact
|
|
// can carry the wheel across that line within a single physics step, and
|
|
// the spring converges on the mirrored (wrong) solution just as happily
|
|
// - the wheel visibly "punches through" to sit high on the chassis and
|
|
// stays there, since it's now a stable equilibrium. This is a hard stop
|
|
// enforced every tick to make crossing impossible, since no amount of
|
|
// spring tuning or solver iteration can fix an ambiguity the constraint
|
|
// itself can't see.
|
|
this._anchorY = anchorY;
|
|
this._onAfterUpdate = this._onAfterUpdate.bind(this);
|
|
scene.matter.world.on('afterupdate', this._onAfterUpdate);
|
|
}
|
|
|
|
// Builds the invisible floor + left/right wall bodies kids ride in -
|
|
// solid on three sides, deliberately no ceiling body at all (that's what
|
|
// makes the top "open" - see KidManager, which drives the actual
|
|
// aboard/ejected state from a kid's position relative to
|
|
// BUS.compartmentTopY, not from anything here). Each fixture is a real,
|
|
// separate dynamic body (not compounded - see the kidCategory comment
|
|
// above) rigidly pinned to the chassis with the same two-point "wishbone"
|
|
// technique as the wheels, just tuned near-rigid (stiffness 1, length 0)
|
|
// instead of springy, so it tracks the chassis's position AND rotation
|
|
// together rather than swinging around a single point. Given non-trivial
|
|
// density (not negligible) so a colliding kid gets a normal, solid-feeling
|
|
// collision response rather than punching through underweight fixtures -
|
|
// KidManager's per-tick clamp is the actual containment guarantee
|
|
// (mirroring the wheel's hard clamp above), this is just what makes
|
|
// contact feel physical before that clamp would ever need to matter.
|
|
_buildCompartment(x, y) {
|
|
const wallHeight = (BUS.compartmentFloorY - BUS.compartmentTopY) + BUS.compartmentFloorThickness;
|
|
const wallCenterY = (BUS.compartmentFloorY + BUS.compartmentTopY) / 2;
|
|
|
|
this.compartmentFloor = this._buildFixture(x, y, 0, BUS.compartmentFloorY, BUS.compartmentHalfWidth * 2, BUS.compartmentFloorThickness);
|
|
this.compartmentLeftWall = this._buildFixture(x, y, -BUS.compartmentHalfWidth, wallCenterY, BUS.compartmentWallThickness, wallHeight);
|
|
this.compartmentRightWall = this._buildFixture(x, y, BUS.compartmentHalfWidth, wallCenterY, BUS.compartmentWallThickness, wallHeight);
|
|
}
|
|
|
|
_buildFixture(chassisX, chassisY, localX, localY, width, height) {
|
|
const matter = this.scene.matter;
|
|
const body = matter.add.rectangle(chassisX + localX, chassisY + localY, width, height, {
|
|
density: BUS.wheelDensity,
|
|
friction: BUS.chassisFriction,
|
|
collisionFilter: { mask: this.kidCategory },
|
|
});
|
|
|
|
const pinSpread = Math.max(5, Math.min(width, height) / 2);
|
|
const pinOptions = (anchorX) => ({
|
|
pointA: { x: anchorX, y: localY },
|
|
pointB: { x: 0, y: 0 },
|
|
});
|
|
matter.add.constraint(this.chassis, body, 0, 1, pinOptions(localX - pinSpread));
|
|
matter.add.constraint(this.chassis, body, 0, 1, pinOptions(localX + pinSpread));
|
|
|
|
return body;
|
|
}
|
|
|
|
_onAfterUpdate() {
|
|
this._clampWheelToWishbone(this.wheelRear);
|
|
this._clampWheelToWishbone(this.wheelFront);
|
|
}
|
|
|
|
// Keeps a wheel from ever crossing above its wishbone's anchor line (both
|
|
// anchors share the same anchorY, so the line is horizontal in the
|
|
// chassis's own rotated frame).
|
|
_clampWheelToWishbone(wheel) {
|
|
const chassis = this.chassis;
|
|
const cos = Math.cos(chassis.rotation);
|
|
const sin = Math.sin(chassis.rotation);
|
|
const dx = wheel.x - chassis.x;
|
|
const dy = wheel.y - chassis.y;
|
|
// Un-rotate the wheel's offset into the chassis's own frame, where the
|
|
// anchor line is simply y = this._anchorY.
|
|
const localX = dx * cos + dy * sin;
|
|
const localY = -dx * sin + dy * cos;
|
|
|
|
// Small margin below the true anchor line so the clamp settles instead
|
|
// of the spring and the clamp fighting exactly at the boundary.
|
|
const limit = this._anchorY + BUS.axleSpread;
|
|
if (localY >= limit) return;
|
|
|
|
const worldDX = localX * cos - limit * sin;
|
|
const worldDY = localX * sin + limit * cos;
|
|
wheel.setPosition(chassis.x + worldDX, chassis.y + worldDY);
|
|
|
|
// Kill only the velocity component still driving it through the line
|
|
// (in the chassis's frame), so it doesn't immediately re-punch next
|
|
// tick - like a real suspension bump stop absorbing the hit rather than
|
|
// bouncing off it. Sideways/rolling relative motion is left untouched.
|
|
const chassisBody = chassis.body;
|
|
const relVX = wheel.body.velocity.x - chassisBody.velocity.x;
|
|
const relVY = wheel.body.velocity.y - chassisBody.velocity.y;
|
|
const localVX = relVX * cos + relVY * sin;
|
|
const localVY = -relVX * sin + relVY * cos;
|
|
if (localVY < 0) {
|
|
const newRelVX = localVX * cos;
|
|
const newRelVY = localVX * sin;
|
|
wheel.setVelocity(chassisBody.velocity.x + newRelVX, chassisBody.velocity.y + newRelVY);
|
|
}
|
|
}
|
|
|
|
// 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
|
|
// to the same wheel center) form a narrow wishbone: moving the wheel
|
|
// sideways stretches one link and slackens the other, which the springs
|
|
// resist, while moving straight down lengthens both links symmetrically
|
|
// and is barely resisted - so the wheel is free to bob vertically but
|
|
// held from wandering left/right under its axle.
|
|
_attachWishbone(wheel, offsetX, anchorY, length) {
|
|
const matter = this.scene.matter;
|
|
const options = (anchorX) => ({
|
|
pointA: { x: anchorX, y: anchorY },
|
|
pointB: { x: 0, y: 0 },
|
|
damping: BUS.suspensionDamping,
|
|
});
|
|
|
|
return [
|
|
matter.add.constraint(this.chassis, wheel, length, BUS.suspensionStiffness, options(offsetX - BUS.axleSpread)),
|
|
matter.add.constraint(this.chassis, wheel, length, BUS.suspensionStiffness, options(offsetX + BUS.axleSpread)),
|
|
];
|
|
}
|
|
|
|
_createWheel(x, y) {
|
|
const wheel = this.scene.matter.add.image(x, y, 'bus_wheel', null, {
|
|
shape: { type: 'circle', radius: BUS.wheelRadius },
|
|
density: BUS.wheelDensity,
|
|
friction: BUS.wheelFriction,
|
|
frictionStatic: BUS.wheelFrictionStatic,
|
|
frictionAir: BUS.wheelFrictionAir,
|
|
restitution: BUS.wheelRestitution,
|
|
collisionFilter: { group: this.group, category: this.busCategory },
|
|
});
|
|
wheel.setDisplaySize(BUS.wheelRadius * 2, BUS.wheelRadius * 2);
|
|
wheel.setDepth(2); // matches the chassis - see its setDepth comment
|
|
return wheel;
|
|
}
|
|
|
|
_buildSeatOffsets() {
|
|
const offsets = [];
|
|
const margin = BUS.chassisWidth * 0.32;
|
|
for (let i = 0; i < BUS.maxSeats; i++) {
|
|
const t = BUS.maxSeats === 1 ? 0 : (i / (BUS.maxSeats - 1)) * 2 - 1;
|
|
offsets.push({ x: t * margin, y: -BUS.chassisHeight * 0.1 });
|
|
}
|
|
return offsets;
|
|
}
|
|
|
|
// 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(
|
|
wheelBody.angularVelocity + BUS.driveTorque,
|
|
-BUS.maxWheelAngularVelocity,
|
|
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,
|
|
-BUS.maxBrakeAngularVelocity,
|
|
BUS.maxBrakeAngularVelocity
|
|
);
|
|
this.wheelRear.setAngularVelocity(next);
|
|
|
|
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,
|
|
-BUS.maxLeanAngularVelocity,
|
|
BUS.maxLeanAngularVelocity
|
|
);
|
|
this.chassis.setAngularVelocity(next);
|
|
} else if (intent.leanRight && !intent.leanLeft) {
|
|
const next = Phaser.Math.Clamp(
|
|
chassisBody.angularVelocity + BUS.leanTorqueStep,
|
|
-BUS.maxLeanAngularVelocity,
|
|
BUS.maxLeanAngularVelocity
|
|
);
|
|
this.chassis.setAngularVelocity(next);
|
|
}
|
|
}
|
|
|
|
getSeatWorldPosition(seatIndex) {
|
|
const seat = this.seatOffsets[seatIndex];
|
|
return { x: this.chassis.x + seat.x, y: this.chassis.y + seat.y };
|
|
}
|
|
|
|
destroy() {
|
|
// Deliberately empty, matching GForceMonitor.destroy(): Matter World's
|
|
// own shutdown already removes every listener registered on it
|
|
// (including _onAfterUpdate above), and by the time a scene's
|
|
// 'shutdown' handler runs, this.scene.matter.world may already be null.
|
|
}
|
|
}
|