diff --git a/assets/sprites/kid_ejected.png b/assets/sprites/kid_ejected.png new file mode 100644 index 0000000..73ec214 Binary files /dev/null and b/assets/sprites/kid_ejected.png differ diff --git a/assets/sprites/kid_idle.png b/assets/sprites/kid_idle.png new file mode 100644 index 0000000..5c713e3 Binary files /dev/null and b/assets/sprites/kid_idle.png differ diff --git a/src/config.js b/src/config.js index 6cc5a65..b944d37 100644 --- a/src/config.js +++ b/src/config.js @@ -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, }; diff --git a/src/entities/Bus.js b/src/entities/Bus.js index 53793b6..4f8d9fc 100644 --- a/src/entities/Bus.js +++ b/src/entities/Bus.js @@ -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; } diff --git a/src/entities/Kid.js b/src/entities/Kid.js index b67837c..730bad1 100644 --- a/src/entities/Kid.js +++ b/src/entities/Kid.js @@ -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(); } } diff --git a/src/scenes/PlayScene.js b/src/scenes/PlayScene.js index bb9d704..01234fc 100644 --- a/src/scenes/PlayScene.js +++ b/src/scenes/PlayScene.js @@ -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 diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index 8e7501d..55089f4 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -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); + } } } diff --git a/src/systems/GForceMonitor.js b/src/systems/GForceMonitor.js index 56b31fd..bb8394b 100644 --- a/src/systems/GForceMonitor.js +++ b/src/systems/GForceMonitor.js @@ -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() { diff --git a/src/systems/KidManager.js b/src/systems/KidManager.js index 457e64b..70d7397 100644 --- a/src/systems/KidManager.js +++ b/src/systems/KidManager.js @@ -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(); } } diff --git a/src/util/assetManifest.js b/src/util/assetManifest.js index 2ff6cca..b17307a 100644 --- a/src/util/assetManifest.js +++ b/src/util/assetManifest.js @@ -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 },