Snake
Description
Nouveau jeu dans ma collection : un clone du célèbre Snake ! Ce projet m'a permis d'implémenter deux systèmes réutilisables pour mes futurs jeux : un système de vies et un système de high score local.
Fonctionnalités
- Grille de jeu 20x20 : Mouvement discret case par case
- Système de vies : 3 vies au départ, affichage en temps réel
- High score local : Sauvegarde automatique du meilleur score dans le navigateur
- Score : +10 points par nourriture mangée
- Contrôles : Flèches directionnelles (ou WASD)
- Game Over : Affichage du meilleur score et possibilité de recommencer (touche R)
Systèmes réutilisables
Ce jeu utilise deux nouveaux systèmes que j'ai créés pour faciliter le développement de mes futurs jeux :
-
Système de vies (
src/games/utils/lives.ts) :- Classe
LivesManagerpour gérer les vies dans Phaser - Affichage automatique du nombre de vies
- Callbacks pour gérer les événements (perte de vie, game over)
- Classe
-
Système de high score (
src/games/utils/highScore.ts) :- Gestion des high scores via localStorage
- Fonctions
getHighScore(),setHighScore(),clearHighScore() - Gestion gracieuse des erreurs (mode privé, quota dépassé)
Mécaniques du jeu
- Le serpent grandit d'une case à chaque nourriture mangée
- Collision avec les murs ou le corps du serpent = perte d'une vie
- Quand toutes les vies sont perdues, le score est sauvegardé si c'est un nouveau record
- La vitesse est constante (mouvement toutes les 150ms)
Améliorations possibles
- Vitesse progressive (le jeu s'accélère au fur et à mesure)
- Nourritures spéciales (ralentissement, bonus de points)
- Meilleur affichage visuel (sprites au lieu de rectangles)
- Système de niveaux
- Mode multijoueur local
Chargement du jeu en cours ...
Code source
Snake/SnakeGame.ts
// games/Snake/SnakeGame.ts
import * as Phaser from "phaser";
import SnakePlayScene from "./scenes/SnakePlayScene";
import { GameCreator } from "@/types/game";
const DEFAULT_WIDTH = 600;
const DEFAULT_HEIGHT = 660; // 60 (UI) + 600 (grille 20x20 * 30px)
let config: Phaser.Types.Core.GameConfig = {
type: Phaser.AUTO,
parent: "phaser-container",
backgroundColor: "#282c34",
scale: {
mode: Phaser.Scale.ScaleModes.NONE,
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
},
physics: {
default: "arcade",
arcade: {
gravity: { x: 0, y: 0 },
},
},
scene: [SnakePlayScene],
};
interface SnakeGameConfig {
width: number;
height: number;
}
const createGame: GameCreator = (props: SnakeGameConfig) => {
const { width, height } = props;
if (config.scale) {
config.scale.width = width || DEFAULT_WIDTH;
config.scale.height = height || DEFAULT_HEIGHT;
}
const game = new Phaser.Game(config);
return game;
};
export default createGame;
Snake/scenes/SnakePlayScene.ts
// games/Snake/scenes/SnakePlayScene.ts
import * as Phaser from "phaser";
import { random } from "lodash";
import { LivesManager } from "@/games/utils/lives";
import { getHighScore, setHighScore } from "@/games/utils/highScore";
const GRID_SIZE = 20; // 20x20 cases
const CELL_SIZE = 30; // Taille de chaque case en pixels
const GAME_AREA_X = 0;
const GAME_AREA_Y = 60; // Espace pour l'UI en haut
const MOVE_INTERVAL = 150; // Intervalle de mouvement en ms
enum Direction {
UP = "UP",
DOWN = "DOWN",
LEFT = "LEFT",
RIGHT = "RIGHT",
}
interface Position {
x: number;
y: number;
}
export default class SnakePlayScene extends Phaser.Scene {
canvas?: HTMLCanvasElement;
canvasWidth?: number;
canvasHeight?: number;
gameAreaWidth?: number;
gameAreaHeight?: number;
// Serpent
snake: Position[] = [];
direction: Direction = Direction.RIGHT;
nextDirection: Direction = Direction.RIGHT;
snakeGraphics?: Phaser.GameObjects.Graphics;
// Nourriture
food?: Position;
foodGraphics?: Phaser.GameObjects.Graphics;
// Contrôles
cursors?: Phaser.Types.Input.Keyboard.CursorKeys;
upKey?: Phaser.Input.Keyboard.Key;
downKey?: Phaser.Input.Keyboard.Key;
leftKey?: Phaser.Input.Keyboard.Key;
rightKey?: Phaser.Input.Keyboard.Key;
// Score et UI
score: number = 0;
scoreText?: Phaser.GameObjects.Text;
highScoreText?: Phaser.GameObjects.Text;
livesManager?: LivesManager;
// Game state
isGameOver: boolean = false;
moveTimer?: Phaser.Time.TimerEvent;
constructor() {
super("snake");
}
preload(): void {
this.canvas = this.sys.game.canvas;
this.canvasWidth = this.canvas.width;
this.canvasHeight = this.canvas.height;
this.gameAreaWidth = GRID_SIZE * CELL_SIZE;
this.gameAreaHeight = GRID_SIZE * CELL_SIZE;
}
create(): void {
this.initializeInputs();
this.initializeUI();
this.initializeSnake();
this.spawnFood();
this.startGame();
}
initializeInputs(): void {
this.cursors = this.input.keyboard?.createCursorKeys();
this.upKey = this.input.keyboard?.addKey(Phaser.Input.Keyboard.KeyCodes.UP);
this.downKey = this.input.keyboard?.addKey(
Phaser.Input.Keyboard.KeyCodes.DOWN
);
this.leftKey = this.input.keyboard?.addKey(
Phaser.Input.Keyboard.KeyCodes.LEFT
);
this.rightKey = this.input.keyboard?.addKey(
Phaser.Input.Keyboard.KeyCodes.RIGHT
);
}
initializeUI(): void {
// Score actuel (centre)
this.score = 0;
this.scoreText = this.add.text(
this.canvasWidth! / 2,
20,
`Score: ${this.score}`,
{
fontSize: "20px",
fontFamily: "PressStart2P",
color: "#ffffff",
}
);
this.scoreText.setX(this.canvasWidth! / 2 - this.scoreText.width / 2);
this.scoreText.setDepth(999_999);
// High score (droite)
const highScore = getHighScore("snake");
this.highScoreText = this.add.text(
this.canvasWidth! - 20,
20,
`Meilleur: ${highScore}`,
{
fontSize: "16px",
fontFamily: "PressStart2P",
color: "#ffff00",
}
);
this.highScoreText.setOrigin(1, 0);
this.highScoreText.setDepth(999_999);
// Vies (gauche)
this.livesManager = new LivesManager();
this.livesManager.onGameOver = () => {
this.handleGameOver();
};
this.livesManager.initialize(this, 20, 20, 3);
}
initializeSnake(): void {
// Serpent initial au centre de la grille
const startX = Math.floor(GRID_SIZE / 2);
const startY = Math.floor(GRID_SIZE / 2);
this.snake = [
{ x: startX, y: startY },
{ x: startX - 1, y: startY },
{ x: startX - 2, y: startY },
];
this.direction = Direction.RIGHT;
this.nextDirection = Direction.RIGHT;
// Graphics pour le serpent
this.snakeGraphics = this.add.graphics();
this.drawSnake();
}
spawnFood(): void {
let foodPosition: Position;
do {
foodPosition = {
x: random(0, GRID_SIZE - 1),
y: random(0, GRID_SIZE - 1),
};
} while (this.isPositionInSnake(foodPosition));
this.food = foodPosition;
// Graphics pour la nourriture
if (this.foodGraphics) {
this.foodGraphics.destroy();
}
this.foodGraphics = this.add.graphics();
this.drawFood();
}
isPositionInSnake(position: Position): boolean {
return this.snake.some(
(segment) => segment.x === position.x && segment.y === position.y
);
}
drawSnake(): void {
if (!this.snakeGraphics) return;
this.snakeGraphics.clear();
this.snakeGraphics.fillStyle(0x4ade80, 1); // Vert
this.snake.forEach((segment, index) => {
const x = GAME_AREA_X + segment.x * CELL_SIZE;
const y = GAME_AREA_Y + segment.y * CELL_SIZE;
// Tête du serpent légèrement plus foncée
if (index === 0) {
this.snakeGraphics?.fillStyle(0x22c55e, 1);
} else {
this.snakeGraphics?.fillStyle(0x4ade80, 1);
}
this.snakeGraphics?.fillRect(x, y, CELL_SIZE - 2, CELL_SIZE - 2);
});
}
drawFood(): void {
if (!this.foodGraphics || !this.food) return;
this.foodGraphics.clear();
this.foodGraphics.fillStyle(0xef4444, 1); // Rouge
const x = GAME_AREA_X + this.food.x * CELL_SIZE;
const y = GAME_AREA_Y + this.food.y * CELL_SIZE;
this.foodGraphics.fillRect(x, y, CELL_SIZE - 2, CELL_SIZE - 2);
}
startGame(): void {
this.isGameOver = false;
this.moveTimer = this.time.addEvent({
delay: MOVE_INTERVAL,
callback: this.moveSnake,
callbackScope: this,
loop: true,
});
}
update(): void {
if (this.isGameOver) return;
this.handleInput();
}
handleInput(): void {
// Empêcher le retour en arrière
if (
Phaser.Input.Keyboard.JustDown(this.upKey!) &&
this.direction !== Direction.DOWN
) {
this.nextDirection = Direction.UP;
} else if (
Phaser.Input.Keyboard.JustDown(this.downKey!) &&
this.direction !== Direction.UP
) {
this.nextDirection = Direction.DOWN;
} else if (
Phaser.Input.Keyboard.JustDown(this.leftKey!) &&
this.direction !== Direction.RIGHT
) {
this.nextDirection = Direction.LEFT;
} else if (
Phaser.Input.Keyboard.JustDown(this.rightKey!) &&
this.direction !== Direction.LEFT
) {
this.nextDirection = Direction.RIGHT;
}
}
moveSnake(): void {
if (this.isGameOver) return;
// Mettre à jour la direction
this.direction = this.nextDirection;
// Calculer la nouvelle position de la tête
const head = { ...this.snake[0] };
switch (this.direction) {
case Direction.UP:
head.y -= 1;
break;
case Direction.DOWN:
head.y += 1;
break;
case Direction.LEFT:
head.x -= 1;
break;
case Direction.RIGHT:
head.x += 1;
break;
}
// Vérifier les collisions avec les murs
if (
head.x < 0 ||
head.x >= GRID_SIZE ||
head.y < 0 ||
head.y >= GRID_SIZE
) {
this.handleCollision();
return;
}
// Vérifier les collisions avec le corps
if (this.isPositionInSnake(head)) {
this.handleCollision();
return;
}
// Ajouter la nouvelle tête
this.snake.unshift(head);
// Vérifier si la nourriture est mangée
if (head.x === this.food!.x && head.y === this.food!.y) {
this.eatFood();
} else {
// Retirer la queue si pas de nourriture mangée
this.snake.pop();
}
// Redessiner
this.drawSnake();
}
eatFood(): void {
this.score += 10;
this.scoreText!.setText(`Score: ${this.score}`);
this.scoreText!.setX(this.canvasWidth! / 2 - this.scoreText!.width / 2);
this.spawnFood();
}
handleCollision(): void {
// Perdre une vie
const isGameOver = this.livesManager!.loseLife();
if (!isGameOver) {
// Réinitialiser le serpent
this.resetSnake();
}
// Si game over, handleGameOver sera appelé par le callback
}
resetSnake(): void {
// Réinitialiser le serpent au centre
const startX = Math.floor(GRID_SIZE / 2);
const startY = Math.floor(GRID_SIZE / 2);
this.snake = [
{ x: startX, y: startY },
{ x: startX - 1, y: startY },
{ x: startX - 2, y: startY },
];
this.direction = Direction.RIGHT;
this.nextDirection = Direction.RIGHT;
this.drawSnake();
}
handleGameOver(): void {
this.isGameOver = true;
if (this.moveTimer) {
this.moveTimer.destroy();
}
// Sauvegarder le high score si nouveau record
const isNewRecord = setHighScore("snake", this.score);
if (isNewRecord) {
this.highScoreText!.setText(`Meilleur: ${this.score}`);
// La position X est déjà correcte grâce à setOrigin(1, 0)
}
// Afficher Game Over
const gameOverText = this.add.text(
this.canvasWidth! / 2,
this.canvasHeight! / 2,
"GAME OVER",
{
fontSize: "48px",
fontFamily: "PressStart2P",
color: "#ffffff",
}
);
gameOverText.setX(this.canvasWidth! / 2 - gameOverText.width / 2);
gameOverText.setY(this.canvasHeight! / 2 - gameOverText.height / 2);
gameOverText.setDepth(999_999);
// Message de restart
const restartText = this.add.text(
this.canvasWidth! / 2,
this.canvasHeight! / 2 + 60,
"Appuyez sur R pour recommencer",
{
fontSize: "16px",
fontFamily: "PressStart2P",
color: "#aaaaaa",
}
);
restartText.setX(this.canvasWidth! / 2 - restartText.width / 2);
restartText.setDepth(999_999);
// Écouter la touche R pour restart
const rKey = this.input.keyboard?.addKey(Phaser.Input.Keyboard.KeyCodes.R);
rKey?.on("down", () => {
this.scene.restart();
});
}
restart(): void {
this.scene.restart();
}
}