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 2·35 min

Scènes, textures générées et menu

Un jeu est une succession d'écrans : chargement, menu, partie, fin. Dans Phaser, chaque écran est une scène, et les scènes se démarrent, s'arrêtent, se mettent en pause ou tournent en parallèle. Ce chapitre crée les trois premières : Boot qui fabrique les graphismes, Preload qui charge le niveau avec une barre de progression, et Menu.

Déclarer une scène

Une scène est une classe qui étend Phaser.Scene et passe sa clé au constructeur. Cette clé sert à la démarrer depuis n'importe où.

export class MenuScene extends Phaser.Scene {
  constructor() {
    super('Menu')
  }
}

Les méthodes de navigation, disponibles via this.scene :

AppelEffet
this.scene.start('Game', data)arrête la scène courante, démarre Game
this.scene.launch('UI')démarre UI en plus de la scène courante
this.scene.pause() / resume('Game')fige / relance une scène sans la détruire
this.scene.stop('UI')arrête une scène
this.scene.restart()redémarre la scène courante

L'objet data passé à start arrive dans init(data) et create(data) de la scène cible. C'est ainsi qu'on transmet le niveau à jouer ou le score final.

Générer les textures en code

Un jeu utilise normalement des images PNG et des spritesheets (une image contenant toutes les étapes d'une animation). Pour que ce cours fonctionne sans aucun téléchargement, on dessine tous les graphismes avec l'API Canvas 2D du navigateur, puis on les enregistre dans le gestionnaire de textures de Phaser.

Le principe : this.textures.createCanvas(cle, largeur, hauteur) crée une texture vide dans laquelle on dessine. Ensuite texture.add(nom, 0, x, y, l, h) découpe des frames dans cette texture, exactement comme une spritesheet.

src/scenes/BootScene.js
import Phaser from 'phaser'
 
export const TILE = 32
 
// Crée une texture de plusieurs frames dessinées avec l'API Canvas 2D
function feuille(scene, cle, largeur, hauteur, nbFrames, dessiner) {
  const texture = scene.textures.createCanvas(cle, largeur * nbFrames, hauteur)
  const ctx = texture.getContext()
  for (let i = 0; i < nbFrames; i++) {
    ctx.save()
    ctx.translate(i * largeur, 0)
    dessiner(ctx, i)
    ctx.restore()
    texture.add(i, 0, i * largeur, 0, largeur, hauteur)
  }
  texture.refresh()
}

ctx.translate décale l'origine pour que chaque frame se dessine entre (0, 0) et (largeur, hauteur). texture.refresh() envoie le résultat à la carte graphique.

La scène Boot

src/scenes/BootScene.js
export class BootScene extends Phaser.Scene {
  constructor() {
    super('Boot')
  }
 
  create() {
    this.creerTextures()
    this.creerAnimations()
    this.scene.start('Preload')
  }
 
  creerTextures() {
    // Tuiles : 0 = sol avec herbe, 1 = terre, 2 = plateforme traversable
    feuille(this, 'tuiles', TILE, TILE, 3, (ctx, i) => {
      if (i === 2) {
        ctx.fillStyle = '#c2c3c7'
        ctx.fillRect(0, 0, TILE, 10)
        ctx.fillStyle = '#83769c'
        ctx.fillRect(0, 8, TILE, 2)
        return
      }
      ctx.fillStyle = '#ab5236'
      ctx.fillRect(0, 0, TILE, TILE)
      ctx.fillStyle = '#7e2553'
      for (const [x, y] of [[6, 14], [20, 22], [12, 27], [25, 10]]) ctx.fillRect(x, y, 3, 3)
      if (i === 0) {
        ctx.fillStyle = '#00e436'
        ctx.fillRect(0, 0, TILE, 7)
        ctx.fillStyle = '#008751'
        ctx.fillRect(0, 7, TILE, 2)
      }
    })
 
    // Joueur : 0 repos, 1-2 course, 3 saut
    feuille(this, 'joueur', 24, 32, 4, (ctx, i) => {
      ctx.fillStyle = '#ffccaa'
      ctx.fillRect(6, 0, 12, 10)
      ctx.fillStyle = '#1d2b53'
      ctx.fillRect(13, 3, 2, 3)
      ctx.fillStyle = '#29adff'
      ctx.fillRect(4, 10, 16, 14)
      ctx.fillStyle = '#1d2b53'
      const jambes = [
        [[7, 24, 4, 8], [13, 24, 4, 8]],
        [[4, 24, 4, 7], [16, 23, 4, 7]],
        [[9, 23, 4, 7], [12, 24, 4, 8]],
        [[5, 22, 4, 6], [15, 23, 4, 6]],
      ][i]
      for (const [x, y, w, h] of jambes) ctx.fillRect(x, y, w, h)
    })
 
    // Slime : 2 frames (étiré, tassé)
    feuille(this, 'slime', 28, 20, 2, (ctx, i) => {
      const h = i === 0 ? 18 : 14
      ctx.fillStyle = '#00e436'
      ctx.beginPath()
      ctx.ellipse(14, 20 - h / 2, 13, h / 2, 0, 0, Math.PI * 2)
      ctx.fill()
      ctx.fillStyle = '#1d2b53'
      ctx.fillRect(8, 20 - h + 5, 3, 4)
      ctx.fillRect(17, 20 - h + 5, 3, 4)
    })
 
    feuille(this, 'piece', 16, 16, 1, ctx => {
      ctx.fillStyle = '#ffa300'
      ctx.beginPath()
      ctx.arc(8, 8, 8, 0, Math.PI * 2)
      ctx.fill()
      ctx.fillStyle = '#ffec27'
      ctx.beginPath()
      ctx.arc(8, 8, 5, 0, Math.PI * 2)
      ctx.fill()
    })
 
    feuille(this, 'pic', 32, 16, 1, ctx => {
      ctx.fillStyle = '#c2c3c7'
      for (let x = 0; x < 32; x += 11) {
        ctx.beginPath()
        ctx.moveTo(x, 16)
        ctx.lineTo(x + 5, 0)
        ctx.lineTo(x + 10, 16)
        ctx.fill()
      }
    })
 
    feuille(this, 'drapeau', 32, 64, 1, ctx => {
      ctx.fillStyle = '#5f574f'
      ctx.fillRect(4, 0, 4, 64)
      ctx.fillStyle = '#ff004d'
      ctx.fillRect(8, 2, 22, 16)
    })
 
    feuille(this, 'nuage', 64, 24, 1, ctx => {
      ctx.fillStyle = '#fff1e8'
      for (const [x, y, r] of [[18, 14, 10], [32, 10, 12], [46, 14, 10]]) {
        ctx.beginPath()
        ctx.arc(x, y, r, 0, Math.PI * 2)
        ctx.fill()
      }
      ctx.fillRect(8, 14, 48, 10)
    })
 
    feuille(this, 'particule', 6, 6, 1, ctx => {
      ctx.fillStyle = '#ffffff'
      ctx.fillRect(0, 0, 6, 6)
    })
 
    feuille(this, 'coeur', 16, 14, 1, ctx => {
      ctx.fillStyle = '#ff004d'
      ctx.beginPath()
      ctx.arc(4.5, 4.5, 4.5, 0, Math.PI * 2)
      ctx.arc(11.5, 4.5, 4.5, 0, Math.PI * 2)
      ctx.fill()
      ctx.beginPath()
      ctx.moveTo(0, 6)
      ctx.lineTo(8, 14)
      ctx.lineTo(16, 6)
      ctx.fill()
    })
  }
 
  creerAnimations() {
    // Détaillé au chapitre 4
  }
}
Planche des textures générées : tuiles, frames du joueur, slime, pièce, pics, drapeau, nuage, cœur et particule
Toutes les textures du jeu, dessinées en Canvas 2D au démarrage. Aucun fichier image.

Les couleurs viennent de la palette PICO-8, une palette de 16 couleurs très utilisée en pixel art : elle donne une cohérence immédiate sans talent de dessinateur.

Les textures créées ici vivent dans le gestionnaire de textures global : toutes les scènes y ont accès, et elles ne sont créées qu'une seule fois au lancement du jeu.

Et avec de vraies images ?

Quand vous aurez des graphismes, remplacez creerTextures par un chargement dans preload :

preload() {
  this.load.image('nuage', 'images/nuage.png')
  this.load.spritesheet('joueur', 'images/joueur.png', { frameWidth: 24, frameHeight: 32 })
  this.load.image('tuiles', 'images/tuiles.png')
}

Les clés restent les mêmes : le reste du jeu n'a pas à changer. Des banques d'images libres comme Kenney ou OpenGameArt fournissent des spritesheets prêtes à l'emploi.

La scène Preload et la barre de chargement

Le niveau est décrit dans un fichier JSON qu'on charge avec le loader de Phaser. Tout ce qui est demandé dans preload() est téléchargé avant l'appel à create(). L'événement progress permet d'afficher une barre de chargement.

src/scenes/PreloadScene.js
import Phaser from 'phaser'
 
export class PreloadScene extends Phaser.Scene {
  constructor() {
    super('Preload')
  }
 
  preload() {
    const { width, height } = this.scale
    this.add.rectangle(width / 2, height / 2, 304, 24).setStrokeStyle(2, 0xffffff)
    const barre = this.add.rectangle(width / 2 - 150, height / 2, 300, 18, 0xffec27).setOrigin(0, 0.5)
    barre.scaleX = 0
    const texte = this.add.text(width / 2, height / 2 + 40, 'Chargement…', {
      fontFamily: 'monospace',
      fontSize: '18px',
    }).setOrigin(0.5)
 
    this.load.on('progress', progression => {
      barre.scaleX = progression
      texte.setText(`Chargement… ${Math.round(progression * 100)} %`)
    })
 
    this.load.json('niveau-1', 'levels/level1.json')
  }
 
  create() {
    this.scene.start('Menu')
  }
}

Quelques notions au passage :

  • this.scale donne la taille logique du jeu (960 × 540)
  • setOrigin(0, 0.5) place le point d'ancrage à gauche : en augmentant scaleX de 0 à 1, la barre grandit vers la droite. Par défaut, l'origine d'un objet est son centre (0.5, 0.5)
  • les méthodes de Phaser renvoient l'objet, ce qui permet de les chaîner : this.add.text(...).setOrigin(0.5)

Avec un seul petit JSON, la barre est à peine visible. Elle prendra tout son sens quand vous chargerez des images et des musiques.

Le contenu du fichier level1.json est présenté au chapitre suivant. Créez-le dès maintenant avec un contenu minimal pour éviter une erreur de chargement :

public/levels/level1.json
{ "nom": "Prairie", "temps": 120, "map": [] }

La scène Menu

src/scenes/MenuScene.js
import Phaser from 'phaser'
import { lireRecord } from '../record.js'
 
export class MenuScene extends Phaser.Scene {
  constructor() {
    super('Menu')
  }
 
  create() {
    const { width, height } = this.scale
    this.lance = false
 
    this.add.text(width / 2, height * 0.3, 'STAR RUNNER', {
      fontFamily: 'monospace',
      fontSize: '72px',
      color: '#ffec27',
      stroke: '#1d2b53',
      strokeThickness: 8,
    }).setOrigin(0.5)
 
    this.add.text(width / 2, height * 0.45, `Record : ${lireRecord()}`, {
      fontFamily: 'monospace',
      fontSize: '22px',
      color: '#1d2b53',
    }).setOrigin(0.5)
 
    const invite = this.add.text(width / 2, height * 0.65, 'ESPACE ou clic pour jouer', {
      fontFamily: 'monospace',
      fontSize: '26px',
      color: '#ffffff',
    }).setOrigin(0.5)
 
    this.tweens.add({ targets: invite, alpha: 0.2, duration: 600, yoyo: true, repeat: -1 })
 
    this.add.text(width / 2, height * 0.85, '← → / Q D : bouger   ↑ / ESPACE / Z : sauter   P : pause   M : son', {
      fontFamily: 'monospace',
      fontSize: '16px',
      color: '#1d2b53',
    }).setOrigin(0.5)
 
    this.input.keyboard.once('keydown-SPACE', () => this.lancer())
    this.input.once('pointerdown', () => this.lancer())
  }
 
  lancer() {
    if (this.lance) return
    this.lance = true
    this.cameras.main.fadeOut(300, 0, 0, 0)
    this.cameras.main.once('camerafadeoutcomplete', () => {
      this.scene.start('Game', { niveau: 'niveau-1' })
    })
  }
}

Le texte

this.add.text(x, y, contenu, style) affiche du texte. Le style accepte fontFamily, fontSize, color, stroke (contour) et bien d'autres. Pour une police personnalisée, chargez-la en CSS et attendez qu'elle soit prête avant d'afficher le texte, sinon Phaser utilisera la police de secours.

Premier tween

Un tween anime une propriété d'un objet dans le temps. Ici, l'opacité (alpha) de l'invite passe de 1 à 0,2 en 600 ms, revient (yoyo) et recommence indéfiniment (repeat: -1). On utilisera les tweens partout : pièces qui flottent, score qui grossit, ennemis qui s'écrasent.

Les entrées

  • this.input.keyboard.once('keydown-SPACE', fn) : écoute la touche Espace une seule fois
  • this.input.once('pointerdown', fn) : clic souris ou toucher d'écran, une seule fois

once plutôt que on évite de lancer deux parties si le joueur appuie deux fois. La variable this.lance protège aussi contre un clic et un appui presque simultanés.

Transition en fondu

this.cameras.main.fadeOut(durée, r, g, b) fait passer l'écran au noir. L'événement camerafadeoutcomplete signale la fin du fondu : on démarre alors la scène de jeu en lui passant le nom du niveau.

Le record

Le menu affiche le meilleur score, stocké dans le navigateur. Un petit module isole cette logique :

src/record.js
const CLE = 'star-runner-record'
 
export function lireRecord() {
  try {
    return Number(localStorage.getItem(CLE)) || 0
  } catch {
    return 0
  }
}
 
export function enregistrerRecord(score) {
  const record = Math.max(score, lireRecord())
  try {
    localStorage.setItem(CLE, String(record))
  } catch {
    // stockage indisponible (navigation privée) : on ignore
  }
  return record
}

Le try/catch n'est pas du zèle : localStorage peut lever une exception dans certains navigateurs en navigation privée, ou quand le jeu est intégré dans une iframe d'un autre domaine. Un jeu ne doit jamais planter parce qu'un record n'a pas pu être sauvegardé.

Écran titre Star Runner avec le record, l'invite pour jouer et le rappel des touches
La scène Menu : textes, record lu dans localStorage et invite qui clignote grâce à un tween.

À ce stade, npm run dev affiche le menu. Cliquer provoque une erreur, normal : la scène Game n'existe pas encore. Pour tester, commentez temporairement les scènes manquantes dans main.js.


Une scène par écran, des textures créées une fois et partagées par tout le jeu, des transitions en fondu : la structure est en place. Le prochain chapitre construit le niveau.

Précédent
Introduction et installation
Suivant
Tilemap, niveau et caméra

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