CAPITOLO 12
Particle System
Introduzione
Il particle system di Phaser 3 è uno degli strumenti più potenti per aggiungere effetti visivi dinamici ai tuoi giochi: esplosioni, fuoco, fumo, scintille, magie, pioggia, stelle.
In Phaser 3.9, il particle system è organizzato in due livelli:
ParticleEmitterManager— il GameObject aggiunto alla scena, associato a una singola texture. Gestisce depth, blend mode e il rendering di tutte le particelle.ParticleEmitter— creato a partire dal manager, definisce il comportamento effettivo (velocità, durata, colore...). Un manager può contenere più emitter contemporaneamente, tutti condividendo la stessa texture.
Questa architettura a due livelli (manager + emitter) è quella di Phaser 3.9. Se in futuro incontrerai codice Phaser più recente con this.add.particles(x, y, texture, config) in un'unica chiamata, sappi che è una API successiva (introdotta molte versioni dopo, quando il manager è stato rimosso e l'emitter è diventato esso stesso il GameObject). I due stili non sono intercambiabili.
Creare un ParticleEmitterManager e un Emitter
Il metodo this.add.particles() crea il manager, associandolo a una texture:
const particles = this.add.particles('star');
Dal manager, il metodo .createEmitter(config) crea e restituisce l'emitter vero e proprio:
const emitter = particles.createEmitter({
x: 400,
y: 300,
speed: 100,
lifespan: 1000,
quantity: 5,
});
| Parametro | Tipo | Descrizione |
|---|---|---|
x, y | number | Posizione dell'emitter (dentro la config, non come argomenti di add.particles) |
config | ParticleEmitterConfig | Oggetto con tutte le impostazioni delle particelle |
Modalità di emissione
Un emitter può lavorare in due modalità distinte.
Flow (flusso continuo)
La modalità di default. L'emitter genera quantity particelle ogni frequency millisecondi finché non viene fermato. Parte automaticamente alla creazione.
const particles = this.add.particles('spark');
const emitter = particles.createEmitter({
x: 400,
y: 300,
speed: { min: 50, max: 200 },
lifespan: 800,
quantity: 3,
frequency: 100, // emette ogni 100ms
});
Explode (burst)
Emette un numero fisso di particelle in un singolo momento, poi si ferma. Utile per esplosioni, impatti, effetti on-demand.
const particles = this.add.particles('spark');
const emitter = particles.createEmitter({
speed: { min: 100, max: 300 },
lifespan: 600,
quantity: 30,
on: false, // non parte automaticamente
});
// esplosione in un punto specifico
emitter.explode(30, x, y);
explode(count, x, y) è il metodo da usare per burst: emette count particelle nella posizione (x, y) ignorando la posizione corrente dell'emitter.
La proprietà per tenere l'emitter fermo alla creazione si chiama on: false (non emitting: false come in versioni successive di Phaser). emitter.stop() ferma un emitter già in flusso; on: false in config lo crea già spento.
Proprietà principali della config
Velocità e direzione
{
speed: 200, // velocità fissa in px/sec
speed: { min: 50, max: 300 }, // velocità casuale nel range
speedX: 100, // solo asse orizzontale
speedY: -200, // solo asse verticale (negativo = su)
angle: { min: 0, max: 360 }, // direzione di emissione in gradi
}
Durata di vita
{
lifespan: 1000, // 1 secondo fisso
lifespan: { min: 500, max: 1500 }, // durata casuale
}
Scala
{
scale: 0.5, // scala fissa
scale: { start: 1, end: 0 }, // si rimpicciolisce fino a scomparire
scaleX: { start: 1, end: 2 }, // solo asse X
}
Trasparenza
{
alpha: 0.8, // fisso
alpha: { start: 1, end: 0 }, // dissolvenza graduale
}
Gravità e accelerazione
{
gravityY: 300, // gravità verso il basso (px/sec²)
gravityX: -50, // spinta orizzontale costante
}
Tint e colore
{
tint: 0xff4400, // tinta arancione fissa
tint: [ 0xff0000, 0xffff00, 0x00ff00 ],// colore scelto casualmente
}
L'interpolazione di colore nel tempo (color: [...], che sfuma da un colore all'altro durante la vita della particella) è stata aggiunta in versioni di Phaser successive alla 3.9. In 3.9 puoi solo assegnare un tint fisso o scelto casualmente da un array per l'intera vita della particella.
Quantità e frequenza
{
quantity: 5, // particelle per ciclo
frequency: 50, // millisecondi tra un ciclo e l'altro (più basso = più denso)
maxParticles: 100, // limite totale di istanze vive gestite dall'emitter
}
Rimbalzo e bounds
{
bounds: { x: 0, y: 0, w: 800, h: 600 }, // bounding box entro cui le particelle rimangono
bounce: 0.5, // coefficiente di rimbalzo sui bordi (0 = stop, 1 = elastico)
}
Valori dinamici — il sistema EmitterOp
La maggior parte delle proprietà accetta valori in più formati. Questo è il sistema EmitterOp, il cuore della flessibilità del particle system.
| Formato | Esempio | Comportamento |
|---|---|---|
| Statico | speed: 100 | Valore fisso per tutte le particelle |
| Array | speed: [50, 100, 200] | Scelta casuale dall'array |
| Min/Max | speed: { min: 50, max: 200 } | Valore casuale nel range |
| Start/End | scale: { start: 1, end: 0 } | Interpolazione lineare durante la vita |
| Callback | x: (p, k, t, v) => v + 10 | Funzione eseguita ad ogni update |
Esempio avanzato — particella che cambia scala e alpha durante la vita:
const particles = this.add.particles('circle');
const emitter = particles.createEmitter({
x: 400,
y: 300,
speed: { min: 80, max: 200 },
angle: { min: 0, max: 360 },
lifespan: 1200,
scale: { start: 0.8, end: 0 },
alpha: { start: 1, end: 0 },
gravityY: 150,
quantity: 2,
frequency: 30,
});
Emettere al click / pointer
Pattern comune: emettere particelle dove l'utente clicca o trascina il mouse.
create() {
const particles = this.add.particles('spark');
const emitter = particles.createEmitter({
speed: { min: 50, max: 150 },
angle: { min: 0, max: 360 },
scale: { start: 0.5, end: 0 },
alpha: { start: 1, end: 0 },
lifespan: 600,
quantity: 15,
on: false, // non parte automaticamente
});
this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
emitter.explode(15, pointer.x, pointer.y);
});
}
Per un effetto continuo mentre il mouse è premuto:
this.input.on('pointermove', (pointer: Phaser.Input.Pointer) => {
if (pointer.isDown) {
emitter.explode(5, pointer.x, pointer.y);
}
});
Follow — agganciare l'emitter a un GameObject
L'emitter può seguire automaticamente la posizione di un altro GameObject tramite startFollow():
const player = this.physics.add.sprite(400, 300, 'player');
const particles = this.add.particles('smoke');
const trail = particles.createEmitter({
speed: { min: 10, max: 40 },
scale: { start: 0.3, end: 0.8 },
alpha: { start: 0.6, end: 0 },
lifespan: 800,
quantity: 1,
frequency: 30,
});
trail.startFollow(player);
Con offset opzionale (es. per lo scarico di un razzo):
trail.startFollow(player, 0, 20); // offset 20px verso il basso
EmitZone — zone di emissione
Per default le particelle partono dal punto (x, y) dell'emitter. Con emitZone puoi definire un'area o una forma da cui partono.
Zone rettangolare
const particles = this.add.particles('rain');
const emitter = particles.createEmitter({
lifespan: 1500,
speedY: { min: 200, max: 400 },
scale: { min: 0.1, max: 0.3 },
quantity: 5,
frequency: 50,
emitZone: {
type: 'random',
source: new Phaser.Geom.Rectangle(0, 0, 800, 10), // striscia in cima
},
});
Zone circolare
emitZone: {
type: 'random',
source: new Phaser.Geom.Circle(0, 0, 100), // cerchio di raggio 100
}
Zone sul perimetro
emitZone: {
type: 'edge', // le particelle partono dal bordo, non dall'interno
source: new Phaser.Geom.Ellipse(0, 0, 200, 100),
quantity: 30,
}
type | Comportamento |
|---|---|
'random' | Posizione casuale dentro la forma |
'edge' | Posizione sul perimetro della forma |
DeathZone — zone di morte
Le particelle vengono distrutte quando entrano (o escono) da una zona definita.
const particles = this.add.particles('bubble');
const emitter = particles.createEmitter({
x: 400,
y: 500,
speed: { min: 50, max: 100 },
angle: { min: 250, max: 290 },
lifespan: 3000,
quantity: 1,
frequency: 200,
deathZone: {
type: 'onEnter', // muore quando entra nella zona
source: new Phaser.Geom.Rectangle(0, 0, 800, 50), // striscia in cima
},
});
type | Comportamento |
|---|---|
'onEnter' | La particella muore appena entra nella zona |
'onLeave' | La particella muore appena esce dalla zona |
Controllare l'emitter
emitter.start(); // avvia il flusso
emitter.stop(); // ferma l'emissione (le particelle esistenti continuano la loro vita)
emitter.pause(); // mette in pausa (il mondo si congela)
emitter.resume(); // riprende dalla pausa
// flusso con durata limitata (3 secondi poi stop automatico)
emitter.flow(100, 3000);
Per fermare un emitter dopo un numero fisso di particelle emesse (senza una proprietà di config dedicata in 3.9), usa un contatore manuale nella callback emitCallback:
let count = 0;
const particles = this.add.particles('star');
const emitter = particles.createEmitter({
speed: 100,
lifespan: 800,
emitCallback: () => {
count++;
if (count >= 50) {
emitter.stop();
}
},
});
Callback su emit e death
const particles = this.add.particles('spark');
const emitter = particles.createEmitter({
x: 400,
y: 300,
speed: 150,
lifespan: 600,
quantity: 2,
emitCallback: (particle: Phaser.GameObjects.Particles.Particle) => {
// eseguita ogni volta che una particella nasce
particle.tint = Phaser.Math.Between(0xff0000, 0xffff00);
},
deathCallback: (particle: Phaser.GameObjects.Particles.Particle) => {
// eseguita ogni volta che una particella muore
console.log('particle dead at', particle.x, particle.y);
},
});
Esempi pratici
Esplosione al collider
Pattern tipico: l'emitter è pronto ma silenzioso. Al momento dell'impatto scatta il burst.
private _explosionParticles: Phaser.GameObjects.Particles.ParticleEmitterManager;
private _explosion: Phaser.GameObjects.Particles.ParticleEmitter;
create() {
this._explosionParticles = this.add.particles('spark');
this._explosion = this._explosionParticles.createEmitter({
speed: { min: 100, max: 400 },
angle: { min: 0, max: 360 },
scale: { start: 0.6, end: 0 },
alpha: { start: 1, end: 0 },
gravityY: 200,
lifespan: { min: 300, max: 700 },
on: false,
});
this.physics.add.overlap(this._bullet, this._enemy, (bullet, enemy) => {
this._explosion.explode(20, enemy.x, enemy.y);
enemy.destroy();
bullet.destroy();
});
}
Effetto fuoco
const fireParticles = this.add.particles('particle');
const fire = fireParticles.createEmitter({
x: 400,
y: 500,
speed: { min: 20, max: 60 },
angle: { min: 260, max: 280 }, // verso l'alto con leggera variazione
scale: { start: 0.4, end: 0 },
alpha: { start: 0.8, end: 0 },
tint: [ 0xffff00, 0xff6600, 0xff0000 ],
lifespan: { min: 400, max: 900 },
quantity: 3,
frequency: 20,
});
In assenza dell'interpolazione color (arrivata dopo la 3.9), per un effetto fuoco più ricco puoi creare due emitter separati sullo stesso manager — uno con tint giallo/arancio a vita breve per il nucleo, uno con tint rosso/grigio a vita più lunga per il fumo residuo — sovrapposti nella stessa posizione.
Pioggia
const rainParticles = this.add.particles('raindrop');
const rain = rainParticles.createEmitter({
x: 0,
y: -10,
speedY: { min: 400, max: 700 },
speedX: { min: -20, max: 20 },
scale: { min: 0.1, max: 0.3 },
alpha: 0.6,
lifespan: 1500,
quantity: 8,
frequency: 30,
emitZone: {
type: 'random',
source: new Phaser.Geom.Rectangle(0, 0, 800, 1),
},
});
Depth e ordine di rendering
Il ParticleEmitterManager è il GameObject vero e proprio nella display list — il depth va impostato su di esso, non sui singoli emitter che contiene:
const particles = this.add.particles('spark');
particles.setDepth(10); // sopra agli sprite con depth < 10
const emitter = particles.createEmitter({ speed: 100, lifespan: 500 });
Schema completo — emitter con tutto
create() {
// texture caricata in preload come immagine o frame di atlante
const particles = this.add.particles('star');
particles.setDepth(5);
const emitter = particles.createEmitter({
// posizione
x: 400,
y: 300,
// moto
speed: { min: 50, max: 250 },
angle: { min: 0, max: 360 },
gravityY: 200,
// aspetto
scale: { start: 1, end: 0 },
alpha: { start: 1, end: 0 },
tint: [ 0xffffff, 0x00d4ff, 0x6c3fe8 ],
// ciclo di vita
lifespan: { min: 500, max: 1200 },
// flusso
quantity: 4,
frequency: 50,
});
// ferma dopo 3 secondi
this.time.delayedCall(3000, () => emitter.stop());
}


Il particle system è dove il gioco smette di sembrare un prototipo e inizia ad avere personalità. Un'esplosione da 20 particelle dura 600ms e pesa quasi niente sul framerate — ma cambia completamente la percezione del feedback.
La cosa a cui devi abituarti subito in Phaser 3.9: si lavora in due passaggi. Prima crei il ParticleEmitterManager con this.add.particles('texture'), poi da lì crei uno o più ParticleEmitter con .createEmitter(config). Il manager è quello che vive nella scena (e ha il depth); gli emitter sono i "comportamenti" che ci agganci sopra.
Il pattern da memorizzare per gli effetti on-demand è: crei l'emitter con on: false all'inizio del create(), poi chiami explode() nel momento esatto. Non creare manager/emitter dentro i collider o negli event handler — li creeresti ogni frame.
EmitterOp è il vero potere del sistema. Quando scrivi scale: { start: 1, end: 0 }, ogni particella percorre autonomamente quella curva durante la sua vita. Per il fuoco: angolo stretto verso l'alto (260-280°), tint che alterna giallo/arancio/rosso, scale che cresce e poi scompare. Il particle system non è magia — è configurazione. 🔥