iducationducation
IndexArticlesFormationsProfilOutilsBibliothech
N°014 — 2026
Navigation
01Index02Articles03Formations04Profil05Outils06Bibliothech
N°014 — 2026
iducationducation
IndexArticlesFormationsProfilOutilsBibliothech
N°014 — 2026
Navigation
01Index02Articles03Formations04Profil05Outils06Bibliothech
N°014 — 2026
Formations
JavaScript · Débutant

Phaser

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.

Phaser 4JavaScriptViteArcade PhysicsWeb Audio
01Introduction et installation02Scènes, textures générées et menu03Tilemap, niveau et caméra04Le joueur : physique Arcade, clavier et animations05Pièces, pics et ennemis : groupes et collisions06Game feel : particules, événements, caméra et son07Interface, chronomètre, pause et fin de partie08Mobile, build et mise en ligne
Chapitre 7·40 min

Interface, chronomètre, pause et fin de partie

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.

Pourquoi une scène séparée pour l'interface

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

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

Le registry : des données partagées

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éthodeEffet
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 :

src/scenes/GameScene.js
    const niveau = this.cache.json.get(this.niveau)
    this.registry.set({ score: 0, vies: 3, temps: niveau.temps })

La scène UI

src/scenes/UIScene.js
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 :

  • l'écouteur est retiré à l'arrêt de la scène. Le registry vit aussi longtemps que le jeu : sans ce 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 erreur
  • setOrigin(1, 0) ancre le chronomètre par son coin supérieur droit : il reste collé au bord quel que soit le nombre de chiffres
  • le score pulse à chaque gain grâce à un tween de 80 ms : un détail qui attire l'œil sur la récompense
  • le chronomètre passe au rouge sous 20 secondes : l'urgence se lit sans avoir à lire le chiffre
Jeu en cours avec le score en haut à gauche, deux cœurs de vie et le chronomètre en haut à droite
La scène UI dessinée par-dessus le jeu, avec sa propre caméra immobile.

Le chronomètre avec un timer

Phaser a son propre système de temps, this.time, lié à la scène : il se met en pause avec elle, contrairement à setInterval.

src/scenes/GameScene.js
    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.

La pause

src/scenes/GameScene.js
  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 :

src/scenes/PauseScene.js
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.

Écran de pause : jeu assombri avec le mot PAUSE en jaune
La scène Pause lancée par-dessus Game, qui reste affichée mais figée.

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.

L'écran de fin

src/scenes/GameOverScene.js
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.

Écran de fin Niveau terminé avec le score et la mention Nouveau record
Victoire avec un nouveau record, mis en avant par un tween en boucle.
Écran Game Over avec le score et le record précédent
Défaite : le record existant est rappelé.

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

La scène de jeu complète

Voici GameScene.js dans sa version finale, avec toutes les méthodes vues depuis le chapitre 3 :

src/scenes/GameScene.js
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.

Précédent
Game feel : particules, événements, caméra et son
Suivant
Mobile, build et mise en ligne

Développeur fullstack passionné. J'apprends en construisant et je documente tout — front, back, outils. Le code s'apprend mieux en public.

Naviguer

IndexTous les articlesFormationsProfilOutilsBibliothech

Ailleurs

GitHub RSS

Newsletter

Les articles, libs et découvertes. Une fois par semaine, pas plus.

© 2026 William LoreeConçu & codé à la main