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.
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.
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 :
| Appel | Effet |
|---|---|
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.
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.
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.
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
}
}
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.
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.
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.
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)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 :
{ "nom": "Prairie", "temps": 120, "map": [] }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' })
})
}
}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.
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.
this.input.keyboard.once('keydown-SPACE', fn) : écoute la touche Espace une seule foisthis.input.once('pointerdown', fn) : clic souris ou toucher d'écran, une seule foisonce 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.
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 menu affiche le meilleur score, stocké dans le navigateur. Un petit module isole cette logique :
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é.

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