Article écrit par Victor Darras
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 :
See the Pen Shiny Game Of Life by Victor Darras (@victordarras) on CodePen.
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.
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.
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)";
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.
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.
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 !