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 :

  1. Système de vies (src/games/utils/lives.ts) :

    • Classe LivesManager pour gérer les vies dans Phaser
    • Affichage automatique du nombre de vies
    • Callbacks pour gérer les événements (perte de vie, game over)
  2. 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();
  }
}

Tags

  • phaser

  • javascript

  • typescript

  • arcade

  • snake

  • jeux-video

Cet article à été posté le