refactor: replace bus ceiling fixture with open-top ejection and catch logic

- Remove physical ceiling fixture; compartment top is now intentionally open
- Kids eject when passing above the top and re-board when falling back down
- Add KID.ceilingReboardDelayMs to prevent accidental catches from residual
  downward drift immediately after ejection
- Introduce a one-shot `ejected` latch to prevent kids who landed outside
  the bus from being pulled back in later
- Replace `isSettledRelativeToChassis` with `_isMovingDownwardIntoCompartment`
  to verify downward chassis-relative velocity
- Add "kid_reload" sound effect on successful re-boarding
- Update comments across config, Bus, Kid, and KidManager to document the
  new position/velocity-based architecture
This commit is contained in:
Brian Fertig 2026-08-21 10:00:47 -06:00
parent 23977ab9b9
commit b22758afb4
5 changed files with 113 additions and 49 deletions

View File

@ -41,13 +41,13 @@ export const BUS = {
maxSeats: 5, maxSeats: 5,
// The invisible box kids ride in, local to the chassis (0,0 = chassis // The invisible box kids ride in, local to the chassis (0,0 = chassis
// center, y down): solid floor + left/right walls, but deliberately no // center, y down): solid floor + left/right walls only - deliberately NO
// ceiling fixture at all - see Bus.js's _buildCompartment. A kid whose // ceiling fixture (see Bus.js's _buildCompartment). A kid whose own local Y
// own local Y (rotation-corrected relative to the chassis) rises above // (rotation-corrected relative to the chassis) rises above compartmentTopY
// compartmentTopY has nothing left to collide with and is airborne; the // is airborne; if it then falls back down into the compartment (within its
// same check runs both directions each tick (see KidManager), so falling // horizontal span, below the open top, moving downward) it counts as being
// back below it while inside compartmentHalfWidth re-contains the kid. // caught/re-boarded (see KidManager._onAfterUpdate). Tune by playtesting -
// Tune by playtesting - not physically derived. // not physically derived.
compartmentHalfWidth: 75 * WORLD_SCALE, compartmentHalfWidth: 75 * WORLD_SCALE,
// A bit thicker than the floor - gives a fast-moving kid more physical // 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 // 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 // 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. // landed), so a momentary dip alone isn't proof it's actually stopped.
settleDurationMs: 500, 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 // Points awarded on LevelScoreScene's end-of-level tally, in the order

View File

@ -108,6 +108,10 @@ export default class Bus {
this.compartmentFloor = this._buildFixture(x, y, 0, BUS.compartmentFloorY, BUS.compartmentHalfWidth * 2, BUS.compartmentFloorThickness); 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.compartmentLeftWall = this._buildFixture(x, y, -BUS.compartmentHalfWidth, wallCenterY, BUS.compartmentWallThickness, wallHeight);
this.compartmentRightWall = 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) { _buildFixture(chassisX, chassisY, localX, localY, width, height) {

View File

@ -13,7 +13,19 @@ export default class Kid {
this.seatIndex = seatIndex; this.seatIndex = seatIndex;
this.state = 'aboard'; // aboard | ejected this.state = 'aboard'; // aboard | ejected
this._slowSince = null; // see isSettled() 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); const worldPos = bus.getSeatWorldPosition(seatIndex);
@ -58,6 +70,8 @@ export default class Kid {
markEjected() { markEjected() {
if (this.state === 'ejected') return; if (this.state === 'ejected') return;
this.state = 'ejected'; this.state = 'ejected';
this.ejected = true; // latch, see field comment above - never unset
this.ejectedAt = this.scene.time.now;
this._applyTexture('kid_ejected'); this._applyTexture('kid_ejected');
// Ignores the world's real gravity - KidManager applies a lighter, // Ignores the world's real gravity - KidManager applies a lighter,
// custom fraction of it instead (see KidManager._onBeforeUpdate) while // custom fraction of it instead (see KidManager._onBeforeUpdate) while
@ -92,26 +106,6 @@ export default class Kid {
return now - this._slowSince >= KID.settleDurationMs; 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() { destroy() {
this.image.destroy(); this.image.destroy();
} }

View File

@ -32,6 +32,7 @@ export default class PreloadScene extends Phaser.Scene {
this.load.audio('crowd_cheer', 'assets/fx/crowd-cheer.mp3'); this.load.audio('crowd_cheer', 'assets/fx/crowd-cheer.mp3');
this.load.audio('kid_count', 'assets/fx/kid-count.mp3'); this.load.audio('kid_count', 'assets/fx/kid-count.mp3');
this.load.audio('score_count', 'assets/fx/score-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); loadKidFallSounds(this.load);
// Not every level has a voice-over - this just attempts one per level // Not every level has a voice-over - this just attempts one per level

View File

@ -2,6 +2,15 @@ import { KID, BUS } from '../config.js';
import Kid from '../entities/Kid.js'; import Kid from '../entities/Kid.js';
import { playRandomKidFallSound } from '../util/kidFallSound.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 { export default class KidManager {
constructor(scene, bus, kidsAboard) { constructor(scene, bus, kidsAboard) {
this.scene = scene; this.scene = scene;
@ -41,16 +50,25 @@ export default class KidManager {
} }
} }
// Every physics step: hard-clamps each kid against the floor/walls (see // Every physics step: hard-clamps each kid against the compartment's
// _clampToCompartment - same "soft collision + hard clamp" pairing as // floor and walls (see _clampToCompartment, same "soft collision + hard
// Bus.js's wheel wishbone, since the fixtures' own collision response // clamp" pairing as Bus.js's wheel wishbone), then checks the
// alone isn't a hard guarantee against a fast enough hit), then checks // (now-corrected) position against the compartment's open top in the
// 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 // chassis's own rotated frame. A kid drifting up past the open top
// becomes ejected immediately. A floating kid drifting back down through // becomes ejected immediately - and stays ejected from here on (see the
// the open top only re-boards once it has actually come to rest inside // re-boarding rule below, deliberately one-shot, so a kid that landed on
// the compartment (see Kid.isSettledRelativeToChassis), so a kid still mid-arc can't // the ground outside the bus never teleports back in).
// teleport back in mid-air. //
// 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() { _onAfterUpdate() {
const chassis = this.bus.chassis; const chassis = this.bus.chassis;
const cos = Math.cos(chassis.rotation); const cos = Math.cos(chassis.rotation);
@ -67,25 +85,49 @@ export default class KidManager {
const isAboveOpenTop = localY < BUS.compartmentTopY; const isAboveOpenTop = localY < BUS.compartmentTopY;
// Re-boarding additionally requires being within the compartment's // Re-boarding additionally requires being within the compartment's
// horizontal span, not just below the open top - localY alone can't // horizontal span, not just below the open top - localY alone can't
// tell "resting inside the compartment" apart from "resting on the // tell "inside the compartment" apart from "resting on the ground
// ground anywhere else in the level at roughly the bus's height," // anywhere else in the level at roughly the bus's height," since
// since compartmentTopY is measured purely along the chassis's local Y // compartmentTopY is measured purely along the chassis's local Y
// axis with no horizontal extent to it. // axis with no horizontal extent to it.
const isWithinCompartmentWidth = Math.abs(localX) <= BUS.compartmentHalfWidth; const isWithinCompartmentWidth = Math.abs(localX) <= BUS.compartmentHalfWidth;
if (isAboveOpenTop && kid.state === 'aboard') { if (isAboveOpenTop && kid.state === 'aboard') {
kid.markEjected(); 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); playRandomKidFallSound(this.scene);
anyChanged = true; anyChanged = true;
} else if (!isAboveOpenTop && isWithinCompartmentWidth && kid.state === 'ejected' && } else if (kid.state === 'ejected' && !isAboveOpenTop && isWithinCompartmentWidth &&
kid.isSettledRelativeToChassis(this.scene.time.now, chassis.body.velocity)) { kid.ejectedAt !== null &&
// Re-boarding is gated on the kid actually settling relative to the this.scene.time.now - kid.ejectedAt >= KID.ceilingReboardDelayMs &&
// chassis (see Kid.isSettledRelativeToChassis), not just passing this._isMovingDownwardIntoCompartment(kid, cos, sin)) {
// through the compartment's open top again. Ejected kids run under // Re-boarding is gated on the kid genuinely falling back DOWN into
// reduced gravity, so a kid still floating/arc-ing will happily // the compartment (see _isMovingDownwardIntoCompartment - its
// drift back through the open top while airborne; without this gate // chassis-relative velocity points downward, i.e. toward the floor,
// it would flip back to aboard mid-air and "teleport" back in. // 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(); kid.markAboard();
playKidReloadSound(this.scene);
anyChanged = true; 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 // 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 // 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 // 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) { _clampToCompartment(kid, chassis, cos, sin) {
const dx = kid.image.x - chassis.x; const dx = kid.image.x - chassis.x;
const dy = kid.image.y - chassis.y; const dy = kid.image.y - chassis.y;