Admin/Dev

31
Mars
2019

Développer un Démineur en Javascript - Partie 3

Publié par sky

Il était temps, voici la dernière partie dédiée à la création d'un jeu de démineur en javascript.

Maintenant que notre démineur est initialisé, il ne nous reste plus qu'à mettre en place les fonctions de jeu. Lors de la création du plateau, nous avions mis deux interactions sur l'ensemble des cases. Nous avions attaché la fonction "checkPosition" sur le clic, et la fonction "markPosition" sur le clic-droit.

Mais avant, il faut réfléchir, nous avons besoin de trouver une solution pour indiquer qu'une case est marquée ou déjà visitée. Visuellement, cela ne sera pas un soucis, nous avons créé des styles que nous appliquerons aux cases. Mais il sera aussi nécessaire de mettre à jour la matrice représentant notre terrain, afin d'indiquer les cases visitées, mais aussi les cases marquées.

Nous pourrions refaire une matrice à côté, avec ces informations, mais dans un soucis d'optimisation mémoire, j'ai choisi de trouver une solution n'utilisant qu'une matrice.

Pour rappel, cette matrice indique la valeur -1, lorsqu'une mine est présente, et une chiffre positif, représentant le nombre de mines qui jouxtent la case. Je vous propose de marquer par la valeur -2, une case visitée. Certes nous perdons la valeur originale qu'elle contient, mais au final, une fois cliquée, nous n'en avons plus besoin du tout.

Plus compliqué, une case doit pouvoir être marquée, mais surtout, nous devons pouvoir la démarquer, et la faire revenir à sa valeur originale. Voici une des solutions, ce n'est pas la plus "jolie", mais elle reste sacrément efficace. Lorsque le joueur marque une case, nous allons mettre à jour la valeur de la case en lui ajoutant -100. En proposant une valeur largement négative et fixée, nous pourrons facilement revenir en arrière, lors du démarquage, en ré-ajoutant 100 à la valeur de la case. Et nous conservons le fait que les valeurs positives indiquent l'état présent des cases cliquables, et les valeurs négatives représentent des cases spéciales.

Commençons à créer nos deux méthodes, vous allez voir qu'une fois que notre stratégie est déterminée, elles ne sont pas si compliquées que cela. Au final, la majeure partie du travail a été fait lors de l'initialisation du jeu.

Nous allons commencer par la méthode "markPosition" permettant de marquer une case. Elle est un peu plus simple, et permettra de mieux appréhender la méthode "checkPosition".

Ecrivons l'en-tête de notre méthode, avec les paramètres de la position à marquer.

markPosition: function(x, y) {
},

A l'intérieur de la méthode, nous allons insérer l'ensemble du code nécessaire, à commencer la vérification la plus basique, à savoir, est ce qu'une partie est en cours.
Dans le cas contraire, nous sortons de la méthode sans rien exécuter.

if (this.game.status != 1)
return;

Ensuite, nous vérifions si la case peut être marquée. C'est finalement, assez facile, seules les cases non-visitées peuvent être marquée. Encore une fois, nous sortons de la méthode sans rien exécuter, si la case a déjà été visitée.

if (this.game.field[x][y] == -2)
return;

Enfin, il ne reste plus qu'à gérer le marquage d'une cellule, sans oublier qu'il faut pouvoir retirer ce marquage. Avec le marquage la valeur d'une case peut varier de -92 à -100, respectivement avec le maximum de mines adjacentes et sans aucune mine présente à ses côtés. Logiquement, pour tester si une case est marquée, nous allons testé si sa valeur est inférieure à -90, ainsi nous sommes sur.

Ensuite, que nous marquions ou que nous retirions la marque, il faudra mettre à jour la valeur de la case dans la matrice, et ne pas oublier de mettre à jour la case visuellement.

if (this.game.field[x][y] < -90) {
document.getElementById('cell-'+x+'-'+y).className = 'cell'; document.getElementById('cell-'+x+'-'+y).innerHTML = ''; this.game.field[x][y] += 100;
} else {
document.getElementById('cell-'+x+'-'+y).className = 'cell marked'; document.getElementById('cell-'+x+'-'+y).innerHTML = '!'; this.game.field[x][y] -= 100;
}

Et voilà, notre méthode est terminée, ce n'est pas plus compliqué que ça.

Maintenant, concentrons nous la seconde méthode, qui, elle aussi, nécessitera un peu de réflexion. En effet, si vérifier la case n'est compliqué, et ressemble beaucoup à ce que l'on a déjà fait dans la méthode "markPosition", il y a cependant un cas qui va nous donner un peu de fil à retordre. Dans tout bon démineur qui se respecte, lorsque le joueur clique sur une case sans aucune bombe à ses côtés (dont la valeur dans la matrice sera égale à 0), le jeu révèle automatiquement les cases alentours. Il s'agit d'une optimisation d'ergonomie sur laquelle nous ne pouvons faire l'impasse, et puis cela donne un petit challenge technique qui donne tout l'intérêt à ce projet.

Insérons l'en-tête de notre fonction, ici encore avec la position de la case, en paramètres.

checkPosition: function(x, y) {
}

Commençons par les mêmes vérifications que précédemment, dans la précédente méthode. Est que ce que le jeu est en cours, et est ce que la case a déjà été cliquée.

if (this.game.status != 1)
return;
if (this.game.field[x][y] == -2)
return;

Petite subtilité, nous allons vérifier si la case a été marquée, ici encore nous sortons de la méthode, si c'est le cas. Ainsi nous protégeons la cellule marquée.

if (this.game.field[x][y] < -90) {
return;

Entrons dans le vif du sujet, testons si la case est minée. Dans ce cas, nous marquerons visuellement la cellule, avant d'afficher la défaite et de sortir de la méthode, puisqu'il n'est plus nécessaire d'aller plus loin.

if (this.game.field[x][y] == -1) {
document.getElementById('cell-'+x+'-'+y).className = 'cell bomb'; this.displayLose(); return;
}

Maintenant que nous avons fait toutes les vérifications nécessaires, nous savons que la case cliquée est libre. Nous pouvons d'ores et déjà l'indiquer visuellement.

document.getElementById('cell-'+x+'-'+y).className = 'cell clear';

Pour faire suite à notre réflexion, nous allons devoir gérer deux cas. Si la case jouxtent au moins une mine, ou non.

Démarrons par la partie la plus facile. Si notre case indique un certain nombre de mines à ses côtés, nous devons indiquer cette valeur dans la case. Puis nous définissons la case comme visitée.

if (this.game.field[x][y] > 0) {
document.getElementById('cell-'+x+'-'+y).innerHTML = this.game.field[x][y]; this.game.field[x][y] = -2;
}

Puis gérons le cas où aucune mine n'est présente à côté de la case. Que faire ? Comme nous sommes sûrs qu'aucune mine est présente autour, nous pouvons faire exécuter la méthode "checkPosition" sur toutes ces cases. Si la valeur de ces cases est elle aussi à 0, nous lancerons "checkPosition" sur d'autant plus de cases, et ainsi de suite jusqu'à avoir vérifier toutes les cases autour des cases sûres. Heureusement, nous avons mis nos vérifications en début de méthode qui permettront que le script s'arrête si la case est déjà vérifiée. Dans le cas contraire, nous aurions réalisé une jolie boucle sans fin, qui aurait fini par faire crasher le programme.

Nous devons donc écrire deux petites boucles imbriquées qui vérifieront les cases alentour. Simplement ce sont les cases aux coordonnées de notre case courante -1 à +1.

Et dans ce cas, il y a trois précautions à prendre, d'une part, il faut être sur qu'on ne tente pas de tester des cases qui sont en dehors du jeu. En effet, si nous sommes sur une case de la première ligne, il ne faut pas essayer de vérifier les cases de la ligne supérieure (qui n'existe donc).

L'autre précaution est plus subtile, il faut s'assurer que nos variables sont bien locales. Sinon, les fonctions checkPosition imbriquées utiliseraient les mêmes variables, ce qui donnerait des résultats rigolos, mais pas forcément efficaces.

Avant tout autre test, nous devons marquer la case courante comme visitée. Sinon notre programme pourrait vérifier plusieurs fois la même case

else if (this.game.field[x][y] == 0) {
this.game.field[x][y] = -2; for (var j = x-1; j <= x+1; j++) {
if (j == 0 || j == (this.settings['columns'] + 1))
continue;
for (var k = y-1; k <= y+1; k++) {
if (k == 0 || k == (this.settings['lines'] + 1))
continue;
if (this.game.field[j][k] > -1) {
this.checkPosition(j, k);
}
}
}
}

Maintenant, il ne nous reste plus qu'à tester si la partie est gagnée. Pour plus de simplicité, nous l'externalisons dans une méthode dédiée.

this.checkWin();

L'externaliser, c'est bien, mais cela ne nous exempte pas de l'écrire.

checkWin: function() {
},

Encore un peu de réflexion, nous devons déterminer quand une partie est gagnée. Une partie est gagnée quand toutes les cases sont soit minées, soit découvertes.

Pour réaliser cette action, nous n'avons pas d'autre choix que de parcourir l'ensemble des cases du jeu pour vérifier. Cependant, à la première case qui ne correspond pas à ces pré-requis, nous pouvons arrêter la vérification. Et il ne faut pas oublier que certaines cases minées peuvent être marquées, et donc répondre à la vérification malgré une valeur différente.

for (var i = 1; i <= this.settings['lines']; i++) {
for (var j = 1; j <= this.settings['columns']; j++) {
v = this.game.field[i][j]; if (v != -1 && v != -2 && v != -101)
return;
}
}

Toutes les cases ont été vérifiées, et aucune case non-jouée, et non minée n'a été trouvé. La partie est donc gagnée, nous appelons la méthode adéquat.

this.displayWin();

Les méthodes affichant la victoire et la défaite, sont très proches de celles que nous avions créées pour le MasterMind. Nous leur ajoutons seulement l'information que la partie est terminée, et n'est donc plus en cours.

displayWin: function() {
document.getElementById('result').innerHTML = 'Gagné'; document.getElementById('result').style.color = '#43b456'; this.game.status = 0;
}, displayLose: function() {
document.getElementById('result').innerHTML = 'Perdu'; document.getElementById('result').style.color = '#CC3333'; this.game.status = 0;
},

 

Toujours plus loin ?

Cela vous a plu, mais vous souhaitez aller plus loin ? Voici un petit exercice.

Si notre programme, dans son état actuel, fonctionne, nous pouvons encore l'optimiser afin de le rendre plus rapide, et donc consommant moins de resources, et donc au final moins d'électricité. Donc afin de penser à mère nature, je vous invite à réfléchir, une fois de plus comment optimiser le programme.

Pour cela il faut identifier les parties qui sont le plus consommatrice de ressources, il s'agit en général des boucles et des fonctions à multiples imbrications, et voir quelles sont les parties que nous pourrions déplacer ou retirer si elles sont inutiles.

L'une des parties les plus lourdes et complexes est, évidemment, l'imbrication des fonctions "checkPosition". Si l'enchainement des fonctions doit mener à la vérification, de x cases, la boucle exécutera x fois la vérification que la partie est bien lancée, alors qu'au final, une seule fois suffit, lors du premier clic. Mais c'est un calcul qui est simple à faire et quitte à faire un test pour savoir si nous devons faire ce test, autant faire le test directement.

Par contre, en fin de fonction, nous exécutons la fonction checkWin, et elle aussi se fera x fois. Et comme c'est une fonction, qui, dans le pire des cas, va parcourir toutes les cases du jeu, est consommatrice de ressources. Hors, comme nous ne dévoilons que des cases qui ne sont pas des mines, nous ne pourrions faire ce test qu'une fois, une fois l'ensemble des cases parcourues.

Nous devons donc trouver une solution pour éviter de faire autant de tests inutiles.

La solution la plus simple et d'ajouter un switch, indiquant à la méthode, si elle doit faire la vérification ou non. Puis de ne faire en sorte que le test ne soit fait que sur l'appel original de la fonction, celui qui a déclencher la cascade.

Encore une fois, c'est y penser qui est le plus compliqué, réaliser cette petite action ne l'est vraiment pas. Cela nécessite que le changement de 3 lignes dans notre code.

Commençons par l'en-tête de notre fonction checkPosition, pour y ajouter un paramètre indiquant si la vérification doit être faite ou non.

checkPosition: function(x, y, check)

Puis lorsque la fonction checkPosition est appelée, à l'intérieur d'elle-même, nous désactivons la vérification

this.checkPosition(j, k, false);

pour compléter la modification, en fin de méthode, il faut ajouter un petit test sur l'appel de la fonction checkWin.

if (check !== false)
this.checkWin();

Et le tour est joué !

Pour rendre le code plus propre et plus clair, il serait judicieux de modifier l'appel à la fonction checkPosition lors de la création de la case dans la fonction drawGame.

cell.setAttribute('onclick', this.name+'.checkPosition('+i+', '+j+', true);');

 

Pour terminer...

Si vous souhaitez voir la progression du parcours des checkPosition imbriqués, nous pouvons mettre un petit délai, ici d'1/2 seconde, entre chaque appel à la fonction.

Pour cela, il suffit de remplacer la ligne

this.checkPosition(j, k, false);

par

setTimeout(this.name+'.checkPosition('+j+', '+k+', false)', 500);

Cela permet de mieux voir ce que fait la fonction pour découvrir toutes les cases dont la valeur est 0. Attention, nous n'avons pas mis de bloquage, et si vous cliquez sur une case pendant que le parcours se fait, vous pourriez avoir un résultat plutôt rigolo.

Comme d'habitude, voici le code complet avec les commentaires reprenant les explications de chaque bribe de code.

 
Sommaire de la série
 
 
Commentaires
Aucun commentaire pour le moment.

 

Poster un commentaire
En postant sur skymac.org, je m'engage à être courtois et à ce que mon message soit pertinent avec le sujet de l'article.
En outre, j'accepte, sans condition, que mon message soit refusé et supprimé si ces règles ne sont pas appliquées.
Les cookies assurent le bon fonctionnement de nos services. En continuant, vous acceptez leur utilisation sur notre site internet.
Accepter