import { KID } from '../config.js'; // A kid has no constraint holding it to the bus at all - it's a free body, // contained purely by physically colliding with the invisible floor/wall // fixtures pinned to the chassis (see Bus.js's _buildCompartment). state is // a derived label KidManager updates every tick from the kid's actual // position relative to the chassis's open top (see // KidManager._onAfterUpdate), not something this class decides on its own. export default class Kid { constructor(scene, bus, seatIndex) { this.scene = scene; this.bus = bus; this.seatIndex = seatIndex; this.state = 'aboard'; // aboard | ejected this._slowSince = null; // see isSettled() // 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); this.image = scene.matter.add.image(worldPos.x, worldPos.y, 'kid_idle', null, { shape: { type: 'circle', radius: KID.radius }, density: KID.density, friction: KID.friction, // Exempt from the chassis/wheels forever (contained or not) via mask // rather than by sharing the bus's group - sharing a group would also // make every kid on this bus mutually exempt from every OTHER kid // (group exemption is "never collide with anything sharing this // group," not "never collide with this one specific body"), which is // exactly why kids used to pass through each other. Containment // itself is the floor/wall fixtures' job, which target this category // specifically (see bus.kidCategory in Bus.js) so they still collide // with the kid despite this mask. collisionFilter: { category: bus.kidCategory, mask: 0xffffffff & ~bus.busCategory }, }); this.image.setDisplaySize(KID.radius * 2, KID.radius * 2); this._applyTexture('kid_idle'); // Between the background/terrain (default depth 0) and the bus // (explicit depth 2, see Bus.js) - visible over the ground, but drawn // over by the chassis/wheels, contained or not. this.image.setDepth(1); } // kid_idle/kid_ejected are 5-frame sheets (one distinct kid per frame, // matching BUS.maxSeats) - picks the frame matching this kid's own seat // so it stays visually the same character whichever texture it's on. // Falls back to frame 0 for the single-frame placeholder texture used // when the real art fails to load (see placeholderTextures.js). _applyTexture(key) { if (this.scene.textures.get(key).has(this.seatIndex)) { this.image.setTexture(key, this.seatIndex); } else { this.image.setTexture(key); } } // Called by KidManager when this kid's position rises above the open top // of the compartment. 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 // ejected, so it floats through a much bigger arc than the bus's own // fall would. this.image.body.ignoreGravity = true; } // Called by KidManager when a floating kid drifts back down through the // open top and lands back inside the compartment. markAboard() { if (this.state === 'aboard') return; this.state = 'aboard'; this._applyTexture('kid_idle'); 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(); } }