From f3e7f962206e83b09521385c36d7a8f59f25fd76 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Wed, 19 Aug 2026 21:40:03 -0600 Subject: [PATCH] Fix all-kids-lost failing too early; add bus-fell fail condition The level now only fails on "all kids lost" once every ejected kid has settled outside the bus (restSpeed/settleDuration hysteresis) or fallen past the camera bounds, giving a rescue window after a jump. Also fails when the bus itself drops below the level, and guards the compartment clamp with a distance radius so far-away kids aren't yanked toward the bus. --- src/config.js | 28 +++++++++++++++++++++++++++ src/entities/Kid.js | 18 +++++++++++++++++ src/scenes/LevelFailedScene.js | 1 + src/scenes/PlayScene.js | 35 ++++++++++++++++++++++++++++++++++ src/systems/KidManager.js | 19 ++++++++++++++---- 5 files changed, 97 insertions(+), 4 deletions(-) diff --git a/src/config.js b/src/config.js index b944d37..6074bb3 100644 --- a/src/config.js +++ b/src/config.js @@ -53,6 +53,17 @@ export const BUS = { compartmentFloorThickness: 10 * WORLD_SCALE, compartmentFloorY: 12 * WORLD_SCALE, compartmentTopY: -60 * WORLD_SCALE, + // The clamp in KidManager only makes sense as a same-tick tunnel-through + // guard against the compartment's OWN fixtures - a kid further than this + // from the chassis was never inside the compartment to begin with, and + // local-frame coordinates alone can't tell "just tunneled through" apart + // from "resting somewhere else in the level entirely" without a distance + // check like this (a kid at rest far away can still end up with local + // coordinates that look like a boundary violation purely because the + // chassis itself has moved since). Generous enough to still catch a + // genuinely fast tunnel-through, nowhere near large enough to reach a kid + // that's actually elsewhere on the level. + compartmentClampRadius: 400 * WORLD_SCALE, // Density/friction/stiffness/damping are dimensionless ratios, not pixel // distances - they don't scale with WORLD_SCALE. @@ -138,6 +149,23 @@ export const KID = { // bus does, Snuggle-Truck style. Primary tuning knob for "how floaty," // not physically derived. ejectedGravityScale: 0.35, + // How slow (raw Matter velocity magnitude, same convention as everything + // else that reads body.velocity directly in this codebase) an ejected + // kid needs to be moving to count as "settled" outside the bus - used by + // PlayScene to delay the all-kids-lost failure until every kid has + // actually landed and stopped, not the instant the last one leaves the + // compartment, so a jump that pops everyone loose still leaves a window + // to drive back underneath and catch them. Calibrated via a headless + // matter-js simulation of a kid landing with realistic horizontal speed + // under ejectedGravityScale: settles below this within ~3s of landing, + // fully stops within ~10s - tune by playtesting if that feels off. + restSpeedThreshold: 1, + // Must stay below restSpeedThreshold continuously for this long to count + // as settled, not just an instant - a kid arcing under weak gravity can + // pass through near-zero speed for a single tick right at the peak of + // 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, }; export const CAMERA = { diff --git a/src/entities/Kid.js b/src/entities/Kid.js index 730bad1..6933faf 100644 --- a/src/entities/Kid.js +++ b/src/entities/Kid.js @@ -12,6 +12,7 @@ export default class Kid { this.bus = bus; this.seatIndex = seatIndex; this.state = 'aboard'; // aboard | ejected + this._slowSince = null; // see isSettled() const worldPos = bus.getSeatWorldPosition(seatIndex); @@ -73,6 +74,23 @@ export default class Kid { this.image.body.ignoreGravity = false; } + // True once this kid's speed has stayed below KID.restSpeedThreshold for + // KID.settleDurationMs straight - used by PlayScene to tell "actually + // landed and stopped" apart from a kid just passing through low speed for + // an instant at the peak of its arc while still airborne. Only meaningful + // to call repeatedly (e.g. every frame) once ejected - the timer resets + // itself the moment speed rises back above threshold. + isSettled(now) { + const v = this.image.body.velocity; + const speed = Math.hypot(v.x, v.y); + if (speed >= KID.restSpeedThreshold) { + this._slowSince = null; + return false; + } + if (this._slowSince === null) this._slowSince = now; + return now - this._slowSince >= KID.settleDurationMs; + } + destroy() { this.image.destroy(); } diff --git a/src/scenes/LevelFailedScene.js b/src/scenes/LevelFailedScene.js index 010235a..2c2fbae 100644 --- a/src/scenes/LevelFailedScene.js +++ b/src/scenes/LevelFailedScene.js @@ -4,6 +4,7 @@ import { createButton } from '../util/ui.js'; const REASON_TEXT = { 'all-kids-lost': 'Every kid got thrown off the bus!', + 'bus-fell': 'The bus fell into the gap!', }; export default class LevelFailedScene extends Phaser.Scene { diff --git a/src/scenes/PlayScene.js b/src/scenes/PlayScene.js index 01234fc..63c0758 100644 --- a/src/scenes/PlayScene.js +++ b/src/scenes/PlayScene.js @@ -55,6 +55,8 @@ export default class PlayScene extends Phaser.Scene { this.bus.applyIntent(intent); this.cameraRig.update(); this._updateParallax(); + this._checkBusFell(); + this._checkAllKidsLost(); if (DEBUG && this.debugText) { const g = this.gForceMonitor.lastGForce || 0; @@ -121,6 +123,39 @@ export default class PlayScene extends Phaser.Scene { this.bgFar.tilePositionX = progress * this._bgFarPanRange; } + // A bus that's fallen past the bottom of the level's own camera bounds + // is somewhere the camera can never scroll down far enough to show again + // (that's the literal definition of "off screen" here) - treated the + // same as losing every kid, since there's no way to recover either. + _checkBusFell() { + if (this._isBelowCameraBounds(this.bus.chassis.y)) { + this.events.emit('level-failed', { reason: 'bus-fell' }); + } + } + + _isBelowCameraBounds(y) { + const bounds = this.level.cameraBounds; + return y > bounds.y + bounds.height; + } + + // 0 kids aboard isn't itself a fail condition - KidManager just tracks + // who's still seated. A kid popped loose by a jump is still catchable + // (falls down through the compartment's open top and re-boards - see + // KidManager) right up until it's either settled outside the bus or lost + // down a gap, so the level only fails once EVERY kid has reached one of + // those two unrecoverable states, not the instant the last one leaves. + _checkAllKidsLost() { + if (this.kidManager.kidsAboardCount > 0) return; + + const allUnrecoverable = this.kidManager.kids.every((kid) => { + return this._isBelowCameraBounds(kid.image.y) || kid.isSettled(this.time.now); + }); + + if (allUnrecoverable) { + this.events.emit('level-failed', { reason: 'all-kids-lost' }); + } + } + _buildGoal() { const goal = this.level.goal; this.goalBody = this.matter.add.rectangle(goal.x, goal.y, goal.width, goal.height, { diff --git a/src/systems/KidManager.js b/src/systems/KidManager.js index 70d7397..264e2a6 100644 --- a/src/systems/KidManager.js +++ b/src/systems/KidManager.js @@ -78,10 +78,11 @@ export default class KidManager { kidsAboard: this.kidsAboardCount, total: this.total, }); - - if (this.kidsAboardCount === 0) { - this.scene.events.emit('level-failed', { reason: 'all-kids-lost' }); - } + // No level-failed here even at 0 aboard - PlayScene decides that once + // every kid has actually settled outside the bus (or fallen down a + // gap), not the instant the last one leaves, so a jump that pops + // everyone loose still leaves a window to drive back underneath and + // catch them (see PlayScene._checkAllKidsLost). } } @@ -92,6 +93,16 @@ export default class KidManager { _clampToCompartment(kid, chassis, cos, sin) { const dx = kid.image.x - chassis.x; const dy = kid.image.y - chassis.y; + + // A kid genuinely outside compartmentClampRadius was never inside the + // compartment this tick - skip it entirely, rather than let the below + // checks (which only bound ONE axis each) treat "far away in the other + // axis" as a boundary violation. Without this, a kid resting anywhere + // else in the level could get yanked toward wherever the bus currently + // is, since local-frame coordinates alone don't distinguish "just + // tunneled through" from "elsewhere entirely." + if (dx * dx + dy * dy > BUS.compartmentClampRadius * BUS.compartmentClampRadius) return; + let localX = dx * cos + dy * sin; let localY = -dx * sin + dy * cos; let hitFloor = false;