Go to Hackademy website

Jeu de la vie et rendus de la mort

Victor Darras

Posté par Victor Darras dans les catégories front

Bonjour à tous, aujourd’hui je vous propose de revoir un classique du monde du développement, le jeu de la vie. Automate cellulaire plus qu’un vrai jeu, c’est avant tout un algorithme qui va nous permettre de générer des visuels plus ou moins complexes, avec des mouvements, des couleurs, bref : de quoi amuser un graphiste/dev-front/curieux comme moi. J’ai d’abord eu l’occasion de créer un moteur de rendu pour une implémentation Elixir sur laquelle Benoît travaille en ce moment puis j’ai pris le temps de refaire une implémentation JavaScript afin de mieux comprendre l’objet de mes recherches.

Voici ce que nous devrions obtenir en fin d’article :

Nous verrons dans un premier temps de quelle manière nous pouvons l’implémenter en JavaScript, gérer ses cycles de vie, l’afficher dans un canvas et enfin jouer avec le rendu !

Conway’s game of life

Le jeu de la vie ne nécessite aucun joueur, il est en général représenté par une grille (de taille infinie en théorie), divisée en cellules « vivantes » ou « mortes » (d’où le jeu de la vie) qui réagissent les unes par rapport aux autres pour créer des cycles de vie.

Pour chaque cycle, nous nous assurerons que :

  • Une cellule morte possédant exactement trois voisines vivantes devient vivante (elle naît);
  • Une cellule vivante possédant deux ou trois voisines vivantes le reste, sinon elle meurt.

Gospers_glider_gun

Ces règles très simples créent — dans certaines situations précises — des patterns reconnaissables et pourtant peu prévisibles que notre cerveau assimile facilement à un ersatz de vie.

Une vidéo plus en détail qui explique et explore beaucoup d’aspects du jeu.

Des règles simples effectivement, mais pour l’implémentation ?

Comme je vous l’expliquais en début d’article, j’ai commencé par afficher un rendu à partir de données statiques générées par une autre app. C’était pour moi l’occasion de me faire la main sur l’API canvas du navigateur.

Bonus ou pré-requis : modification de la structure de donnée

La donnée de base que j’avais en entrée est une chaîne de caractères constituée de 0, de 1 et de passages à la ligne \n. Nous allons commencer par la changer en un tableau de tableaux afin de pouvoir boucler sur les 2 niveaux plus facilement :

const data = `0101010101010101
0101010101010101
0101010101010101`; // do not copy-paste this data

let dataArray = data.split('\n').reduce((acc, cur) => {
  return acc = [
    ...acc,
    Array.from(cur).map(cell => parseInt(cell))
  ]
}, [])

Ainsi dataArray devient un tableau contenant un tableau pour chaque ligne. Chaque ligne étant un tableau d’entiers correspondant à l’état d’une cellule.

[ [ 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1 ],
  [ 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1 ],
  [ 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1 ] ]

Rendu en canvas

Maintenant que nous avons des données correctement formatées, nous allons pouvoir lancer notre rendu.

Je vous invite dans un premier temps à ouvrir un nouveau fichier HTML et y ajouter la balise canvas qui affichera notre rendu. Attention dans ces exemples, je pars d’un document HTML avec un fond noir.

<canvas id="gol" width="1000" height="1000"></canvas>

Ouvrons maintenant une balise script dans laquelle nous allons faire appel à la Canvas API.

Commençons par récupérer notre canvas HTML dans une variable du même nom puis nous récupérons son contexte de dessin que nous appelons ctx.

const `canvas` = document.getElementById('gol');
const ctx = canvas.getContext('2d');

Dans une fonction draw (que nous aurons l’occasion d’appeler plus tard), nous allons itérer (ou boucler) sur dataArray puis sur chaque ligne. Pour chaque cellule, nous définissons une couleur en fonction de l’état de la cellule (blanc = en vie, noir = morte) avec fillStyle puis dessinons un rectangle aux coordonnées correspondantes avec fillRect. Ici j’ai 100x100 cellules, affichées dans un contexte de 1000x1000, je multiplie donc les positions par 10 et affiche des carrés de 10x10.

function draw() {
  dataArray.forEach((line, y) => {
    line.forEach((cell, x) => {
      ctx.fillStyle = cell ? "#fff" : "#000";
      ctx.fillRect(x * 10, y * 10, 10, 10);
    })
  })
}

Avec ce peu de code, vous devriez être en mesure d’afficher le rendu de cette première image du jeu en appelant draw(). Malheureusement nous ne donnerons pas l’impression d’une vie dans ce canvas sans mouvement, alors passons à la suite !

Génération d’un nouveau cycle de vie (ou algorithme du jeu de la vie)

Dans une seconde fonction nous allons faire en sorte de générer un nouveau tableau à partir du premier arrayData qui prendra en compte les 2 règles que nous avons vues plus haut.

On commence par ré-assigner dataArray (pas besoin de garder l’état précédent pour cette démo). Pour chaque ligne, nous retournons un tableau sur lequel nous allons boucler sur chacune des cellules. Pour chaque cellule il nous faut compter le nombre de cellules qui l’entourent. Pour ce faire, il suffit d’additionner les valeurs des cellules environnantes (1 = vivante, 0 = morte) et appliquer en conséquence une nouvelle valeur à la cellule courante.

function newCycle() {
  dataArray = dataArray.reduce((res, line, y) => {
    return res = [
      ...res,
      line.map((cell, x) => {
        // prevent to process the edges of our canvas
        if (x > 0 && y > 0 && x < 99 && y < 99) { 
          let surroundings = [
            dataArray[y-1][x-1], // top-left
            dataArray[y-1][x],   // top
            dataArray[y-1][x+1], // top-right
            dataArray[y][x-1],   // center-left
            dataArray[y][x+1],   // center-right
            dataArray[y+1][x-1], // bottom-left
            dataArray[y+1][x],   // bottom
            dataArray[y+1][x+1]  // bottom-right
          ]
          surroundings = surroundings.reduce((acc, cur) => acc += cur , 0);
          if (cell) {
            // Une cellule vivante possédant deux ou 
            // trois voisines vivantes le reste, sinon elle meurt.
            cell = surroundings === 2 || surroundings === 3  ? 1 : 0;
          } else {
            // Une cellule morte possédant exactement 
            // trois voisines vivantes devient vivante (elle naît).
            cell = surroundings === 3 ? 1 : 0;
          }
        }
      })
    ]
  }, [])
}

Générer plusieurs cycles consécutifs et donner la vie

Une solution simple pour gérer le framerate d’une animation est de lancer un setInterval en divisant 1000ms par le framerate voulu. Dans cet intervalle, nous allons donc générer un nouveau cycle, et redessiner notre canvas 60 fois par seconde.

FRAME_RATE = 60 // fps;
const loop = window.setInterval(function(){
  newCycle();
  draw();
}, 1000/FRAME_RATE)

Jouons un peu avec ce rendu

Arrivé ici vous devriez avoir une « animation » qui démarre toujours du même point et qui — si tout va bien — devrait globalement se stabiliser avec le temps.

Pour être sûr de pouvoir animer notre jeu de la vie plus longtemps, nous allons régulièrement ajouter un peu de « bruit » dans notre jeu de la vie. Commençons par écrire une fonction qui se chargera de switcher quelques cellules (autrement dit, les tuer ou leur donner la vie). Dans mes recherches, j’ai eu besoin de modifier d’abord une seule cellule, mais je me suis vite rendu compte qu’elle était tuée trop vite (la frame suivante !), j’ai donc pris le parti d’ajouter un pattern spécifique conditionné par l’argument multiple. Je m’assure également d’avoir la place pour switcher les cellules avec x+5 && x-5 afin d’éviter d’inutile erreur dans la console (ça a peu d’influence sur l’exécution du code).

function toggleCell(x, y, multiple){
  dataArray[x][y] = !dataArray[x][y];
  if (multiple && dataArray[x+5] && dataArray[x-5]) {
    dataArray[x+1][y] = !dataArray[x+1][y];
    dataArray[x-3][y] = !dataArray[x-3][y];
    dataArray[x-1][y] = !dataArray[x-1][y];
    dataArray[x][y+1] = !dataArray[x][y+1];
    dataArray[x+3][y] = !dataArray[x+3][y];
    dataArray[x-5][y] = !dataArray[x-5][y];
    dataArray[x][y+3] = !dataArray[x-3][y+3];
  }
}

Je lance maintenant cette fonction au hasard dans mon espace de rendu, 10 fois par seconde :

const loopRand = window.setInterval(function(){
  toggleCell(parseInt(Math.random()*100), parseInt(Math.random()*100), true)
}, 100)

Si tout va pour le mieux, vous devriez obtenir une animation continuellement en mouvement.

classic

Smooth

Passons maintenant aux choses sérieuses (et pourtant les plus simples). Nous allons faire en sorte de « lisser » le rendu de chaque frame par rapport aux précédentes. Pour ce faire il nous faut changer le fillStyle des cellules pour appliquer des couches transparentes qui viendront effacer les couches précédentes.

Dans la fonction draw() on va donc changer pour un blanc à 50% et un noir à 5% avec une valeur en RGBA. Je vous invite à jouer avec ces valeurs pour modifier le rendu à votre guise.

ctx.fillStyle = cell ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.05)";

smooth

Vous voilà avec un rendu un peu plus organique qu’un jeu de la vie ordinaire.

Shiny

Pour la version « Shiny » de cette démonstration, j’ai simplement modifié le fillStyle pour une valeur de RGBA aléatoire.

function random_rgba() {
  const o = Math.round, r = Math.random, s = 255;
  return 'rgba(' + o(r()*s) + ',' + o(r()*s) + ',' + o(r()*s) + ',' + r().toFixed(1) + ')';
}

Puis dans draw() comme pour l’exemple précédent :

ctx.fillStyle = cell ? random_rgba() : "rgba(0,0,0,0.05)";

Pour cette version en particulier, je trouvais que le rendu manquait un peu de fluidité entre les frames (qui s’explique par des changements parfois radicaux de couleur), j’ai donc pris le parti d’ajouter un filtre SVG à mon canvas qui va flouter les formes puis les préciser avec du contraste (et par là même rendre les couleurs plus brillantes).

canvas.style = "filter: blur(2px) contrast(10)";

Attention c’est clairement avec ce genre de méthode que l’on commence à faire souffler la machine.

shinny

Blob

Ma machine est lancée à plein régime, et je commence à me fatiguer de cette avalanche de couleur, je reviens donc à mon rendu « Smooth » vu précédemment, mais je vais accentuer les filtres de blur et de contrast.

return canvas.style = "filter: blur(7px) contrast(50)";

Maintenant notre première version relativement floue se précise et crée des formes molles et très organiques que l’on associe difficilement à un jeu de la vie standard.

blob

Encore un peu de hasard et on conclut

Pour continuer dans la foulée, je vous incite à générer la première frame de l’animation au hasard, pour éviter de garder les données en dur, et alléger grandement votre code. Ça sera aussi l’occasion de visualiser une « vie » différente à chaque chargement. Une méthode dans ce genre fera parfaitement l’affaire :

function newMap() {
  map = []
  for (var i = 0; i < 100; i++) {
    var suBmap = [];
    for (var j = 0; j < 100; j++) {
      suBmap.push(Math.random() > 0.5);
    }
    map.push(suBmap)
  }
  return map;
}
dataArray = newMap();

Et je terminerai là-dessus, j’espère avoir attisé votre curiosité sur le jeu de la vie mais surtout autour des rendus en canvas et leur grande flexibilité avec parfois quelques changements infimes. N’hésitez pas à m’envoyer vos expérimentations dans les commentaires !

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

Articles connexes

Houdini, CSS by JS

21/03/2019

Bonjour à tous, bienvenue dans le monde magique de l’illusion et des faux-semblants, où un background peut souvent en cacher un autre. Que le rideau se lève le temps d’apercevoir ce qui se cache...

Architecture en trois tiers d'une application Vue.js

21/02/2019

Ça commence à faire un petit moment que je vous bassine avec Vue.js, et que je vous fais construire des single-page applications en s’appuyant dessus. Néanmoins, nous n’avons jamais réellement parlé...

Une bibliothèque pour gérer l'authentification avec Vue.js, partie 2 — en route vers HTTP

08/02/2019

À l’heure où j’écris ces lignes, il m’est difficile de prévoir le temps qu’il fera quand vous les lirez. Laissons donc cette fois-ci les considérations météorologiques de côté et replongeons-nous...

Dark mode et CSS

24/01/2019

Bonjour à tous, aujourd’hui un sujet (presque) d’actualité puisque nous allons parler du mode sombre de MacOS, mais surtout d’une nouvelle manière — assez radicale — de penser nos interfaces. Le...