Replace impact-based kid ejection with open-top compartment

Ejection used to be g-force-triggered, but the suspension isolates the
chassis from shocks so the old threshold was effectively unreachable -
kids were never actually being ejected. Instead the bus now carries an
invisible three-sided box (floor + left/right walls, deliberately no
ceiling) pinned to the chassis with rigid constraints. The fixtures are
standalone bodies in a dedicated kid collision category, not compounded
into the chassis, because Matter only consults the root body's filter
on compounds, so only standalone bodies can be made to collide with kids
while the chassis part stays exempt.

KidManager now checks each kid's rotation-corrected chassis-local
position every physics tick and flips state in both directions: crossing
above the open top marks the kid ejected (light custom gravity,
kid_ejected texture), and drifting back down through it re-marks the kid
aboard. A per-tick hard clamp keeps kids from tunneling through the
floor/walls on a fast hit. Seat constraints and the settle timer are
gone - containment is purely physical collision. GForceMonitor is
demoted to a debug-only readout for landing diagnosis.

Kid art is now 5-frame spritesheets (one distinct kid per seat), with
the frame picked by seat index in Kid.js, and render order is fixed with
explicit depths (kids 1, bus chassis/wheels 2).
This commit is contained in:
Brian Fertig 2026-08-19 21:05:08 -06:00
parent 9892df762c
commit c4954b2292
10 changed files with 285 additions and 153 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
assets/sprites/kid_idle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -40,6 +40,20 @@ export const BUS = {
maxSeats: 5,
// The invisible box kids ride in, local to the chassis (0,0 = chassis
// center, y down): solid floor + left/right walls, but deliberately no
// ceiling fixture at all - see Bus.js's _buildCompartment. A kid whose
// own local Y (rotation-corrected relative to the chassis) rises above
// compartmentTopY has nothing left to collide with and is airborne; the
// same check runs both directions each tick (see KidManager), so falling
// back below it while inside compartmentHalfWidth re-contains the kid.
// Tune by playtesting - not physically derived.
compartmentHalfWidth: 75 * WORLD_SCALE,
compartmentWallThickness: 10 * WORLD_SCALE,
compartmentFloorThickness: 10 * WORLD_SCALE,
compartmentFloorY: 12 * WORLD_SCALE,
compartmentTopY: -60 * WORLD_SCALE,
// Density/friction/stiffness/damping are dimensionless ratios, not pixel
// distances - they don't scale with WORLD_SCALE.
chassisDensity: 0.0015,
@ -100,44 +114,29 @@ export const BUS = {
leanTorqueStep: 0.004,
};
// Kid ejection is no longer impact-triggered (see KidManager/Bus's
// compartment box) - GFORCE now only drives the DEBUG g-force readout in
// PlayScene, kept because it's still a useful "how hard did that landing
// hit" diagnostic on its own.
export const GFORCE = {
// Scaled with WORLD_SCALE so "meters" keeps the same real-world meaning
// relative to the bus, and ejectThresholdG doesn't need re-tuning.
// relative to the bus.
pixelsPerMeter: 50 * WORLD_SCALE,
// The bouncier suspension (see BUS.suspensionStiffness/Damping/Travel)
// isolates the CHASSIS from shock by design - which starves this of any
// signal to trigger on. Verified via a headless matter-js simulation
// replicating these exact constants: even a 1300px vertical drop from
// rest only reaches ~0.8g at the chassis, so the old 3-6g range was
// effectively unreachable - kids were never actually being ejected at
// all, not just "rarely." 0.5 is set from that same data (crossed by
// roughly a 400px+ drop, not by gentle rolling hills) - primary tuning
// knob, re-verify by playtesting since the simulation didn't model real
// terrain bumps, only vertical drops.
ejectThresholdG: 0.5,
gracePeriodMs: 500,
ejectImpulseMagnitude: 0.03 * WORLD_SCALE,
// A single hard landing can stay above threshold for several consecutive
// physics ticks; without a cooldown that would eject several kids from one
// impact instead of one. Debounces ejections to one per impact.
ejectCooldownMs: 400,
};
export const KID = {
radius: 14 * WORLD_SCALE,
density: 0.001,
friction: 0.6,
seatStiffness: 0.22,
seatDamping: 0.1,
seatLength: 4 * WORLD_SCALE,
settleTimeMs: 2000,
// Once ejected, a kid ignores the world's real gravity entirely and
// KidManager applies this fraction of it instead (see
// While outside the compartment (past the open top - see
// BUS.compartmentTopY), a kid ignores the world's real gravity entirely
// and KidManager applies this fraction of it instead (see
// KidManager._onBeforeUpdate) - horizontal velocity is untouched either
// way (this game's gravity is purely vertical), so a lighter fall is all
// it takes for an ejected kid to hang in the air and arc much further
// than the bus does, Snuggle-Truck style. Primary tuning knob for "how
// floaty," not physically derived.
// it takes for a kid to hang in the air and arc much further than the
// bus does, Snuggle-Truck style. Primary tuning knob for "how floaty,"
// not physically derived.
ejectedGravityScale: 0.35,
};

View File

@ -13,6 +13,24 @@ export default class Bus {
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
@ -32,10 +50,15 @@ export default class Bus {
density: BUS.chassisDensity,
friction: BUS.chassisFriction,
frictionAir: BUS.chassisFrictionAir,
collisionFilter: { group: this.group },
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);
@ -45,6 +68,7 @@ export default class Bus {
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
@ -62,6 +86,49 @@ export default class Bus {
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);
@ -162,9 +229,10 @@ export default class Bus {
frictionStatic: BUS.wheelFrictionStatic,
frictionAir: BUS.wheelFrictionAir,
restitution: BUS.wheelRestitution,
collisionFilter: { group: this.group },
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;
}

View File

@ -1,85 +1,79 @@
import Phaser from 'phaser';
import { GFORCE, KID } from '../config.js';
import { KID } from '../config.js';
// A kid has no constraint holding it to the bus at all - it's a free body,
// contained purely by physically colliding with the invisible floor/wall
// fixtures pinned to the chassis (see Bus.js's _buildCompartment). state is
// a derived label KidManager updates every tick from the kid's actual
// position relative to the chassis's open top (see
// KidManager._onAfterUpdate), not something this class decides on its own.
export default class Kid {
constructor(scene, bus, seatIndex) {
this.scene = scene;
this.bus = bus;
this.seatIndex = seatIndex;
this.state = 'aboard'; // aboard | ejected | settled
this.state = 'aboard'; // aboard | ejected
const seat = bus.seatOffsets[seatIndex];
const worldPos = bus.getSeatWorldPosition(seatIndex);
this.image = scene.matter.add.image(worldPos.x, worldPos.y, 'kid_idle', null, {
shape: { type: 'circle', radius: KID.radius },
density: KID.density,
friction: KID.friction,
collisionFilter: { group: bus.group },
// Exempt from the chassis/wheels forever (contained or not) via mask
// rather than by sharing the bus's group - sharing a group would also
// make every kid on this bus mutually exempt from every OTHER kid
// (group exemption is "never collide with anything sharing this
// group," not "never collide with this one specific body"), which is
// exactly why kids used to pass through each other. Containment
// itself is the floor/wall fixtures' job, which target this category
// specifically (see bus.kidCategory in Bus.js) so they still collide
// with the kid despite this mask.
collisionFilter: { category: bus.kidCategory, mask: 0xffffffff & ~bus.busCategory },
});
this.image.setDisplaySize(KID.radius * 2, KID.radius * 2);
// Bus parts are all left at Phaser's default depth (0) - render kids
// just behind that, so the chassis always draws over them (seated,
// ejected, or falling back down) instead of insertion order deciding
// it implicitly.
this.image.setDepth(-1);
this.seatConstraint = scene.matter.add.constraint(bus.chassis, this.image, KID.seatLength, KID.seatStiffness, {
pointA: seat,
pointB: { x: 0, y: 0 },
damping: KID.seatDamping,
});
this._settleAt = 0;
this._applyTexture('kid_idle');
// Between the background/terrain (default depth 0) and the bus
// (explicit depth 2, see Bus.js) - visible over the ground, but drawn
// over by the chassis/wheels, contained or not.
this.image.setDepth(1);
}
eject(gForce) {
if (this.state !== 'aboard') return;
this.state = 'ejected';
this.scene.matter.world.removeConstraint(this.seatConstraint, true);
this.seatConstraint = null;
// Neutral group re-enables normal category/mask collision (was sharing
// the bus's non-colliding group while seated).
this.image.body.collisionFilter.group = 0;
this.image.setTexture('kid_ejected');
// Opt out of the world's real gravity - KidManager applies a lighter,
// custom fraction of it each tick instead (see
// KidManager._onBeforeUpdate), so the kid floats through a much bigger
// arc than the bus's own fall would.
this.image.body.ignoreGravity = true;
const severity = Phaser.Math.Clamp(gForce / GFORCE.ejectThresholdG, 1, 3);
// Always pops straight up through the roof, never sideways or downward
// - deliberately ignores the chassis's actual velocity/tilt at impact,
// which previously could fling a kid out sideways or into the ground
// depending on which way the bus was moving. Horizontal speed still
// carries over on its own from the velocity the seat constraint was
// already imparting, so no sideways force is needed to "retain" it.
this.image.applyForce({
x: 0,
y: -GFORCE.ejectImpulseMagnitude * severity,
});
this._settleAt = this.scene.time.now + KID.settleTimeMs;
}
update(time) {
if (this.state === 'ejected' && time >= this._settleAt) {
this.state = 'settled';
// kid_idle/kid_ejected are 5-frame sheets (one distinct kid per frame,
// matching BUS.maxSeats) - picks the frame matching this kid's own seat
// so it stays visually the same character whichever texture it's on.
// Falls back to frame 0 for the single-frame placeholder texture used
// when the real art fails to load (see placeholderTextures.js).
_applyTexture(key) {
if (this.scene.textures.get(key).has(this.seatIndex)) {
this.image.setTexture(key, this.seatIndex);
} else {
this.image.setTexture(key);
}
}
// Called by KidManager when this kid's position rises above the open top
// of the compartment.
markEjected() {
if (this.state === 'ejected') return;
this.state = 'ejected';
this._applyTexture('kid_ejected');
// Ignores the world's real gravity - KidManager applies a lighter,
// custom fraction of it instead (see KidManager._onBeforeUpdate) while
// ejected, so it floats through a much bigger arc than the bus's own
// fall would.
this.image.body.ignoreGravity = true;
}
// Called by KidManager when a floating kid drifts back down through the
// open top and lands back inside the compartment.
markAboard() {
if (this.state === 'aboard') return;
this.state = 'aboard';
this._applyTexture('kid_idle');
this.image.body.ignoreGravity = false;
}
destroy() {
// 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

@ -33,21 +33,14 @@ export default class PlayScene extends Phaser.Scene {
this.inputController = new InputController(this);
this.gForceMonitor = new GForceMonitor(this, this.bus.chassis.body);
// Measured via headless testing at the original gravity (1): the
// spawn-to-ground drop settled by ~4.5s. Free-fall time scales as
// 1/sqrt(g), and gravity is now 0.675 (see main.js), so this is scaled
// up by ~1.2x (sqrt(1/0.675)) to ~5.5s rather than re-measured -
// re-measure for real if landings still trip the g-force ejection check
// right at level start. Re-measure this if startPosition, WORLD_SCALE,
// gravity, or the bus's mass/suspension change again.
this.gForceMonitor.startGracePeriod(5500);
this.gForceMonitor.resetBaseline();
this.cameraRig = new CameraRig(this, this.bus.chassis, this.level.cameraBounds);
this._buildGoal();
this._buildHud();
this.events.on('kid-ejected', this._onKidEjected, this);
this.events.on('kid-status-changed', this._onKidStatusChanged, this);
this.events.on('level-failed', this._onLevelFailed, this);
this.matter.world.on('collisionstart', this._onCollisionStart, this);
@ -60,7 +53,6 @@ export default class PlayScene extends Phaser.Scene {
const intent = this.inputController.getIntent();
this.bus.applyIntent(intent);
this.kidManager.update(time);
this.cameraRig.update();
this._updateParallax();
@ -167,7 +159,7 @@ export default class PlayScene extends Phaser.Scene {
}
}
_onKidEjected({ kidsAboard, total }) {
_onKidStatusChanged({ kidsAboard, total }) {
const ejectedCount = total - kidsAboard;
for (let i = 0; i < this.hudIcons.length; i++) {
this.hudIcons[i].setAlpha(i < ejectedCount ? 0.2 : 1);
@ -203,7 +195,7 @@ export default class PlayScene extends Phaser.Scene {
}
_cleanup() {
this.events.off('kid-ejected', this._onKidEjected, this);
this.events.off('kid-status-changed', this._onKidStatusChanged, this);
this.events.off('level-failed', this._onLevelFailed, 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

View File

@ -17,7 +17,11 @@ export default class PreloadScene extends Phaser.Scene {
});
for (const entry of ASSET_MANIFEST) {
this.load.image(entry.key, entry.path);
if (entry.frameWidth) {
this.load.spritesheet(entry.key, entry.path, { frameWidth: entry.frameWidth, frameHeight: entry.frameHeight });
} else {
this.load.image(entry.key, entry.path);
}
}
}

View File

@ -1,24 +1,26 @@
import { GFORCE, PHYSICS_TIMESTEP_SECONDS } from '../config.js';
import { PHYSICS_TIMESTEP_SECONDS, GFORCE } from '../config.js';
// Pure detector: samples the chassis's linear velocity every fixed physics
// step, estimates acceleration via finite difference, and emits
// 'gforce-exceeded' on the scene when it crosses the tunable threshold.
// Ejection logic lives elsewhere (KidManager) so this stays a single-purpose
// sensor.
// Pure debug diagnostic: kid ejection is no longer impact-triggered (see
// KidManager/Bus's open-top compartment box) - this just samples the
// chassis's linear velocity every fixed physics step and estimates
// acceleration via finite difference, purely for the DEBUG g-force readout
// in PlayScene.
export default class GForceMonitor {
constructor(scene, chassisBody) {
this.scene = scene;
this.chassisBody = chassisBody;
this.previousVelocity = { x: 0, y: 0 };
this.graceUntil = 0;
this.enabled = true;
this.lastGForce = 0;
this._onAfterUpdate = this._onAfterUpdate.bind(this);
scene.matter.world.on('afterupdate', this._onAfterUpdate);
}
startGracePeriod(durationMs = GFORCE.gracePeriodMs) {
this.graceUntil = this.scene.time.now + durationMs;
// Resets the velocity baseline to whatever it is right now, so the next
// tick's comparison isn't against a stale one - call this once whatever
// isn't a "real" impact (e.g. the spawn drop) has settled.
resetBaseline() {
const v = this.chassisBody.velocity;
this.previousVelocity = { x: v.x, y: v.y };
}
@ -33,16 +35,9 @@ export default class GForceMonitor {
const accelPxPerSec2 = deltaV / PHYSICS_TIMESTEP_SECONDS;
const accelMPerSec2 = accelPxPerSec2 / GFORCE.pixelsPerMeter;
const gForce = accelMPerSec2 / 9.8;
this.lastGForce = accelMPerSec2 / 9.8;
this.previousVelocity = { x: v.x, y: v.y };
this.lastGForce = gForce;
if (this.scene.time.now < this.graceUntil) return;
if (gForce > GFORCE.ejectThresholdG) {
this.scene.events.emit('gforce-exceeded', { gForce });
}
}
destroy() {

View File

@ -1,5 +1,4 @@
import Phaser from 'phaser';
import { GFORCE, KID } from '../config.js';
import { KID, BUS } from '../config.js';
import Kid from '../entities/Kid.js';
export default class KidManager {
@ -8,23 +7,27 @@ export default class KidManager {
this.bus = bus;
this.total = kidsAboard;
this.kids = [];
this._lastEjectTime = -Infinity;
for (let i = 0; i < kidsAboard; i++) {
this.kids.push(new Kid(scene, bus, i));
}
this._onGForceExceeded = this._onGForceExceeded.bind(this);
scene.events.on('gforce-exceeded', this._onGForceExceeded);
// Ejected kids set body.ignoreGravity (see Kid.eject) so the engine's
// own gravity application skips them entirely; this replaces it with a
// lighter fraction, applied here (before the engine integrates the
// step, matching how Matter itself applies gravity) rather than in
// Kid.update, since it needs the world's live gravity config, which
// KidManager already has scene access to.
// A kid past the open top (see Kid.markEjected) sets body.ignoreGravity
// so the engine's own gravity application skips it entirely; this
// replaces it with a lighter fraction, applied here (before the engine
// integrates the step, matching how Matter itself applies gravity)
// rather than in an afterupdate hook.
this._onBeforeUpdate = this._onBeforeUpdate.bind(this);
scene.matter.world.on('beforeupdate', this._onBeforeUpdate);
// Drives the actual aboard/ejected transition, in both directions - see
// _onAfterUpdate.
this._onAfterUpdate = this._onAfterUpdate.bind(this);
scene.matter.world.on('afterupdate', this._onAfterUpdate);
}
get kidsAboardCount() {
return this.kids.filter((kid) => kid.state === 'aboard').length;
}
_onBeforeUpdate() {
@ -37,37 +40,108 @@ export default class KidManager {
}
}
get kidsAboardCount() {
return this.kids.filter((kid) => kid.state === 'aboard').length;
}
// Every physics step: hard-clamps each kid against the floor/walls (see
// _clampToCompartment - same "soft collision + hard clamp" pairing as
// Bus.js's wheel wishbone, since the fixtures' own collision response
// alone isn't a hard guarantee against a fast enough hit), then checks
// the (now-corrected) position against the compartment's open top in the
// chassis's own rotated frame and flips state whichever way that puts it
// - a kid drifting up past the open top becomes ejected, and a floating
// kid drifting back down through it becomes aboard again, both from the
// same check. There's no synthetic impulse or event driving either
// direction - it's purely "is this kid still inside the box."
_onAfterUpdate() {
const chassis = this.bus.chassis;
const cos = Math.cos(chassis.rotation);
const sin = Math.sin(chassis.rotation);
_onGForceExceeded({ gForce }) {
const now = this.scene.time.now;
if (now - this._lastEjectTime < GFORCE.ejectCooldownMs) return;
let anyChanged = false;
for (const kid of this.kids) {
this._clampToCompartment(kid, chassis, cos, sin);
const aboard = this.kids.filter((kid) => kid.state === 'aboard');
if (aboard.length === 0) return;
const dx = kid.image.x - chassis.x;
const dy = kid.image.y - chassis.y;
const localY = -dx * sin + dy * cos;
const isAboveOpenTop = localY < BUS.compartmentTopY;
this._lastEjectTime = now;
const kid = Phaser.Utils.Array.GetRandom(aboard);
kid.eject(gForce);
if (isAboveOpenTop && kid.state === 'aboard') {
kid.markEjected();
anyChanged = true;
} else if (!isAboveOpenTop && kid.state === 'ejected') {
kid.markAboard();
anyChanged = true;
}
}
this.scene.events.emit('kid-ejected', {
kidsAboard: this.kidsAboardCount,
total: this.total,
});
if (anyChanged) {
this.scene.events.emit('kid-status-changed', {
kidsAboard: this.kidsAboardCount,
total: this.total,
});
if (this.kidsAboardCount === 0) {
this.scene.events.emit('level-failed', { reason: 'all-kids-lost' });
if (this.kidsAboardCount === 0) {
this.scene.events.emit('level-failed', { reason: 'all-kids-lost' });
}
}
}
update(time) {
for (const kid of this.kids) kid.update(time);
// Keeps a kid from ever tunneling through the floor or a side wall in a
// single hard hit, in the chassis's own rotated frame (same un-rotate
// technique as Bus.js's wheel clamp). Never clamps the top - that's the
// one side deliberately left open.
_clampToCompartment(kid, chassis, cos, sin) {
const dx = kid.image.x - chassis.x;
const dy = kid.image.y - chassis.y;
let localX = dx * cos + dy * sin;
let localY = -dx * sin + dy * cos;
let hitFloor = false;
let hitLeftWall = false;
let hitRightWall = false;
const restY = BUS.compartmentFloorY - BUS.compartmentFloorThickness / 2 - KID.radius;
if (localY > restY) {
localY = restY;
hitFloor = true;
}
// Walls only exist at/below the open top - a kid that's already escaped
// upward has nothing left to bump sideways into.
if (localY >= BUS.compartmentTopY) {
const leftBoundary = -BUS.compartmentHalfWidth + BUS.compartmentWallThickness / 2 + KID.radius;
const rightBoundary = BUS.compartmentHalfWidth - BUS.compartmentWallThickness / 2 - KID.radius;
if (localX < leftBoundary) {
localX = leftBoundary;
hitLeftWall = true;
} else if (localX > rightBoundary) {
localX = rightBoundary;
hitRightWall = true;
}
}
if (!hitFloor && !hitLeftWall && !hitRightWall) return;
const worldX = chassis.x + (localX * cos - localY * sin);
const worldY = chassis.y + (localX * sin + localY * cos);
kid.image.setPosition(worldX, worldY);
// Kill only the relative velocity component(s) still driving it through
// whichever boundary caught it, same reasoning as the wheel clamp - a
// bump stop absorbing the hit, not bouncing off it.
const chassisBody = chassis.body;
const body = kid.image.body;
const relVX = body.velocity.x - chassisBody.velocity.x;
const relVY = body.velocity.y - chassisBody.velocity.y;
let localVX = relVX * cos + relVY * sin;
let localVY = -relVX * sin + relVY * cos;
if (hitFloor && localVY > 0) localVY = 0;
if (hitLeftWall && localVX < 0) localVX = 0;
if (hitRightWall && localVX > 0) localVX = 0;
const newRelVX = localVX * cos - localVY * sin;
const newRelVY = localVX * sin + localVY * cos;
kid.image.setVelocity(chassisBody.velocity.x + newRelVX, chassisBody.velocity.y + newRelVY);
}
destroy() {
this.scene.events.off('gforce-exceeded', this._onGForceExceeded);
for (const kid of this.kids) kid.destroy();
}
}

View File

@ -6,8 +6,14 @@ import { WORLD_SCALE } from '../config.js';
export const ASSET_MANIFEST = [
{ key: 'bus_chassis', path: 'assets/sprites/bus_chassis.png', width: 170 * WORLD_SCALE, height: 64 * WORLD_SCALE, kind: 'rect', color: 0x2255aa },
{ key: 'bus_wheel', path: 'assets/sprites/bus_wheel.png', width: 34 * WORLD_SCALE, height: 34 * WORLD_SCALE, kind: 'circle', color: 0x222222 },
{ key: 'kid_idle', path: 'assets/sprites/kid_idle.png', width: 28 * WORLD_SCALE, height: 28 * WORLD_SCALE, kind: 'circle', color: 0xf2c14e },
{ key: 'kid_ejected', path: 'assets/sprites/kid_ejected.png', width: 28 * WORLD_SCALE, height: 28 * WORLD_SCALE, kind: 'circle', color: 0xe0553b },
// Real art is a 5-frame sheet (one distinct kid per frame, 56x56px each,
// matching BUS.maxSeats) - frameWidth/frameHeight (raw source pixels, NOT
// WORLD_SCALE-relative) make PreloadScene load it as a spritesheet
// instead of a single squashed image; Kid.js picks the frame matching its
// seat index. width/height here stay the DISPLAY size of one frame, same
// as before - only used for the single-frame placeholder fallback.
{ key: 'kid_idle', path: 'assets/sprites/kid_idle.png', width: 28 * WORLD_SCALE, height: 28 * WORLD_SCALE, kind: 'circle', color: 0xf2c14e, frameWidth: 56, frameHeight: 56 },
{ key: 'kid_ejected', path: 'assets/sprites/kid_ejected.png', width: 28 * WORLD_SCALE, height: 28 * WORLD_SCALE, kind: 'circle', color: 0xe0553b, frameWidth: 56, frameHeight: 56 },
{ key: 'bg_far', path: 'assets/backgrounds/bg_far.png', width: 256 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x8fc7e8, tileable: true },
{ key: 'bg_mid', path: 'assets/backgrounds/bg_mid.png', width: 256 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x6fae7d, tileable: true },
{ key: 'bg_near', path: 'assets/backgrounds/bg_near.png', width: 256 * WORLD_SCALE, height: 540 * WORLD_SCALE, kind: 'rect', color: 0x4c8a5c, tileable: true },