import { GAME_WIDTH, GAME_HEIGHT } from '../config/game.config.js'; import { storage, KEYS } from '../utils/storage.js'; /** * Owns a fixed set of pooled Phaser particle emitters, created once per scene. * All hot-path particle effects (jump, explosion, boost flame/wind, etc.) reuse * these emitters instead of allocating GameObjects + tweens every frame. */ export class ParticleManager { constructor(scene) { this.scene = scene; this.quality = ParticleManager.resolveQuality(scene); this.low = this.quality === 'low'; this.emitters = {}; this._build(); } static resolveQuality(scene) { const saved = storage.getItem(KEYS.particleQuality, null); if (saved === 'low' || saved === 'high') return saved; const d = scene.sys.game.device; const small = Math.min(window.innerWidth, window.innerHeight) < 500; return (d.os.android || d.os.iOS || small) ? 'low' : 'high'; } _q(high, low) { return this.low ? low : high; } _build() { const add = (key, texture, config, depth) => { const e = this.scene.add.particles(0, 0, texture, { emitting: false, ...config }); e.setDepth(depth); this.emitters[key] = e; return e; }; // --- Burst emitters (manual explode) --- add('jump', 'px_square', { lifespan: 260, speed: { min: 40, max: 130 }, angle: { min: 250, max: 290 }, scale: { start: 0.9, end: 0 }, alpha: { start: 0.9, end: 0 }, rotate: { min: -180, max: 180 }, tint: [0xa855f7, 0xc084fc, 0x8b5cf6], frequency: -1, }, -2); add('explosion', 'px_square', { lifespan: 500, speed: { min: 60, max: 170 }, angle: { min: 0, max: 360 }, scale: { start: 1.1, end: 0 }, alpha: { start: 1, end: 0 }, rotate: { min: -180, max: 180 }, tint: [0xef4444, 0xf97316, 0xfca5a5], frequency: -1, }, 1); add('powerup', 'px_square', { lifespan: 600, speed: { min: 50, max: 150 }, angle: { min: 0, max: 360 }, scale: { start: 1, end: 0 }, alpha: { start: 1, end: 0 }, tint: [0xffd700, 0xa855f7, 0x22c55e], frequency: -1, }, 1); add('puff', 'px_soft', { lifespan: { min: 500, max: 800 }, speed: { min: 30, max: 70 }, angle: { min: 0, max: 360 }, scale: { start: 1.2, end: 2.2 }, alpha: { start: 0.55, end: 0 }, tint: [0x888888, 0xaaaaaa, 0x666666], frequency: -1, }, -2); add('sparks', 'px_square', { lifespan: 500, speed: { min: 70, max: 150 }, angle: { min: 0, max: 360 }, scale: { start: 1, end: 0 }, alpha: { start: 1, end: 0 }, frequency: -1, }, 1); // --- Flow emitters (toggle start/stop, follow a position) --- add('flame', 'px_soft', { lifespan: { min: 360, max: 620 }, speedY: { min: 120, max: 260 }, speedX: { min: -45, max: 45 }, scale: { start: this._q(1.2, 0.9), end: 0 }, alpha: { start: 0.95, end: 0 }, tint: [0xffd700, 0xffaa00, 0xff6600, 0xff3300, 0xaa0000], blendMode: 'ADD', frequency: this._q(28, 55), quantity: 1, }, -2); add('wind', 'px_streak', { lifespan: { min: 300, max: 540 }, speedY: { min: 30, max: 80 }, speedX: { min: -25, max: 25 }, scale: { start: 1, end: 0.3 }, alpha: { start: 0.7, end: 0 }, tint: [0xffffff, 0xbbddff, 0x66aaff, 0x4477cc], blendMode: 'ADD', frequency: this._q(40, 80), quantity: 1, }, -2); add('spring', 'px_square', { lifespan: { min: 400, max: 620 }, speedY: { min: 60, max: 130 }, speedX: { min: -35, max: 35 }, scale: { start: 1, end: 0 }, alpha: { start: 0.9, end: 0 }, rotate: { min: -180, max: 180 }, tint: [0xffd700, 0xfff066, 0x22c55e, 0xa3e635], frequency: this._q(35, 70), quantity: 1, }, -2); add('speedline', 'px_streak', { lifespan: { min: 280, max: 450 }, speedY: { min: 150, max: 300 }, scaleX: { start: 1, end: 1 }, scaleY: { start: 3.5, end: 1.5 }, alpha: { start: 0.45, end: 0 }, tint: 0xffffff, frequency: -1, }, -1); } // --- Bursts --- burstJump(x, y) { this.emitters.jump.explode(this._q(8, 4), x, y); } burstExplosion(x, y) { this.emitters.explosion.explode(this._q(14, 7), x, y); } burstPowerup(x, y) { this.emitters.powerup.explode(this._q(12, 6), x, y); } puff(x, y) { this.emitters.puff.explode(this._q(8, 4), x, y); } // --- Boost flow control (called each frame while active) --- flameAt(x, y) { const e = this.emitters.flame; e.setPosition(x, y); if (!e.emitting) e.start(); } windAt(x, y) { const e = this.emitters.wind; e.setPosition(x, y); if (!e.emitting) e.start(); } springAt(x, y) { const e = this.emitters.spring; e.setPosition(x, y); if (!e.emitting) e.start(); } stopFlow() { if (this.emitters.flame.emitting) this.emitters.flame.stop(); if (this.emitters.wind.emitting) this.emitters.wind.stop(); if (this.emitters.spring.emitting) this.emitters.spring.stop(); } speedLine() { if (this.low) return; const cam = this.scene.cameras.main; const left = Math.random() < 0.5; const x = left ? Phaser.Math.Between(0, 40) : Phaser.Math.Between(GAME_WIDTH - 40, GAME_WIDTH); const y = cam.scrollY + Phaser.Math.Between(0, GAME_HEIGHT); this.emitters.speedline.emitParticleAt(x, y, 1); } // --- Boost start burst: rings (rare, 2 sprites) + spark explode --- boostBurst(x, y, type) { let ringColor, sparkColor; if (type === 'rocket') { ringColor = 0xff6600; sparkColor = 0xffd700; } else if (type === 'spring') { ringColor = 0x22c55e; sparkColor = 0xffd700; } else { ringColor = 0x88ccff; sparkColor = 0xffffff; } this._ring(x, y, ringColor, 0.6, 380); this._ring(x, y, sparkColor, 0.45, 520); this.emitters.sparks.setParticleTint(sparkColor); this.emitters.sparks.explode(this._q(type === 'rocket' ? 16 : 10, 6), x, y); const shake = type === 'rocket' ? 0.006 : type === 'spring' ? 0.002 : 0.003; const dur = type === 'rocket' ? 120 : type === 'spring' ? 60 : 80; this.scene.cameras.main.shake(dur, shake); } _ring(x, y, color, alpha, duration) { const ring = this.scene.add.image(x, y, 'px_ring').setTint(color).setAlpha(alpha).setDepth(-1).setScale(0.4); this.scene.tweens.add({ targets: ring, scale: 3, alpha: 0, duration, ease: 'Quad.easeOut', onComplete: () => ring.destroy(), }); } destroy() { Object.values(this.emitters).forEach((e) => { if (e && e.destroy) e.destroy(); }); this.emitters = {}; } }