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