diff --git a/src/config.js b/src/config.js index 3abc2ee..7305e5f 100644 --- a/src/config.js +++ b/src/config.js @@ -41,13 +41,13 @@ 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. + // center, y down): solid floor + left/right walls only - deliberately NO + // ceiling fixture (see Bus.js's _buildCompartment). A kid whose own local Y + // (rotation-corrected relative to the chassis) rises above compartmentTopY + // is airborne; if it then falls back down into the compartment (within its + // horizontal span, below the open top, moving downward) it counts as being + // caught/re-boarded (see KidManager._onAfterUpdate). Tune by playtesting - + // not physically derived. compartmentHalfWidth: 75 * WORLD_SCALE, // A bit thicker than the floor - gives a fast-moving kid more physical // wall to hit before KidManager's per-tick clamp is what's actually @@ -169,6 +169,16 @@ export const KID = { // its jump while still very much airborne (about to fall again, not // landed), so a momentary dip alone isn't proof it's actually stopped. settleDurationMs: 500, + // How long after a kid is FIRST ejected (see Kid.ejectedAt) before the + // catch window re-opens at all (see KidManager._onAfterUpdate) - without + // this, a kid that was just pushed out is still drifting slowly back + // down through the open top for a few ticks after being ejected, and the + // catch would fire on that residual drift instead of on a genuine, + // deliberate "fell back in." 500ms of "actually gone" before the catch + // window reopens. (Kept under its original `ceilingReboardDelayMs` name + // for now even though the ceiling itself is gone - the delay is still + // doing the same job it always was.) + ceilingReboardDelayMs: 500, }; // Points awarded on LevelScoreScene's end-of-level tally, in the order diff --git a/src/entities/Bus.js b/src/entities/Bus.js index 4f8d9fc..b9ea168 100644 --- a/src/entities/Bus.js +++ b/src/entities/Bus.js @@ -108,6 +108,10 @@ export default class Bus { 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); + // Deliberately NO ceiling fixture - the compartment's top is open, by + // design (see config.js). Re-boarding (catching a kid that fell back in) + // is detected from position/velocity alone in KidManager._onAfterUpdate, + // not from a physical ceiling surface. } _buildFixture(chassisX, chassisY, localX, localY, width, height) { diff --git a/src/entities/Kid.js b/src/entities/Kid.js index 2a56316..5225dac 100644 --- a/src/entities/Kid.js +++ b/src/entities/Kid.js @@ -13,7 +13,19 @@ export default class Kid { this.seatIndex = seatIndex; this.state = 'aboard'; // aboard | ejected this._slowSince = null; // see isSettled() - this._slowSinceRelative = null; // see isSettledRelativeToChassis() + // Latch set on the first aboard->ejected transition (see markEjected) + // and never cleared again - KidManager uses it to make re-boarding a + // one-shot event, so a kid that landed on the ground outside the bus + // can't be pulled back in later just because the bus happens to be + // under them at that height (see KidManager._onAfterUpdate). + this.ejected = false; + // Wall-clock time (scene.time.now) of the last aboard->ejected + // transition - KidManager uses this to briefly delay the catch window + // re-opening after a fresh ejection (see KidManager._onAfterUpdate / the + // KID.ceilingReboardDelayMs config), so the catch doesn't just undo an + // ejection a few ticks later (the kid is still drifting slowly back down + // through the open top right after being pushed out). + this.ejectedAt = null; const worldPos = bus.getSeatWorldPosition(seatIndex); @@ -58,6 +70,8 @@ export default class Kid { markEjected() { if (this.state === 'ejected') return; this.state = 'ejected'; + this.ejected = true; // latch, see field comment above - never unset + this.ejectedAt = this.scene.time.now; this._applyTexture('kid_ejected'); // Ignores the world's real gravity - KidManager applies a lighter, // custom fraction of it instead (see KidManager._onBeforeUpdate) while @@ -92,26 +106,6 @@ export default class Kid { return now - this._slowSince >= KID.settleDurationMs; } - // Same idea as isSettled, but measured relative to the chassis's own - // velocity rather than the world - a kid at rest inside a moving bus - // shares the bus's velocity, which is almost always well above - // restSpeedThreshold, so gating re-boarding on isSettled's absolute speed - // would mean a kid resting inside the compartment while the bus drives - // could never actually re-board. Kept as its own method with its own - // timer (not a parameter on isSettled) since KidManager and PlayScene call - // these in the same tick with different references - sharing one timer - // between them would have each call reset the other's progress. - isSettledRelativeToChassis(now, chassisVelocity) { - const v = this.image.body.velocity; - const speed = Math.hypot(v.x - chassisVelocity.x, v.y - chassisVelocity.y); - if (speed >= KID.restSpeedThreshold) { - this._slowSinceRelative = null; - return false; - } - if (this._slowSinceRelative === null) this._slowSinceRelative = now; - return now - this._slowSinceRelative >= KID.settleDurationMs; - } - destroy() { this.image.destroy(); } diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index 9123cd7..f1540c6 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -32,6 +32,7 @@ export default class PreloadScene extends Phaser.Scene { this.load.audio('crowd_cheer', 'assets/fx/crowd-cheer.mp3'); this.load.audio('kid_count', 'assets/fx/kid-count.mp3'); this.load.audio('score_count', 'assets/fx/score-count.mp3'); + this.load.audio('kid_reload', 'assets/fx/crowd-cheer.mp3'); loadKidFallSounds(this.load); // Not every level has a voice-over - this just attempts one per level diff --git a/src/systems/KidManager.js b/src/systems/KidManager.js index c2f615d..8de5c73 100644 --- a/src/systems/KidManager.js +++ b/src/systems/KidManager.js @@ -2,6 +2,15 @@ import { KID, BUS } from '../config.js'; import Kid from '../entities/Kid.js'; import { playRandomKidFallSound } from '../util/kidFallSound.js'; +// Quick little "reload" flourish - plays if the matching SFX clip is in +// the audio cache (see PreloadScene), no-ops if not, same load-tolerant +// pattern as kidFallSound.js. +function playKidReloadSound(scene) { + if (scene.cache.audio.exists('kid_reload')) { + scene.sound.add('kid_reload').play(); + } +} + export default class KidManager { constructor(scene, bus, kidsAboard) { this.scene = scene; @@ -41,16 +50,25 @@ export default class KidManager { } } - // 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 + // Every physics step: hard-clamps each kid against the compartment's + // floor and walls (see _clampToCompartment, same "soft collision + hard + // clamp" pairing as Bus.js's wheel wishbone), then checks the + // (now-corrected) position against the compartment's open top in the // chassis's own rotated frame. A kid drifting up past the open top - // becomes ejected immediately. A floating kid drifting back down through - // the open top only re-boards once it has actually come to rest inside - // the compartment (see Kid.isSettledRelativeToChassis), so a kid still mid-arc can't - // teleport back in mid-air. + // becomes ejected immediately - and stays ejected from here on (see the + // re-boarding rule below, deliberately one-shot, so a kid that landed on + // the ground outside the bus never teleports back in). + // + // Re-boarding (catching) a previously-ejected kid happens when it falls + // back down into the compartment: it must be below the open top, within + // the compartment's horizontal span, moving downward, AND the ejection + // itself must be old enough (KID.ceilingReboardDelayMs, kept under its + // original name since it's a config constant) so the catch window doesn't + // reopen the instant a kid was pushed out. No physical ceiling fixture + // involved - the compartment's top is open by design (see Bus.js / + // config.js), this is pure position+velocity, which is simpler and + // avoids a fourth pinned body to tunnel through / accidentally block + // ejection with (see the ceiling bug that was just removed). _onAfterUpdate() { const chassis = this.bus.chassis; const cos = Math.cos(chassis.rotation); @@ -67,25 +85,49 @@ export default class KidManager { const isAboveOpenTop = localY < BUS.compartmentTopY; // Re-boarding additionally requires being within the compartment's // horizontal span, not just below the open top - localY alone can't - // tell "resting inside the compartment" apart from "resting on the - // ground anywhere else in the level at roughly the bus's height," - // since compartmentTopY is measured purely along the chassis's local Y + // tell "inside the compartment" apart from "resting on the ground + // anywhere else in the level at roughly the bus's height," since + // compartmentTopY is measured purely along the chassis's local Y // axis with no horizontal extent to it. const isWithinCompartmentWidth = Math.abs(localX) <= BUS.compartmentHalfWidth; if (isAboveOpenTop && kid.state === 'aboard') { kid.markEjected(); + // Latching here (see Kid.markEjected) - not every tick - so the + // sound only plays on the actual transition, matching the old + // behavior. playRandomKidFallSound(this.scene); anyChanged = true; - } else if (!isAboveOpenTop && isWithinCompartmentWidth && kid.state === 'ejected' && - kid.isSettledRelativeToChassis(this.scene.time.now, chassis.body.velocity)) { - // Re-boarding is gated on the kid actually settling relative to the - // chassis (see Kid.isSettledRelativeToChassis), not just passing - // through the compartment's open top again. Ejected kids run under - // reduced gravity, so a kid still floating/arc-ing will happily - // drift back through the open top while airborne; without this gate - // it would flip back to aboard mid-air and "teleport" back in. + } else if (kid.state === 'ejected' && !isAboveOpenTop && isWithinCompartmentWidth && + kid.ejectedAt !== null && + this.scene.time.now - kid.ejectedAt >= KID.ceilingReboardDelayMs && + this._isMovingDownwardIntoCompartment(kid, cos, sin)) { + // Re-boarding is gated on the kid genuinely falling back DOWN into + // the compartment (see _isMovingDownwardIntoCompartment - its + // chassis-relative velocity points downward, i.e. toward the floor, + // not just "happens to be at the right height right now"). This is + // what "vertically fell into the bus" means, and it's what keeps a + // kid resting on the ground elsewhere at the same height from being + // scooped up the moment the bus happens to pass by. + // + // AND on the ejection itself being old enough (KID.ceilingReboardDelayMs + // since kid.ejectedAt) - otherwise a kid that was just pushed out + // is still drifting slowly back down through the open top for a few + // ticks after being ejected, and the catch would fire on that + // residual drift instead of on a genuine, deliberate "fell back in." + // + // One-shot: once a kid has been ejected and landed somewhere + // OUTSIDE the bus (on the ground, in a gap, etc.), `kid.ejected` + // (the latch, see Kid.markEjected) stays true, so this branch can + // never fire for them again - they stay where they landed instead + // of being pulled back into the bus next time the bus happens to be + // under them. Only a kid that re-boards via this fall-back-down + // check (and is subsequently ejected again by leaving through the + // open top) can ever be caught once more - i.e. exactly the "bus + // drives under a loose kid and catches them" case we want, with no + // other path. kid.markAboard(); + playKidReloadSound(this.scene); anyChanged = true; } } @@ -103,10 +145,23 @@ export default class KidManager { } } + // True if this kid's chassis-relative velocity points downward (toward + // the compartment's floor) - i.e. it's actually falling back in, not + // just sitting/drifting at the right height for some unrelated reason. + _isMovingDownwardIntoCompartment(kid, cos, sin) { + const body = kid.image.body; + const chassisBody = this.bus.chassis.body; + const relVX = body.velocity.x - chassisBody.velocity.x; + const relVY = body.velocity.y - chassisBody.velocity.y; + const localVY = -relVX * sin + relVY * cos; + return localVY > 0; + } + // 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. + // one side deliberately left open (the compartment has no ceiling, see + // Bus.js/_buildCompartment and config.js). _clampToCompartment(kid, chassis, cos, sin) { const dx = kid.image.x - chassis.x; const dy = kid.image.y - chassis.y;