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.
C'est le chapitre qui fait d'un décor un jeu. On crée le personnage, on lui donne un corps physique soumis à la gravité, on le pilote au clavier et on l'anime. Puis on soigne ce qui distingue un jeu agréable d'un jeu frustrant : l'accélération, le saut à hauteur variable, le « coyote time » et le tampon de saut.
Phaser propose deux moteurs physiques : Arcade et Matter.js.
| Moteur | Formes | Coût | Pour |
|---|---|---|---|
| Arcade | rectangles et cercles alignés sur les axes | très faible | plateformes, shoot'em up, top-down |
| Matter | polygones, rotations, articulations | élevé | puzzles physiques, objets qui roulent et s'empilent |
Pour un jeu de plateforme, Arcade est le bon choix : rapide, prévisible, et suffisant pour 95 % des besoins.
Un objet physique Arcade possède un corps (body) avec :
| Propriété | Rôle |
|---|---|
velocity | vitesse en pixels par seconde |
acceleration | variation de vitesse par seconde |
drag | freinage quand aucune accélération n'est appliquée |
maxVelocity | vitesse plafond |
gravity | s'ajoute à la gravité du monde (1200 dans notre config) |
blocked | { up, down, left, right } : de quel côté le corps touche un obstacle solide |
touching | idem, pour les contacts avec d'autres corps |
Le corps est un rectangle indépendant de l'image : on le réduit souvent pour que les collisions paraissent justes.
Une animation est une suite de frames jouée à une cadence donnée. On les déclare une fois, dans la scène Boot, et elles sont disponibles dans tout le jeu. Complétez creerAnimations :
creerAnimations() {
this.anims.create({ key: 'repos', frames: [{ key: 'joueur', frame: 0 }] })
this.anims.create({
key: 'course',
frames: [{ key: 'joueur', frame: 1 }, { key: 'joueur', frame: 2 }],
frameRate: 10,
repeat: -1,
})
this.anims.create({ key: 'saut', frames: [{ key: 'joueur', frame: 3 }] })
this.anims.create({
key: 'slime-marche',
frames: [{ key: 'slime', frame: 0 }, { key: 'slime', frame: 1 }],
frameRate: 4,
repeat: -1,
})
}Avec une vraie spritesheet chargée par this.load.spritesheet, on écrit plus souvent :
frames: this.anims.generateFrameNumbers('joueur', { start: 1, end: 6 })On encapsule tout le comportement du joueur dans une classe qui étend Phaser.Physics.Arcade.Sprite. La scène reste lisible, et le joueur devient réutilisable dans d'autres niveaux.
import Phaser from 'phaser'
import { sfx } from '../sfx.js'
const ACCELERATION = 1800
const VITESSE_MAX = 260
const FREINAGE = 2000
const IMPULSION_SAUT = 600
const COYOTE_MS = 100
const TAMPON_SAUT_MS = 120
export class Player extends Phaser.Physics.Arcade.Sprite {
constructor(scene, x, y) {
super(scene, x, y, 'joueur', 0)
scene.add.existing(this)
scene.physics.add.existing(this)
this.setCollideWorldBounds(true)
this.body.setSize(14, 30).setOffset(5, 2)
this.setMaxVelocity(VITESSE_MAX, 900)
this.setDragX(FREINAGE)
this.fleches = scene.input.keyboard.createCursorKeys()
this.touches = scene.input.keyboard.addKeys({
gauche: 'Q',
gaucheQwerty: 'A',
droite: 'D',
saut: 'Z',
sautQwerty: 'W',
})
this.coyote = 0
this.tamponSaut = 0
this.invincible = false
this.controleBloque = 0
this.etaitAuSol = true
}Points clés du constructeur :
scene.add.existing(this) ajoute le sprite à l'affichage, scene.physics.add.existing(this) lui crée un corps physique. Sans ces deux lignes, un objet construit avec new est invisible et immobilesetSize(14, 30).setOffset(5, 2) : le sprite fait 24 × 32 pixels, mais le corps est plus étroit que les bras. Les collisions paraissent plus justes, et le joueur ne reste pas accroché aux bords des plateformescreateCursorKeys() renvoie les flèches, Espace et ShiftaddKeys enregistre des touches supplémentaires. On gère ZQSD et WASD : un jeu web est joué sur des claviers AZERTY comme QWERTY
Pour le module sfx.js importé ici, créez pour l'instant un fichier qui exporte une fonction vide : export function sfx() {}. On le remplira au chapitre 6.
Les objets de jeu ont une méthode preUpdate(time, delta) appelée automatiquement à chaque image. En la surchargeant, le joueur se met à jour tout seul, sans que la scène ait à s'en occuper.
preUpdate(time, delta) {
super.preUpdate(time, delta)
if (this.scene.fini) return
const tactile = this.scene.registry.get('tactile') ?? {}
const { fleches, touches } = this
const gauche = fleches.left.isDown || touches.gauche.isDown || touches.gaucheQwerty.isDown || tactile.gauche
const droite = fleches.right.isDown || touches.droite.isDown || tactile.droite
const sautTenu = fleches.up.isDown || fleches.space.isDown || touches.saut.isDown || touches.sautQwerty.isDown || tactile.saut
const sautPresse =
Phaser.Input.Keyboard.JustDown(fleches.up) ||
Phaser.Input.Keyboard.JustDown(fleches.space) ||
Phaser.Input.Keyboard.JustDown(touches.saut) ||
Phaser.Input.Keyboard.JustDown(touches.sautQwerty) ||
tactile.sautPresse
if (tactile.sautPresse) tactile.sautPresse = false
const auSol = this.body.blocked.downsuper.preUpdate est obligatoire : c'est lui qui fait avancer les animations. L'oublier donne un personnage figé sur sa première frame.
Deux façons de lire une touche :
touche.isDown : vrai tant que la touche est enfoncée (pour courir)Phaser.Input.Keyboard.JustDown(touche) : vrai une seule fois au moment de l'appui (pour sauter). Sans ça, maintenir la touche ferait sauter en boucleLes lignes tactile préparent les boutons à l'écran du chapitre 8 ; elles n'ont aucun effet sur ordinateur.
// Déplacement horizontal avec accélération et freinage
this.controleBloque -= delta
if (this.controleBloque > 0) {
this.setAccelerationX(0)
} else if (gauche) {
this.setAccelerationX(-ACCELERATION)
this.setFlipX(true)
} else if (droite) {
this.setAccelerationX(ACCELERATION)
this.setFlipX(false)
} else {
this.setAccelerationX(0)
}On ne fixe pas directement la vitesse : on applique une accélération, plafonnée par maxVelocity, et le drag freine quand on relâche. Le personnage démarre et s'arrête en quelques images au lieu de passer instantanément de 0 à pleine vitesse. Essayez de remplacer par setVelocityX(±260) pour sentir la différence : le mouvement devient raide.
setFlipX(true) retourne l'image horizontalement : une seule série de frames suffit pour les deux directions.
controleBloque servira au chapitre 5 : après un coup, le joueur perd le contrôle un quart de seconde pour que le recul soit visible.
// Coyote time : on peut encore sauter un court instant après avoir quitté le bord
this.coyote = auSol ? COYOTE_MS : this.coyote - delta
// Tampon de saut : un appui juste avant l'atterrissage est mémorisé
this.tamponSaut = sautPresse ? TAMPON_SAUT_MS : this.tamponSaut - delta
if (this.tamponSaut > 0 && this.coyote > 0) {
this.setVelocityY(-IMPULSION_SAUT)
this.coyote = 0
this.tamponSaut = 0
sfx(this.scene, 'saut')
}
// Saut à hauteur variable : relâcher tôt coupe l'élan
if (!sautTenu && this.body.velocity.y < -200) {
this.setVelocityY(-200)
}Trois techniques utilisées par tous les bons jeux de plateforme :
| Technique | Problème résolu |
|---|---|
| Coyote time | le joueur appuie sur saut une fraction de seconde après avoir quitté le bord et tombe : injuste. On l'autorise à sauter pendant 100 ms après avoir quitté le sol |
| Tampon de saut | le joueur appuie juste avant de toucher le sol et rien ne se passe. On mémorise l'appui pendant 120 ms |
| Hauteur variable | un appui bref fait un petit saut, un appui long un grand saut. Si la touche est relâchée pendant la montée, on réduit la vitesse verticale |
Ces trois mécanismes représentent une quinzaine de lignes. Aucun joueur ne les remarque, mais tous sentent leur absence.
if (auSol && !this.etaitAuSol) {
this.scene.events.emit('atterrissage', this.x, this.body.bottom)
}
this.etaitAuSol = auSol
if (!auSol) this.anims.play('saut', true)
else if (Math.abs(this.body.velocity.x) > 20) this.anims.play('course', true)
else this.anims.play('repos', true)
}
L'événement atterrissage sera utilisé au chapitre 6 pour faire voler de la poussière. Le joueur ne sait pas ce qui se passe quand il atterrit : il le signale, et la scène décide.
anims.play(cle, true) : le second argument true signifie « ne rien faire si cette animation joue déjà ». Sans lui, l'animation de course redémarrerait à chaque image et resterait bloquée sur sa première frame.
Deux méthodes utilisées au chapitre suivant :
rebondir() {
this.setVelocityY(-380)
}
blesser(depuisX) {
this.invincible = true
this.controleBloque = 250
this.setVelocity(this.x < depuisX ? -220 : 220, -320)
this.scene.tweens.add({
targets: this,
alpha: 0.2,
duration: 100,
yoyo: true,
repeat: 7,
onComplete: () => {
this.alpha = 1
this.invincible = false
},
})
}
}Le point de départ est la case P du niveau. Dans GameScene, ajoutez l'import et une méthode creerObjets qui parcourt la grille (on la complétera au chapitre 5) :
import { Player } from '../objects/Player.js'
// ...
creerObjets(lignes) {
lignes.forEach((ligne, y) => {
;[...ligne].forEach((c, x) => {
const px = x * TILE + TILE / 2
const py = y * TILE + TILE / 2
if (c === 'P') this.depart = { x: px, y: py }
})
})
}Le point-virgule en début de ligne n'est pas une faute de frappe : sans lui, une ligne commençant par [ se collerait à la précédente, un piège classique de l'insertion automatique des points-virgules en JavaScript.
Puis dans create, après la carte :
this.creerObjets(niveau.map)
this.joueur = new Player(this, this.depart.x, this.depart.y)
this.creerCollisions()
this.creerCamera()Et les collisions avec le sol :
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)
}world.setBounds définit les limites du monde physique ; par défaut elles correspondent à l'écran, pas au niveausetBoundsCollision(gauche, droite, haut, bas) : le bas est ouvert pour que les trous soient mortelsphysics.add.collider(a, b) : a et b se bloquent mutuellement. Un seul appel suffit pour que le joueur tienne sur toute la tilemapLancez le jeu : le personnage court, saute, monte sur les plateformes par-dessous, et la caméra le suit.

Les constantes en haut de Player.js sont vos réglages de « game feel ». Quelques repères :
| Constante | Plus haut | Plus bas |
|---|---|---|
ACCELERATION | démarrage nerveux | personnage lourd |
FREINAGE | arrêt net | glisse (sol gelé) |
IMPULSION_SAUT | saut plus haut | saut plus bas |
| gravité du monde | chute rapide, saut « sec » | saut flottant |
Hauteur maximale de saut = IMPULSION_SAUT² / (2 × gravité) = 600² / 2400 = 150 pixels, soit un peu moins de 5 tuiles. Garder ce calcul en tête évite de dessiner des plateformes inaccessibles.
Un sprite physique, quelques touches, trois animations, et surtout une poignée de mécanismes invisibles qui rendent le saut agréable. Le chapitre suivant peuple le niveau : pièces, pics, ennemis et drapeau.