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.
Aaaarggh ! Mauvaise idée. Essayons de n'en écouter qu'une à la fois.
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 !
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é).
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.
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).

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 !

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.

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.

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 ?

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.

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
- Un vrai CSS digne de ce nom;
- Des animations de victoire et de défaite ;
- Le chronomètre qui vous permet de déterminer votre performance;
- Une fonctionnalité "Historique" listant vos parties passées;
- Les dernières fonctionnalités du framework.
Et voilà le résultat !

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 ;)