monsterplex/src/entities/Kid.js

113 lines
5.0 KiB
JavaScript

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