CAPITOLO 12

Particle System

Intermedio
~30 min

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,
});
ParametroTipoDescrizione
x, ynumberPosizione dell'emitter (dentro la config, non come argomenti di add.particles)
configParticleEmitterConfigOggetto 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.

FormatoEsempioComportamento
Staticospeed: 100Valore fisso per tutte le particelle
Arrayspeed: [50, 100, 200]Scelta casuale dall'array
Min/Maxspeed: { min: 50, max: 200 }Valore casuale nel range
Start/Endscale: { start: 1, end: 0 }Interpolazione lineare durante la vita
Callbackx: (p, k, t, v) => v + 10Funzione 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,
}
typeComportamento
'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
  },
});
typeComportamento
'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());
}
Phaser Labs
Phaser LabsLIVE
Prova tutto dal vivo — centinaia di esempi interattivi
Phaser Labs raccoglie esempi funzionanti per ogni argomento del corso. Copia, modifica, sperimenta — direttamente nel browser.
Apri gli esempi
Sh4rk0

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