198 lines
9.3 KiB
JavaScript
198 lines
9.3 KiB
JavaScript
import { SMASH_ITEM } from '../config.js';
|
|
|
|
// smash-items.png is 12 frames of 64x64 in an 8-wide x 2-row grid
|
|
// (frame 6 onward wraps to row 2), in order:
|
|
// 0 intact TV, 1 smashed TV, 2 intact chair, 3 smashed chair,
|
|
// 4 intact cone, 5 smashed cone, 6 intact trash can, 7 smashed trash can,
|
|
// 8 intact tent, 9 smashed tent, 10 intact cat, 11 smashed cat.
|
|
const ITEM_TYPES = {
|
|
tv: { baseFrame: 0, smashedFrame: 1, label: 'TV', soundKey: 'smash_tv' },
|
|
chair: { baseFrame: 2, smashedFrame: 3, label: 'Chair', soundKey: 'smash_chair' },
|
|
cone: { baseFrame: 4, smashedFrame: 5, label: 'Cone', soundKey: 'smash_cone' },
|
|
trashcan: { baseFrame: 6, smashedFrame: 7, label: 'Trash Can', soundKey: 'smash_trashcan' },
|
|
tent: { baseFrame: 8, smashedFrame: 9, label: 'Tent', soundKey: 'smash_tent' },
|
|
cat: { baseFrame: 10, smashedFrame: 11, label: 'Cat', soundKey: 'smash_cat' },
|
|
};
|
|
|
|
export default class SmashItemManager {
|
|
/**
|
|
* @param scene PlayScene
|
|
* @param bus the level's Bus
|
|
* @param level the level data object (items come from `level.items`)
|
|
*
|
|
* Owns the level's smashable props: builds one image+body per item,
|
|
* watches for the bus touching an intact item, and flies each one off
|
|
* with a smash (swap to its smashed frame + up-and-forward impulse on the
|
|
* world's real gravity). Float items stay exactly where placed; gravity
|
|
* items settle onto the ground at level start.
|
|
*/
|
|
constructor(scene, bus, level) {
|
|
this.scene = scene;
|
|
this.bus = bus;
|
|
this.items = [];
|
|
|
|
for (const spec of level.items || []) {
|
|
const item = this._buildItem(spec);
|
|
if (item) this.items.push(item);
|
|
}
|
|
|
|
// The bus's bodies that can count as "the bus hitting an item": chassis
|
|
// + both wheels (the wheel usually does the touching). The compartment
|
|
// fixtures are intentionally NOT here - their collisionFilter mask is
|
|
// kidCategory-only, so an item can never actually collide with them
|
|
// (and neither can it with the kids, whose bodies are on a separate
|
|
// category the items don't mask against).
|
|
this._busBodies = [
|
|
bus.chassis.body,
|
|
bus.wheelRear.body,
|
|
bus.wheelFront.body,
|
|
];
|
|
|
|
scene.matter.world.on('collisionstart', (event) => this._onCollisionStart(event));
|
|
}
|
|
|
|
get smashedCount() {
|
|
return this.items.filter((item) => item.smashed).length;
|
|
}
|
|
|
|
_buildItem(spec) {
|
|
const type = ITEM_TYPES[spec.type];
|
|
if (!type) return null; // unknown/misspelled type in a level file - skip
|
|
|
|
const scene = this.scene;
|
|
const isFloat = spec.mode === 'float';
|
|
|
|
// Both modes spawn at the editor's exact placement (spec.x, spec.y).
|
|
// Float items are static, so they simply stay there. Gravity items are
|
|
// dynamic, so the first physics step lets them drop onto the ground
|
|
// under that X and settle - the "falls to the ground" behavior.
|
|
const image = scene.matter.add.image(spec.x, spec.y, 'smash_items', type.baseFrame, {
|
|
shape: { type: 'circle', radius: SMASH_ITEM.radius },
|
|
density: SMASH_ITEM.density,
|
|
friction: SMASH_ITEM.friction,
|
|
frictionAir: SMASH_ITEM.frictionAir,
|
|
restitution: SMASH_ITEM.restitution,
|
|
// Never collide with the bus's kids. Kids ride at the open top of the
|
|
// compartment, so an item slamming into the bus (or a smashed item
|
|
// launched up-and-forward) could otherwise knock them out. Clearing the
|
|
// dedicated kidCategory (not the default category, which terrain also
|
|
// uses) keeps the item solid against the bus (for the smash) and the
|
|
// terrain (so it lands) while making it a pure ghost to the kids -
|
|
// its impact can no longer contribute to ejecting them. (setStatic/
|
|
// setVelocity etc. are untouched; see the rest of this file.)
|
|
collisionFilter: { mask: 0xffffffff & ~this.bus.kidCategory },
|
|
});
|
|
image.setDisplaySize(SMASH_ITEM.displaySize, SMASH_ITEM.displaySize);
|
|
image.setDepth(1); // same layer as the kids - over the ground, under the bus
|
|
image.setAngle(0);
|
|
|
|
// Intact items are STATIC (a solid prop, infinite mass in the engine's
|
|
// bookkeeping). A gravity item is left dynamic (it still "rests" where
|
|
// the editor put it, just supported by the terrain); a float item is
|
|
// made static here so it holds its placed height forever. When smashed,
|
|
// setStatic(false) restores the body's real density/mass so the launch
|
|
// moves it. (setStatic is a Phaser Matter component on the body wrapper,
|
|
// not on the raw Matter body - see Bus.js / the vendored build.)
|
|
if (isFloat) image.setStatic(true);
|
|
|
|
return { spec, type, image, smashed: false };
|
|
}
|
|
|
|
// Phaser's Matter plugin re-emits matter-js's `collisionStart` as
|
|
// `collisionstart` with the raw event (see World.js in the vendored build):
|
|
// the event carries `pairs`, each with `.bodyA`/`.bodyB` - there is no
|
|
// top-level event.bodyA (PlayScene._onCollisionStart's
|
|
// `bodyA`/`bodyB` params are actually undefined at runtime; it still works
|
|
// because its handler is a no-op for this event shape - don't copy that
|
|
// pattern).
|
|
_onCollisionStart(event) {
|
|
const pairs = (event && event.pairs) || [];
|
|
for (const pair of pairs) {
|
|
const bodyA = pair.bodyA;
|
|
const bodyB = pair.bodyB;
|
|
for (const item of this.items) {
|
|
if (item.smashed) continue;
|
|
if (item.image.body !== bodyA && item.image.body !== bodyB) continue;
|
|
const other = item.image.body === bodyA ? bodyB : bodyA;
|
|
if (!this._busBodies.includes(other)) continue;
|
|
|
|
this._smash(item);
|
|
this.scene.events.emit('item-smashed', { index: this.items.indexOf(item) });
|
|
// Each item smashes at most once, so no `break` needed past the
|
|
// per-pair scan; this loop is O(pairs * items) per physics step,
|
|
// both tiny for any level we'd plausibly author.
|
|
}
|
|
}
|
|
}
|
|
|
|
// The smash: swap to the smashed frame, make the body dynamic (so the
|
|
// launch actually moves it), give it the up-and-forward impulse, and clear
|
|
// the bus out of its collision mask so the bus drives straight through it.
|
|
_smash(item) {
|
|
const image = item.image;
|
|
const dirX = Math.sign(this.bus.chassis.body.velocity.x) >= 0 ? 1 : -1;
|
|
|
|
// From this moment on the smashed item is no longer solid to the bus.
|
|
// Clear the bus's category bit out of the item's collision mask so the
|
|
// broadphase skips every item<->bus pair (Detector.canCollide only
|
|
// considers a pair when each side's mask includes the other's category -
|
|
// see the vendored build). Set on the raw matter body directly: that's the
|
|
// exact field canCollide reads, and matter-js recomputes pairs every step,
|
|
// so this takes effect on the very next physics step. The item keeps its
|
|
// mask against everything ELSE (terrain, other items) so it still lands
|
|
// and rests where it flies - the bus and the kids are exempted. This is
|
|
// what makes the bus "drive through" a smashed prop instead of bumping
|
|
// it. Keep the kid exemption too (see _buildItem): clearing only
|
|
// busCategory here would silently re-enable item<->kid collisions after
|
|
// the smash.
|
|
image.body.collisionFilter.mask = 0xffffffff & ~this.bus.busCategory & ~this.bus.kidCategory;
|
|
|
|
// setStatic(false) (Phaser component, same as in _buildItem) restores
|
|
// the body's saved real mass/inertia - matter-js stashed them when the
|
|
// body was made static - so the velocity math below is meaningful. For
|
|
// items that were never static (gravity items) this is a no-op.
|
|
image.setStatic(false);
|
|
|
|
image.setFrame(item.type.smashedFrame);
|
|
|
|
// Launch up-and-forward relative to where the bus is HEADED, so the item
|
|
// always sails off ahead of it - even on a backward hit.
|
|
// `launchAngle` is a positive magnitude (radians above horizontal):
|
|
// velocity.x = dirX * cos(angle) * speed (forward, in bus's direction)
|
|
// velocity.y = -sin(angle) * speed (always upward = negative Y)
|
|
// This works for both leftward and rightward bus motion.
|
|
// image.setVelocity / setAngularVelocity are the Phaser Matter components
|
|
// (the raw body has no such methods - see Components.Velocity in the
|
|
// vendored build); they delegate to the same Body.setVelocity that the
|
|
// rest of this codebase uses via KidManager / Bus.
|
|
const angle = Math.abs(SMASH_ITEM.launchAngle);
|
|
const speed = SMASH_ITEM.launchSpeed;
|
|
image.setVelocity(
|
|
dirX * Math.cos(angle) * speed,
|
|
-Math.sin(angle) * speed,
|
|
);
|
|
|
|
// A visible tumble while airborne. matter-js stores angular velocity in
|
|
// radians PER PHYSICS STEP (not per second) - hence the small value.
|
|
image.setAngularVelocity(SMASH_ITEM.launchSpin * dirX);
|
|
|
|
item.smashed = true;
|
|
|
|
// SFX: the item's own smash clip (smash-tv.mp3 / smash-chair.mp3 /
|
|
// smash-cone.mp3, loaded in PreloadScene). cache.audio.exists guards
|
|
// against a failed load - same load-tolerant pattern as voiceLine.js /
|
|
// kidFallSound.js, so a missing clip degrades to silent instead of
|
|
// throwing. Each item smashes at most once, so no overlap concern.
|
|
const key = item.type.soundKey;
|
|
if (key && this.scene.cache.audio.exists(key)) {
|
|
this.scene.sound.add(key).play();
|
|
}
|
|
}
|
|
|
|
destroy() {
|
|
// Nothing to do: the Matter world's shutdown removes the bodies and its
|
|
// registered listeners (and nulls scene.matter before this runs), matching
|
|
// the empty destroy() in Bus.js.
|
|
}
|
|
}
|