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.
This commit is contained in:
Brian Fertig 2026-08-19 21:40:03 -06:00
parent c4954b2292
commit f3e7f96220
5 changed files with 97 additions and 4 deletions

View File

@ -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 = {

View File

@ -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();
}

View File

@ -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 {

View File

@ -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, {

View File

@ -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;