A1 Pooled particle system - New ParticleManager owns 9 reusable Phaser emitters created once per scene (jump, explosion, powerup, puff, sparks, flame, wind, spring, speedline) - BootScene generates reusable white textures (px_square/soft/streak/ring) - GameScene burst helpers + EffectsManager flow effects now delegate to the pool instead of allocating rectangles + tweens every frame - Quality auto-detect (low on mobile/small screens) cuts particle counts A2 Fix high-speed landing tunneling - Cap downward velocity at PHYSICS.maxFallSpeed (boosts unaffected) - platformCollisionFilter now uses a swept deltaY one-way check so a fast fall can never be wrongly rejected or passed through A3 Safe storage - New utils/storage.js wraps localStorage with in-memory fallback so private mode / quota errors cannot crash the game; all access routed through it A4-A6 Lifecycle and robustness - Global error / unhandledrejection guard recovers to the menu - GameScene auto-pauses on tab hidden and cleans up the cross-scene listener on shutdown; fps pacing + disableContextMenu in game config - Moving platforms use a proper dynamic body instead of destroy()+new Body Verified in-browser: menu + game load with zero console errors, all emitters active, normal bouncing works, fall speed capped.
226 lines
6.6 KiB
JavaScript
226 lines
6.6 KiB
JavaScript
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 = {};
|
|
}
|
|
}
|