Angular : Et si on développait un jeu du morpion ?
[content-egg module=Amazon template=list]
Dans ce nouveau tutoriel, je vais vous montrer comment développer un jeu du morpion avec le Framework Angular. Le principe de jeu est d’une simplicité enfantine et le coder tout autant. Je vous jure, vous allez voir.
Ce tutoriel est réservé aux personnes ayant déjà quelques bases avec Angular et qui disposent d’un environnement de développement déjà prêt. Dans un prochain article, j’expliquerais comment installer tous les outils nécessaires pour créer un projet Angular.
Ce tutoriel a pour but de vous montrer qu’il est possible de développer des choses amusantes comme un jeu du morpion avec le Framework Angular. Si vous êtes débutant et que vous souhaitez progresser, essayez de suivre chaque étape de ce tutoriel et de coder le jeu par vous-même. On n’apprend pas en faisant de simples copier-coller mais en codant, encore et toujours. En cas de problèmes, ou pour les curieux, vous pourrez trouver toutes les sources de ce projet sur mon Github.
Les classes Joueur et Case
Pour jouer au morpion, on a besoin de deux joueurs, cela va de soi, mais aussi de neufs cases où dessiner les ronds et les croix. Et c’est pour cela qu’on va commencer par créer les classes Joueur et Case.
Classe Joueur
export class Joueur { computer = false; }
Comme son nom l’indique, cette classe représente le joueur. Elle contient la variable computer qui nous indique si le joueur est humain ou non. Par la suite, on peut imaginer y ajouter le nom, le score etc.
Classe Case
export class Case { value = ''; public setValue(val: string) { this.value = val; } }
Cette classe représente les cases du morpion. La variable value nous indique la valeur de la case qu’on alimente à l’aide de la méthode setValue(). Rien de bien compliqué pour le moment.
Le moteur du jeu
Passons maintenant aux choses sérieuses avec le moteur du jeu : GameService
export class GameService { j1: Joueur; j2: Joueur; cases = []; nbCasesVides: number; tour: number; draw = false; constructor() { this.initGame(); } initGame() { this.nbCasesVides = 9; this.tour = 0; this.draw = false; // initialisation des cases this.cases = []; for (let i = 0; i < 9; i++) { const oCase = new Case(); oCase.setValue(''); this.cases.push(oCase); } // initialisation des joueurs this.j1 = new Joueur(); // le joueur 2 sera l'IA this.j2 = new Joueur(); this.j2.computer = true; } getResultats() { if (this.draw) { return "Match null"; } else { if (this.tour === 0) { return "Gagné !"; } else { return "Perdu :-("; } } } changementDeJoueur(): number { if (this.tour === 0) { this.tour = 1; return 1; } else { this.tour = 0; return 0; } } // Méthode qui vérifie si une combinaison est gagnante isGameWin() { const c1 = this.cases[0].value; const c2 = this.cases[1].value; const c3 = this.cases[2].value; const c4 = this.cases[3].value; const c5 = this.cases[4].value; const c6 = this.cases[5].value; const c7 = this.cases[6].value; const c8 = this.cases[7].value; const c9 = this.cases[8].value; const cond1 = c1 && c2 && c3 && (c1 === c2 && c2 === c3); const cond2 = c4 && c5 && c6 && (c4 === c5 && c5 === c6); const cond3 = c7 && c8 && c9 && (c7 === c8 && c8 === c9); const cond4 = c1 && c4 && c7 && (c1 === c4 && c4 === c7); const cond5 = c2 && c5 && c8 && (c2 === c5 && c5 === c8); const cond6 = c3 && c6 && c9 && (c3 === c6 && c6 === c9); const cond7 = c1 && c5 && c9 && (c1 === c5 && c5 === c9); const cond8 = c3 && c5 && c7 && (c3 === c5 && c5 === c7); if (cond1 || cond2 || cond3 || cond4 || cond5 || cond6 || cond7 || cond8) { return true; } else { return false; } } // Mouvement de l'IA computerMove(): number { let move = this.getNextPlayWin('R'); if (move === 0) { move = this.blockPlayerWin(); } if (move === 0) { move = Math.floor(Math.random() * 8) + 1; } return move; } // IA : Bloque un coup gagnant du joueur blockPlayerWin(): number { const c1 = this.cases[0].value; const c2 = this.cases[1].value; const c3 = this.cases[2].value; const c4 = this.cases[3].value; const c5 = this.cases[4].value; const c6 = this.cases[5].value; const c7 = this.cases[6].value; const c8 = this.cases[7].value; const c9 = this.cases[8].value; if (c1 && c2 && !c3 && (c1 === c2)) { return 3; } if (!c1 && c2 && c3 && (c2 === c3)) { return 1; } if (c1 && !c2 && c3 && (c1 === c3)) { return 2; } if (c4 && c5 && !c6 && (c4 === c5)) { return 6; } if (!c4 && c5 && c6 && (c5 === c6)) { return 4; } if (c4 && !c5 && c6 && (c4 === c6)) { return 5; } if (c7 && c8 && !c9 && (c7 === c8)) { return 9; } if (!c7 && c8 && c9 && (c8 === c9)) { return 7; } if (c7 && !c8 && c9 && (c7 === c9)) { return 8; } if (c1 && c4 && !c7 && (c1 === c4)) { return 7; } if (!c1 && c4 && c7 && (c4 === c7)) { return 1; } if (c1 && !c4 && c7 && (c1 === c7)) { return 4; } if (c2 && c5 && !c8 && (c2 === c5)) { return 8; } if (!c2 && c5 && c8 && (c5 === c8)) { return 2; } if (c2 && !c5 && c8 && (c2 === c8)) { return 5; } if (c3 && c6 && !c9 && (c3 === c6)) { return 9; } if (!c3 && c6 && c9 && (c6 === c9)) { return 3; } if (c3 && !c6 && c9 && (c3 === c9)) { return 6; } if (c1 && c5 && !c9 && (c1 === c5)) { return 9; } if (!c1 && c5 && c9 && (c5 === c9)) { return 1; } if (c1 && !c5 && c9 && (c1 === c9)) { return 5; } if (c3 && c5 && !c7 && (c3 === c5)) { return 7; } if (!c3 && c5 && c7 && (c5 === c7)) { return 3; } if (c3 && !c5 && c7 && (c3 === c7)) { return 5; } return 0; } // IA : Coche la case pour un coup gagnant getNextPlayWin(p: string): number { const c1 = this.cases[0].value; const c2 = this.cases[1].value; const c3 = this.cases[2].value; const c4 = this.cases[3].value; const c5 = this.cases[4].value; const c6 = this.cases[5].value; const c7 = this.cases[6].value; const c8 = this.cases[7].value; const c9 = this.cases[8].value; if (c1 && c2 && !c3 && (c1 === c2 && c2 === p)) { return 3; } if (!c1 && c2 && c3 && (c2 === c3 && c3 === p)) { return 1; } if (c1 && !c2 && c3 && (c1 === c3 && c3 === p)) { return 2; } if (c4 && c5 && !c6 && (c4 === c5 && c5 === p)) { return 6; } if (!c4 && c5 && c6 && (c5 === c6 && c6 === p)) { return 4; } if (c4 && !c5 && c6 && (c4 === c6 && c6 === p)) { return 5; } if (c7 && c8 && !c9 && (c7 === c8 && c8 === p)) { return 9; } if (!c7 && c8 && c9 && (c8 === c9 && c9 === p)) { return 7; } if (c7 && !c8 && c9 && (c7 === c9 && c9 === p)) { return 8; } if (c1 && c4 && !c7 && (c1 === c4 && c4 === p)) { return 7; } if (!c1 && c4 && c7 && (c4 === c7 && c7 === p)) { return 1; } if (c1 && !c4 && c7 && (c1 === c7 && c7 === p)) { return 4; } if (c2 && c5 && !c8 && (c2 === c5 && c5 === p)) { return 8; } if (!c2 && c5 && c8 && (c5 === c8 && c8 === p)) { return 2; } if (c2 && !c5 && c8 && (c2 === c8 && c8 === p)) { return 5; } if (c3 && c6 && !c9 && (c3 === c6 && c6 === p)) { return 9; } if (!c3 && c6 && c9 && (c6 === c9 && c9 === p)) { return 3; } if (c3 && !c6 && c9 && (c3 === c9 && c9 === p)) { return 6; } if (c1 && c5 && !c9 && (c1 === c5 && c5 === p)) { return 9; } if (!c1 && c5 && c9 && (c5 === c9 && c9 === p)) { return 1; } if (c1 && !c5 && c9 && (c1 === c9 && c9 === p)) { return 5; } if (c3 && c5 && !c7 && (c3 === c5 && c5 === p)) { return 7; } if (!c3 && c5 && c7 && (c5 === c7 && c7 === p)) { return 3; } if (c3 && !c5 && c7 && (c3 === c7 && c7 === p)) { return 5; } return 0; } }
Comme vous pouvez le voir, il y a nettement plus de lignes de code que dans la première partie. Ce qui ne rend pas les choses plus compliquées, mais juste plus concrètes. Comme l’indique le titre de cette partie, le service GameService (vive les répétitions) représente le moteur du jeu. C’est donc ici qu’on va déclarer, initialiser et manipuler toutes les variables et objets nécessaires au bon fonctionnement du jeu.
Déclarations des variables
On commence par les déclarations de six variables. Les deux joueurs (j1 et j2), un tableau de cases, l’entier nbCasesVides qui indique le nombre de cases vides restantes en jeu, l’entier tour qui indique qui doit jouer et le booléen draw qui nous informe s’il y a match null ou non entre les deux joueurs.
Initialisation
Dans le constructeur du service, on appelle la méthode initGame() qui initialise toutes les variables vues plus haut. Je ne vais pas tout vous détailler, le code parle de lui-même, mais pour le tableau de cases, on boucle neuf fois pour créer un objet Case vide et le mettre à l’intérieur du tableau.
getResultats()
Cette méthode permet d’afficher le message en fin de partie. Si le booléen draw est à true, on affiche match null, sinon on affiche un message de victoire ou de défaite selon qui a joué le dernier coup
changementDeJoueur()
Tout est dans le titre. Cette méthode permet juste de changer de tour entre les joueurs.
isGameWin()
Pour les différentes méthodes qui vont suivre, sachez qu’il existe des moyens nettement plus efficaces pour savoir si une partie est gagnée ou prédire le prochain coup à jouer. Comme avec l’algorithme min-max par exemple. J’ai volontairement fait au plus simple pour mon exemple et l’optimisation de ce morpion pourrait très faire l’objet d’un prochain tutoriel.
Dans cette méthode, je liste les neuf possibilités qui existent pour faire une ligne et je regarde si les trois cases de chacune de ces possibilités contiennent la même valeur. C’est simple et efficace.
blockPlayerWin() et getNewtPlayWin()
Ces deux méthodes se ressemblent beaucoup. Elles permettent de bloquer une ligne gagnante de l’adversaire ou au contraire vérifier s’il est possible de faire une ligne gagnante au prochain coup. Là encore, on liste toutes les possibilités existantes et on vérifie le contenu des cases à la recherche d’une opportunité.
computerMove()
Cette méthode permet d’identifier le prochain coup de l’ordinateur. Il y a trois étapes. D’abord on vérifie s’il faut bloquer un éventuel coup gagnant de l’adversaire. Sinon on vérifie s’il y a un éventuel coup gagnant pour l’ordinateur et sinon on prend une case au hasard.
Tuto : Utiliser le DatePicker de Material au format de date française
Mise en place du morpion
Maintenant que les classes sont créées et que le moteur du jeu est prêt, il est temps de mettre tout ça en place pour matérialiser notre petit morpion et que ça puisse ressembler à ça :
Dans mon exemple, j’ai tout géré directement dans le composant principal de mon projet Angular et je n’ai pas créé de composant à part entière ou même de module. Ce qui aurait été une bonne chose à faire pour une question d’organisation et de lisibilité mais surtout pour la réutilisation du code. Mais là n’est pas le sujet
Angular – Partie HTML : app.component.html
<div class="container"> <div class="row"> <div class="col-md-12"> <h2>Morpion</h2> <hr> <div class="gameZone"> <div class="box" *ngFor="let case of cases; let i = index" [ngClass]="{'croix': case.value === 'C', 'rond': case.value === 'R', 'croixNext' : tour === 0 && case.value === '', 'computer' : tour === 1}" (click)="playerClick(i)"> </div> </div> </div> <div *ngIf="isGameOver" class="col-md-12 resultModal"> <div class="result"> <h3>Terminé</h3> <hr> <p>{{gameResultats}}</p> <button class="btn btn-info" (click)=restartGame()>Nouvelle partie</button> </div> </div> </div> </div>
Dans ce tutoriel, le plus important reste la partie angular, je ne vais donc pas trop m’attarder sur la partie CSS. Vous trouverez tout ce dont vous aurez besoin dans les sources et vous aurez certainement envie de donner une autre tête à votre propre morpion. Par contre, ce que je peux et dois vous dire, c’est que j’utilise CSS Grid pour afficher ma grille de jeu et que c’est à l’aide du CSS que j’affiche mes croix et mes ronds.
La grille
La grille du morpion est contenue dans la div « gameZone ». À l’intérieur de cette div, je boucle sur mon tableau de cases, à l’aide de la directive *ngFor, et je crée une div « box » par objet Case contenu dans mon tableau.
Concernant la mise en forme, j’utilise la directive NgClass pour attribuer un style prédéfinit à chacune de mes cases selon certaines conditions :
- Rien si la case n’a pas de valeur
- Une croix si la valeur est « C »
- Un rond si la valeur est « R »
- Un sens interdit pour le survol lorsque c’est le tour de l’ordinateur
- Une croix pour le survol lorsque c’est au tour du joueur de jouer
Enfin, j’appelle la méthode playerClick() avec le numéro de la case en paramètre dès qu’on clique sur l’une d’entre elles.
Les résultats
À la fin de la partie, j’affiche un bloc qui n’est visible que si la variable isGameOver est à true.
On trouve dans ce bloc un titre, l’appel à la variable gameResultats qui contient le texte à afficher ainsi qu’un bouton recommencer qui appelle la méthode restartGame()
Angular – Partie TypeScript : app.component.ts
Maintenant que nous avons vu la partie HTML, passons à la partie TypeScript (langage utilisé dans le framework angular) qui permet de faire le lien entre l’UX et le moteur du jeu. Là encore, il y a un peu plus de lignes, mais ce n’est toujours pas plus compliqué.
export class AppComponent { title = 'morpion'; cases = []; isGameOver = false; gameResultats = ''; tour: number; scoreJ1 = 0; scoreJ2 = 0; constructor(private gameService: GameService) { this.cases = this.gameService.cases; this.tour = this.gameService.tour; } restartGame() { this.gameService.initGame(); this.cases = this.gameService.cases; this.isGameOver = false; this.tour = 0; } playerClick(i: number) { if (this.gameService.tour === 0) { this.play(i); } } gameOver() { this.isGameOver = true; this.gameResultats = this.gameService.getResultats(); } play(i: number) { if (this.gameService.cases[i].value === '' && !this.isGameOver) { this.gameService.nbCasesVides -= 1; if (this.gameService.tour === 0) { this.gameService.cases[i].setValue('C'); } else { this.gameService.cases[i].setValue('R'); } if (this.gameService.isGameWin()) { this.gameOver(); return; } else { if (this.gameService.nbCasesVides === 0) { this.gameService.draw = true; this.gameOver(); return; } else { this.tour = this.gameService.changementDeJoueur(); if (this.gameService.tour === 1) { this.computerTurn(); } } } } else { return; } } computerTurn() { const move = this.gameService.computerMove() - 1; if (this.gameService.cases[move].value === '') { setTimeout(() => { this.play(move); }, 500); } else { this.computerTurn(); } } }
Les variables
Commençons par le commencement avec les variables. Ici aussi on déclare un tableau de cases, un booléen isGameOver, le champ texte gameResultats, l’entier tour et les deux joueurs.
Initialisation
Afin de pouvoir utiliser toutes les variables et méthodes du moteur de jeu, on injecte le service GameService dans le composant. Avec angular, c’est ce qu’on fait en tapant la ligne suivante en paramètre du constructeur : private gameService : GameService.
Ensuite, toujours dans le constructeur, on initialise les variables cases et tour avec celles du service.
restartGame()
Cette méthode permet de recommencer une partie en réinitialisant les variables à leur valeur de départ. On appelle cette méthode depuis le bloc des résultats.
playerClick()
Cette méthode est appelée dès lors qu’on clique sur une case du jeu. Si c’est au tour de l’ordinateur, il ne se passe rien, mais si c’est au tour du joueur, on appelle la méthode play() avec le numéro de la case en paramètre.
Play()
C’est sans doute la méthode la plus importante. Celle qui permet d’articuler tout le fonctionnement du jeu.
Dès lors que cette méthode est appelée, on vérifie si la case correspondante est bien vide et si la partie n’est pas terminée.
Si les conditions sont respectées, on décrémente la variable nbCasesVides et on attribue la valeur « C » ou « R » à la case selon qui vient de jouer.
Ensuite, on vérifie si quelqu’un a gagné avec la méthode isGameWin(). Si c’est le cas, on appelle la méthode gameOver() pour clore la partie, sinon on passe à la suite :
Si la nbCasesVides est à 0, la partie est terminée et personne n’a gagné. On affecte la valeur true à la variable draw et on appelle la méthode gameOver()
Sinon, on appelle la méthode changementDeJoueur() et si c’est au tour de l’ordinateur de jouer, on appelle la méthode computerTurn()
computerTurn()
C’est la méthode qui gère le choix de l’ordinateur.
On récupère le numéro de case à jouer avec la méthode computerMove(). Si la case correspondante n’est pas vide, on recommence, sinon on appelle la méthode play() avec un petit délai de 500ms pour donner une illusion de réflexion. Pour que ce soit moins mécanique.
C’est terminé
Si vous avez suivi ce tutoriel angular à la lettre et que je n’ai pas écrit trop de bêtises, vous devriez avoir un jeu du morpion parfaitement fonctionnel qui ressemble plus ou moins à ça. (Sans les gifs)
Comme je vous le disais un peu plus haut, le code est très simple et il est possible de faire pas mal d’optimisations, d’ajouter quelques fonctionnalités comme la gestion du score et aussi de rendre la chose un peu plus sexy visuellement. Mais ça, c’est surtout à vous de le faire. Et d’ailleurs, n’hésitez pas à venir le partager si jamais c’est le cas. De mon côté, je vais essayer de travailler sur V2 et je ne manquerai pas de mettre à jour l’article pour vous en faire part.
Pour ma part, je crois bien que c’est la première fois que j’écris un tutoriel aussi long. J’espère que la structure vous plait et qu’elle est suffisamment claire. Si jamais ce n’est pas le cas, vous pouvez vous lâcher dans les commentaires.
Commentaires
Laisser un commentaire