Optionnal Chaining et Démineur

Publié le 29 mai 2020 par Victor Darras | javascript

Cet article est publié sous licence CC BY-NC-SA

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…

external-content.duckduckgo.com

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 voisines 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 bouts 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.

Malheureusement pour le moment il doit sûrement vous retourner (ici avec vue-cli) :

noplugin_babel

Il vous suffit alors d’ajouter une dépendance de dev comme suit :

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 mille 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’aurai probablement l’occasion de revenir sur plusieurs éléments intéressants de cette app, n’hésitez pas dans les commentaires si un point vous intéresse particulièrement.


L’équipe Synbioz.
Libres d’être ensemble.