monsterplex/src/entities/SmashItemManager.js

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.
}
}