Article écrit par Victor Darras
Bonjour à tous, aujourd’hui je vous propose un petit exercice d’algorithme avec JavaScript qui nous permettra d’aborder quelques points d’intérêt du langage.
J’aimerais notamment aborder la notion d’optional chaining
, une nouvelle syntaxe de JavaScript permettant de chaîner des méthodes sans remonter la fameuse erreur TypeError: obj.method is undefined
, pour l’instant disponible avec Babel et le plugin optional-chaining.
Pour cet exercice, nous allons générer une grille du vieux — mais indémodable — Démineur…
Une grille à 2 dimensions
Je vous laisse parcourir ce code basique et ses quelques commentaires :
function generateMap(size = 12, difficulty = 0.1) {
let grid = [];
let bombs = 0; // Initialize a bomb count
for (var y = 0; y < size; y++) {
var row = [];
for (var x = 0; x < size; x++) {
// Here the magic happens: bomb or no bomb
let val = Math.random() > (difficulty * size / 8) ? 0 : "💣";
if (val === "💣") bombs += 1;
row.push({
value: val,
active: false
});
}
grid.push(row);
}
// A bit silly, but if there aren't enough bombs, just generate the map one more time.
if(bombs < Math.round(3 / 4 * size)) return generateMap(size, difficulty);
return grid;
}
export default generateMap;
Un Array
pour chaque ligne dans un Array
contenant toutes ces lignes et nous voilà avec un tableau à 2 dimensions.
Pour simplifier ma réflexion, j’ai décidé de faire l’algorithme en 2 parties distinctes. Dans cette suite, nous allons compter le nombre de bombes adjacentes à chacune des cellules de notre grille.
La logique du jeu
Une cellule contenant une bombe aura donc pour valeur “💣”. Une cellule ayant explosé “💥” et pour les autres, le nombre de bombes adjacentes.
Nous aurons besoin de cette fonction au démarrage de la partie afin de définir l’ensemble des valeurs que nous afficherons une fois une zone découverte. Nous nous en servirons aussi par la suite, après un click du joueur pour savoir si nous devons découvrir sa cellule ainsi que les potentielles cellules adjacentes sans bombe.
Voici l’ensemble de la fonction, nous verrons ensuite ses points d’intérêts :
function processMap (grid) {
let hasChanges = false; // Keep track if the processing toggled a cell
grid = grid.map((line, y) => {
return line.map((cell, x) => {
if (cell.active || cell.value === "💣" || cell.value === "💥") return cell; // No change needed
const suroundings = getSuroundings(map, x, y);
// Check if some empty and active cells exist
if (suroundings.filter(sur => sur?.active && sur?.value <= 0).length >= 1) {
hasChanges = true;
return { ...cell, active: true };
}
// Else, return the cell with its suroundings count
return {
...cell,
value: suroundings.filter(sur => sur?.value === "💣" || sur?.value === "💥").length,
};
})
}, []);
if (hasChanges) return processMap(grid); // Propagate active cells
return grid;
}
export default processMap;
On a donc 2 boucles imbriquées pour parcourir chaque ligne et chaque cellule. Pour chaque cellule, on va lister ses cellules voisine dans suroundings
. Il y a ici un piège quand nous sommes sur les cellules du bord de la grille :
- sur la première ligne il n’existe pas de cellule au-dessus
- sur la dernière, aucune en dessous
- première colonne, pas de cellule précédente
- dernière colonne, pas de cellule suivante
Nous avons donc besoin d’une méthode qui liste les cellules adjacentes :
function getSuroundings(map, x, y) {
return [
map[y - 1]?.[x], // top
map[y - 1]?.[x - 1], // top-left
map[y - 1]?.[x + 1], // top-right
map[y]?.[x - 1], // left
map[y]?.[x + 1], // right
map[y + 1]?.[x], // bottom
map[y + 1]?.[x - 1], // bottom-left
map[y + 1]?.[x + 1] // bottom-right
];
}
Pour éviter d’avoir à gérer explicitement ces cas, nous allons donc choisir d’utiliser un optional chaining operator ?.
qui retournera undefined
dans le cas où l’objet serait undefined
et surtout ne lèvera pas d’erreur d’exécution de type :
TypeError: Cannot read property '0' of undefined
Maintenant dans le cas où l’une des cellules voisines est active et n’est entourée d’aucune bombe nous allons activer la cellule courante. Cela permet de découvrir des zones complètes (et vide) sans risque. Cette méthode devant être récursive pour étendre la zone à chaque occurrence, nous la relancerons en fin de fonction avec le flag hasChanges
.
Comme vu précédemment, la variable sur
est potentiellement undefined
, on utilise donc la même astuce du ?.
pour les 2 prochains bout de code.
// Check if some empty and active cells exist
if (suroundings.filter(sur => sur?.active && sur?.value === 0).length >= 1) {
hasChanges = true;
return { ...cell, active: true };
}
Enfin le fonctionnement relativement par défaut (utilisé à la génération de grille) consiste à compter le nombre de bombes entourant la cellule actuelle pour l’inscrire dans sa méthode value
.
// Else, return the cell with its suroundings count
return {
...cell,
value: suroundings.filter(sur => sur?.value === "💣" || sur?.value === "💥").length,
};
En fin de fonction on s’assure de relancer la propagation de la zone découverte s’il y a eu un changement, et on renvoie la grille mise à jour dans le cas contraire.
Installation du plugin Babel
Imaginons que vous ayez déjà un environnement Vue/React ou même Node pour faire tourner votre code JavaScript, il contient déjà sûrement le compilateur Babel.
Il vous suffit alors d’ajouter une dépendance de dev comme suis :
npm install --save-dev @babel/plugin-proposal-optional-chaining
Puis d’ajouter à votre fichier babel.config.js
la ligne correspondant à notre plugin :
module.exports = {
presets: [
'@vue/app'
],
"plugins": ["@babel/plugin-proposal-optional-chaining"]
}
On joue un peu ?
Je pense avoir fait le tour de la génération de grille pour le démineur et vous avez ici toute la logique nécessaire pour créer et mettre à jour votre jeu. Il ne reste qu’à ajouter interactions, visuels, musique, et milles autres détails pour faire de cet algo un jeu.
J’espère à travers ce simple exemple avoir su mettre en exergue l’intérêt de l’optional-chaining
et je vous invite à tester la version plus complète et édulcorée du jeu qui m’a permis d’écrire cet article. J’aurais probablement l’occasion de revenir sur plusieurs éléments intéressant de cette app, n’hésitez pas dans les commentaires si un point vous intéresse particulièrement.