Comment créer un jeu de serpent avec React, Redux et Redux Saga ?

Dans cet article, je vais vous guider dans la création d’un jeu de serpent en utilisant une application React. Il s’agit d’un simple jeu 2d construit à l’aide de TypeScript, et nous n’aurons pas besoin d’utiliser de bibliothèques graphiques tierces pour le construire.

Voici ce que nous allons réaliser dans ce tutoriel :

ezgif.com-gif-maker--2-

Snake est un jeu amusant auquel vous avez peut-être joué sur d’anciens téléphones fonctionnels comme les modèles Nokia 3310.

Le concept est simple : le serpent se déplace à l’intérieur d’une boîte, et lorsqu’il capture un fruit/objet, vos points augmentent et le serpent grandit. Si le serpent touche les limites de la boîte ou entre en collision avec lui-même, le jeu est terminé.

Cet article vous fournira toutes les compétences/étapes nécessaires pour créer votre propre jeu Snake à partir de zéro. Nous allons d’abord examiner les structures de code et leur logique. Puis j’expliquerai comment elles fonctionnent lorsqu’elles sont toutes connectées.

Sans plus attendre, commençons.

Table des matières

Conditions préalables

Avant de commencer à lire cet article, vous devez avoir une compréhension de base des sujets suivants :

Qu’est-ce qu’un jeu de serpent ? Qu’allons-nous utiliser dans ce jeu ?

Un jeu de serpent est un jeu d’arcade dans lequel un serpent se déplace dans une boîte. Votre score augmente en fonction du nombre d’objets/fruits que le serpent mange. Cela augmente également la taille du serpent. S’il entre en collision avec lui-même ou avec la limite de la boîte, le jeu est terminé.

Vous pouvez en savoir plus sur l’histoire ou les origines du jeu en consultant le lien Wiki.

Nous allons utiliser les outils suivants pour construire notre jeu :

  • Redux : Pour créer et gérer l’état global de l’application.
  • Redux-saga : Un middleware redux que nous utiliserons pour gérer les tâches asynchrones.
  • Balise HTML Canvas : Nous l’utiliserons pour dessiner un objet comme un serpent et le fruit.
  • React : Bibliothèque UI.
  • Chakra-UI : Bibliothèque de composants.

Qu’est-ce que redux ? Pourquoi l’utilisons-nous ?

Redux est un conteneur d’état qui vous aide à créer et à gérer l’état global de votre application. Redux se compose de quelques parties de base comme :

  1. État global
  2. Magasin Redux
  3. Actions et créateurs d’actions
  4. Réducteurs

Vous pouvez apprendre tout ce qui concerne les sujets ci-dessus et comment Redux fonctionne en interne dans la section de démarrage de la doc Redux.

Nous utilisons la bibliothèque de gestion d’état Redux car elle nous aidera à gérer notre état global d’une manière plus simple. Elle nous permettra d’éviter le prop drilling. Elle nous permettra également d’effectuer des actions asynchrones complexes via un middleware.

Vous pouvez en apprendre plus sur les middleware ici.

Qu’est-ce que redux-saga ? Pourquoi l’utilisons-nous ?

Redux-saga est un middleware qui nous aide à nous intercaler entre l’action dispatchée et le reducer du redux store. Cela nous permet d’effectuer certains effets secondaires entre l’action distribuée et le réducteur, comme la récupération de données, l’écoute d’actions particulières ou la mise en place d’abonnements, la création d’actions, et plus encore.

Redux saga utilise des générateurs et des fonctions de générateur. Une saga typique ressemble à ceci :

function* performAction() {
    yield put({
        type : COPY_DATA,
        payload : "Bonjour"
    }) ;
}

performAction est une fonction génératrice. Cette fonction générateur va exécuter la fonction put. Elle crée un objet et le renvoie à la saga, en indiquant quel type d’action doit être exécuté avec quelle charge utile. Ensuite, l’appel put renvoie un descripteur d’objet indiquant quelle saga peut le reprendre plus tard et exécuter l’action particulière.

NOTE : Vous pouvez en savoir plus sur les générateurs et les fonctions de générateur en vous référant à la section des prérequis.

Maintenant la question se pose : pourquoi utilisons-nous le middleware redux-saga ? La réponse est simple :

  1. Il fournit une meilleure façon d’écrire des cas de tests unitaires, ce qui nous aidera à tester les fonctions du générateur d’une manière plus simple.
  2. Il peut vous aider à effectuer beaucoup d’effets secondaires et à fournir un meilleur contrôle sur les changements. Par exemple, lorsque vous voulez voir si une action X particulière est exécutée, puis exécuter l’action Y. Des fonctions comme takeEvery, all, etc. permettent d’effectuer ces opérations en toute simplicité. Nous en parlerons plus en détail dans une section ultérieure.

Si vous n’êtes pas familier avec redux-saga, alors je vous recommande fortement de parcourir la documentation ici.

Description du cas d’utilisation

NOTE : Les diagrammes de contexte, conteneur et classe dessinés dans ce billet de blog ne suivent pas exactement les conventions de ces diagrammes. Je les ai approximés ici pour que vous puissiez comprendre les concepts de base.

Avant de commencer, je vous suggère de vous documenter sur les c4models, les diagrammes de conteneurs et les diagrammes de contexte. Vous pouvez trouver des ressources à leur sujet dans la section des prérequis.

Dans cet article, nous allons considérer le cas d’utilisation suivant : Créer un jeu de serpent.

Le cas d’utilisation est assez explicite, et nous avons discuté de ce que le jeu de serpent implique ci-dessus. Vous trouverez ci-dessous le diagramme de contexte de notre cas d’utilisation :

contextDiagram
Diagramme de contexte du jeu de serpent

Notre diagramme de contexte est assez simple. Le joueur interagit avec l’interface utilisateur. Plongeons plus profondément dans l’interface utilisateur du plateau de jeu et explorons les autres systèmes qui s’y trouvent.

Untitled--2-
Diagramme de conteneur pour le jeu du serpent

Comme vous pouvez le voir sur le diagramme ci-dessus, notre interface utilisateur de plateau de jeu est divisée en deux couches :

  1. Couche d’interface utilisateur
  2. Couche de données

La couche UI est constituée des composants suivants :

  1. Calculatrice de score : Il s’agit d’un composant qui affiche le score chaque fois que le serpent mange le fruit.
  2. CanvasBoard : Il s’agit d’un composant qui gère la majeure partie de l’interface utilisateur de notre jeu. Sa fonctionnalité de base est de dessiner le serpent sur le canevas et de vider le canevas. Il assume également les responsabilités suivantes :
    1. Il détecte si le serpent est entré en collision avec lui-même ou avec les murs d’enceinte (détection de collision).
    2. Aide à déplacer le serpent le long du plateau avec des événements clavier.
    3. Réinitialise le jeu lorsque le jeu est terminé.
  3. Instructions : Il fournit les instructions pour jouer au jeu, ainsi que le bouton de réinitialisation.
  4. Utilitaires : Ce sont les fonctions utilitaires que nous utiliserons tout au long de l’application lorsque cela sera nécessaire.

Parlons maintenant de la couche de données. Elle se compose des éléments suivants :

  1. Redux-saga : Ensemble de fonctions génératrices qui effectueront certaines actions.
  2. Actions et créateurs d’actions : Il s’agit de l’ensemble des constantes et des fonctions qui aideront à distribuer les actions appropriées.
  3. Réducteurs : Cela nous aidera à répondre aux différentes actions distribuées par les créateurs d’actions et les sagas.

Nous allons plonger en profondeur dans tous ces composants et voir comment ils fonctionnent collectivement dans les sections ultérieures. Tout d’abord, initialisons notre projet et mettons en place notre couche de données – c’est-à-dire le magasin Redux.

Mise en place de l’application et de la couche de données

Avant de commencer à comprendre nos composants de jeu, mettons d’abord en place notre application React et la couche de données.

Le jeu est construit avec React. Je recommande vivement d’utiliser le modèle create-react-app pour installer tout ce qui est nécessaire au démarrage de votre application React.

Pour créer un projet CRA (create-react-app), nous devons d’abord l’installer. Tapez la commande ci-dessous dans votre terminal :

npm install -g create-react-app

Note : Avant d’exécuter cette commande, assurez-vous que vous avez installé Node.js dans votre système. Suivez ce lien pour l’installer.

Ensuite, nous allons commencer par créer notre projet. Appelons-le « snake-game ». Tapez la commande ci-dessous dans votre terminal pour créer le projet :

npx create-react-app snake-game

Cela peut prendre quelques minutes. Une fois que c’est terminé, allez dans votre projet nouvellement créé en utilisant la commande suivante :

cd snake-game

Une fois dans le projet, tapez la commande suivante pour démarrer le projet :

npm run start

Cette commande va ouvrir un nouvel onglet dans votre navigateur avec le logo React qui tourne sur la page comme ci-dessous :

image-16
create-react-app page initiale

Maintenant, la configuration initiale de notre projet est terminée. Configurons notre couche de données (le magasin Redux). Notre couche de données nécessite que nous installions les paquets suivants :

Tout d’abord, commençons par installer ces paquets. Avant de commencer, assurez-vous que vous êtes dans le répertoire du projet. Tapez la commande ci-dessous dans le terminal :

npm install redux react-redux redux-saga

Une fois que ces paquets sont installés, nous allons d’abord configurer notre magasin Redux. Pour commencer, créons d’abord un dossier nommé store:

mkdir store

Ce dossier store sera composé de tous les fichiers liés à Redux. Nous allons organiser notre dossier store de la manière suivante :

store/
├─── actions
│ └─── index.ts
├─── reducers
│ └─── index.ts
└── sagas
    └─── index.ts
├─── index.ts

Voyons ce que fait chacun de ces fichiers :

  • action/index.tsx: Ce fichier est constitué de constantes qui représentent les actions que notre application peut effectuer et dispatcher vers le magasin Redux. Un exemple d’une telle constante d’action ressemble à ceci :
export const MOVE_RIGHT = "MOVE_RIGHT"

Nous allons utiliser la même constante d’action pour créer une fonction qui renverra un objet avec les propriétés suivantes :

  • type: Type d’action, c’est-à-dire constante d’action
  • payload: données supplémentaires qui agissent comme une charge utile.

Ces fonctions qui renvoient un objet avec la propriété type sont appelées des créateurs d’actions. Nous utilisons ces fonctions pour envoyer des actions à notre magasin Redux.

L’attribut payload signifie qu’avec l’action, nous pouvons également passer des données supplémentaires qui peuvent être utilisées pour stocker ou mettre à jour la valeur dans l’état global.

NOTE: Il est obligatoire d’avoir la propriété type retournée par le créateur de l’action. La propriété payload est facultative. En outre, le nom de la propriété payload peut être n’importe quoi.

Voyons un exemple de créateur d’action :

//Sans charge utile
export const moveRight = () => ({
	type : MOVE_RIGHT
}) ;

//Avec une charge utile
export const moveRight = (data : string) => ({
	type : MOVE_RIGHT,
	charge utile : données
}) ;

Maintenant que nous savons ce que sont les actions et les créateurs d’actions, nous pouvons passer à la configuration de notre prochain artefact qui est un reducer.

Les réducteurs sont des fonctions qui renvoient un nouvel état global chaque fois qu’une action est envoyée. Ils prennent l’état global actuel et renvoient le nouvel état basé sur l’action qui est envoyée/appelée. Ce nouvel état est calculé sur la base de l’état précédent.

Nous devons veiller à ne pas effectuer d’effets secondaires dans cette fonction. Nous ne devons pas modifier l’état global – nous devons plutôt retourner l’état mis à jour en tant que nouvel objet. Par conséquent, la fonction reducer doit être une fonction pure.

Maintenant, assez parlé des réducteurs. Jetons un coup d’oeil à nos exemples de réducteurs :

const GlobalState = {
    données : ""
} ;

const gameReducer = (state = GlobalState, action) => {
    switch (action.type) {
        cas "MOVE_RIGHT" :
            /**
             * Effectuer un certain nombre d'opérations
             */
            return {
                ...état, données : action.payload
            } ;

        par défaut :
            return state ;
    }
}

Dans cet exemple, nous avons créé une fonction réducteur qui s’appelle gameReducer. Elle prend l’état (paramètre par défaut comme état global) et une action. Chaque fois que le type d'action correspond au cas de commutation, il exécute une action particulière, comme le retour d’un nouvel état basé sur l’action.

Voir aussi :  Défilement de la page vers le haut - Comment défiler vers une section particulière avec React

Le fichier sagas/index.ts comprendra toutes les sagas que nous utiliserons dans notre application. Nous avons quelques notions de base sur les sagas que nous avons brièvement expliquées dans les sections précédentes. Nous approfondirons cette section lorsque nous commencerons à implémenter le jeu du serpent.

Maintenant, nous avons une compréhension de base des artefacts impliqués dans la création de notre magasin Redux. Allons-y et créons store/index.ts comme ci-dessous :

import {
    createStore,
    applyMiddleware
} de "redux" ;
import createSagaMiddleware de "redux-saga" ;
import gameReducer de "./reducers" ;
import watcherSagas de "./sagas" ;
const sagaMiddleware = createSagaMiddleware() ;

const store = createStore(gameReducer, applyMiddleware(sagaMiddleware)) ;

sagaMiddleware.run(watcherSagas) ;
exporter le magasin par défaut ;

Nous allons d’abord importer notre reducer et la saga. Ensuite, nous allons utiliser la fonction createSagaMiddleware() pour créer un middleware saga.

Ensuite, nous allons le connecter à notre magasin en le passant comme argument à la fonction applyMiddleware à l’intérieur de createStore que vous utilisez pour créer un magasin. Nous passerons également gameReducer à cette fonction pour qu’un reducer soit mappé à notre magasin.

Enfin, nous allons exécuter notre sagaMiddleware en utilisant ce code :

sagaMiddleware.run(watcherSagas) ;

Notre dernière étape consiste à injecter ce magasin au niveau supérieur de notre application React en utilisant le composant Provider fourni par react-redux. Vous pouvez le faire comme suit :

import { Provider } de "react-redux" ;
import store de "./store" ;

const App = () => {
  return (
   
    // Composants enfants...
   
  ) ;
} ;

export default App ;

J’ai également installé chakra-UI comme bibliothèque de composants UI pour notre projet. Pour installer chakra-UI, tapez la commande suivante :

npm install @chakra-ui/react @emotion/react@^11 @emotion/styled@^11 framer-motion@^5

Nous devons également configurer le ChakraProvider qui ira dans notre fichier App.tsx. Notre fichier App.tsx mis à jour ressemblera à ceci :

import { ChakraProvider, Container, Heading } from "@chakra-ui/react" ;
import { Provider } de "react-redux" ;
import store de "./store" ;

const App = () => {
  return (
    
      
        
         JEU DE SNAKE
	//Composants pour enfants
        
      
    
  ) ;
} ;

export default App ;

Comprendre la couche UI

Comprenons d’abord la dynamique de notre jeu de serpent du point de vue de l’interface utilisateur. Avant de commencer, notre jeu de serpent final ressemblera à ce qui suit :

snake-game

La couche UI se compose de 3 couches : Le calculateur de score, le tableau et les instructions. Le diagramme ci-dessous présente ces sections :

Untitled--3-

Plongeons plus profondément dans chacune de ces sections pour comprendre le fonctionnement de notre jeu de serpent.

Tableau de bord

Nous allons commencer par comprendre le Canvas Board :

  • Notre tableau de toile va être de dimensions hauteur : 600, largeur : 1000
  • L’ensemble de ce tableau est divisé en blocs de 20x20. Autrement dit, chaque objet dessiné sur ce tableau a une hauteur de 20 et une largeur de 20.
  • Nous utilisons l’élément Pour dessiner les formes dans le composant tableau de toile.

Dans notre projet, nous écrivons le composant CanvasBoard dans le fichier components/CanvasBoard.tsx. Maintenant que notre compréhension de base est claire au sujet du composant CanvasBoard, commençons à construire ce composant.

Créez un composant simple qui renvoie un élément de toile comme ci-dessous :

export interface ICanvasBoard {
  height : nombre ;
  width : nombre ;
}

const CanvasBoard = ({ height, width } : ICanvasBoard) => {
  return (
   
  ) ;
} ;

Appelez ce composant dans notre fichier App.tsx avec une largeur et une hauteur de 1000 et 600 comme accessoire comme ci-dessous :

import { ChakraProvider, Container, Heading } from "@chakra-ui/react" ;
import { Provider } from "react-redux" ;
import CanvasBoard de "./components/CanvasBoard" ;
import ScoreCard de "./components/ScoreCard" ;
import store de "./store" ;

const App = () => {
  return (
    
      
        
          JEU DU SERPENT
           //Composant Canvasboard ajouté 
        
      
    
  ) ;
} ;

export default App ;

Cela créera une boîte simple de hauteur=600 et de largeur=1000 avec une bordure noire comme ci-dessous :

snakeCanvas1
Un élément canvas vierge avec width=1000 et height=600

Maintenant, dessinons un serpent au centre de ce canevas. Mais avant de commencer à dessiner, nous devons obtenir le contexte de cet élément de toile.

Le contexte d’un élément de toile vous fournit toutes les informations dont vous avez besoin concernant l’élément de toile. Il vous donne les dimensions du canevas et vous aide également à dessiner sur le canevas.

Pour obtenir le contexte de notre élément de toile, nous devons appeler la fonction getCanvas('2d') qui renvoie le contexte 2d de la toile. Le type de retour de cette fonction est l’interface CanvasRenderingContext2D.

Pour faire cela en JS pur, il faudrait faire quelque chose comme ci-dessous :

const canvas = document.querySelector('canvas') ;
const canvasCtx = canvas.getContext('2d') ;

Mais pour le faire dans React, nous devons créer un ref et le passer à l’élément canvas afin que nous puissions l’adresser plus tard dans différents hooks. Pour ce faire dans notre application, créez un ref en utilisant le hook useRef:

const canvasRef = useRef(null) ;

Passez le ref dans notre élément canvas:

;

Une fois que le canvasRef est passé dans l’élément canvas, nous pouvons l’utiliser dans un hook useEffect et stocker le contexte dans une variable d’état.

export interface ICanvasBoard {
  height : nombre ;
  width : nombre ;
}

const CanvasBoard = ({hauteur, largeur } : ICanvasBoard) => {
  const canvasRef = (useRef < HTMLCanvasElement) | (null > null) ;
  const [context, setContext] =
    (useState < CanvasRenderingContext2D) | (null > null) ;

  useEffect(() => {
    //Dessinez sur le canevas à chaque fois
    setContext(canvasRef.current && canvasRef.current.getContext("2d")) ; //stocker dans la variable d'état
  }, [context]) ;

  return (
   
  ) ;
} ;
Stockage du contexte du canevas dans une variable d’état

Dessin des objets

Après avoir obtenu le contexte, nous devons effectuer les tâches suivantes à chaque fois qu’un composant se met à jour :

  1. Effacer le canevas
  2. Dessine le serpent avec la position actuelle
  3. Dessine un fruit à une position aléatoire à l’intérieur de la boîte

Nous allons effacer le canevas plusieurs fois, nous allons donc en faire une fonction utilitaire. Donc, pour cela, créons un dossier appelé utilitaires:

mkdir utilitaires
cd utilitaires
touch index.tsx

La commande ci-dessus va également créer un fichier index.tsx dans le dossier utilities. Ajoutez le code ci-dessous dans le fichier utilities/index.tsx:

export const clearBoard = (context : CanvasRenderingContext2D | null) => {
  if (context) {
    context.clearRect(0, 0, 1000, 600) ;
  }
} ;
Code pour effacer le canevas

La fonction clearBoard est assez simple. Elle effectue les actions suivantes :

  1. Il accepte les objets 2d canvas context en tant qu’argument.
  2. Elle vérifie que le contexte n’est pas nul ou indéfini.
  3. La fonction clearRect va effacer tous les pixels ou objets présents à l’intérieur du rectangle. Cette fonction prend la largeur et la hauteur en argument pour le rectangle à effacer.

Nous utiliserons cette fonction clearBoard dans notre useEffect CanvasBoard pour effacer le canevas à chaque fois que le composant est mis à jour. Pour différencier les différents useEffects, nous appellerons le useEffect ci-dessus useEffect1.

Commençons maintenant par dessiner le serpent et le fruit à une position aléatoire. Comme nous allons dessiner les objets plusieurs fois, nous allons créer une fonction utilitaire appelée drawObject. Ajoutez le code ci-dessous dans le fichier utilities/index.tsx:

export interface IObjectBody {
  x : nombre ;
  y : nombre ;
}

export const drawObject = (
  contexte : CanvasRenderingContext2D | null,
  objectBody : IObjectBody[],
  fillColor : string,
  strokeStyle = "#146356")
) => {
  if (context) {
    objectBody.forEach((object : IObjectBody) => {
      context.fillStyle = fillColor ;
      context.strokeStyle = strokeStyle ;
      context ?.fillRect(object.x, object.y, 20, 20) ;
      context ?.strokeRect(objet.x, objet.y, 20, 20) ;
    }) ;
  }
} ;
Fonction permettant de dessiner un objet sur le canevas

La fonction drawObject accepte les arguments suivants :

  1. context – Un objet de contexte de canevas 2D pour dessiner l’objet sur le canevas.
  2. objectBody – Il s’agit d’un tableau d’objets, chaque objet ayant des propriétés x et y, comme l’interface IObjectBody.
  3. fillColor – Couleur à remplir à l’intérieur de l’objet.
  4. strokeStyle – Couleur à remplir dans le contour de l’objet. La valeur par défaut est #146356.

Cette fonction vérifie si le contexte est indéfini ou nul. Ensuite, elle itère sur le corps de l'objet via forEach. Pour chaque objet, elle effectue les opérations suivantes :

  1. Il attribuera le fillStyle et le strokeStyle dans le contexte.
  2. Il utilisera fillReact pour créer un rectangle rempli avec les coordonnées object.x et object.y de taille 20x20
  3. Enfin, il utilisera strokeRect pour créer un rectangle délimité avec les coordonnées object.x et object.y de taille 20x20

Pour dessiner le serpent, nous devons maintenir la position du serpent. Pour cela, nous pouvons utiliser notre outil de gestion d’état global redux.

Nous devons mettre à jour notre fichier reducers/index.ts. Puisque nous voulons suivre la position du serpent, nous allons l’ajouter dans notre état global comme suit :

interface ISnakeCoord {
  x : nombre ;
  y : nombre ;
}

export interface IGlobalState {
  serpent : ISnakeCoord[] | [] ;
}

const globalState : IGlobalState = {
  //Position de l'ensemble du serpent
  serpent : [
    { x : 580, y : 300 },
    { x : 560, y : 300 },
    { x : 540, y : 300 },
    { x : 520, y : 300 },
    { x : 500, y : 300 },
  ],
} ;
Mise à jour de l’état global

Appelons cet état dans notre composant CanvasBoard. Nous utiliserons le hook useSelector de react-redux pour obtenir l’état requis depuis le magasin. Ce qui suit nous donnera l’état global du serpent:

const snake1 = useSelector((state : IGlobalState) => state.snake) ;

Intégrons-le dans notre composant CanvasBoard et passons-le à notre fonction drawObject pour voir le résultat :

//Importation des modules nécessaires
import { useSelector } de "react-redux" ;
import { clearBoard, drawObject, generateRandomPosition } de "../utils" ;

export interface ICanvasBoard {
  height : nombre ;
  width : nombre ;
}

const CanvasBoard = ({ height, width } : ICanvasBoard) => {
	const canvasRef = useRef(null) ;
	const [context, setContext] = useState(null) ;
	const snake1 = useSelector((state : IGlobalState) => state.snake) ;
	const [pos, setPos] = useState(
	    generateRandomPosition(width - 20, height - 20)
	  ) ;

	useEffect(() => {
	  //Dessinez sur le canevas à chaque fois
	 setContext(canvasRef.current && canvasRef.current.getContext("2d")) ; //Stockage dans une variable d'état
		drawObject(context, snake1, "#91C483") ; //Dessine le serpent à la position requise
		drawObject(context, [pos], "#676FA3") ; //Dessine le fruit au hasard
	}, [context])

  retour (
   
  ) ;
} ;
Code pour dessiner le serpent et le fruit

Voyons à quoi ressemblera la sortie lorsque le serpent sera dessiné :

snake_only
Dessiner le serpent

Déplacer le serpent sur le plateau

Maintenant que nous avons dessiné notre serpent sur la toile, apprenons à le déplacer sur le plateau.

Le mouvement du serpent est simple. Il doit toujours suivre les points ci-dessous :

  1. Si le serpent se déplace horizontalement, il ne peut se déplacer que vers le haut, le bas et dans la direction dans laquelle il se déplace. Par exemple, si le serpent se déplace vers la droite, il peut se déplacer vers le haut ou le bas ou continuer à se déplacer vers la droite.
  2. Si le serpent se déplace verticalement, il ne peut se déplacer que vers la droite, la gauche ou continuer dans la direction dans laquelle il se déplace actuellement. Par exemple, si le serpent se déplace vers le haut, il peut se déplacer vers la droite ou la gauche (ou continuer vers le haut).
  3. Le serpent ne peut pas se déplacer dans la direction opposée à celle de son déplacement actuel. Autrement dit, si le serpent se déplace vers la gauche, il ne peut pas se déplacer directement vers la droite. De même, s’il se déplace vers le haut, il ne peut pas se déplacer vers le bas.

Pour que le mouvement de notre serpent soit fluide, le serpent doit toujours se déplacer de façon rectangulaire. Et il doit respecter les points ci-dessus pour avoir ce mouvement.

Le diagramme ci-dessous résume le fonctionnement du mouvement du serpent dans l’ensemble de l’application :

temp
Explication du mouvement du serpent

REMARQUE : dans le diagramme ci-dessus, le mouvement complet du serpent commence avec le composant CanvasBoard.

ASTUCE : Ne vous inquiétez pas si vous ne pouvez pas suivre le schéma ci-dessus. Lisez simplement les sections suivantes pour y voir plus clair.

Voir aussi :  Tutoriel Next.js et Firebase - Comment construire un clone d'Evernote

Pour maintenir le mouvement du serpent, nous allons introduire une autre variable d’état dans notre état global, appelée disallowedDirection. Le but de cette variable est de garder la trace de la direction opposée au mouvement du serpent.

Par exemple, si le serpent se déplace vers la gauche, la variable disallowedDirection sera définie sur la droite. Donc, pour résumer, nous suivons cette direction afin d’éviter que le serpent ne se déplace dans la direction opposée.

Créons cette variable dans notre état global :

interface ISnakeCoord {
  x : nombre ;
  y : nombre ;
}

export interface IGlobalState {
  serpent : ISnakeCoord[] | [] ;
  disallowedDirection : string ;
}

const globalState : IGlobalState = {
	//Position de l'ensemble du serpent
  serpent : [
    { x : 580, y : 300 },
    { x : 560, y : 300 },
    { x : 540, y : 300 },
    { x : 520, y : 300 },
    { x : 500, y : 300 },
  ],
	disallowedDirection : ""
} ;
Ajout d’un nouvel état global

Créons maintenant des actions et des créateurs d’actions qui nous aideront à déplacer le serpent.

Nous aurons deux types d’actions pour ce cas :

  • Actions pour les sagas
    • Voici les actions qui seront envoyées par le composant CanvasBoard. Ces actions seront :
      • MOVE_RIGHT
      • MOVE_LEFT
      • MOVE_UP
      • MOVE_DOWN
  • Actions pour les réducteurs
    • Ce sont les actions qui seront produites par la saga pour propager les appels aux reducers. Ces actions seront :

Nous allons examiner de plus près ces actions dans les sections suivantes.

Nous allons créer une action supplémentaire appelée SET_DIS_DIRECTION pour définir l’état disallowedDirection.

Créons quelques créateurs d’action pour le mouvement du serpent :

  • setDisDirection – Ce créateur d’action sera utilisé pour définir la disallowedDirection via l’action SET_DIS_DIRECTION. Voici le code de ce créateur d’action :
export const setDisDirection = (direction : string) => ({
  type : SET_DIS_DIRECTION,
  charge utile : direction
}) ;
  • makeMove – Cette action sera utilisée pour définir/mettre à jour les nouvelles coordonnées du serpent en mettant à jour la variable d’état du serpent. Voici le code de ce créateur d’action :
export const makeMove = (dx : nombre, dy : nombre, move : chaîne) => ({
  type : move,
  payload : [dx, dy]
}) ;

Les paramètres dx et dy sont les deltas. Ils indiquent au magasin Redux de combien nous devons augmenter/diminuer les coordonnées de chaque bloc du serpent pour déplacer le serpent dans la direction donnée.

Le paramètre move est utilisé pour spécifier dans quelle direction le serpent va se déplacer. Nous verrons bientôt ces créateurs d’actions dans les prochaines sections.

Enfin, notre fichier actions/index. ts mis à jour ressemblera à quelque chose comme ceci :

export const MOVE_RIGHT = "MOVE_RIGHT" ;
export const MOVE_LEFT = "MOVE_LEFT" ;
export const MOVE_UP = "MOVE_UP" ;
export const MOVE_DOWN = "MOVE_DOWN" ;

export const RIGHT = "RIGHT" ;
export const LEFT = "LEFT" ;
export const UP = "UP" ;
export const DOWN = "DOWN" ;

export const SET_DIS_DIRECTION = "SET_DIS_DIRECTION" ;

export interface ISnakeCoord {
  x : nombre ;
  y : nombre ;
}
export const makeMove = (dx : nombre, dy : nombre, move : chaîne) => ({
  type : move,
  charge utile : [dx, dy]
}) ;

export const setDisDirection = (direction : string) => ({
  type : SET_DIS_DIRECTION,
  charge utile : direction
}) ;

Maintenant, regardons la logique que nous utilisons pour déplacer le serpent en fonction des actions ci-dessus. Tous les mouvements du serpent seront suivis par les actions suivantes :

Toutes ces actions sont les éléments constitutifs du mouvement du serpent. Ces actions, lorsqu’elles sont lancées, mettent toujours à jour l’état global du serpent en fonction de la logique que nous décrivons ci-dessous. Et elles calculeront les nouvelles coordonnées du serpent à chaque mouvement.

Pour calculer les nouvelles coordonnées du serpent après chaque mouvement, nous utiliserons la logique suivante :

  1. Copiez les coordonnées dans une nouvelle variable appelée newSnake
  2. Ajoutez au début de la variable newSnake les nouvelles coordonnées x et y. Les attributs x et y de ces coordonnées sont mis à jour en ajoutant les valeurs x et y de la charge utile de l’action.
  3. Enfin, supprimez la dernière entrée du tableau newSnake.

Maintenant que nous avons une certaine compréhension de la façon dont le serpent se déplace, ajoutons les cas suivants dans notre gameReducer:

    cas RIGHT :
    cas GAUCHE :
    cas UP :
    cas DOWN : {
      let newSnake = [...state.snake] ;
      newSnake = [{
        //Nouvelles coordonnées x et y
        x : state.snake[0].x + action.payload[0],
        y : state.snake[0].y + action.payload[1],
      }, ...nouveauSerpent] ;
      nouveauSnake.pop() ;

      return {
        ...état,
        serpent : newSnake,
      } ;
    }
Cas du mouvement du serpent

Pour chaque mouvement du serpent, nous mettons à jour les nouvelles coordonnées x et y qui sont augmentées par les payloads action.payload[0] et action.payload[1]. Nous avons terminé avec succès la mise en place des actions, des créateurs d’actions et de la logique du réducteur.

Nous sommes prêts et pouvons maintenant utiliser tout cela dans notre composant CanvasBoard.

Tout d’abord, ajoutons un hook useEffect dans notre composant CanvasBoard. Nous utiliserons ce crochet pour attacher/ajouter un gestionnaire d’événement. Ce gestionnaire d’événements sera lié à l’événement keypress. Nous utilisons cet événement parce qu’à chaque fois que nous appuyons sur les touches w a s d, nous devons être en mesure de contrôler le mouvement du serpent.

Notre useEffect ressemblera à quelque chose comme ci-dessous :

useEffect(() => {
    window.addEventListener("keypress", handleKeyEvents) ;

    return () => {
      window.removeEventListener("keypress", handleKeyEvents) ;
    } ;
  }, [disallowedDirection, handleKeyEvents]) 
Capture des événements clavier via le hook useEffect

Il fonctionne de la manière suivante :

  1. Lors du montage du composant, l’écouteur d’événements avec la fonction de rappel handleKeyEvents est attaché à l’objet fenêtre.
  2. Lors du démontage du composant, l’écouteur d’événements est retiré de l’objet fenêtre.
  3. S’il y a un changement dans la direction ou la fonction handleKeyEvents, nous réexécuterons cet useEffect. Par conséquent, nous avons ajouté disallowedDirection et handleKeyEvents dans le tableau de dépendances.

Regardons comment le callback handleKeyEvents est créé. Vous trouverez ci-dessous le code correspondant :

const handleKeyEvents = useCallback(
    (event : KeyboardEvent) => {
      if (disallowedDirection) {
        switch (event.key) {
          cas "w" :
            moveSnake(0, -20, disallowedDirection) ;
            pause ;
          cas "s" :
            moveSnake(0, 20, disallowedDirection) ;
            pause ;
          cas "a" :
            moveSnake(-20, 0, disallowedDirection) ;
            pause ;
          cas "d" :
            event.preventDefault() ;
            moveSnake(20, 0, disallowedDirection) ;
            pause ;
        }
      } else {
        si (
          disallowedDirection !== "LEFT" &&
          disallowedDirection !== "UP" &&
          disallowedDirection !== "DOWN" &&
          event.key === "d"
        )
          moveSnake(20, 0, disallowedDirection) ; //Déplacement vers la DROITE au départ
      }
    },
    [disallowedDirection, moveSnake]
  ) ;

Nous avons enveloppé cette fonction avec un crochet useCallback. C’est parce que nous voulons la version mémorisée de cette fonction qui est appelée à chaque changement d’état (c’est-à-dire, sur le changement de disallowedDirection et moveSnake). Cette fonction est appelée à chaque fois que l’on appuie sur une touche du clavier.

Cette fonction de rappel du gestionnaire d’événements a l’objectif suivant :

  • Si la direction disallowedDirection est vide, nous nous assurons que le jeu ne démarrera que lorsque l’utilisateur appuiera sur la touche d. Cela signifie que le jeu ne démarrera que lorsque le serpent sera en mouvement. Cela signifie que le jeu ne démarre que lorsque le serpent se déplace vers la droite.

REMARQUE: au départ, la valeur de la variable d’état globale disallowedDirection est une chaîne vide. De cette façon, nous savons que si sa valeur est vide alors c’est le début du jeu.

Une fois que le jeu commence, la variable disallowedDirection ne sera pas vide et elle écoutera alors toutes les pressions du clavier telles que w s et a.

Enfin, à chaque pression sur le clavier, nous appelons la fonction appelée moveSnake. Nous allons l’examiner de plus près dans la prochaine section.

La fonction moveSnake est une fonction qui distribue une action passée au créateur d’action makeMove. Cette fonction accepte trois arguments :

  1. dxDelta pour l’axe des x. Ceci indique de combien le serpent doit se déplacer le long de l’axe des x. Si dx est positif, il se déplace vers la droite, s’il est négatif, il se déplace vers la gauche.
  2. dy – Delta pour l’axe des ordonnées. Il indique de combien le serpent doit se déplacer le long de l’axe des ordonnées. Si dy est positif, il se déplace vers le bas, si c’est négatif, il se déplace vers le haut.
  3. disallowedDirection – Cette valeur indique que le serpent ne doit pas se déplacer dans la direction opposée. C’est une action qui est capturée par notre middleware saga.

Le code de la fonction moveSnake ressemblera à ceci :

const moveSnake = useCallback(
    (dx = 0, dy = 0, ds : string) => {
      if (dx > 0 && dy === 0 && ds !== "RIGHT") {
        dispatch(makeMove(dx, dy, MOVE_RIGHT)) ;
      }

      if (dx < 0 && dy === 0 && ds !== "LEFT") {
        dispatch(makeMove(dx, dy, MOVE_LEFT)) ;
      }

      si (dx === 0 && dy < 0 && ds !== "UP") {
        dispatch(makeMove(dx, dy, MOVE_UP)) ;
      }

      if (dx === 0 && dy > 0 && ds !== "DOWN") {
        dispatch(makeMove(dx, dy, MOVE_DOWN)) ;
      }
    },
    [dispatch]
  ) ;
Envoi de l’action pour chaque mouvement du serpent

La fonction moveSnake est une fonction simple qui vérifie les conditions :

  1. Si dx > 0, et que la disallowedDirection n’est pas RIGHT, alors il peut se déplacer dans la direction RIGHT.
  2. Si dx < 0, et que la disallowedDirection n’est pas LEFT, alors il peut se déplacer dans la direction LEFT.
  3. Si dy > 0, et que la disallowedDirection n’est pas DOWN, il peut se déplacer dans la direction DOWN.
  4. Si dy < 0, et que la disallowedDirection n’est pas UP, alors il peut se déplacer dans la direction UP.

Cette valeur disallowedDirection est définie dans nos sagas dont nous parlerons davantage dans les sections ultérieures de cet article. Si nous revoyons la fonction handleKeyEvents maintenant, elle a beaucoup plus de sens. Prenons un exemple ici :

  • Supposons que vous voulez déplacer le serpent vers la DROITE. Alors cette fonction détectera que la touche d est pressée.
  • Une fois cette touche enfoncée, elle appelle la fonction makeMove (condition de démarrage du jeu) avec dx égal à 20 (+ve), dy égal à 0, et la disallowedDirection précédemment définie est appelée ici.

De cette façon, nous faisons le mouvement du serpent dans une direction particulière. Maintenant, regardons les sagas que nous avons utilisées et comment elles gèrent le mouvement du serpent.

Créons un fichier appelé saga/index.ts. Ce fichier contiendra toutes nos sagas. Ce n’est pas une règle, mais en général, nous créons deux sagas.

La première est la saga qui distribue les actions réelles au magasin – appelons-la saga worker. La seconde est la saga watcher qui surveille toute action en cours de distribution – appelons-la saga watcher.

Maintenant, nous devons créer une saga watcher qui surveillera les actions suivantes : MOVE_RIGHT, MOVE_LEFT, MOVE_UP, MOVE_DOWN.

function* watcherSaga() {
	yield takeLatest(
      [MOVE_RIGHT, MOVE_LEFT, MOVE_UP, MOVE_DOWN],
      moveSaga
    ) 
}
Watcher saga

Cette saga watcher va surveiller les actions ci-dessus et exécuter la fonction moveSaga qui est une saga worker.

Vous remarquerez que nous avons utilisé une nouvelle fonction appelée takeLatest. Cette fonction appellera la saga worker et annulera tous les appels précédents de la saga si l’une des actions mentionnées dans le premier argument est distribuée.

D’après les mots de la docs de redux-saga :

takeLatest(pattern, saga, ...args)

Crée une saga pour chaque action distribuée au magasin qui correspond au motif. Et annule automatiquement toute tâche saga lancée précédemment si elle est toujours en cours.

    • Chaque fois qu’une action est envoyée au magasin. Et si cette action correspond au modèle, takeLatest lance une nouvelle tâche saga en arrière-plan. Si une tâche saga

a été lancée précédemment (sur la dernière action distribuée avant l’action actuelle), et si cette tâche est toujours en cours, la tâche sera annulée.

    • motif : String | Array | Function – pour plus d’informations, voir la documentation de [take(pattern)](https://redux-saga.js.org/docs/api/#takepattern)
    • saga :Function

– une fonction Generator

  • args :Tableau – arguments à passer à la tâche lancée. takeLatest ajoutera l’action entrante à la liste des arguments (c’est-à-dire que l’action sera le dernier argument fourni à saga

)

Maintenant, créons une saga ouvrière appelée moveSaga qui va effectivement distribuer les actions au magasin Redux :

export function* moveSaga(params : {
    type : string ;
    charge utile : ISnakeCoord ;
  }) : Générateur<
    | PutEffect<{ type : string ; payload : ISnakeCoord }>
    | PutEffect<{ type : string ; payload : string }>
    | CallEffect
  > {
    while (true) {
	//dispatche les actions de mouvement
	 yield put({
           type : params.type.split("_")[1],
           payload : params.payload,
	  }) 

      //Dispatche l'action SET_DIS_DIRECTION
      switch (params.type.split("_")[1]) {
        cas DROIT :
          yield put(setDisDirection(LEFT)) ;
          break ;

        cas GAUCHE :
          yield put(setDisDirection(RIGHT)) ;
          pause ;

        cas UP :
          yield put(setDisDirection(DOWN)) ;
          pause ;

        cas DOWN :
          yield put(setDisDirection(UP)) ;
          pause ;
      }
      yield delay(100) ;
    }
  }
La saga du travailleur

La saga worker moveSaga exécute les fonctions suivantes :

  1. Il s’exécute à l’intérieur d’une boucle infinie.
  2. Ainsi, une fois qu’une direction est donnée – c’est-à-dire si la touche d est enfoncée et que l’action MOVE_RIGHT est envoyée – elle commence à envoyer la même action jusqu’à ce qu’une nouvelle action (c’est-à-dire une direction) soit donnée. Ceci est géré par le snippet ci-dessous :
Voir aussi :  React State pour les débutants absolus
yield put({
    type : params.type.split("_")[1],
    payload : params.payload,
}) ;
Répartition des actions de mouvement

3. Une fois que l’action ci-dessus est envoyée, nous définissons la direction interdite dans la direction opposée, ce qui est pris en charge par le créateur de l’action setDisDirection.

Maintenant, assemblons ces sagas dans notre fichier sagas/index.ts:

import {
    CallEffect,
    delay,
    put,
    PutEffect,
    takeLatest
} de "redux-saga/effets" ;
import {
    DOWN,
    ISnakeCoord,
    LEFT,
    MOVE_DOWN,
    MOVE_LEFT,
    MOVE_RIGHT,
    MOVE_UP, RIGHT,
    setDisDirection, UP
} de "../actions" ;
  
  export function* moveSaga(params : {
    type : string ;
    charge utile : ISnakeCoord ;
  }) : Générateur<
    | PutEffect<{ type : string ; payload : ISnakeCoord }>
    | PutEffect<{ type : string ; payload : string }>
    | CallEffect
  > {
    while (true) {
      yield put({
        type : params.type.split("_")[1],
        payload : params.payload,
      }) ;
      switch (params.type.split("_")[1]) {
        cas DROIT :
          yield put(setDisDirection(LEFT)) ;
          break ;
  
        cas GAUCHE :
          yield put(setDisDirection(RIGHT)) ;
          pause ;
  
        cas UP :
          yield put(setDisDirection(DOWN)) ;
          pause ;
  
        cas DOWN :
          yield put(setDisDirection(UP)) ;
          pause ;
      }
      yield delay(100) ;
    }
  }
  
  function* watcherSagas() {
    yield takeLatest(
      [MOVE_RIGHT, MOVE_LEFT, MOVE_UP, MOVE_DOWN],
      moveSaga
    ) ;
  }
  
  export default watcherSagas ;
Notre fichier saga

Mettons maintenant à jour notre composant CanvasBoard pour intégrer ces changements.

//Importation des modules nécessaires
import { useSelector } de "react-redux" ;
import { drawObject, generateRandomPosition } de "../utils" ;

export interface ICanvasBoard {
    height : nombre ;
    width : nombre ;
}

const CanvasBoard = ({ height, width } : ICanvasBoard) => {
    const canvasRef = useRef < HTMLCanvasElement | null > (null) ;
    const [context, setContext] = useState < CanvasRenderingContext2D | null > (null) ;
    const snake1 = useSelector((state : IGlobalState) => state.snake) ;
    const [pos, setPos] = useState < IObjectBody > (
        generateRandomPosition(width - 20, height - 20)
    ) ;

    const moveSnake = useCallback(
        (dx = 0, dy = 0, ds : string) => {
            si (dx > 0 && dy === 0 && ds !== "RIGHT") {
                dispatch(makeMove(dx, dy, MOVE_RIGHT)) ;
            }

            if (dx < 0 && dy === 0 && ds !== "LEFT") {
                dispatch(makeMove(dx, dy, MOVE_LEFT)) ;
            }

            si (dx === 0 && dy < 0 && ds !== "UP") {
                dispatch(makeMove(dx, dy, MOVE_UP)) ;
            }

            if (dx === 0 && dy > 0 && ds !== "DOWN") {
                dispatch(makeMove(dx, dy, MOVE_DOWN)) ;
            }
        },
        [dispatch]
    ) ;

    const handleKeyEvents = useCallback(
        (event : KeyboardEvent) => {
            if (disallowedDirection) {
                switch (event.key) {
                    cas "w" :
                        moveSnake(0, -20, disallowedDirection) ;
                        pause ;
                    cas "s" :
                        moveSnake(0, 20, disallowedDirection) ;
                        pause ;
                    cas "a" :
                        moveSnake(-20, 0, disallowedDirection) ;
                        pause ;
                    cas "d" :
                        event.preventDefault() ;
                        moveSnake(20, 0, disallowedDirection) ;
                        pause ;
                }
            } else {
                si (
                    disallowedDirection !== "LEFT" &&
                    disallowedDirection !== "UP" &&
                    disallowedDirection !== "DOWN" &&
                    event.key === "d"
                )
                    moveSnake(20, 0, disallowedDirection) ; //Déplacement vers la DROITE au départ
            }
        },
        [disallowedDirection, moveSnake]
    ) ;
    useEffect(() => {
        //Dessinez sur le canevas à chaque fois
        setContext(canvasRef.current && canvasRef.current.getContext("2d")) ; //stocke dans la variable d'état
					clearBoard(context) ;
        drawObject(context, snake1, "#91C483") ; //Dessine le serpent à la position requise
    }, [context]) ;

    useEffect(() => {
        window.addEventListener("keypress", handleKeyEvents) ;

        return () => {
            window.removeEventListener("keypress", handleKeyEvents) ;
        } ;
    }, [disallowedDirection, handleKeyEvents]) ;

    retour (
       
    ) ;
} ;
Mise à jour du composant CanvasBoard avec le déplacement du serpent

Une fois que vous avez effectué ces changements, vous pouvez essayer de déplacer le serpent. Et voilà ! Vous verrez le résultat suivant :

ezgif.com-gif-maker--3-
Déplacement du serpent sur le tableau

Dessiner le fruit à une position aléatoire

Pour dessiner un fruit à une position aléatoire sur le plateau, nous allons utiliser la fonction utilitaire generateRandomPosition. Jetons un coup d’œil à cette fonction :

function randomNumber(min : nombre, max : nombre) {
  let random = Math.random() * max ;
  retourne random - (random % 20) ;
}
export const generateRandomPosition = (width : number, height : number) => {
  return {
    x : randomNumber(0, width),
    y : randomNumber(0, height),
  } ;
} ;
Générer des coordonnées x et y aléatoires sur le plateau de jeu

Il s’agit d’une fonction qui va générer des coordonnées x et y aléatoires en multiples de 20. Ces coordonnées seront toujours inférieures à la largeur et à la hauteur du tableau. Elle accepte la largeur et la hauteur comme arguments.

Une fois que nous avons cette fonction, nous pouvons l’utiliser pour dessiner le fruit à une position aléatoire dans le tableau.

Tout d’abord, créons une variable d’état pos qui sera initialement constituée d’une position aléatoire.

const [pos, setPos] = useState(generateRandomPosition(width - 20, height - 20)) ;
Génération d’une variable d’état de coordonnées aléatoires

Ensuite, nous allons dessiner le fruit via notre fonction drawObject. Après cela, nous mettrons légèrement à jour notre hook useEffect:

 useEffect(() => {
        //Dessinez sur le canevas à chaque fois
        setContext(canvasRef.current && canvasRef.current.getContext("2d")) ; //stocke dans la variable d'état
        
        clearBoard(context) ;
        
        drawObject(context, snake1, "#91C483") ; //Dessine le serpent à la position requise
        
        drawObject(context, [pos], "#676FA3") ; //Dessine l'objet au hasard
    }, [context]) ;
Dessine des fruits au hasard avec le serpent

Une fois que nous aurons effectué ces changements, notre tableau ressemblera à ce qui suit :

snake_fruit
Serpent et fruits dessinés sur le tableau

Calculateur de score

Le score du jeu est calculé en fonction du nombre de fruits que le serpent a consommés sans se heurter à lui-même ou à la limite de la boîte. Si le serpent consomme le fruit, la taille du serpent augmente. S’il entre en collision avec le bord de la boîte, alors le jeu est terminé.

Maintenant que nous savons quels sont nos critères pour calculer le score, voyons comment calculer la récompense.

Calcul de la récompense

La récompense après que le serpent ait consommé le fruit est la suivante :

  1. Augmente la taille du serpent.
  2. Augmente le score.
  3. Place le nouveau fruit à un autre endroit aléatoire.

Si le serpent consomme le fruit, alors nous devons augmenter la taille du serpent. C’est une tâche très simple, nous pouvons simplement ajouter les nouvelles coordonnées x et y qui sont inférieures à 20 du dernier élément du tableau d’état global du serpent. Par exemple, si le serpent a les coordonnées suivantes :

{
serpent : [
    { x : 580, y : 300 },
    { x : 560, y : 300 },
    { x : 540, y : 300 },
    { x : 520, y : 300 },
    { x : 500, y : 300 },
  ],
}

Nous devrions simplement ajouter l’objet suivant dans le tableau du serpent : { x : 480, y : 280 }

De cette façon, nous augmentons la taille du serpent et nous ajoutons la nouvelle partie/bloc à la fin de celui-ci. Pour que cela soit mis en œuvre via Redux et redux-saga, nous aurons besoin de l’action et du créateur d’action suivants :

export const INCREMENT_SCORE = "INCREMENT_SCORE" ; //action

export const increaseSnake = () => ({ //action creator
    type : INCREASE_SNAKE
  }) ;

Nous allons également mettre à jour notre gameReducer pour tenir compte de ces changements. Nous allons ajouter le cas suivant :

cas INCREASE_SNAKE :
      const snakeLen = state.snake.length ;
      return {
        ...état,
        serpent : [
          ...état.serpent,
          {
            x : state.snake[snakeLen - 1].x - 20,
            y : state.snake[snakeLen - 1].y - 20,
          },
        ],
      } ;

Dans notre composant CanvasBoard, nous allons d’abord introduire une variable d’état appelée isConsumed. Cette variable va vérifier si le fruit est consommé ou non.

const [isConsumed, setIsConsumed] = useState(false) ;

Dans notre hook useEffect où nous dessinons notre serpent et le fruit juste en dessous, nous allons ajouter la condition suivante :

//Quand l'objet est consommé
    if (snake1[0].x === pos ?.x && snake1[0].y === pos ?.y) {
      setIsConsumed(true) ;
    }

La condition ci-dessus vérifiera si la tête du serpent snake [0] est égale à la pos, ou la position du fruit. Si c’est le cas, alors la variable d’état isConsumed sera mise à true.

Une fois le fruit consommé, nous devons augmenter la taille du serpent. Nous pouvons le faire facilement via un autre useEffect. Créons un autre useEffect et appelons le créateur d’action increaseSnake:

//utilisationEffect2
useEffect(() => {
    if (isConsumed) {
      //Augmente la taille du serpent lorsque l'objet est consommé avec succès
      dispatch(increaseSnake()) ;
    }
  }, [isConsumed]) ;

Maintenant que nous avons augmenté la taille du serpent, voyons comment nous pouvons mettre à jour le score et générer un nouveau fruit à une autre position aléatoire.

Pour générer un nouveau fruit à une autre position aléatoire, nous mettons à jour la variable d’état pos qui va réexécuter le useEffect1 et dessiner l’objet à pos. Nous devons mettre à jour notre useEffect1 avec une nouvelle dépendance de pos et mettre à jour useEffect2 comme suit :

useEffect(() => {
    //Génère un nouvel objet
    if (isConsumed) {
      const posi = generateRandomPosition(width - 20, height - 20) ;
      setPos(posi) ;
      setIsConsumed(false) ;

      //Augmente la taille du serpent lorsque l'objet est consommé avec succès
      dispatch(increaseSnake()) ;
    }
  }, [isConsumed, pos, height, width, dispatch]) ;

Une dernière chose à faire dans ce système de récompense est de mettre à jour le score chaque fois que le serpent mange le fruit. Pour ce faire, suivez les étapes ci-dessous :

  1. Introduire une nouvelle variable d’état globale appelée score. Mettez à jour notre état global comme ci-dessous dans le fichier reducers/index.ts:
export interface IGlobalState {
  serpent : ISnakeCoord[] | [] ;
  disallowedDirection : string ;
  score : nombre ;
}

const globalState : IGlobalState = {
  serpent : [
    { x : 580, y : 300 },
    { x : 560, y : 300 },
    { x : 540, y : 300 },
    { x : 520, y : 300 },
    { x : 500, y : 300 },
  ],
  disallowedDirection : "",
  score : 0,
} ;

2. Créez l’action et le créateur d’action suivants dans notre fichier actions/index.ts:

export const INCREMENT_SCORE = "INCREMENT_SCORE" ; //action

//action créateur :
export const scoreUpdates = (type : string) => ({
  type
}) ;

3. Ensuite, nous mettons à jour notre réducteur pour gérer l’action INCREMENT_SCORE. Cela va simplement incrémenter le score global de l’état de un.

cas INCREMENT_SCORE :
      return {
        ...état,
        score : state.score + 1,
      } ;

4. Ensuite, nous mettons à jour notre état de score, en envoyant l’action INCREMENT_SCORE chaque fois que le serpent attrape le fruit. Pour cela, nous pouvons mettre à jour notre useEffect2 comme suit :

useEffect(() => {
    //Génère un nouvel objet
    if (isConsumed) {
      const posi = generateRandomPosition(width - 20, height - 20) ;
      setPos(posi) ;
      setIsConsumed(false) ;

      //Augmente la taille du serpent lorsque l'objet est consommé avec succès
      dispatch(increaseSnake()) ;

      //Incrémente le score
      dispatch(scoreUpdates(INCREMENT_SCORE)) ;
    }
  }, [isConsumed, pos, height, width, dispatch]) ;

5. Enfin, nous créons un composant appelé ScoreCard. Il affichera le score actuel du joueur. Nous allons le stocker dans le fichier components/ScoreCard.tsx.

import { Heading } de "@chakra-ui/react" ;
import { useSelector } de "react-redux" ;
import { IGlobalState } de "../store/reducers" ;

const ScoreCard = () => {
    const score = useSelector((state : IGlobalState) => state.score) ;
    return (
       Score actuel : {score}
    ) ;
}

export default ScoreCard ;

Ensuite, nous devrons également ajouter le composant ScoreCard dans le fichier App.tsx pour l’afficher sur notre page.

import { ChakraProvider, Container, Heading } from "@chakra-ui/react" ;
import { Provider } de "react-redux" ;
import CanvasBoard de "./components/CanvasBoard" ;
import ScoreCard de "./components/ScoreCard" ;
import store de "./store" ;

const App = () => {
  return (
    
      
        
          JEU DU SERPENT
          
          
        
      
    
  ) ;
} ;

export default App ;

Une fois que tout est en place, notre serpent aura un système de récompense complet qui augmente la taille du serpent pour mettre à jour le score.

ezgif.com-gif-maker--4-
Le joueur joue au serpent avec mise à jour du score et de la longueur du serpent.

Détection des collisions

Dans cette section, nous allons voir comment implémenter la détection de collision pour notre jeu de serpent.

Dans notre jeu Snake, si une collision est détectée, le jeu est terminé, c’est-à-dire qu’il s’arrête. Il y a deux conditions pour que les collisions se produisent :

  1. Le serpent entre en collision avec les limites de la boîte, ou
  2. Le serpent entre en collision avec lui-même.

Examinons la première condition. Supposons que la tête du serpent touche les limites de la boîte. Dans ce cas, nous arrêtons immédiatement le jeu.

Pour que cette condition soit intégrée à notre jeu, nous devons procéder comme suit :

  1. Créez une action et un créateur d’action comme ci-dessous :
export const STOP_GAME = "STOP_GAME" ; //action

//créateur d'action
export const stopGame = () => ({
  type : STOP_GAME
}) ;

2. Nous devons également mettre à jour notre fichier sagas/index.ts. Nous allons nous assurer que saga arrête de distribuer des actions lorsque l’action STOP_GAME est rencontrée.

export function* moveSaga(params : {
  type : string ;
  payload : ISnakeCoord ;
}) : Générateur<
  | PutEffect<{ type : string ; payload : ISnakeCoord }>
  | PutEffect<{ type : string ; payload : string }>
  | CallEffect
> {
  while (params.type !== STOP_GAME) {
    yield put({
      type : params.type.split("_")[1],
      payload : params.payload,
    }) ;
    switch (params.type.split("_")[1]) {
      cas DROIT :
        yield put(setDisDirection(LEFT)) ;
        break ;

      cas GAUCHE :
        yield put(setDisDirection(RIGHT)) ;
        pause ;

      cas UP :
        yield put(setDisDirection(DOWN)) ;
        pause ;

      cas DOWN :
        yield put(setDisDirection(UP)) ;
        pause ;
    }
    yield delay(100) ;
  }
}

function* watcherSagas() {
  yield takeLatest(
    [MOVE_RIGHT, MOVE_LEFT, MOVE_UP, MOVE_DOWN, STOP_GAME],
    moveSaga
  ) ;
}

3. Enfin, nous devons mettre à jour notre useEffect1 en ajoutant la condition suivante :

if ( //Check si la tête du serpent est hors des limites de l'obox
      snake1[0].x >= largeur ||
      snake1[0].x <= 0 ||
      snake1[0].y <= 0 ||
      snake1[0].y >= hauteur
    ) {
      setGameEnded(true) ;
      dispatch(stopGame()) ;
      window.removeEventListener("keypress", handleKeyEvents) ;
    }

Nous supprimons également l’écouteur d’événements handleKeyEvents. Cela permettra de s’assurer qu’une fois le jeu terminé, le joueur ne pourra plus déplacer le serpent.

Enfin, voyons comment nous pouvons détecter l’auto-collision du serpent. Nous allons utiliser une fonction utilitaire appelée hasSnakeCollided. Elle accepte deux paramètres : le premier est le tableau de serpents, et le second est la tête du serpent. Si la tête du serpent touche une partie de lui-même, elle renvoie true ou false.

La fonction hasSnakeCollided ressemblera à ce qui suit :

export const hasSnakeCollided = (
  serpent : IObjectBody[],
  currentHeadPos : IObjectBody
) => {
  let flag = false ;
  snake.forEach((pos : IObjectBody, index : nombre) => {
    si (
      pos.x === currentHeadPos.x &&
      pos.y === currentHeadPos.y &&
      index !== 0
    ) {
      flag = true ;
    }
  }) ;

  retourne le drapeau ;
} ;

Nous devrons peut-être légèrement mettre à jour notre useEffect1 en mettant à jour la condition de détection de collision comme ceci :

si ( 
      //Chèque si le serpent est entré en collision avec lui-même 
      hasSnakeCollided(snake1, snake1[0]) ||
      
      //Contrôle si la tête du serpent est en dehors des limites de l'obox
      snake1[0].x >= largeur ||
      snake1[0].x <= 0 ||
      snake1[0].y <= 0 ||
      snake1[0].y >= hauteur
    ) {
      setGameEnded(true) ;
      dispatch(stopGame()) ;
      window.removeEventListener("keypress", handleKeyEvents) ;
    }

Notre useEffect1 ressemblera finalement à ce qui suit :

//utiliserEffet1
useEffect(() => {
    //Dessinez sur le canevas à chaque fois
    setContext(canvasRef.current && canvasRef.current.getContext("2d")) ;
    clearBoard(context) ;
    drawObject(context, snake1, "#91C483") ;
    drawObject(context, [pos], "#676FA3") ; //Drawn object randomly

    //Quand l'objet est consommé
    if (snake1[0].x === pos ?.x && snake1[0].y === pos ?.y) {
      setIsConsumed(true) ;
    }

    if (
      hasSnakeCollided(snake1, snake1[0]) ||
      snake1[0].x >= largeur ||
      snake1[0].x <= 0 ||
      snake1[0].y <= 0 ||
      snake1[0].y >= hauteur
    ) {
      setGameEnded(true) ;
      dispatch(stopGame()) ;
      window.removeEventListener("keypress", handleKeyEvents) ;
    } else setGameEnded(false) ;
  }, [context, pos, snake1, height, width, dispatch, handleKeyEvents]) ;

Notre jeu ressemblera à ce qui suit une fois que nous aurons ajouté le système de détection des collisions :

ezgif.com-gif-maker--5-
Détection des collisions

Composant d’instruction

Nous sommes maintenant dans la phase finale du jeu ! Notre dernier composant sera le composant Instruction. Il comprendra des instructions sur le jeu, comme la condition initiale du jeu, les clés à utiliser et un bouton de réinitialisation.

Commençons par créer un fichier appelé components/Instructions.tsx. Placez le code ci-dessous dans ce fichier :

import { Box, Button, Flex, Heading, Kbd } de "@chakra-ui/react" ;

export interface IInstructionProps {
  resetBoard : () => void ;
}
const Instruction = ({ resetBoard } : IInstructionProps) => (
  
    
      Comment jouer
    
    
    NOTE : Commencez le jeu en appuyant sur d
    
    
      
        
         w Déplacement vers le haut
        
        
         a Déplacement vers la gauche
        
        
         s Déplacement vers le bas
        
        
         d Déplacement vers la droite
        
      
      
        
      
    
  
) ;

export default Instruction 

Le composant Instruction acceptera resetBoard comme prop qui est une fonction qui aidera l’utilisateur lorsque le jeu sera terminé ou lorsqu’il voudra réinitialiser le jeu.

Avant de nous plonger dans la fonction resetBoard, nous devons effectuer les mises à jour suivantes dans notre magasin Redux et notre saga :

  1. Ajoutez l’action et le créateur d’action suivants dans le fichier actions/index.ts:
export const RESET_SCORE = "RESET_SCORE" ; //action
export const RESET = "RESET" ; //action

//Action creator :
export const resetGame = () => ({
  type : RESET
}) ;

2. Ajoutez ensuite la condition suivante dans notre fichier sagas/index.ts. Nous allons nous assurer que saga cesse de distribuer des actions lorsque les actions RESET et STOP_GAME sont rencontrées.

export function* moveSaga(params : {
  type : string ;
  payload : ISnakeCoord ;
}) : Générateur<
  | PutEffect<{ type : string ; payload : ISnakeCoord }>
  | PutEffect<{ type : string ; payload : string }>
  | CallEffect
> {
  while (params.type !== RESET && params.type !== STOP_GAME) {
    yield put({
      type : params.type.split("_")[1],
      payload : params.payload,
    }) ;
    switch (params.type.split("_")[1]) {
      cas DROIT :
        yield put(setDisDirection(LEFT)) ;
        break ;

      cas GAUCHE :
        yield put(setDisDirection(RIGHT)) ;
        pause ;

      cas UP :
        yield put(setDisDirection(DOWN)) ;
        pause ;

      cas DOWN :
        yield put(setDisDirection(UP)) ;
        pause ;
    }
    yield delay(100) ;
  }
}

function* watcherSagas() {
  yield takeLatest(
    [MOVE_RIGHT, MOVE_LEFT, MOVE_UP, MOVE_DOWN, RESET, STOP_GAME],
    moveSaga
  ) ;
}

3. Enfin, nous mettons à jour notre fichier reducers/index.ts pour le cas RESET_SCORE comme suit :

cas RESET_SCORE :
      return { ...state, score : 0 } ;

Une fois que nos sagas et nos reducers sont mis à jour, nous pouvons jeter un coup d’œil aux opérations que le callback resetBoard effectuera.

La fonction resetBoard effectue les opérations suivantes :

  1. Supprime l’écouteur d’événements handleKeyEvents
  2. exécute les actions nécessaires à la réinitialisation du jeu.
  3. Distribue l’action pour réinitialiser le score.
  4. Nettoie le canevas.
  5. Dessine à nouveau le serpent à sa position initiale
  6. Dessine le fruit à une nouvelle position aléatoire.
  7. Enfin, ajoute l’écouteur d’événements handleKeyEvents pour l’événement d’appui sur la touche.

Voici à quoi ressemblera notre fonction resetBoard:

const resetBoard = useCallback(() => {
    window.removeEventListener("keypress", handleKeyEvents) ;
    dispatch(resetGame()) ;
    dispatch(scoreUpdates(RESET_SCORE)) ;
    clearBoard(context) ;
    drawObject(context, snake1, "#91C483") ;
    drawObject(
      contexte,
      [generateRandomPosition(width - 20, height - 20)],
      "#676FA3"
    ) ; //Dessine l'objet de façon aléatoire
    window.addEventListener("keypress", handleKeyEvents) ;
  }, [context, dispatch, handleKeyEvents, height, snake1, width]) ;

Vous devez placer cette fonction à l’intérieur du composant CanvasBoard et passer la fonction resetBoard en tant que prop à la fonction Instruction comme ci-dessous :

<>
      
      
    

Une fois cette fonction placée, le composant Instruction sera configuré comme ci-dessous :

image-17
Instructions avec bouton de réinitialisation

Jeu final

Si vous avez suivi jusqu’à ce point, alors félicitations ! Vous avez réussi à créer un jeu de serpent amusant avec React, Redux et redux-sagas. Une fois que tous ces éléments sont connectés, votre jeu ressemblera à ce qui suit :

ezgif.com-gif-maker--2--1
Le jeu de serpent complet

Résumé

Voilà donc comment vous pouvez construire un jeu Snake à partir de rien. Vous pouvez trouver le code source complet du jeu dans le dépôt ci-dessous :

https://github.com/keyurparalkar/snake-game

Si vous avez aimé l’idée de construire votre propre jeu Snake à partir de rien, vous pouvez passer à la vitesse supérieure en apportant ces améliorations :

  • Construisez le jeu de serpent avec three.js
  • Ajouter un tableau des scores en ligne

Merci de votre lecture !

Suivez-moi sur Twitter, GitHub et LinkedIn.