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