Exercice 06 - SNAKE
Bonjour,
Avec ce sixième exercice on va mettre un peu de vie dans nos jeux.
SNAKE va nous permettre d'aborder une autre notion essentielle dans les jeux, la trigonométrie.
Avant de commencer, voici à quoi tout ceci va ressembler.
*pour des raisons de sécurité il n'est pas possible de vous présenter le jeu au sein d'une page du wiki, reportez-vous à la source située en bas de cette page pour voir comment ça marche.
Etude préliminaire
Tout d'abord le SNAKE c'est quoi ? (merci Wikipedia)
Snake, de l'anglais signifiant « serpent », est un jeu vidéo populaire créé au milieu des années 1970, disponible de par sa simplicité sur l'ensemble des plate-formes de jeu existantes sous des noms de clone. Il s'est de nouveau fait connaître dans les années 1990 avec l'émergence du nouveau support de jeu qu'est le téléphone portable. Aujourd'hui, il est toujours aussi populaire et est devenu un classique dans les jeux vidéo.
Le joueur contrôle une longue et fine créature semblable à un serpent, qui doit slalomer entre les bords de l'écran et les obstacles qui parsèment le niveau. Pour gagner chacun des niveaux, le joueur doit faire manger à son serpent un certain nombre de pastilles ou de fruits (de la nourriture en général), allongeant à chaque fois la taille de la bestiole. Alors que le serpent avance inexorablement, le joueur ne peut que lui indiquer une direction à suivre (en haut, en bas, à gauche, à droite) afin d'éviter que la tête du serpent ne touche les murs ou son propre corps, dans ce cas il risque de mourir.
Le niveau de difficulté est contrôlé par l'aspect du niveau (simple ou labyrinthique), le nombre de pastilles à manger, l'allongement du serpent et sa vitesse.
Dans la version que nous allons réaliser, nous allons tenir compte de certains facteurs, comme l'allongement du serpent lorsqu'il mange, la perte de la partie si il touche un bord de l'écran ou un de ses propres anneaux, mais nous allons modifier la jouabilité en proposant une approche basée sur la trigonométrie. L'aspect labyrinthe des niveaux sera laissé à votre libre appréaciation.
Les pré-requis
Pour ce programme vous devez connaître :
Variables et fonctions en Javascript : http://forums.mediabox.fr/wiki/tutoriaux/javascript/language/notions_base
Ecouteurs d'événements en Javascript : https://developer.mozilla.org/fr/docs/Web/API/EventTarget/addEventListener
Manipulation de tableaux en Javascript : http://www.commentcamarche.net/contents/587-javascript-les-tableaux
Exercice PONG en Javascript : http://forums.mediabox.fr/wiki/tutoriaux/javascript/divers/exercice_pong
Exercice TAQUIN en Javascript : http://forums.mediabox.fr/wiki/tutoriaux/javascript/divers/exercice_taquin
Si vous souhaitez plus de précisions sur ces points, je vous encourage à parcourir le Wiki de Mediabox où vous trouverez de nombreux tutoriaux détaillés.
La structure
Le support principal est une page HTML classique utilisant une simple balise canvas et intégrant une feuille de style et le script du jeu.
<!DOCTYPE html> <html> <head> <title>SNAKE</title> <link rel="stylesheet" type="text/css" href="css/styles.css" /> <script type="text/javascript" src="js/jeu.js"></script> <!--[if lt IE 9]><script type="text/javascript" src="excanvas.compiled.js"></script><![endif]--> </head> <body> <canvas id="canvas">Votre navigateur ne supporte pas HTML5.</canvas> </body> </html>
L'habillage
J'ajoute une bordure à la balise canvas :
canvas { border: 1px solid black; }
Les images
Je prépare tous mes assets, c'est à dire toutes les images prédécoupées qui vont servir à mon jeu.
Je range le tout dans le dossier “assets”.
Le code Javascript
// variables var canvas, ctx, longueur, angle, compteur, ajouteAnneaux, victoire, vitesse, droite, gauche, snake, pomme, posx, posy, stockAnneaux, timer; var W = 480; var H = 480; // charger les images du jeu var snakeImg = new Image(); var head = new Image(); var pommeImg = new Image(); var fond = new Image(); fond.src = "assets/fond.jpg"; pommeImg.src = "assets/pomme.png"; head.src = "assets/head.png"; snakeImg.src = "assets/anneau.png"; // préparation du jeu window.onload = function() { canvas = document.getElementById('canvas'); ctx = canvas.getContext('2d'); canvas.width = W; canvas.height = H; init(); } // initialisation du jeu function init() { snake = {}; pomme = {}; posx = []; posy = []; stockAnneaux = []; longueur = 5; angle = 0; compteur = 0; ajouteAnneaux = 4; victoire = 140; vitesse = 10; droite = 0; gauche = 0; for (var i=0; i<longueur; i++) { stockAnneaux.push({x:0,y:0,width:snakeImg.width,height:snakeImg.height}) } snake.x = W*.5; snake.y = H*.5; snake.width = head.width; snake.height = head.height; pomme.x = 30+Math.random()*(W-60); pomme.y = 30+Math.random()*(H-60); pomme.width = pommeImg.width; pomme.height = pommeImg.height; canvas.setAttribute('tabindex','1'); canvas.focus(); timer = setInterval(main,60); canvas.addEventListener("keydown", appuie, false); canvas.addEventListener("keyup", relache, false); } // gestion clavier function appuie(e){ if (e.keyCode == 39) droite = 1; if (e.keyCode == 37) gauche = 1; } // gestion clavier function relache(e){ if (e.keyCode == 39) droite = 0; if (e.keyCode == 37) gauche = 0; } // boucle principale function main(){ var i; var j; // Direction angle += (parseInt(droite)-parseInt(gauche))*20; // Enregistre la position de la tête posx[0] = snake.x; posy[0] = snake.y // Deplace la tête snake.x += Math.cos(angle*Math.PI/180)*vitesse; snake.y += Math.sin(angle*Math.PI/180)*vitesse; // Envoye le dernier anneau en premier var dernier = longueur-1-compteur++; if (dernier == 0) compteur = 0; stockAnneaux[dernier].x = posx[0]; stockAnneaux[dernier].y = posy[0]; // Déplace les anneaux for (i=longueur-1; i>0; i--) { posx[i] = posx[i-1]; posy[i] = posy[i-1]; } // si la tête touche de la nourriture if (collisions(snake,pomme)) { // ajoute des anneaux for (i=0; i<ajouteAnneaux; i++) { posx[longueur] = posx[longueur-1]; posy[longueur] = posy[longueur-1]; longueur++; stockAnneaux.push({x:0,y:0,width:snakeImg.width,height:snakeImg.height}); } // place la pomme ailleurs pomme.x = 30+Math.random()*(W-60); pomme.y = 30+Math.random()*(H-60); // Repositionne le Snake complet (assure la cohésion) for (i=0; i<longueur; i++) { stockAnneaux[i].x = posx[i]; stockAnneaux[i].y = posy[i]; } compteur = 0; } // Vérifie si la tête touche un bord if (snake.x<0 || snake.x>W || snake.y<0 || snake.y>H) finPartie(); // Vérifie si la tête touche un anneau var point = {x:snake.x+Math.cos(angle*Math.PI/180)*4, y:snake.y+Math.sin(angle*Math.PI/180)*4, width:1, height:1}; for (i =0; i<stockAnneaux.length; i++){ if(collisions(point,stockAnneaux[i])) finPartie(); } // vérifie si le joueur gagne if (longueur>=victoire) gagnePartie(); // dessin final render(); } // collisions function collisions(A,B) { if (A.y+A.height < B.y || A.y > B.y+B.height || A.x > B.x+B.width || A.x+A.width < B.x) return false; return true; } function finPartie(){ alert("Perdu, cliquez pour rejouer."); clearInterval(timer); init(); } function gagnePartie(){ alert("Bravo, cliquez pour rejouer."); clearInterval(timer); init(); } // Dessine le jeu function render() { ctx.drawImage(fond,0,0); ctx.save(); ctx.translate(snake.x + snake.width / 4, snake.y + snake.height / 2); ctx.rotate(angle*Math.PI/180); ctx.drawImage(head,-snake.width/4,-snake.height/2); ctx.translate(-(snake.x + snake.width / 4), -(snake.y + snake.height / 2)); ctx.restore(); for(var i =0; i<stockAnneaux.length;i++){ ctx.drawImage(snakeImg,posx[i],posy[i]); } ctx.drawImage(pommeImg,pomme.x,pomme.y); }
Etude du programme
// variables var canvas, ctx, longueur, angle, compteur, ajouteAnneaux, victoire, vitesse, droite, gauche, snake, pomme, posx, posy, stockAnneaux, timer; var W = 480; var H = 480; // charger les images du jeu var snakeImg = new Image(); var head = new Image(); var pommeImg = new Image(); var fond = new Image(); fond.src = "assets/fond.jpg"; pommeImg.src = "assets/pomme.png"; head.src = "assets/head.png"; snakeImg.src = "assets/anneau.png"; // préparation du jeu window.onload = function() { canvas = document.getElementById('canvas'); ctx = canvas.getContext('2d'); canvas.width = W; canvas.height = H; init(); }
Si vous avez suivit les exercices précédents, c'est toujours la même base, on accélère un peu.
La listes des variables globales, et deux variables pour la hauteur et la largeur de la zone de jeu.
Le chargement des images, la préparation de la zone de jeu et on lance l'initialisation du jeu.
Cette fois pas de loader pour les images, il n'y a pas grand chose à charger.
// initialisation du jeu function init() { snake = {}; pomme = {}; posx = []; posy = []; stockAnneaux = []; longueur = 5; angle = 0; compteur = 0; ajouteAnneaux = 4; victoire = 140; vitesse = 10; droite = 0; gauche = 0; for (var i=0; i<longueur; i++) { stockAnneaux.push({x:0,y:0,width:snakeImg.width,height:snakeImg.height}) } snake.x = W*.5; snake.y = H*.5; snake.width = head.width; snake.height = head.height; pomme.x = 30+Math.random()*(W-60); pomme.y = 30+Math.random()*(H-60); pomme.width = pommeImg.width; pomme.height = pommeImg.height; canvas.setAttribute('tabindex','1'); canvas.focus(); timer = setInterval(main,60); canvas.addEventListener("keydown", appuie, false); canvas.addEventListener("keyup", relache, false); }
On va travailler avec deux objets (snake et pomme), trois tableaux (deux pour stocker des positions temporaires et un pour stocker les anneaux du serpent) et quelques variables dont le nom est assez évocateur. A l'aide d'une première boucle on va créer 4 anneaux pour notre serpent, chacun est un objet. On place la tête du serpent (snake) et la pomme qu'il doit manger pour grandir. Il reste à donner le focus au canvas puisque c'est un jeu qui se joue au clavier, définir la boucle principale (timer) et ajouter deux écouteurs pour les touches du clavier.
Voilà, le jeu est prêt, on peut lancer la partie.
// gestion clavier function appuie(e){ if (e.keyCode == 39) droite = 1; if (e.keyCode == 37) gauche = 1; } // gestion clavier function relache(e){ if (e.keyCode == 39) droite = 0; if (e.keyCode == 37) gauche = 0; }
Deux touches suffisent à gérer la direction du serpent.
L'important c'est ce qui suit :
// boucle principale function main(){ var i; var j; // Direction angle += (parseInt(droite)-parseInt(gauche))*20; // Enregistre la position de la tête posx[0] = snake.x; posy[0] = snake.y // Deplace la tête snake.x += Math.cos(angle*Math.PI/180)*vitesse; snake.y += Math.sin(angle*Math.PI/180)*vitesse; // Envoye le dernier anneau en premier var dernier = longueur-1-compteur++; if (dernier == 0) compteur = 0; stockAnneaux[dernier].x = posx[0]; stockAnneaux[dernier].y = posy[0]; // Déplace les anneaux for (i=longueur-1; i>0; i--) { posx[i] = posx[i-1]; posy[i] = posy[i-1]; } // si la tête touche de la nourriture if (collisions(snake,pomme)) { // ajoute des anneaux for (i=0; i<ajouteAnneaux; i++) { posx[longueur] = posx[longueur-1]; posy[longueur] = posy[longueur-1]; longueur++; stockAnneaux.push({x:0,y:0,width:snakeImg.width,height:snakeImg.height}); } // place la pomme ailleurs pomme.x = 30+Math.random()*(W-60); pomme.y = 30+Math.random()*(H-60); // Repositionne le Snake complet (assure la cohésion) for (i=0; i<longueur; i++) { stockAnneaux[i].x = posx[i]; stockAnneaux[i].y = posy[i]; } compteur = 0; } // Vérifie si la tête touche un bord if (snake.x<0 || snake.x>W || snake.y<0 || snake.y>H) finPartie(); // Vérifie si la tête touche un anneau var point = {x:snake.x+Math.cos(angle*Math.PI/180)*4, y:snake.y+Math.sin(angle*Math.PI/180)*4, width:1, height:1}; for (i =0; i<stockAnneaux.length; i++){ if(collisions(point,stockAnneaux[i])) finPartie(); } // vérifie si le joueur gagne if (longueur>=victoire) gagnePartie(); // dessin final render(); }
On commence par récupérer l'angle, l'orientation du serpent : angle actuel + touches appuyées * vitesse de rotation.
On enregistre l'ancienne position de la tête.
On déplace la tête à sa nouvelle position : cos(angle)*vitesse et sin(angle)*vitesse.
Notez qu'on convertit l'angle en radians : angle*Math.PI/180
Voilà pour la trigonométrie de base, on détermine un angle et on déplace un objet en fonction de cet angle.
Ensuite on envoie le dernier anneau en premier.
Attend…. quoi ?!
Immédiatement après avoir déplacé la tête il y a un trou entre elle et les anneaux, c'est la place qu'elle occupais juste avant. Pour déplacer tout le serpent, c'est à dire la tête et les anneaux, nous avions le choix de déplacer tous les anneaux en permanence, mais c'est très lourd surtout dès que le serpent atteint une certaine longueur. En réfléchissant un peu on peut trouver une petite astuce très simple qui suit le raisonnement suivant : tous les anneaux sont identiques, la tête du serpent avance, plutôt que de déplacer tous les anneaux je prend le dernier et je viens le replacer à la position qu'occupait la tête juste avant son déplacement.
Voilà pourquoi on à enregistré la position de la tête dans les tableaux de position juste avant de la déplacer, car c'est cette position que doit occuper le dernier anneau de la file afin de faire avancer tout le serpent.
Résumons :
- lorsque le snake avance on enregistre la position de la tête
- on déplace la tête en fonction de sa rotation et de sa vitesse
- on prend le dernier anneau de la queue et on le repositionne à l'endroit qu'occupait la tête avant son déplacement.
Vous allez me dire, ok c'est très simple, mais alors pourquoi juste après on déplace tous les anneaux ?
// Déplace les anneaux for (i=longueur-1; i>0; i--) { posx[i] = posx[i-1]; posy[i] = posy[i-1]; }
En fait il ne s'agit pas de les déplacer, mais plutôt de les replacer à la bonne position, chaque anneau doit prendre la position de celui qui le précède dans la liste, ce qui assure la cohésion de l'ensemble. Ce n'est pas visible tant que le snake conserve sa taille de départ, il y a toujours le même nombre d'anneaux et on replace le dernier en premier pour assurer le déplacement, mais dès que le serpent mange une pomme on lui ajoute une flopé de nouveaux anneaux, si on les repositionne pas tout de suite comme il faut on va se retrouver avec des anneaux superposés ou avec un trou dans le serpent car n'oubliez pas que le dernier anneau se place en premier et que les nouveaux anneaux ne sont pas encore positionnés à la suite de la queue du serpent. On fait donc une petite boucle sur tous les anneaux pour s'assurer que chacun est à sa place même si leur nombre augmente.
Le snake se déplace, lorsqu'il touche une pomme on augmente le nombre d'anneaux (avec la méthode dont on vient de parler), et on replace la pomme, au passage on revérifie la position de chaque anneau.
Lorsqu'il touche un bord la partie est perdu, lorsque la tête touche un anneau la partie est perdue, et si le serpent atteint la longueur souhaitée pour la victoire le joueur gagne.
Pour finir on lance le rendu du jeu.
// collisions function collisions(A,B) { if (A.y+A.height < B.y || A.y > B.y+B.height || A.x > B.x+B.width || A.x+A.width < B.x) return false; return true; } function finPartie(){ alert("Perdu, cliquez pour rejouer."); clearInterval(timer); init(); } function gagnePartie(){ alert("Bravo, cliquez pour rejouer."); clearInterval(timer); init(); }
La gestion des collisions est un classique que l'on à vu dans le PONG (prérequis), quand aux fins de parties elles sont faciles à comprendre, on affiche une alerte, on stoppe le timer et on réinitialise le jeu.
Reste donc le rendu :
// Dessine le jeu function render() { ctx.drawImage(fond,0,0); ctx.save(); ctx.translate(snake.x + snake.width / 4, snake.y + snake.height / 2); ctx.rotate(angle*Math.PI/180); ctx.drawImage(head,-snake.width/4,-snake.height/2); ctx.translate(-(snake.x + snake.width / 4), -(snake.y + snake.height / 2)); ctx.restore(); for(var i =0; i<stockAnneaux.length;i++){ ctx.drawImage(snakeImg,posx[i],posy[i]); } ctx.drawImage(pommeImg,pomme.x,pomme.y); }
La première chose à faire c'est dessiner l'image de fond.
Puis on s'attaque à la tête du serpent, la particularité c'est qu'elle doit être tournée dans la bonne direction.
Pour appliquer une transformation à un sprite que l'on dessinne il faut jouer sur le context.
On commence donc par sauver le context actuel, histoire de le remettre en place quand on a fini.
On déplace l'outil de dessin là où on veut dessiner la tête.
On oriente le tout dans la bonne direction.
on dessine l'image de la tête.
On replace la tête au bon endroit et on retaure le context avec ses paramètres d'origine.
Pour le reste c'est facile, pas besoin de rotation sur les anneaux, ils sont ronds, ni pour la pomme.
On trace donc le tout tel quel.
Conclusion
Cette fois pas de grilles, mais de la trigonométrie, des collisions, et quelques bidouilles amusantes. On commence à avoir pas mal de notions intéressantes qui vont nous permettre de nous lancer sur des jeux au moteur un peu plus costaud. Cependant quand je vois les manipulations qu'il faut faire pour appliquer une simple rotation à un sprite, je me dit qu'il serait enfin temps d'utiliser quelques frameworks qui vont simplifier tout ça. Mais il me reste encore quelques petits jeux simples à vous montrer avant d'en arriver là.
Les sources
