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.
Le joueur a besoin de savoir où il en est : score, vies, temps restant. Il doit pouvoir mettre en pause, et voir un écran de fin qui lui donne envie de rejouer. Ce chapitre utilise une fonctionnalité centrale de Phaser : plusieurs scènes qui tournent en même temps et partagent des données.
On pourrait afficher le score dans la scène de jeu avec setScrollFactor(0) pour qu'il ne défile pas. Mais tout effet de caméra l'affecterait : le score tremblerait avec les secousses, disparaîtrait dans les fondus, grossirait avec un zoom. Une scène UI lancée par-dessus la scène de jeu a sa propre caméra, immobile.
this.scene.launch('UI') // dans GameScene.createlaunch démarre une scène sans arrêter la courante. Les scènes s'affichent dans l'ordre de la liste de configuration : UI étant déclarée après Game, elle est dessinée au-dessus.
this.registry est un espace de données global au jeu, accessible depuis toutes les scènes. Et surtout, il émet un événement à chaque modification. La scène de jeu écrit, l'interface écoute : aucune des deux n'a besoin de connaître l'autre.
| Méthode | Effet |
|---|---|
registry.set('vies', 3) | écrit une valeur ; set({ a: 1, b: 2 }) en écrit plusieurs |
registry.get('vies') | lit |
registry.inc('score', 10) | ajoute à une valeur numérique |
registry.events.on('changedata', fn) | écoute toute modification d'une clé existante |
registry.events.on('changedata-score', fn) | écoute une clé précise |
Au début de chaque partie, GameScene.create réinitialise les valeurs à partir du niveau :
const niveau = this.cache.json.get(this.niveau)
this.registry.set({ score: 0, vies: 3, temps: niveau.temps })import Phaser from 'phaser'
const STYLE = { fontFamily: 'monospace', fontSize: '24px', color: '#ffffff', stroke: '#1d2b53', strokeThickness: 5 }
export class UIScene extends Phaser.Scene {
constructor() {
super('UI')
}
create() {
const { width } = this.scale
this.texteScore = this.add.text(16, 12, '', STYLE)
this.texteTemps = this.add.text(width - 16, 12, '', STYLE).setOrigin(1, 0)
this.coeurs = [0, 1, 2].map(i => this.add.image(24 + i * 26, 60, 'coeur').setScale(1.4))
this.majAffichage()
const surChangement = (parent, cle) => {
this.majAffichage()
if (cle === 'score') {
this.tweens.add({ targets: this.texteScore, scale: 1.25, duration: 80, yoyo: true })
}
}
this.registry.events.on('changedata', surChangement)
// Indispensable : retirer l'écouteur quand la scène s'arrête
this.events.once('shutdown', () => this.registry.events.off('changedata', surChangement))
if (this.sys.game.device.input.touch) this.creerControlesTactiles()
}
majAffichage() {
const vies = this.registry.get('vies')
const temps = this.registry.get('temps')
this.texteScore.setText(`SCORE ${String(this.registry.get('score')).padStart(5, '0')}`)
this.texteTemps.setText(`⏱ ${temps}`)
this.texteTemps.setColor(temps <= 20 ? '#ff004d' : '#ffffff')
this.coeurs.forEach((coeur, i) => coeur.setVisible(i < vies))
}
}La méthode creerControlesTactiles est écrite au chapitre 8 ; en attendant, retirez la dernière ligne de create.
Observations :
off, chaque partie ajouterait un écouteur qui manipulerait des textes détruits. C'est le même piège qu'au chapitre 6, en plus grave, car il finit en erreursetOrigin(1, 0) ancre le chronomètre par son coin supérieur droit : il reste collé au bord quel que soit le nombre de chiffres
Phaser a son propre système de temps, this.time, lié à la scène : il se met en pause avec elle, contrairement à setInterval.
this.time.addEvent({
delay: 1000,
loop: true,
callback: () => {
if (this.fini) return
this.registry.inc('temps', -1)
if (this.registry.get('temps') <= 0) this.finDePartie(false)
},
})time.addEvent accepte aussi repeat: n pour un nombre fixe de répétitions, et time.delayedCall(ms, fn) pour une action unique différée, déjà utilisé dans finDePartie.
N'utilisez jamais setTimeout ou setInterval dans une scène : ils continuent pendant la pause, et continuent même après l'arrêt de la scène, sur des objets détruits.
mettreEnPause() {
if (this.fini) return
this.scene.launch('Pause')
this.scene.pause()
}this.scene.pause() fige la scène de jeu : plus d'update, plus de physique, plus de tweens, plus de timers. Elle reste affichée. Par-dessus, on lance une scène Pause :
import Phaser from 'phaser'
export class PauseScene extends Phaser.Scene {
constructor() {
super('Pause')
}
create() {
const { width, height } = this.scale
this.add.rectangle(0, 0, width, height, 0x000000, 0.6).setOrigin(0)
this.add.text(width / 2, height / 2 - 20, 'PAUSE', {
fontFamily: 'monospace',
fontSize: '64px',
color: '#ffec27',
}).setOrigin(0.5)
this.add.text(width / 2, height / 2 + 40, 'P, Échap ou clic pour reprendre', {
fontFamily: 'monospace',
fontSize: '20px',
color: '#ffffff',
}).setOrigin(0.5)
const reprendre = () => {
this.scene.resume('Game')
this.scene.stop()
}
this.input.keyboard.once('keydown-P', reprendre)
this.input.keyboard.once('keydown-ESC', reprendre)
this.input.once('pointerdown', reprendre)
}
}Une scène en pause ne reçoit plus les entrées : c'est donc la scène Pause qui écoute la touche de reprise. Le rectangle semi-transparent assombrit le jeu figé en arrière-plan.

Une amélioration utile : mettre en pause automatiquement quand le joueur change d'onglet. Phaser émet this.game.events.on('blur', ...) quand la fenêtre perd le focus.
import Phaser from 'phaser'
import { enregistrerRecord, lireRecord } from '../record.js'
export class GameOverScene extends Phaser.Scene {
constructor() {
super('GameOver')
}
init(data) {
this.gagne = data.gagne
this.score = data.score
}
create() {
const { width, height } = this.scale
this.cameras.main.setBackgroundColor('#1d2b53')
const ancienRecord = lireRecord()
const record = enregistrerRecord(this.score)
this.add.text(width / 2, height * 0.28, this.gagne ? 'NIVEAU TERMINÉ !' : 'GAME OVER', {
fontFamily: 'monospace',
fontSize: '64px',
color: this.gagne ? '#00e436' : '#ff004d',
}).setOrigin(0.5)
this.add.text(width / 2, height * 0.45, `Score : ${this.score}`, {
fontFamily: 'monospace',
fontSize: '32px',
color: '#ffffff',
}).setOrigin(0.5)
const nouveau = this.score > ancienRecord
const texteRecord = this.add.text(width / 2, height * 0.55, nouveau ? 'NOUVEAU RECORD !' : `Record : ${record}`, {
fontFamily: 'monospace',
fontSize: '24px',
color: '#ffec27',
}).setOrigin(0.5)
if (nouveau) {
this.tweens.add({ targets: texteRecord, scale: 1.2, duration: 400, yoyo: true, repeat: -1 })
}
this.add.text(width / 2, height * 0.75, 'ESPACE : rejouer M : menu', {
fontFamily: 'monospace',
fontSize: '22px',
color: '#c2c3c7',
}).setOrigin(0.5)
this.input.keyboard.once('keydown-SPACE', () => this.scene.start('Game', { niveau: 'niveau-1' }))
this.input.keyboard.once('keydown-M', () => this.scene.start('Menu'))
this.input.once('pointerdown', () => this.scene.start('Game', { niveau: 'niveau-1' }))
}
}Le score arrive par init(data), transmis par finDePartie avec this.scene.start('GameOver', { gagne, score }). On lit l'ancien record avant d'enregistrer le nouveau, pour savoir s'il a été battu et le célébrer.


Rejouer relance simplement Game : init remet fini à false, create réinitialise le registry et reconstruit le niveau. C'est à ce moment que les nettoyages d'écouteurs des chapitres 6 et 7 prouvent leur utilité.
Voici GameScene.js dans sa version finale, avec toutes les méthodes vues depuis le chapitre 3 :
import Phaser from 'phaser'
import { TILE } from './BootScene.js'
import { Player } from '../objects/Player.js'
import { Slime } from '../objects/Slime.js'
import { sfx } from '../sfx.js'
export class GameScene extends Phaser.Scene {
constructor() {
super('Game')
}
init(data) {
this.niveau = data.niveau ?? 'niveau-1'
this.fini = false
}
create() {
const niveau = this.cache.json.get(this.niveau)
this.registry.set({ score: 0, vies: 3, temps: niveau.temps })
this.creerDecor(niveau.map)
this.creerCarte(niveau.map)
this.creerObjets(niveau.map)
this.joueur = new Player(this, this.depart.x, this.depart.y)
this.creerEffets()
this.creerCollisions()
this.creerCamera()
this.creerControles()
this.time.addEvent({
delay: 1000,
loop: true,
callback: () => {
if (this.fini) return
this.registry.inc('temps', -1)
if (this.registry.get('temps') <= 0) this.finDePartie(false)
},
})
this.scene.launch('UI')
this.cameras.main.fadeIn(300)
}
creerDecor(lignes) {
const largeur = lignes[0].length * TILE
for (let i = 0; i < 12; i++) {
const facteur = Phaser.Math.FloatBetween(0.2, 0.5)
this.add
.image(Phaser.Math.Between(0, largeur * facteur + 960), Phaser.Math.Between(30, 220), 'nuage')
.setScrollFactor(facteur)
.setScale(facteur * 3)
.setAlpha(0.9)
}
}
creerCarte(lignes) {
// Convertit les caractères du niveau en indices de tuiles (-1 = vide)
const donnees = lignes.map((ligne, y) =>
[...ligne].map((c, x) => {
if (c === '#') return lignes[y - 1]?.[x] === '#' ? 1 : 0
if (c === '=') return 2
return -1
})
)
this.carte = this.make.tilemap({ data: donnees, tileWidth: TILE, tileHeight: TILE })
const tileset = this.carte.addTilesetImage('tuiles')
this.sol = this.carte.createLayer(0, tileset, 0, 0)
this.sol.setCollision([0, 1])
// Plateformes traversables : collision uniquement par le dessus
this.sol.forEachTile(tuile => {
if (tuile.index === 2) tuile.setCollision(false, false, true, false)
})
}
creerObjets(lignes) {
this.pieces = this.physics.add.group({ allowGravity: false, immovable: true })
this.pics = this.physics.add.staticGroup()
this.slimes = this.physics.add.group()
lignes.forEach((ligne, y) => {
;[...ligne].forEach((c, x) => {
const px = x * TILE + TILE / 2
const py = y * TILE + TILE / 2
const basDeCase = (y + 1) * TILE
if (c === 'P') this.depart = { x: px, y: py }
if (c === 'C') this.pieces.create(px, py, 'piece')
if (c === 'S') this.slimes.add(new Slime(this, px, basDeCase - 10))
if (c === '^') {
const pic = this.pics.create(px, basDeCase - 8, 'pic')
pic.body.setSize(26, 10).setOffset(3, 6)
}
if (c === 'F') this.drapeau = this.physics.add.staticImage(px, basDeCase - 32, 'drapeau')
})
})
this.tweens.add({
targets: this.pieces.getChildren(),
y: '-=6',
duration: 700,
ease: 'Sine.easeInOut',
yoyo: true,
repeat: -1,
delay: (cible, cle, valeur, index) => index * 90,
})
}
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'))
}
creerCollisions() {
const largeur = this.carte.widthInPixels
const hauteur = this.carte.heightInPixels
// Pas de mur en bas : le joueur peut tomber dans les trous
this.physics.world.setBounds(0, 0, largeur, hauteur + 200)
this.physics.world.setBoundsCollision(true, true, true, false)
this.physics.add.collider(this.joueur, this.sol)
this.physics.add.collider(this.slimes, this.sol)
this.physics.add.overlap(this.joueur, this.pieces, this.ramasserPiece, null, this)
this.physics.add.overlap(this.joueur, this.pics, () => this.blesserJoueur(this.joueur.x))
this.physics.add.overlap(this.joueur, this.slimes, this.toucherSlime, null, this)
this.physics.add.overlap(this.joueur, this.drapeau, this.victoire, null, this)
}
creerCamera() {
const camera = this.cameras.main
camera.setBounds(0, 0, this.carte.widthInPixels, this.carte.heightInPixels)
camera.startFollow(this.joueur, true, 0.12, 0.12)
camera.setDeadzone(120, 80)
}
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
})
}
update() {
if (!this.fini && this.joueur.y > this.carte.heightInPixels + 64) {
this.perdreVie()
if (!this.fini) {
this.joueur.setPosition(this.depart.x, this.depart.y).setVelocity(0, 0)
}
}
}
ramasserPiece(joueur, piece) {
piece.disableBody(true, true)
this.registry.inc('score', 10)
this.etincelles.explode(12, piece.x, piece.y)
sfx(this, 'piece')
}
toucherSlime(joueur, slime) {
const tombe = joueur.body.velocity.y > 0
const auDessus = joueur.body.bottom <= slime.body.top + 12
if (tombe && auDessus) {
slime.ecraser()
joueur.rebondir()
this.registry.inc('score', 50)
this.etincelles.explode(8, slime.x, slime.y)
sfx(this, 'ecrase')
} else {
this.blesserJoueur(slime.x)
}
}
blesserJoueur(depuisX) {
if (this.joueur.invincible || this.fini) return
this.perdreVie()
if (!this.fini) this.joueur.blesser(depuisX)
}
perdreVie() {
const vies = this.registry.get('vies') - 1
this.registry.set('vies', vies)
this.cameras.main.shake(200, 0.01)
sfx(this, 'aie')
if (vies <= 0) this.finDePartie(false)
}
victoire() {
if (this.fini) return
this.registry.inc('score', this.registry.get('temps') * 5)
sfx(this, 'victoire')
this.finDePartie(true)
}
finDePartie(gagne) {
this.fini = true
this.physics.pause()
this.joueur.anims.stop()
if (!gagne) this.joueur.setTint(0xff004d)
this.time.delayedCall(900, () => {
this.cameras.main.fadeOut(400, 0, 0, 0)
this.cameras.main.once('camerafadeoutcomplete', () => {
this.scene.stop('UI')
this.scene.start('GameOver', { gagne, score: this.registry.get('score') })
})
})
}
mettreEnPause() {
if (this.fini) return
this.scene.launch('Pause')
this.scene.pause()
}
}Le jeu est maintenant complet sur ordinateur : menu, partie, pause, fin, record et nouvelle partie.
Une scène par couche d'affichage, un registry pour partager l'état, des timers liés à la scène et un nettoyage systématique des écouteurs : c'est l'architecture qui permet à un jeu de grandir sans devenir un nid de bugs. Le dernier chapitre l'adapte au mobile et le met en ligne.