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:
parent
23977ab9b9
commit
b22758afb4
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue