Dans cet article, nous allons poursuivre le développement de notre Démineur là où nous nous étions arrêtés dans l'article précédent.
Mais, il était fini non ?
Alors … oui mais non.
Nous avons en effet un programme qui imite assez bien le comportement que l’on connaît du jeu Démineur tel qu'on le joue depuis des années.
Mais aujourd’hui, j’ai envie de changer ce comportement.
Les raisons peuvent être variées :
- Les mécaniques du jeu classique sont trop bien maîtrisées et l’on s’ennuie;
- La simple envie de changement;
- C’est une manière de justifier notre architecture mise en place dans l’article précédent.
Vous l’aurez compris, pour toutes ces raisons, je me dois d’offrir une petite revisite du Démineur !
Tout d'abord, pour une revisite réussie, il est important de bien identifier l’essence même du projet via ses invariants ontologiques i.e. ce qui ne changera pas.
Pour rappel, nous considérons qu’un Démineur c’est
- 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 mines qui est dans son voisinage;
- on gagne si on découvre toutes les cases qui sont inoffensives;
- on perd si l'on clique sur une mine.
Maintenant, en comparant avec notre implémentation, nous constatons que cette dernière a des propriétés supplémentaires à notre définition.
De manière générale, personnaliser un jeu, cela consiste en la modification, la suppression ou l'addition de propriétés relatives à l'expérience de jeu. Partant de cela, nous pouvons imaginer ce que nous pourrions réaliser.
Ajouter des propriétés supplémentaires ?
Et si on ajoutait un petit personnage (au hasard, Axo) qui va lentement avancer sur le plateau pour le traverser de part en part. Votre but sera donc d’identifier les mines sur son chemin avant qu’il ne marche dessus.
Modifier les propriétés additionnelles existantes ?
Rien n’oblige un Démineur à être en 2 dimensions. ;-)
(Par contre, je n’ai pas les capacités graphiques pour créer un plateau de jeu 3D qui soit lisible et ergonomique :D )
Supprimer des propriétés que l'on aurait ajoutées par habitude ?
Actuellement notre plateau de jeu est un rectangle en deux dimensions réagissant aux clics de notre souris. Par ergonomie, nous avons décidé que lorsque l'on clique sur une case sans mines voisines, les cases voisines se dévoilent à leur tour.
Dans cet article, nous allons garder ce choix ergonomique mais nous allons cependant investiguer pour supprimer cette contrainte d'être rectangulaire et ainsi permettre de jouer sur d’autres plateaux !
Commençons avec une modification simple : je veux que le plateau ne soit plus un rectangle mais … un cercle.
Mais … pourquoi ?
Réponse simple : Et pourquoi pas !
Pour cela, nous allons devoir définir des nouveaux composants correspondants chacun à un plateau mais également des modèles et classes parentes contenant les caractéristiques communes à chacun de ces plateaux.
La quadrature du Démineur en cercle
Mettons-nous à la place d'un plateau de jeu et commençons de manière très humaniste (ou platiste en l'occurrence) par regarder tout ce qui nous rassemble.
Plus exactement, afin de créer un système où tous les plateaux vont pouvoir coexister pacifiquement, il est impératif de définir des règles de vie commune à ces derniers.
- Chaque plateau est constitué de cases (nos objets Tile);
- Les effets des clics gauche et droit sont inchangés : ils révèlent une tuile ou la marque comme suspecte (le petit drapeau);
- Les conditions de victoire et de défaite restent également inchangées;
- Une tuile révélée indique le nombre de mines voisines et si ce nombre est à zéro, chaque tuile voisine se révèle à son tour;
- Chaque plateau sera en deux dimensions.
Au vu de cette liste, nous allons pouvoir définir des classes parentes contenant ces propriétés et responsabilités communes.
Ce qui change peu
Bonne nouvelle ! Nos modèles Board et ClassicalBoard sont suffisamment abstraits et resteront inchangés. On peut, tout au plus, renommer le dernier BiDimensionalBoard.
Vu que les interactions avec le joueur ne changent pas, la seule modification au niveau du composant Game consistera en une modification de l'interface utilisateur afin de permettre à ce dernier de choisir son plateau de jeu. Il restera ensuite à afficher le composant choisi.
Au niveau des tuiles, une modification mineure sera par contre à prévoir.
Si on affiche un cercle, par simplicité, on va le dessiner sur un rectangle en 2D et noircir des tuiles que nous considérerons alors comme inactives.
Le statut enabled est alors à ajouter dans la classe Tile ainsi que dans les traitements tels que revealTile dans le TileService.
isEnabled: boolean;On a vu pire comme modification. :D
Ce qui change un peu plus
Attaquons-nous à présent aux plateaux proprement dit.
Nous allons avoir un composant BoardComponant générique qui va gérer les éléments et événements communs indépendants de la forme de nos plateaux.
La logique étant de préférence extraite des composants pour être dans un Service, certaines méthodes seront dans un BoardService lui aussi générique.
Chaque plateau aura ainsi son composant dédié (hérité de BoardComponant), son service dédié (hérité de BoardService) et agira sur un modèle d’un BiDimensionalBoard cité précédemment.
Notre architecture ressemblera au final à ceci
Model
Comme écrit plus haut, il n'y a pas de modifications profondes à prévoir à ce niveau ci. 🥳
Service
Au niveau des services, nous définissons notre BoardService comme une classe abstraite qui sera en charge des fonctions revealTile, setGameOver et checkVictory.
En effet, ces dernières ont un comportement indépendant de la forme de nos plateaux.
export abstract class BoardService {
protected tileService: TileService;
constructor(tileService: TileService) {
this.tileService = tileService;
}
revealTile(board: Board, tile: Tile): void {
// concrete implementation with calls to TileService
// ...
}
setGameOver(board: Board) {
// concrete implementation
// ...
}
checkVictory(board: Board) {
// concrete implementation
// ...
}
}De même, nous créons un service BiDimensionalBoardService en charge de l’implémentation concrète des méthodes se basant sur le caractère 2D de nos plateaux pour déterminer les voisins de chacune de nos tuiles et assigner les mines sur les cases en évitant celle de notre premier clic.
export abstract class BiDimensionalBoardService extends BoardService {
getNeighbors(tileBoard: Tile[][], row: number, column: number) {
// concrete implementation
// ...
}
finishInitialization(
tileBoard: Tile[][],
generationStrategy: GenerationStrategy,
minesNumber: number,
tile: Tile,
): void {
if (generationStrategy == 'AT_FIRST_CLICK') {
this.assignMinesAroundTile(tileBoard, minesNumber, tile);
}
}
getTilesNumber(tileBoard: Tile[][]) {
return tileBoard
.map((row) => row.filter((t) => t.isEnabled).length)
.reduce((a, b) => a + b, 0);
}
protected assignMinesAroundTile(
tileBoard: Tile[][],
minesNumber: number,
tile: Tile,
): void {
// concrete implementation
// ...
}
}Ce qui nous amène à redéfinir notre ClassicalBoardService avec uniquement une seule méthode qui est propre à la spécificité de la forme rectangulaire du plateau.
class ClassicalBoardService extends BiDimensionalBoardService {
generateTileBoard(
rowsNumber: number,
columnsNumber: number,
minesNumber: number,
generationStrategy: GenerationStrategy,
): BiDimensionalBoard {
//...
}Cette méthode generateTileBoard sera propre à chaque Service en fonction des spécifications du plateau. Ici, notre plateau est de forme rectangulaire et a donc une largeur et une hauteur en paramètres.
Au niveau composant:
On arrive au coeur de notre projet. C'est en effet ici que l'on va définir un composant abstrait dont le but est de mutualiser les méthodes communes à tous les plateaux.
Ce composant sera donc en charge
- d’écouter le click gauche et droit,
- de tenir à jour le nombre de flag mis par l’utilisateur,
- de garder le statut du jeu en cours,
- de calculer le nombre de mines qu’il reste à découvrir
- et de lancer l'initialisation des valeurs communes en cas de nouvelle partie.
Pour ce dernier point, les méthodes initializeTileBoard et finishInitialization seront quant à elles laissées à l’implémentation concrète des plateaux spécialisés
abstract class BoardComponent<T extends Board> {
notifyGameStatus = output<NotificationStatus>();
restartGameEvent = output<void>();
public boardService: BoardService;
//...
initializeBoard() {
let initBoard = this.initializeTileBoard();
initBoard.status = 'ONGOING';
this.board.set(initBoard);
this.hasStarted = false;
this.firstTileRevealed = false;
this.flagsNumber.set(0);
this.restartGameEvent.emit();
}
abstract initializeTileBoard(): T;
abstract finishInitialization(tile: Tile): void;
//...
}A présent, chacun de nos plateaux de jeu va hériter de ce composant en y injectant son propre BoardService correspondant.
Un exemple
ClassicalBoardComponent va injecter un ClassicalBoardService à qui il va en pratique pouvoir déléguer la majeure partie de la responsabilité des méthodes qu’il doit implémenter
class ClassicalBoardComponent extends BoardComponent<BiDimensionalBoard> {
boardService = inject(ClassicalBoardService);
initializeTileBoard(): BiDimensionalBoard {
return this.boardService.generateTileBoard(
this.rowsNumber(),
this.columnsNumber(),
this.minesNumber(),
this.generationStrategy,
);
}
finishInitialization(tile: Tile) {
this.boardService.finishInitialization(
this.board().tiles,
this.generationStrategy,
this.minesNumber(),
tile,
);
}
}Le code se retrouve fortement réduit pour un plateau rectangulaire !
De même, un CircularBoardComponent dépendra d'un CircularBoardService...
On a donc à présent tout ce qu'il faut pour créer un plateau circulaire !
Quoi que l'on fasse, il suffit d'avoir les bonnes entrées
La différence la plus importante que l'on ait rencontré jusqu'à présent est la suivante : les inputs.
En effet, s'ils consistaient en une largeur et une hauteur pour un plateau rectangulaire, ils consistent à présent en une seule valeur pour un plateau circulaire : son rayon (ou son diamètre selon votre préférence).
Ça tombe bien, nous avons un service dédié qui peut définir une méthode spécifique qui sera ensuite appelée par le composant correspondant.
class CircularBoardService extends BiDimensionalBoardService {
generateTileBoard(
radius: number,
minesNumber: number,
generationStrategy: GenerationStrategy,
): BiDimensionalBoard {
//...
}En pratique, j’ai construit un tableau bidimensionnel constitué uniquement de tuiles désactivées. J’ai ensuite déterminé les tuiles “vivantes” via l’algorithme de Bresenham.
Ce genre de petit besoin algorithmique m'a rappelé les exercices que l'on rencontre lors de l'événement Advent of Code que SFEIR promeut chaque année.
Par contre, il faut un certain rayon avant qu’il ne ressemble vraiment à un cercle mais bon... cela motivera les fans de cercles à faire des grosses sessions de Démineur.
Maintenant que l'on a vu comment faire un plateau "disque", on pourrait faire un board “emoji” !
Et quel meilleur emoji pour représenter cela que notre belle mascotte Axo ?
class AxoBoardService extends BiDimensionalBoardService {
generateTileBoard(
minesNumber: number,
generationStrategy: GenerationStrategy,
): BiDimensionalBoard {
//...
}Limitons-nous pour le moment à un plateau de taille fixe.
Vu que je suis parti sur un plateau de taille fixe, c’est hardcodé. Mais vous ne m’en voudrez pas trop longtemps car voilà le rendu.
Intégrons ces plateaux à notre jeu
L’heure est venue à présent de modifier le composant Game. Ce dernier contenait une référence vers le ClassicalBoardComponent via une annotation ViewChild et appelait directement ses méthodes lors du démarrage d’une partie.
Vu que notre plateau est indéterminé et surtout qu’il possède des inputs différents en fonction du plateau choisi, il va falloir “couper” cette dépendance et implémenter quelque chose de plus dynamique.

Vu que je ne désire pas avoir dans mon fichier template une succession de if & else pouvant devenir longue en fonction de ma motivation et mon imagination, je vais plutôt utiliser un ng-template avec une template variable afin de pouvoir déléguer la création du composant à une factory en fonction d’un input donné par l’utilisateur.
<ng-template #boardSlot></ng-template>Cet élément sera cependant toujours accédé via l'annotation ViewChild
Dans notre cas, l’utilisateur cliquera sur un bouton afin de choisir son plateau de jeu. Le clic sur le bouton mettra à jour une valeur qui sera utilisée dans la factory pour créer le composant du plateau correspondant. Ce composant sera ensuite injecté en lieu et place du boardSlot.
Le nouveau GameComponent
En générant ainsi nos composants, nous devons leur associer dynamiquement leurs inputs et outputs.
Pour les outputs, ces derniers étant communs pour tous les plateaux, nous y souscrirons à chaque création d’un nouveau plateau.
Afin d’éviter tout problème mémoire, nous cesserons d‘y souscrire avant de supprimer la référence au plateau précédent. Pour ce faire, nous stockons nos souscriptions dans un tableau dédié.
export class GameComponent implements AfterViewInit {
@ViewChild('boardSlot', { read: ViewContainerRef })
boardSlot!: ViewContainerRef;
minesweeper!: BoardComponent<Board>;
boardType = signal(BoardType.Classic);
//…
private renderDynamicBoard(type: BoardType) {
this.subscriptions.forEach((sub) => sub.unsubscribe());
this.boardSlot.clear();
this.subscriptions = [];
this.minesweeper = this.boardFactoryService.generateBoardComponent(this.boardSlot, type);
this.subscriptions.push(
this.minesweeper.notifyGameStatus.subscribe((val) =>
this.updateGameStatus(val),
),
);
this.subscriptions.push(
this.minesweeper.restartGameEvent.subscribe(() => this.startNewGame()),
);
}Pour les inputs, ils peuvent varier d’un plateau à l’autre. Afin d’éviter de devoir traiter ces derniers conditionnellement au type de plateau, j’ai opté pour … leur suppression.
Mais nous verrons cela plus tard. ;)
Concernant le BoardFactory, rien de plus simple ! Il va retourner le bon composant en fonction du BoardType passé en paramètre (réfletant le mode choisi par l’utilisateur) et va le générer.
export class BoardFactoryService {
componentMap: Record<BoardType, Type<BoardComponent>> = {
[BoardType.Classic]: ClassicalBoardComponent,
[BoardType.Toric]: ToricBoardComponent,
[BoardType.Circle]: CircleBoardComponent,
[BoardType.Diamond]: DiamondBoardComponent,
[BoardType.Axo]: AxoBoardComponent,
};
generateBoardComponent(
viewContainerRef: ViewContainerRef,
boardType: BoardType,
): BoardComponent<Board> {
let newComponentRef = viewContainerRef.createComponent(
this.componentMap[boardType],
);
const board = newComponentRef.instance;
return <BoardComponent<Board>>board;
}
}Une usine à Démineurs ! \o/
Et les inputs alors ?
Comme dit plus haut, il n’y a plus d’input. Bye-bye les inputs.
À partir du moment où on a abstrait la création d’un plateau de jeu indépendamment de leurs spécificités, il aurait été illogique de mettre au chausse-pied une série d’opérateur conditionnel pour savoir à quel moment écouter quel input.
Pour peu que notre collection se complexifie, on pourrait se retrouver avec 15 types d’input différents (largeur, hauteur, profondeur, vitesse de rotation, température de la pièce, etc.) et il serait pénible de les trimballer d’une classe à l’autre pour n’utiliser que ceux réellement utiles au plateau sélectionné.
D’accord, mais comment fournir les valeurs en entrées ?
En jouant sur les mots. 😄
Je vais considérer que les caractèristiques d’un plateau de jeu ne sont plus des entrées... mais des éléments constitutifs de l’état d’un plateau.
Les “inputs” sont donc à présent des données stockées dans un StateService dont chaque plateau sera réactif.
D’un point de vue imagé, nous avons “éloigné” un plateau du GameComponent. Pour passer des paramètres de l’un à l’autre, nous avons deux options
- Passer les paramètres (dont certains inutiles pour l’option choisie) dans toutes les classes intermédiaires entre notre GameComponent et notre plateau à générer;
- Ne plus passer ces paramètres mais faire en sorte que notre plateau les récupère et, si possible,soit réactif aux changements de valeurs de ces paramètres.
J’ai choisi la seconde option 🙂
Au niveau de notre plateau classique, cela se fait comme suit
export class ClassicalBoardComponent extends BoardComponent<BiDimensionalBoard> {
rowsNumber = computed(
() => this.stateService.getGameSettings()().get('rowsNumber') ?? DEFAULT_ROW_VALUE,
);
columnsNumber = computed(
() => this.stateService.getGameSettings()().get('columnsNumber') ?? DEFAULT_COLUMN_VALUE,
);
où StateService est un simple service qui va être responsable d’un signal<Map<string, number>> contenant des paramètres de jeu.
Vu le principe des signaux Angular, notre plateau est à l’écoute des modifications apportées à nos paramètres.
Un simple
effect(() => {
let settings = this.stateService.getGameSettings();
this.initializeBoard();
});dans notre classe parent BoardComponant suffit pour forcer le refresh du plateau de jeu à chaque modification des paramètres de jeu.
Un mode Custom sur des plateaux custom
Dans la version Démineur classique, nous avions la possibilité de définir nous-même les dimensions du plateau de jeu : largeur, hauteur et nombre de mines.
Et si nous réalisions également cette implémentation ?
De cette manière, on laisserait au joueur la possibilité de définir le nombre de mines et … un ensemble de valeurs dimensionnelles dépendant du type de plateau choisi.
“un ensemble de valeurs dimensionnelles dépendant du type de plateau choisi.” …
Depuis le temps, vous reconnaissez ces tournures de phrase. OH NON, DE L’ABSTRACTION ! 😱
Je vous rassure, on a déjà quasi tout fait, le reste sera plus simple, promis :D
Pour réaliser l’étape précédente, on a déjà extrait les caractéristiques d’un plateau dans une collection d’état que nous stockons sous la forme signal<Map<string, number>>. Permettre aux utilisateurs de modifier les dimensions d’un plateau consiste simplement à leur permettre de mettre à jour les valeurs dans cette Map.
Pour notre jeu, nous allons écrire un composant GridOptionComponent qui va permettre la mise à jour de ces valeurs. Ce composant sera générique et ne contiendra pas de dépendance vers les différents types de plateaux.
Pour cela, nous allons lui renseigner un ensemble de dimensions que le joueur pourra modifier.
Une dimension pour ce composant aura la forme suivante
export type GridDimensionOption = {
label: string;
settingName: string;
min: number;
max?: number;
};Et … c’est tout.
Le composant va afficher une série d’élément HTML “input”, transmettre les modifications à la Map et mettre à jour le StateService. Cette mise à jour déclenchera un rafraichissement automatique du plateau de jeu.
Nous savons donc à présent personnaliser nos plateaux personnalisés. \o/
Avec une logique similaire, on définit des configurations “par défaut” en fonction d’un niveau de jeu : FACILE, MOYEN, DIFFICILE.
Joueur interdimensionnel
Il nous reste à définir au niveau des plateaux eux-mêmes les dimensions que le joueur peut mettre à jour. Une petite méthode abstraite au niveau du Board parent nous obligera à ne pas oublier de définir ces valeurs là pour chaque plateau.
abstract getDimensions(): GridDimensionOption[];
abstract getDefaultConfigsMap(): Map<GameMode, Map<string, number>>;Et l’implémentation pour le plateau classique ressemble simplement à ceci
getDimensions() {
let dimensions = [
{ label: 'rows', settingName: 'rowsNumber', min: 3, max: 75 },
{ label: 'columns', settingName: 'columnsNumber', min: 3, max: 75 },
{ label: 'mines', settingName: 'minesNumber', min: 1 },
];
return dimensions;
}Mais … tout ça pour un plateau rond ?
Alors, non pas seulement même si c’est déjà chouette de se dire en pensant à nos joueurs qu’ils ont des plateaux ronds (vive les bretons).

Vous vous souvenez du fait qu’on ait déterminé que les plateaux étant responsables de la définition de “voisinage” ?
Eh bien, cela nous permet de définir des plateaux avec des règles différentes nous permettant d’apporter un vent de fraicheur aux joueurs invétérés ayant les mécaniques classiques au bout des doigts.
Par exemple, on peut à présent déterminer qu’un plateau n’a pas de bord; que le bord gauche est collé au bord droit et que le bord supérieur est collé au bord inférieur. En géométrie,on appelle cela un tore et c'est ainsi que l'on appellera ce plateau particulier.
Si nous voulons ce comportement sur un plateau rectangulaire, il nous suffit de constater que ce nouveau plateau EST un plateau classique avec une règle particulière de définition des voisins.
Cela se traduit via une relation d’héritage.
@Component({
selector: 'toric-board',
imports: [TileComponent, AsyncPipe],
templateUrl: './../classical-board.component.html',
styleUrl: './../classical-board.component.css',
})
export class ToricBoardComponent extends ClassicalBoardComponent {
public override boardService = inject(ToricBoardService);
}Et c'est aussi simple que cela !
La logique particulière sera dans la définition des voisins qui est au niveau du service.
@Injectable({
providedIn: 'root',
})
export class ToricBoardService extends ClassicalBoardService {
public override getNeighbors(
tileBoard: Tile[][],
row: number,
column: number,
) {
// code here
}
}Pareillement, on pourrait définir des plateaux avec comme définition du voisinage une règle copiée du déplacement du cavalier aux échecs, ou bien considérer comme voisins des cases a priori plus lointaines (car nos bombes ont plus de portée).
Bref, nous sommes un peu plus libre de créer NOTRE version du jeu.
Conclusion
Voilà, c'est la fin de ce duo d'articles concernant la réimplémentation du Démineur.
Nous avons pu constater que malgré son apparente simplicité, il est possible d'apporter de l'innovation à ce jeu populaire ... à condition d'être prêt à plonger dans un peu plus d'abstraction.
À titre personnel, ce projet m'a permis d'approfondir ma compréhension de TypeScript, d'expérimenter en Angular ainsi que de challenger mes connaissances sur les signaux (aaahhh, la mutabilité des objets et les signaux …).
Et en plus, ça fait un jeu sympa à partager avec les collègues. Que du positif donc !
Merci à mon confrère Guillaume pour avoir embarqué avec moi dans cette aventure pédagogique dont vous trouverez les sources sur ce repo GitHub.
Quant à la démo, vous pouvez y jouer directement en suivant ce lien.
Je vous laisse, j'ai du travail...
J'ai donc quelques parties de Démineur à faire avant. 👼