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 niveau est jouable mais vide. Ce chapitre ajoute tout ce qui fait réagir le joueur : des pièces à ramasser, des pics qui blessent, des slimes qui patrouillent, et un drapeau d'arrivée. Au passage, on voit les groupes, la différence entre collider et overlap, les corps statiques et une petite intelligence artificielle d'ennemi.
| Méthode | Les objets se bloquent | Callback | Usage |
|---|---|---|---|
physics.add.collider(a, b, cb) | oui | à chaque contact | sol, murs, caisses |
physics.add.overlap(a, b, cb) | non, ils se traversent | quand ils se chevauchent | pièces, pics, ennemis, zones |
Le callback reçoit les deux objets dans l'ordre des arguments : overlap(joueur, pieces, cb) appelle cb(joueur, piece) avec la pièce précise touchée, même si on a passé tout le groupe.
Un groupe rassemble des objets du même type. On peut tester une collision contre le groupe entier en un seul appel, et appliquer des réglages communs.
| Type | Création | Pour |
|---|---|---|
| groupe dynamique | this.physics.add.group(config) | objets qui bougent : ennemis, projectiles |
| groupe statique | this.physics.add.staticGroup() | objets fixes : pics, décor solide |
Un corps statique ne bouge jamais et ne subit pas la gravité. Il est beaucoup moins coûteux : Phaser le range dans un arbre spatial et ne le recalcule pas à chaque image.
Complétez creerObjets dans GameScene :
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,
})
}Détails importants :
allowGravity: false) : elles flottent. group.create(x, y, texture) crée un sprite physique et l'ajoute au groupebasDeCase) et on remonte de la moitié de sa hauteur

Un seul tween anime toutes les pièces. y: '-=6' est une valeur relative : chaque pièce monte de 6 pixels par rapport à sa position. delay accepte une fonction : chaque pièce démarre 90 ms après la précédente, ce qui crée une vague au lieu d'un mouvement synchronisé et mécanique.
ease choisit la courbe d'accélération. Sine.easeInOut ralentit en haut et en bas, comme un objet qui flotte. Les autres familles utiles : Quad, Cubic, Back (léger dépassement), Bounce, Elastic.
Le slime avance, fait demi-tour contre un mur et au bord du vide. Ce second point transforme un obstacle bête en ennemi crédible.
import Phaser from 'phaser'
export class Slime extends Phaser.Physics.Arcade.Sprite {
constructor(scene, x, y) {
super(scene, x, y, 'slime', 0)
scene.add.existing(this)
scene.physics.add.existing(this)
this.body.setSize(24, 16).setOffset(2, 4)
this.direction = -1
this.vitesse = 60
this.mort = false
this.play('slime-marche')
}
preUpdate(time, delta) {
super.preUpdate(time, delta)
if (this.mort) return
const corps = this.body
if (corps.blocked.left) {
this.direction = 1
} else if (corps.blocked.right) {
this.direction = -1
} else if (corps.blocked.down) {
// Demi-tour au bord du vide : pas de tuile juste devant, sous les pieds
const devantX = this.direction > 0 ? corps.right + 2 : corps.left - 2
const tuile = this.scene.sol.getTileAtWorldXY(devantX, corps.bottom + 4)
if (!tuile) this.direction *= -1
}
this.setVelocityX(this.vitesse * this.direction)
this.setFlipX(this.direction > 0)
}
ecraser() {
this.mort = true
this.body.enable = false
this.anims.stop()
this.scene.tweens.add({
targets: this,
scaleY: 0.2,
y: this.y + 8,
alpha: 0,
duration: 250,
ease: 'Quad.easeIn',
onComplete: () => this.destroy(),
})
}
}La détection du vide utilise la tilemap : getTileAtWorldXY(x, y) renvoie la tuile à une position en pixels, ou null si la case est vide. On regarde juste devant le slime et juste sous ses pieds.
On pourrait appeler setVelocityX une seule fois dans le constructeur. Mais quand un objet est ajouté à un groupe physique, le groupe applique ses réglages par défaut au corps, dont une vitesse nulle. En réaffirmant la vitesse dans preUpdate, le slime est insensible à cet ordre d'initialisation, et aussi aux collisions qui l'arrêteraient.
body.enable = false dans ecraser coupe toute physique : le slime mourant ne blesse plus et ne bloque plus. Le tween l'écrase verticalement et le fait disparaître, puis destroy() le retire définitivement du jeu et de son groupe.
Complétez creerCollisions :
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)Le quatrième argument (null) est un processCallback optionnel : une fonction qui peut annuler la collision au cas par cas. Le cinquième (this) est le contexte d'appel, pour que this désigne la scène dans les méthodes.
Les pics passent this.joueur.x comme origine du coup : le joueur est alors repoussé vers le haut, là où il était déjà.
ramasserPiece(joueur, piece) {
piece.disableBody(true, true)
this.registry.inc('score', 10)
this.etincelles.explode(12, piece.x, piece.y)
sfx(this, 'piece')
}disableBody(desactiver, masquer) coupe le corps et cache la pièce, sans la détruire. C'est plus économique qu'un destroy et permet de la réactiver avec enableBody (utile pour des pièces qui réapparaissent).
this.registry est un espace de données partagé entre toutes les scènes, détaillé au chapitre 7. Les étincelles et le son arrivent au chapitre 6 : commentez ces deux lignes en attendant.
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)
}
}La règle classique : si le joueur descend et que ses pieds sont au niveau du haut du slime, il l'écrase et rebondit. Sinon, c'est le joueur qui prend le coup. La marge de 12 pixels compense le fait que la physique avance par pas : à grande vitesse, les pieds peuvent déjà être légèrement enfoncés dans le slime au moment où la collision est détectée.
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)
}Après un coup, Player.blesser (chapitre 4) rend le joueur invincible 1,6 seconde en le faisant clignoter, et le repousse à l'opposé de l'ennemi. Sans cette invincibilité, un overlap déclenché à chaque image viderait les trois vies en trois images.
La chute se teste dans update, puisque rien ne « touche » le joueur :
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)
}
}
}Le joueur perd une vie et réapparaît au départ. Pour un niveau plus long, on ajouterait des points de sauvegarde : il suffirait de mettre à jour this.depart en passant devant un drapeau intermédiaire.
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') })
})
})
}physics.pause() fige tout le monde physiquesetTint colore le sprite : rouge pour une défaitetime.delayedCall(900, fn) attend un peu avant de quitter : le joueur a le temps de comprendre ce qui s'est passé. Un changement d'écran instantané est toujours déroutantLa garde if (this.fini) return est essentielle : l'overlap avec le drapeau se déclenche à chaque image tant que le joueur est dessus. Sans elle, la victoire serait comptée soixante fois par seconde.
Groupes pour les objets semblables, overlap pour ce qui se traverse, collider pour ce qui bloque, et des gardes pour que chaque événement ne compte qu'une fois : le jeu a désormais des règles. Le chapitre suivant lui donne du caractère avec des particules, des sons et des effets de caméra.