Aller au contenu

Anatomie d'un Démineur : recoder sa nostalgie en Angular

Nouvelle série estivale sur sfeir.dev : on démonte les jeux de notre enfance, on les reconstruit pièce par pièce… et on en profite pour explorer des frameworks modernes. Dans ce premier épisode, Anatomie d’un Démineur 🧨, je m’attelle à recoder ma nostalgie avec Angular

Remember me ? I am Démineur

Ah, le Démineur ! Ce petit jeu qui a offert une petite pause mentale rapide à tant d'employés du monde entier. Mais que faire quand les jeux de votre enfance disparaissent sans prévenir ? Résister, évidemment.

Présent depuis 1992 au sein de Windows 3.1 (#coup de vieux), disparu depuis Windows 8. Ce jeu est à présent trouvable via un Store et soumis à des publicités intempestives. Bref, le Démineur de mon enfance n'est plus. :'(


Entre nostalgie caféinée, frustration logicielle et curiosité de codeur, j’ai décidé de redonner vie au Démineur. L’occasion rêvée de transformer une pause en projet, un souvenir en side project.

Chronique d'une pause-café en péril

Ayant passé les 10 dernières années sur des postes Linux, ce n'est que récemment que j'ai découvert cette disparition.

Alors, comment surmonter ce deuil solitaire ? Comment retrouver ces quelques secondes de distraction nécessaires et préalables à une reconcentration dans des sujets abscons ?

Première idée : écouter la petite voix que j'ai dans la tête.

audio-thumbnail
Petite voix dans ma tête
0:00
/6.11

Aaaarggh ! Mauvaise idée. Essayons de n'en écouter qu'une à la fois.

audio-thumbnail
Unique petite voix dans ma tête
0:00
/6.5

Deuxième idée : ne pas écouter la petite voix que j'ai dans la tête...
Bon … reconcentrons-nous et posons-nous les bonnes questions :

  • Est-ce si grave que cela ? Assurément.
  • Cela m'empêche-t-il de travailler dans de bonnes conditions ? Absolument.

Partant de ces constats implacables, j'ai fait ce que toute personne sensée aurait fait :

  • I̶n̶s̶t̶a̶l̶l̶e̶r̶ ̶l̶a̶ ̶v̶e̶r̶s̶i̶o̶n̶ ̶g̶r̶a̶t̶u̶i̶t̶e̶ ̶e̶t̶ ̶a̶c̶c̶e̶p̶t̶e̶r̶ ̶d̶e̶s̶ ̶p̶l̶a̶g̶e̶s̶ ̶d̶e̶ ̶p̶u̶b̶l̶i̶c̶i̶t̶é̶s̶
  • A̶c̶h̶e̶t̶e̶r̶ ̶u̶n̶e̶ ̶v̶e̶r̶s̶i̶o̶n̶ ̶p̶a̶y̶a̶n̶t̶e̶ ̶p̶o̶u̶r̶ ̶u̶n̶e̶ ̶s̶o̶m̶m̶e̶ ̶d̶é̶r̶i̶s̶o̶i̶r̶e̶
  • Tout recoder moi-même et ainsi en profiter pour apprendre un nouveau langage et framework.

(Note de l'auteur : penser à remplacer “toute personne censée” par “tout développeur passionné et un peu maso”)

Pourquoi demander la permission quand on peut coder la solution ?

C'est décidé, ce démineur sera mon nouveau side project !

Après tout, il est vrai que je suis moi-même un peu hackeur et puis, je ne serais pas le premier à avoir comme motivation l'envie de jouer à un jeu vidéo !
Bref, ne jamais sous-estimer la puissance d'un développeur qui veut accéder à son jeu vidéo !

S'il n'y a aucune chance que cela mène à la création d'un système d'exploitation, au contraire de Ken Thompson, cela pourrait par contre me pousser à apprendre un framework voire approfondir ma connaissance d'un langage et c'est déjà une excellente raison en soi.

Sans oublier que c'est finalement l'essence même du développement : les projets naissent simplement parce que quelqu'un s'est dit "Je veux que ça existe, alors je vais le créer".

C'est parti pour la technique !

Enfin … en fait non. Essayons de faire les choses proprement et commençons par déterminer ce que l'on veut faire et comment on va le faire avant de le faire.

C'est parti pour ̶l̶a̶ ̶t̶e̶c̶h̶n̶i̶q̶u̶e̶ le cahier des charges !

Car en fait, un Démineur, c'est quoi ?
Bah, c'est ça !

Le niveau facile par lequel beaucoup ont commencé

Oui … mais plus précisément ?

Revenons aux fondamentaux

Le Démineur, c'est un jeu vidéo qui se joue à la souris et dont le but (condition de victoire) est de révéler toutes les cases inofensives en cliquant dessus tout en évitant de cliquer sur une mine (condition de défaite). Les mines étant cachées parmi les autres cases.
Pour atteindre notre objectif, toute case révélée (qui n'est pas une mine) affiche le nombre de mines parmi ses cases voisines.

L'idée est donc d'utiliser les informations données par les cases révélées afin de déterminer les positions des mines et de continuer à révéler des cases inoffensives.

Prenons un exemple.
Sur le plateau suivant, nous avons déjà révélé des cases qui affichent donc des chiffres (à l'exception du zéro qui ne sera pas affiché).

Après un premier clic

Or, nous pouvons constater que la case cachée en bas à gauche (plus exactement en 4ème colonne de la dernière ligne) est obligatoirement une mine.
En effet, la case en 3ème colonne de la dernière ligne affiche le chiffre 1 et cette case n'a plus, dans ses voisines, qu'une seule case cachée !
Pour nous aider à nous y retrouver entre les cases suspectes et les autres, nous sommes aidés de drapeaux que l'on placera avec un clic droit.

On identifie notre première mine

Grâce à cette nouvelle information, on sait que le "1" situé juste au-dessus du drapeau renseigne la mine déjà identifiée. Par conséquent, les 3 cases à droite, en haut à droite et en bas à droite ne sont pas des mines et on peut cliquer dessus sans crainte et progresser dans la découverte du plateau.

Voilà. On a tout:

  • des cases cachées sur lesquelles on peut cliquer;
  • chaque case est soit une mine soit non;
  • une fois révélée, une case qui n'est pas une mine affiche le nombre de mine qui est dans son voisinage;
  • on gagne si on découvre toutes les cases qui sont inofensives;
  • on perd si l'on clique sur une mine.

À présent, commençons à coder.
Le framework utilisé pour ce projet sera Angular.

C'est (re)parti pour la technique !

Vue globale

Dans les sections suivantes, nous allons décrire les composants, modèles et services de notre application.
Globalement, cette dernière s'appuiera sur 3 concepts fondamentaux : le Game (le jeu en lui-même), le Board (plateau de jeu) et le Tile (une tuile, c'est à dire, une case du plateau).

Petit graphique pour la vue générale

Les composants

Tout d'abord, nous allons découper notre application en plusieurs composants et définir les responsabilités de chacun.
Commençons par distinguer le jeu du plateau de case en lui-même.

Le Game sera le composant général qui contiendra

  • le Board décrit ci-dessous;
  • les boutons de changement de niveaux ou autres options (mais je spoile un peu).
<div style="text-align: center" #content>
  <br>
  <img src="../../assets/images/logo.png">
  <br>
  <br>
  <classical-board
    [rowsNumber]="10"
    [columnsNumber]="10"
    [minesNumber]="18"
    (notifyGameStatus)="updateGameStatus($event)"
    #minesweeper></classical-board>
  <p *ngIf="result == 'GAMEOVER'">Perdu !</p>
  <ul class="actions">
    <br>
    <li><span class="btn" (click)="startNewGame()">New game</span></li>
  </ul>
</div>

Exemple simplifié du template général

Le composant Board correspondra au plateau de jeu principal et contiendra

  • l'affichage du nombre de mine qu'il restera à trouver;
  • le chronomètre (non implémenté ici);
  • le bouton qui permettra de relancer une partie en cliquant dessus;
  • le plateau de cases.

Il peut donc prendre en entrée les nombres de lignes, colonnes et mines et en générer un tableau de cases/tuiles qui sera ensuite affiché dans son fichier template.

<div class="scene">
  <div class="board">
    <div class="banner">
      <div class="box right">{{ minesNumber - flagsNumber }}</div>
      <div class="box left">{{ count }}</div>
    </div>
    <div *ngFor="let row of tiles" class="row">
      <tile *ngFor="let tile of row" [tile]="tile" (click)="handleTileClick(tile)" (contextmenu)="onRightClick(tile)"></tile>
    </div>
  </div>
</div>

Outre une bannière, on affiche simplement un tableau 2D des tuiles

Le composant Tile correspondra, quant à lui, à une case et peut prendre en entrée l'objet qu'il est censé représenter.

<div class="tile" [class.mine]="tile.isRevealed && tile.isMine" [class.flagged]="tile.isFlagged" [class.revealed]="tile.isRevealed">
  <div class="lid" *ngIf="!tile.isRevealed && !tile.isFlagged"></div>
  <div *ngIf="tile.isRevealed && !tile.isMine">
    {{ tile.getThreatCount() > 0 ? tile.getThreatCount() : '' }}
  </div>
  <div *ngIf="tile.isRevealed && tile.isMine">
  </div>
</div>

Une simple tuile dont la classe associée servira au CSS affiché

Les modèles

Pour une Tile (ou tuile), nous avons besoin des informations suivantes qui lui seront associées :

  • isRevealed: boolean,
  • isMine: boolean;
  • isFlagged: boolean;
  • neighbors: Tile[];

Conceptuellement, nous adoptons ici une approche où c'est à chaque tuile de connaître ses voisins. Cette responsabilité ne sera donc pas associée au plateau de jeu et restera indépendante de l'implémentation de ce dernier.
Dans une approche orientée objet, je considère Tile comme une classe (et non un type) afin qu'elle soit également responsable de communiquer le nombre de mines autour d'elle via la fonction getThreatCount

getThreatCount():number {
  return this.neighbors.filter(tile => tile.isMine).length;
}

Retourne le nombre de mines voisines

Un Board, quant à lui, suivra strictement la vision que nous avons établie plus haut et consistera en un ensemble de tuiles auquel on ajoute un Status.

export abstract class Board {
  status: 'ONGOING' | 'GAMEOVER' | 'WON';
  tileSet: Tile[];

//  constructor...

  isGameOver(): boolean {
    return  (this.status == 'GAMEOVER');
  }

  isWon(): boolean {
    return  (this.status == 'WON');
  }
}

La classe Board

Mais, attends ?! Le composant que l'on a vu plus haut attend un tableau à deux dimensions !

C’est exact. Notre modélisation (tout comme notre définition du jeu) se concentre sur les aspects essentiels du jeu… et cette dernière ne précise absolument pas que notre jeu est limité à deux dimensions !

explosing head

Pour retrouver notre démineur historique, nous allons évidemment implémenter un démineur classique consistant en un rectangle en deux dimensions.

Nous avons donc un écart à résoudre entre la modélisation et l’implémentation. Plutôt que de charcuter ma belle classe toute simple, je vais plutôt la spécialiser et ainsi séparer le modèle théorique du modèle pratique utilisable par nos composants :

export class ClassicalBoard extends Board {
  // the representation of the tiles on the board
  tiles: Tile[][];
  numberOfMinesLeft:number;
}

Un board en deux dimensions est une spécialisation d'un Board générique

On en profite pour rajouter dans le modèle des éléments qui rendront l’expérience joueur plus sympa comme le nombre de mines restantes (à savoir le nombre de mines diminué du nombres de flags).

Les services

TileService
Toujours dans une logique “bottom-up”, commençons par implémenter ce qui apparait être de la responsabilité du TileService.

Tout d’abord, la méthode qui va dévoiler une tuile ... pour autant que cette dernière ne le soit pas déjà.
Après s’être dévoilée, si la tuile n’est pas une mine, nous traitons le cas particulier où il n’y a pas de mines dans le voisinage. Dans ce cas particulier, nous dévoilons toutes les tuiles adjacentes (vu qu’aucune d’entre elle n’est une mine).
Il s’agit d’un petit raccourci qui permet de faire gagner un peu de temps facile au joueur.

Il serait en effet fastidieux de devoir cliquer soi-même sur toutes les cases autour d'un 0 alors que l'on sait de manière certaine qu'il n'y a pas de mines.
C'est un jeu de réflexion bon sang ! L'intérêt premier de réfléchir est de ne pas devoir s'infliger des tâches ennuyeuses et répétitives.

reveal(tile: Tile) {
  if(tile.isRevealed) return;
  tile.isRevealed = true;

  if(tile.isMine) return;

  let threatCount = tile.getThreatCount();
  if(threatCount == 0) {
    for(let neighbor of tile.neighbors) {
      this.reveal(neighbor);
    }
  }
}

La révélation d'une tuile

Par la suite, en plus d'une méthode utilitaire servant à générer un ensemble de tuiles (dont la signature est generateTiles(tilesNumber:number):Tile[] ), nous écrirons une méthode assignant aléatoirement les mines sur un ensemble de tuiles.

assignMines(tiles: Tile[], minesToAssignCount:number):Tile[] {
  let tilesNumber = tiles.length;
  minesToAssignCount = Math.min(tilesNumber, minesNumber);
  while (minesToAssignCount > 0) {
    var idxMine = Util.getRandomInt(0, tilesNumber);
    if(!tiles[idxMine].isMine) {
      tiles[idxMine].isMine = true;
      minesToAssignCount--;
    }
  }
  return tiles;
}

Distribution aléatoire des mines au sein des tuiles du plateau

BoardService
À présent, attaquons nous aux méthodes relatives au plateau de jeu en lui-même.

Tout d’abord, la méthode appelée lorsqu'on voudra dévoiler une tuile du plateau. Outre le fait d'appeler la méthode du TileService, cette méthode ci va vérifier des éléments qui sont propres au jeu lui-même à savoir, les conditions de défaite et de victoire.

revealTile(board : ClassicalBoard, tile:Tile):void {
  this.tileService.reveal(tile);
  if(tile?.isMine) {
    this.setGameOver(board);
    this.overlayService.on();
  } else {
    this.checkVictory(board);
  }
}

Après avoir dévoilé une tuile, on vérifie les conditions de victoire ou de défaite

La condition de victoire est d'avoir révèlé toutes les tuiles qui ne sont pas des mines.

checkVictory(board : ClassicalBoard) {
  if(board.tileSet.filter((tile) => !tile.isMine && !tile.isRevealed).length == 0) {
    board.status = 'WON';
  }
}

Tandis que la condition de défaite est qu'il suffit qu'une seule mine soit dévoilée.

Nous avons ensuite des méthodes qui servent à générer notre plateau en deux dimensions ainsi que de définir les “liens de voisinages” entre les tuiles.

generateTileBoard(rowsNumber:number, columnsNumber:number, minesNumber:number):ClassicalBoard {
  let classicalBoard = new ClassicalBoard(1, minesNumber);

  let tiles = this.tileService.generateTiles(rowsNumber*columnsNumber);
  tiles = this.tileService.assignMines(tiles, minesNumber);

  //set them in a two dimensional array
  let tileIdx = 0;
  let tileBoard:Tile[][] = [];
  for(var i = 0;i < rowsNumber; i++){
    for(var j = 0;j < columnsNumber; j++){
      if(j == 0) tileBoard[i] = [];
        tileBoard[i].push(tiles[tileIdx]);
        tileIdx ++;
      }
    }

    //set the neighboors for each tile
    for(let i = 0;i < tileBoard.length; i++){
      for(let j = 0;j < tileBoard[i].length; j++){
        tileBoard[i][j].neighbors = this.getNeighbors(tileBoard, i, j);
      }
    }

    classicalBoard.tileSet = tiles;
    classicalBoard.tiles = tileBoard;
    classicalBoard.numberOfUnRevealedTiles = rowsNumber*columnsNumber;
    return classicalBoard;
}

private getNeighbors(tileBoard:Tile[][], row:number, column: number) {
  let neighbors = [];
  for(var i = -1;i <= 1; i++) {
    if(row + i < 0 || row + i >= tileBoard.length) continue;
    for (var j = -1; j <= 1; j++) {
      if(column + j < 0 || column + j >= tileBoard[row].length) continue;
      if(i == 0 && j == 0) continue;
      neighbors.push(tileBoard[row + i][column + j]);
    }
  }
  return neighbors;
}

La génération du plateau de tuiles en 2D

Et … c’est tout. Enfin presque. Il nous reste à remonter à la surface et à implémenter la seule couche visible par l'utilisateur : les réactions à ses clics de souris.

Pourquoi vouloir définir les liens de voisinage au niveau du Board ?

Parce que c'est votre plateau de jeu qui va en réalité déterminer comment les tuiles vont s'agencer.

Dans une formation classique, à savoir un rectangle en 2D, il serait possible d'avoir la définition du voisinage au niveau de la génération du tableau à deux dimensions.
Cependant, ce choix architectural rendrait beaucoup plus complexes des implémentations plus "exotiques" telle que celle de l'image ci-dessous.

Parce que, après tout, le concept de base n'impose pas que les cases soit carrées. ©polyreplay.com

Les événements

On y est.
C’est ici que tout va s’assembler afin de permettre à nos joueurs de profiter de notre petit jeu.
Plus exactement, dans cette partie, nous allons définir les événements qui réagiront aux clics du joueur.

GameComponent
Une fois n’est pas coutume, partons du plus haut niveau à savoir, le GameComponent.

Nous commençons avec une méthode startNewGame qui sert à réinitialiser le chronomètre et un plateau de jeu.

startNewGame(rows: number, columns: number, mines: number) {
  this.minesweeper.initializeBoard();
  this.result = undefined;
  //stop animation of win or defeat here.
}

La variable minesweeper représente le Board et est obtenue via l'annotation @ViewChild

ainsi qu'une méthode qui va mettre à jour l’écran en fonction du statut du jeu.

 updateGameStatus(status: string) {
  this.result = status;
  if (status === 'WON' || status === 'GAMEOVER') {
    //launch animation of win or defeat.
  }
}

ClassicalBoardComponent
Concernant le plateau de jeu en lui même, nous définissons une méthode d’initialisation (ré)initialisant nos variables.

initializeBoard() {
  this.board = this.tileService.generateTileBoard(this.rowsNumber, this.columnsNumber, this.minesNumber, this.generationStrategy);
  this.board.status = 'ONGOING';
  this.hasStarted = false;
  this.flagsNumber = 0;
}

Maintenant que notre plateau est initialisé, nous pouvons associer une méthode au clic gauche utilisé par le joueur pour dévoiler les tuiles.

 handleTileClick(tile:Tile){
  if(!this.hasStarted) {
    this.hasStarted = true;
    this.notifyGameStatus.emit('ONGOING');
  }

  if(!tile || tile.isFlagged){
    return;
  }
  if (this.board.isGameOver() || this.board.isWon()) {
    return;
  }
  this.tileService.revealTile(this.board, tile);

  if(this.board.isWon() ||this.board.isGameOver()) {
    this.notifyGameStatus.emit(this.board.status);
  }
}

où notifyGameStatus est un output qui sera écouté par le GameComponent

ainsi que la gestion du clic droit utilisé pour placer un flag.

onRightClick(tile:Tile) {
  if(!this.hasStarted) {
    this.hasStarted = true;
    this.notifyGameStatus.emit('ONGOING');
  }

  if(tile.isRevealed) return false;
  tile.isFlagged = !tile.isFlagged;
  if(tile.isFlagged) this.flagsNumber++;
  else this.flagsNumber--;
  return false;
}

Et voilà ! 🥳 Nous avons les bases de notre POC.

Aperçu de notre premier jet

Après avoir fini de connecter mes classes Angular et avoir ajouté un peu de CSS, voilà le premier résultat.

He's alive !

Que faire ensuite ?

Les améliorations ultérieures

Pour améliorer ce prototype, nous pouvons y intégrer des petites fonctionnalités qui offriront à nos joueurs un gameplay plus agréable.
Terminons cet article par 3 améliorations. 💃

Offrir un début plus serein

On lance notre jeu, on clique et ... Mais, quoi ?

Perdu dès le premier clic

Les mines étant distribuées aléatoirement, il est possible de tomber sur une mine dès le premier clic ! Ce qui est assez frustrant.

Changeons cela ! Lors du premier clic du joueur, cela ne doit jamais être une mine. De plus, cela doit être, si possible, une case sans aucune mine dans un voisinage immédiat. Ce qui permet de fournir un maximum d’indice dès le premier clic afin de permettre de continuer le jeu sans recourir au hasard complet.

En pratique, le plus simple est de modifier le BoardService afin que la génération de mines ne soit plus effectuée lors de la génération du plateau mais bien lors du “premier clic”.
Pour cela, nous créons une méthode semblable à assignMines du TileService qui sera appelée suite à un clic capturé par le GameComponent.
De cette façon, nous n’assignons les mines aux tuiles du plateau qu’à un moment où nous connaissons la tuile cliquée et nous pouvons coder le fait que les mines sont générées partout ... sauf dans le voisinage proche de la mine cliquée. 😄

Déminages en parallèle

Vous vous souvenez de notre règle particulière s'appliquant lorsque le joueur clique sur une case qui n'a pas de mines voisines ? Celle qu'on pourra appeler "la règle de la flemme".

Et si on étendait cette règle ?

Imaginez. Vous cliquez sur une tuile et vous avez le chiffre "1". Et, de par la configuration du plateau, vous savez où est la mine. Il serait tout autant pénible de cliquer sur les 7 voisins innocents du "1" que sur 8 voisins innocents d'un "0".

Ajoutons un comportement nous offrant l'effet suivant : lorsqu’on clique sur une tuile déjà révélée dont on a déjà identifié (avec les flags) toutes les mines voisines, on dévoile toutes les cases avoisinantes.

Ça tu fais pas hein Démineur de Google !


Une solution serait de modifier le revealTile du BoardService afin de gérer le cas particulier d'une tuile déjà révélée mais dont le chiffre associé est égal au nombre de flag dans son voisinage.

Pensez aux experts

Notre POC est à présent super cool ! Mais il nous reste à satisfaire les joueurs les plus exigeants : les pros !

Ces derniers ne se satisferont pas d'un petit plateau avec quelques mines. Il leur en faut plus !
Nous allons donc offrir à nos joueurs plusieurs niveaux de jeu.

Cette fonctionnalité sera extrêmement simple à implémenter : il nous suffit d'ajouter des boutons dans l'interface qui vont appeler la méthode startNewGame avec les bons paramètres.

Aperçu de notre deuxième jet.

Une fois notre prototype augmenté de ces quelques fonctionnalités, il va pouvoir commencer à attirer l'attention. Le fait qu'il soit en Open Source va permettre d'attirer des contributions externes comme celle de mon confrère Guillaume Houioux qui a apporté notamment

Et voilà le résultat !

Il est beau hein ? 🥰

Conclusion

Mais, finalement, quel était l'intérêt de faire un modèle théorique dont on allait hériter si c'est pour faire un simple Démineur classique ?

Ça... Vous le saurez dans le prochain article.

En attendant, je vous invite à constater qu'il y a quelques soucis de performances dans les méthodes écrites ci-dessus. Si vous le comprenez pourquoi et si vous avez déjà des idées d'améliorations de ces dernières, félicitations, vous êtes fins prêts à vous frotter à notre PlayOff Algo en postulant chez SFEIR.

Stay tuned ;)

Dernier