Créer un jeu de plateforme complet avec Phaser 4 : scènes, textures générées, tilemap, physique Arcade, animations, ennemis, particules, sons, UI, mobile et déploiement.
À mécanique égale, un jeu avec retours visuels et sonores paraît deux fois meilleur. Les développeurs appellent ça le juice : des étincelles quand on ramasse une pièce, de la poussière à l'atterrissage, l'écran qui tremble quand on est touché, un petit son pour chaque action. Chacun de ces effets tient en quelques lignes avec Phaser.
Un émetteur de particules génère de nombreux petits sprites éphémères avec des vitesses, durées et tailles aléatoires. Phaser les gère très efficacement : des centaines de particules ne coûtent presque rien.
creerEffets() {
this.etincelles = this.add.particles(0, 0, 'particule', {
speed: { min: 80, max: 200 },
angle: { min: 0, max: 360 },
lifespan: 450,
scale: { start: 1, end: 0 },
tint: [0xffec27, 0xffa300, 0xfff1e8],
emitting: false,
}).setDepth(10)
this.poussiere = this.add.particles(0, 0, 'particule', {
speedX: { min: -60, max: 60 },
speedY: { min: -40, max: -10 },
lifespan: 300,
scale: { start: 0.8, end: 0 },
tint: 0xc2c3c7,
gravityY: 200,
emitting: false,
}).setDepth(10)
this.events.on('atterrissage', (x, y) => this.poussiere.explode(6, x, y))
// Les écouteurs de this.events survivent au redémarrage de la scène : on les retire
this.events.once('shutdown', () => this.events.off('atterrissage'))
}Appelez creerEffets() dans create, juste après la création du joueur.
Les options de l'émetteur :
| Option | Effet |
|---|---|
speed, angle | vitesse et direction ; { min, max } donne une valeur aléatoire par particule |
speedX, speedY | alternative : vitesses horizontale et verticale séparées |
lifespan | durée de vie en millisecondes |
scale: { start, end } | taille qui évolue pendant la vie de la particule |
tint | couleur ; un tableau fait choisir au hasard |
gravityY | gravité propre aux particules |
emitting: false | n'émet rien en continu : on déclenche à la demande |

emitter.explode(nombre, x, y) libère d'un coup un nombre de particules à une position. C'est ce qu'on appelle dans ramasserPiece et toucherSlime. Pour un effet continu (fumée, traînée derrière le joueur), on laisse emitting à true et on utilise emitter.startFollow(sprite).
setDepth(10) place les particules au-dessus du décor et des personnages.
Décommentez maintenant les lignes this.etincelles.explode(...) du chapitre 5.
Au chapitre 4, le joueur émettait un événement quand il touchait le sol :
this.scene.events.emit('atterrissage', this.x, this.body.bottom)La scène l'écoute et fait voler la poussière. Ce découplage est précieux : le joueur ignore tout des particules, la scène ignore tout de la façon dont on détecte un atterrissage. Demain, on pourra aussi jouer un son ou secouer la caméra sur une grosse chute sans toucher à Player.js.
Tous les objets Phaser importants sont des émetteurs d'événements : this.events pour la scène, this.input, this.load, this.cameras.main, this.registry.events, chaque sprite…
Quand une scène redémarre (le joueur clique sur « rejouer »), Phaser recrée les objets mais conserve les écouteurs ajoutés à this.events. Sans la ligne this.events.once('shutdown', ...), chaque partie ajouterait un écouteur de plus : à la cinquième partie, un atterrissage déclencherait cinq nuages de poussière. C'est une fuite silencieuse très fréquente dans les jeux Phaser. La règle : tout écouteur ajouté à un émetteur qui survit à la scène se retire à l'événement shutdown.
Les écouteurs du clavier (this.input.keyboard.on), eux, sont nettoyés automatiquement par Phaser à l'arrêt de la scène.
La caméra a des effets intégrés, déjà croisés dans les chapitres précédents :
this.cameras.main.shake(200, 0.01) // tremblement : durée, intensité
this.cameras.main.flash(150, 255, 255, 255) // éclair blanc
this.cameras.main.fadeOut(400, 0, 0, 0) // fondu au noir
this.cameras.main.fadeIn(300) // retour depuis le noir
this.cameras.main.zoomTo(1.5, 600) // zoom animé
this.cameras.main.pan(x, y, 1000) // déplacement animéChacun émet un événement de fin (camerafadeoutcomplete, camerashakecomplete…) pour enchaîner la suite. Le shake dans perdreVie est discret (intensité 0,01, soit 1 % de la taille de l'écran) : au-delà, l'effet devient fatigant. Réservez les grosses secousses aux explosions.
Phaser charge et joue des fichiers audio avec un gestionnaire complet : volume, boucle, fondu, son positionnel, sprites audio.
// preload
this.load.audio('musique', ['audio/theme.ogg', 'audio/theme.mp3'])
this.load.audio('piece', 'audio/piece.wav')
// create
this.musique = this.sound.add('musique', { loop: true, volume: 0.4 })
this.musique.play()
// n'importe où
this.sound.play('piece')Donner plusieurs formats laisse le navigateur choisir celui qu'il sait lire. Les navigateurs bloquent tout son avant la première interaction de l'utilisateur ; Phaser attend automatiquement ce premier clic ou appui pour débloquer l'audio. C'est une raison de plus d'avoir un menu qui demande d'appuyer sur une touche.
Pour rester sans aucun fichier externe, on génère des sons rétro avec la Web Audio API, que Phaser utilise déjà en interne. Un oscillateur produit une onde dont la fréquence glisse d'une note à une autre, avec un volume qui décroît :
const SONS = {
saut: { type: 'square', de: 300, a: 600, duree: 0.12, volume: 0.06 },
piece: { type: 'triangle', de: 880, a: 1760, duree: 0.1, volume: 0.12 },
ecrase: { type: 'square', de: 220, a: 60, duree: 0.15, volume: 0.08 },
aie: { type: 'sawtooth', de: 240, a: 50, duree: 0.35, volume: 0.08 },
victoire: { type: 'triangle', de: 523, a: 1568, duree: 0.8, volume: 0.12 },
}
// Joue un petit son synthétisé avec la Web Audio API, sans fichier audio
export function sfx(scene, nom) {
const ctx = scene.sound.context
if (!ctx || ctx.state !== 'running' || scene.sound.mute) return
const son = SONS[nom]
const t = ctx.currentTime
const oscillateur = ctx.createOscillator()
const gain = ctx.createGain()
oscillateur.type = son.type
oscillateur.frequency.setValueAtTime(son.de, t)
oscillateur.frequency.exponentialRampToValueAtTime(son.a, t + son.duree)
gain.gain.setValueAtTime(son.volume, t)
gain.gain.exponentialRampToValueAtTime(0.001, t + son.duree)
oscillateur.connect(gain).connect(scene.sound.destination ?? ctx.destination)
oscillateur.start(t)
oscillateur.stop(t + son.duree)
}Quelques explications :
scene.sound.context est le contexte audio de Phaser. S'il n'est pas encore débloqué (state !== 'running'), on ne joue rien plutôt que d'accumuler des sons en attentesquare sonne « 8 bits », triangle est plus doux, sawtooth plus agressif, parfait pour un son de dégâtscene.sound.destination, nos sons passent par le volume général de Phaser et respectent le bouton muetPour aller plus loin sans fichier, des outils comme jsfxr génèrent des sons rétro et les exportent en WAV, à charger ensuite avec this.load.audio.
Dans GameScene, une touche pour couper et rétablir le son :
creerControles() {
this.input.keyboard.on('keydown-P', () => this.mettreEnPause())
this.input.keyboard.on('keydown-ESC', () => this.mettreEnPause())
this.input.keyboard.on('keydown-M', () => {
this.sound.mute = !this.sound.mute
})
}this.sound est global au jeu : couper le son dans une scène le coupe partout. La pause est traitée au chapitre suivant.
Faites l'expérience : jouez une minute avec les effets, puis commentez creerEffets, les shake et les sfx, et rejouez. La mécanique est strictement identique, et pourtant le jeu paraît mou et sans retour. C'est exactement ce qu'apporte le juice.
Deux limites à garder en tête : l'accessibilité (proposez de désactiver les secousses d'écran, certaines personnes y sont sensibles) et la lisibilité (trop de particules masquent l'action).
Des particules déclenchées par des événements, des effets de caméra et des sons courts qui montent ou descendent : avec une cinquantaine de lignes, le jeu répond à chaque action du joueur. Il reste à l'habiller d'une interface et à gérer les différents états de la partie.