Développer un Démineur en Javascript - Partie 2
Alors, avez vous réfléchi comment nous devons décomposer notre démineur pour reproduire les différentes étapes avec du code ?
Avec notre expérience du MasterMind, vous devriez pouvoir définir les bases. Comme pour ce dernier, nous avons 2 grandes phases qui sont l'initialisation du jeu, puis le jeu en lui même.
Aujourd'hui, nous allons nous intéresser à la première phase, et voir comment nous pouvons initialiser un jeu de démineur. Rien de bien compliqué au final, il nous suffit de dessiner le plateau de jeu, puis de déterminer où se trouve les mines. Les informations nécessaires pour initialiser notre jeu sont donc, la taille du plateau, en ligne et en colonne ainsi que le nombre de mines qui sont présentes dans le champ.
Nous allons définir ces valeurs pour les 4 niveaux de difficulté. Commençons par remplir les niveaux de difficulté que nous avions créé, vides.
difficulties: {easy: {},lines: 8, columns: 8, mines: 10,}, normal: {lines: 12, columns: 12, mines: 20,}, hard: {lines: 16, columns: 16, mines: 32,}, extreme: {lines: 20, columns: 20, mines: 48,},
Comme vous pouvez le voir, le nombre de ligne est toujours identique au nombre de colonnes. En dehors du fait que l'on obtient toujours un terrain carré, on aurait pu utiliser une seule variable. Mais comme pour le MasterMind, vous aurez tout le loisir de créer vos propres niveaux de difficulté, avec des terrains par forcément carrés, si par exemple, vous souhaitez adapter ce code pour réaliser une version pour un smartphone ou une tablette.
Dans les données de jeu, nous aurons besoin seulement de deux variables. La première sera l'état de la partie, simplement la valeur 0 indique que le jeu n'est pas en cours, et la valeur 1, que le jeu est en marche. Elle nous permettra de bloquer les clics sur le jeu, alors que le joueur a déjà gagné ou perdu.
La seconde valeur est l'emplacement des mines sur le plateau. Il s'agira d'une matrice d'une taille similaire au plateau, nous permettant d'enregistrer l'état de chaque case de manière virtuel. Le plateau en lui-même, comme pour le MasterMind ne sera que l'affichage afin de permettre au joueur de faire sa partie.
Initialisons les deux variables, dans leur état le plus simple.
game: {status: 0, field: new Array(),},
Avec le MasterMind, nous avions initialisé la partie, avant de dessiner le plateau. Pour le démineur, il est plus simple de faire l'inverse. Encore une fois, pour se simplifier le travail, nous allons garder les noms des fonctions. Il ne reste plus qu'à les insérer dans notre fonction qui démarre la partie.
startGame: function(difficulty) {this.settings = this.difficulties[difficulty]; this.drawGameBoard(); this.resetGame();},
En toute logique, la fonction drawGameBoard dessinera le plateau de jeu, tandis que la fonction resetGame initialisera les données de jeu. Ecrivons nos deux fonctions, que nous remplirons au fur et à mesure des explications.
drawGameBoard: function() {} resetGame: function() {}
Petit aparté, avant d'attaquer le code pur et dur, il est évidemment nécessaire de réfléchir pour savoir comment nous allons réaliser cette étape. Car, si l'une ou l'autre des deux fonctions d'initialisation ne sont pas bien faites, cela aura un impact sur l'ensemble du jeu. Si l'initialisation a une petite faiblesse, nous pourrons toujours la corriger à postériori, mais si nous avons fais un erreur de réfléxion dans l'algorithme, nous devrons envisager d'y re-réfléchir, et d'entièrement la ré-écrire. C'est pour cela que nous définissons les variables nécessaires en dehors des fonctions, et que nous séparons les tâches dans des fonctions différentes. Ainsi si nous avons eu une réflexion fausse sur l'une des actions, nous n'aurons pas tout à recommencer, seulement la portion en question.
De quoi avons nous besoin pour dessiner le plateau de jeu ? Basiquement, il s'agit d'un tableau dont chacune des cases représente un emplacement qui pourrait contenir une mine, mais surtout, qui devrait être cliquable par le joueur.
Mais commençons par le début, il est nécessaire de récupérer le plateau de jeu, et de le vider de la partie précédente. Et si c'est la première partie ? Nous vidons un plateau déjà vide, rien de bien dramatique. Dans la fonction drawGameBoard, écrivons
board = document.getElementById('plateau'); board.innerHTML = '';
Il ne faut pas oublier d'effacer le résultat de la précédente partie.
document.getElementById('result').innerHTML = '';
En HTML, le plus simple est d'utiliser une table, avec ses lignes et ses cellules. Pour Ecrire une table en HTML, il faut créer un élement table, qui représentera les bords de la table. Puis un élément tbody qui représente, lui, l'intérieur de la table.
border = document.createElement('table'); field = document.createElement('tbody'); border.appendChild(field);
Sur notre bord, nous appliquons une classe de style que nous écrirons un tout petit plus tard.
border.className = 'field';
Puis nous ajoutons notre champ au plateau de jeu
board.appendChild(border);
Maintenant, nous allons écrire deux boucles imbriquées, la première qui va écrire les lignes, tandis que la seconde, écrira l'ensemble des cellules.
La première boucle est assez simple. Nous créons autant de lignes que définies dans les réglages, et les ajoutons à notre table.
for (i = 1; i <= this.settings['lines']; i++) {line = document.createElement('tr'); field.appendChild(line);}
Ajoutons la boucle écrivant les cellules, attention, il faut reprendre la petite portion précédente pour la faire évoluer. Sur chaque cellule, nous appliquons un identifiant, basé sur sa position, pour récupérer la cellule plus tard, ainsi qu'une classe CSS pour mettre en place un minimum de design.
for (i = 1; i <= this.settings['lines']; i++) {line = document.createElement('tr'); for (j = 1; j <= this.settings['columns']; j++) {}cell = document.createElement('td'); cell.id = 'cell-'+i+'-'+j; cell.className = 'cell'; line.appendChild(cell);} field.appendChild(line);
Sur les cellules, il manque une chose ? Et oui, il manque les interactions qui permettront au joueur d'avancer dans sa partie. Il faut donc définir l'action du clic sur chacune des cellules. Et comme nous voulons faire les choses correctement, nous allons insérer une action sur le clic-droit qui permettra de marquer une cellule, et ainsi éviter de cliquer dessus par inadvertance.
for (i = 1; i <= this.settings['lines']; i++) {line = document.createElement('tr'); for (j = 1; j <= this.settings['columns']; j++) {}cell = document.createElement('td'); cell.id = 'cell-'+i+'-'+j; cell.className = 'cell'; cell.setAttribute('onclick', this.name+'.checkPosition('+i+', '+j+');'); cell.setAttribute('oncontextmenu', this.name+'.markPosition('+i+', '+j+'); return false;'); line.appendChild(cell);} field.appendChild(line);
Comme vous pouvez le remarquer, nous mettons en paramètres des deux fonctions, les coordonnées de la cellule. Vous pouvez aussi noté que l'on ajoute un "return false;" après l'appel de la fonction. Cela permet d'éviter que le menu qui apparait normalement sur le clic droit apparaisse et ne gène le joueur.
D'ailleurs, après avoir fait tester le jeu à ma petite béta-testeuse préférée, ma fille de 5 ans, je me suis rendu compte qu'un clic droit sur les bords de la table, et donc juste à côté des cellules fait venir le menu contextuel. Ce n'était donc pas terrible, pour rendre le jeu plus ergonomique, nous pouvons désactiver le clic-droit sur la table aussi.
Il suffit d'ajouter la ligne suivante, dans la fonction
border.setAttribute('oncontextmenu', 'return false;');
Pour terminer cette partie, il ne reste plus qu'à écrire les styles CSS, à mettre dans le fichier MineSweeper.css. Nous devons écrire les styles à appliquer sur la table puis que la cellule.
Sur la table, une seule ligne suffit, pour les cellules, nous devrons définir leurs états (normale, marquée, avec une bombe, ou découverte), mais aussi un état spécial lorsque le joueur glisse la souris sur la case. Cela montrera que la case est interactive et que le joueur peut cliquer dessus.
/* mine field */ .field {background:#888888; } .field .cell {width:30px; height:30px; background:#33CC33; cursor:pointer; border-left:solid 1px #666666; border-top:solid 1px #666666; border-right:solid 1px #aaaaaa; border-bottom:solid 1px #aaaaaa;} .field .cell:hover { background:#338833;} .field .cell.bomb {background:#cc3333; cursor:default;} .field .cell.bomb:hover {background:#cc3333;} .field .cell.clear {background:none; cursor:default; color:white; font-weight:bold; text-align:center;} .field .cell.clear:hover {background:none;} .field .cell.marked {background:#3333cc; color:white; font-weight:bold; text-align:center; content:"!";} .field .cell.marked:hover {background:#333388;}
Maintenant, que le jeu est prêt graphiquement, nous pouvons attaquer l'initialisation des variables du jeu. Cette étape nécessite un peu plus de réflexion. En effet, lorsque le joueur va cliquer sur une case, il faut lui indiquer le nombre de mines qui jouxtent la case choisie. Pour cela, nous avons 2 possibilités, soit calculer ce nombre au moment du clic, soit le calculer lors de l'initialisation et définir la valeur de toutes les cases dès le début. Pour ma part, je préfère le faire lors de l'initialisation.
Pourquoi ? Si ici le resultat ne serait pas si différent, il faut se demander se qu'impliquerait un tel dilemme dans un programme plus gourmand tel qu'un jeu en temps réel ?
C'est une question intéressante, qui pourra vous faire comprendre l'implication des différentes ressources d'un ordinateur. En pré-calculant les valeurs lors de l'initialisation, il faut stocker les valeurs tout au long de la partie, nous allons donc consommer un peu de mémoire vive. Nous ne consommerons du processeur qu'au moment de l'initialisation, et du calcul des valeurs.
A contrario, si l'on calcule le résultat à chaque clic, nous allons consommer du processeur, certes moins, puisque cela se limiterait aux cases adjacentes, mais beaucoup plus fréquemment. Et il n'y aurait pas besoin de stocker ces valeurs en mémoire, car, une fois affichées, nous n'en avons plus besoin.
Pour un jeu, et plus particulièrement un jeu en temps réel, on stockera le maximum en mémoire, dans la limite du raisonnable afin de laisser le processeur calculer tout ce qu'il a déjà en charge à chaque nouvelle image affichée.
Reprenons le cours de notre jeu, nous devons créer notre champ vide. Pour faire simple, nous allons noter dans la case le nombre de mines adjacentes, et dans le cas d'une mine, nous noterons la valeur -1. Pour initialiser le terrain, il faut commencer à mettre la valeur 0 dans toutes les cases. Dans la fonction resetGame, nous écrivons
this.game.field = new Array(); for (i = 1; i <= this.settings['lines']; i++) {this.game.field[i] = new Array(); for (j = 1; j <= this.settings['columns']; j++) {}this.game.field[i][j] = 0;}
Maintenant, il faut ajouter les mines, et, évidemment, il faut éviter d'en mettre 2 dans la même case. Nous allons donc faire une première boucle sur le nombre de mines, comme la progression dans la boucle est linéaire, et que la fin, est connue d'avance, nous allons utiliser une boucle for.
Ensuite, nous allons déterminer une coordonnée au hasard dans le champ, et, tant qu'une mine est déjà présente à l'endroit sélectionné, nous demanderons une nouvelle coordonnée toujours au hasard. En sortant de la boucle, nous placerons la mine à l'endroit déterminé en étant sur qu'elle est disponible.
Evidemment, si vous demandez à placer plus de mines qu'il n'y a de cases, vous obtiendrez une boucle infinie, et l'initialisation ne se terminera jamais.
De la même manière, plus vous mettrez de mines par rapport au nombre de cases disponibles, plus le script tournera dans la seconde boucle jusqu'à trouver une case vide.
for (i = 1; i <= this.settings['mines']; i++) {x = Math.floor(Math.random() * (this.settings['columns'] - 1) + 1); y = Math.floor(Math.random() * (this.settings['lines'] - 1) + 1); while (this.game.field[x][y] == -1) {}x = Math.floor(Math.random() * (this.settings['columns'] - 1) + 1); y = Math.floor(Math.random() * (this.settings['lines'] - 1) + 1);} this.game.field[x][y] = -1;
Maintenant que la mine est placée, il faut mettre à jour les valeurs des cases adjacentes, en ajoutant 1, pour leur indiquer la présence de la mine.
Si nous avons des coordonnées x et x, les cases adjacentes sont les 8 cases dont les coordonnées vont de x-1 à x+1 et de y-1 à y+1. En y pensant un peu, il faut faire attention à ne pas essayer de modifier des valeurs qui sont en dehors de la zone prévue, ni des cases qui possèdent une mine.
Nous mettons la dernière portion à jour en intégrant les nouveaux éléments.
for (i = 1; i <= this.settings['mines']; i++) {x = Math.floor(Math.random() * (this.settings['columns'] - 1) + 1); y = Math.floor(Math.random() * (this.settings['lines'] - 1) + 1); while (this.game.field[x][y] == -1) {}x = Math.floor(Math.random() * (this.settings['columns'] - 1) + 1); y = Math.floor(Math.random() * (this.settings['lines'] - 1) + 1);} this.game.field[x][y] = -1; for (j = x-1; j <= x+1; j++) {if (j == 0 || j == (this.settings['columns'] + 1)) {}continue;} for (k = y-1; k <= y+1; k++) {if (k == 0 || k == (this.settings['lines'] + 1)) {}continue;} if (this.game.field[j][k] != -1) {this.game.field[j][k] ++;}
Il ne reste plus qu'à lancer la partie, en modifiant la valeur du statut.
this.game.status = 1;
Pour terminer se second épisode, il ne reste plus qu'à lancer la partie en facile, lors du chargement de la page.
initialise: function() {this.startGame('easy');},
Vous pouvez d'ores et déjà lancer des parties dans les différents modes de difficulté pour tester. Lors du prochain épisode, nous ajouterons les interactions avec le joueur, et donc le jeu en lui-même. Comme à chaque fois, je vous invite à réfléchir sur la suite des événements. Comment décomposer les actions ? Comment les implémenter ?