I. Avant-propos

Ce tutoriel est extrait de mon blog Game in Progress, destiné à partager mon expérience dans la réalisation d'un jeu en HTML 5 - JavaScript.

II. Menu principal

Dans ce premier tutoriel nous allons nous intéresser à la bibliothèque Box2dWeb.
Cette bibliothèque sera utilisée pour réaliser le moteur physique du jeu. Ici, nous allons nous pencher sur les fonctionnalités de base de la bibliothèque :

  • créer un « monde » ou environnement physique 2D dans un canvas HTML 5 ;
  • ajouter des objets statiques et dynamiques ;
  • configurer le mode debug de box2d.

Bref, une petite mise en bouche qui devrait, si tout va bien, nous mener à ce résultat :

Image non disponible

II-A. Résultat final

Vous pouvez voir le résultat final ici.
Et pour récupérer les sources correspondantes, c'est ici.

II-B. Qu'est-ce que Box2d ?

Box2D est une bibliothèque logicielle libre de moteur physique 2D utilisée pour la réalisation de jeux ou d'applications. Initialement écrite en C++, elle a été portée vers de nombreux autres langages, tels que Java (JBox2d), ActionScript (Box2dFlash) et JavaScript (Box2dJS).
La version qui nous intéresse est Box2dWeb, qui est en fait un portage de Box2dFlash vers JavaScript.

III. Paramétrage

III-A. Bibliothèques

Avant de commencer à développer, voici la liste des bibliothèques qui vont être utilisées dans ce projet et les liens pour les récupérer :

  • évidemment, la bibliothèque Box2dWeb qui est au centre du projet ;
  • mais aussi, jQuery, dont nous nous servirons très peu dans ce tutoriel, mais qu'il est toujours bon d'avoir sous la main lorsque l'on développe un projet en JavaScript.

III-B. Structure du projet

Une fois les bibliothèques récupérées, sortons notre plus fidèle IDE et initialisons un nouveau projet. Nous allons créer un nouveau projet contenant les répertoires et fichiers suivants :

  • libs : répertoire contenant nos bibliothèques JavaScript. On y place les deux bibliothèques récupérées juste avant ;
  • css : répertoire contenant nos feuilles de style. Ici un seul fichier CSS, subtilement nommé « style.css » ;
  • js : répertoire contenant nos sources JavaScript. On y crée deux fichiers : « box2dutils.js » et « gip.js » ;
  • index.html: à la racine, évidemment !
Image non disponible

IV. Play !

IV-A. Les fichiers index.html et style.css

Dans le fichier index.html, on place simplement un élément canvas (de 800*600 px) qui va nous permettre de manipuler les objets graphiques.

 
Sélectionnez
<div id="divCanvas">
    <canvas width="800" height="600" id="gipCanvas"></canvas>
</div>

Le canvas est placé dans une div ce qui va nous permettre de le centrer dans la page et d'appliquer une couleur de fond plus facilement, grâce aux styles du fichier style.css :

 
Sélectionnez
#divCanvas {
    background-color: #808080;
    width: 800px;
    height: 600px;
    margin: auto;
}

Pour finir, n'oublions pas d'importer l'ensemble des fichiers JavaScript et CSS dans le fichier index.html :

index.html
Sélectionnez
<meta charset="utf-8" />
<title>Game in Progress - Box2d Web Tuto 1</title>              
<link href="css/style.css" rel="stylesheet" />
<!-- Import JS -->
<script type="text/javascript" src="libs/Box2dWeb-2.1.a.3.min.js"></script>
<script type="text/javascript" src="libs/jquery-1.9.0.min.js"></script>
<script type="text/javascript" src="js/box2dutils.js"></script>
<script type="text/javascript" src="js/gip.js"></script>

On notera au passage qu'avec HTML 5 l'attribut « type » des balises « link » et « script » est devenu obsolète. Plus besoin de spécifier qu'il s'agit d'un fichier de type CSS ou JavaScript. Dans le premier cas, la relation « stylesheet » indique implicitement la nature du fichier attendu, et dans le second cas, la balise elle-même (« script ») est suffisamment explicite.

Et voilà, c'est fini pour la partie HTML ! Place maintenant au gros morceau : le code JavaScript.

IV-B. Initialiser les fichiers JavaScript

Tout d'abord, nous allons initialiser les fichiers JavaScript. Ouvrons donc les deux fichiers créés précédemment pour y insérer les quelques lignes suivantes :

 
Sélectionnez
(function(){
    /** Contenu du script **/
}());

Vous avez sans doute déjà vu ce genre de chose. Il s'agit en fait d'englober tout le code dans une fonction anonyme. Le but est simple : nous créons une portée (ou scope) aux différentes déclarations de variables et de fonctions de notre fichier. Nous nous assurons ainsi qu'il n'y aura pas de perturbations avec les sources des autres fichiers chargés dans la page.
Si le sujet vous intéresse et que vous souhaitez en savoir plus, je vous laisse vous référer à cet article très bien écrit.

IV-C. Le fichier box2dutils.js

Entrons maintenant dans le vif du sujet. Le fichier box2dutils.js va nous servir de classe utilitaire. Il contiendra un ensemble de fonctions permettant de créer facilement des objets pour notre environnement physique. Il n'est pas possible en JavaScript d'effectuer d'import de packages ou de classes. Il faut donc passer par des déclarations de variables si l'on veut éviter de toujours appeler les adresses complètes des éléments box2d.

Commençons donc par « inclure » les classes suivantes dans notre fichier :

 
Sélectionnez
// "Import" des classes box2dweb
var b2World = Box2D.Dynamics.b2World;
var b2Vec2 = Box2D.Common.Math.b2Vec2;
var b2AABB = Box2D.Collision.b2AABB;
var b2BodyDef = Box2D.Dynamics.b2BodyDef;
var b2Body = Box2D.Dynamics.b2Body;
var b2FixtureDef = Box2D.Dynamics.b2FixtureDef;
var b2Fixture = Box2D.Dynamics.b2Fixture;
var b2MassData = Box2D.Collision.Shapes.b2MassData;
var b2PolygonShape = Box2D.Collision.Shapes.b2PolygonShape;
var b2CircleShape = Box2D.Collision.Shapes.b2CircleShape;
var b2DebugDraw = Box2D.Dynamics.b2DebugDraw;
var b2MouseJointDef =  Box2D.Dynamics.Joints.b2MouseJointDef;

Il s'agit des classes les plus couramment utilisées pour les projets box2d. Nous n'utiliserons pas la totalité de ces dernières dans ce premier tutoriel, mais cela pourrait bien nous servir plus tard !


Créons maintenant notre classe utilitaire Box2dUtils. Commençons, par lui définir un constructeur et un ensemble de méthodes :

 
Sélectionnez
/**
 * Constructeur
 */
Box2dUtils = function() {       
}

/**
 * Classe Box2dUtils
 */
Box2dUtils.prototype = {
    createWorld : function(context) {
          // Créer le "monde" 2dbox
    },
      createBody : function(type, world, x, y, dimensions, fixed, userData) {
          // Créer un objet
    },
      createBox : function(world, x, y, width, height, fixed, userData) {
          // Créer un objet "box"
    },
      createBall : function(world, x, y, radius, fixed, userData) {
          // Créer un objet "ball"
      }
}

Nous avons ici créé le patron de notre classe avec un constructeur, qui pour le moment reste vide, et les méthodes suivantes :

  • createWorld : permettant d'instancier un monde 2dbox avec des propriétés physiques et dans lequel nous allons pouvoir manipuler nos objets ;
  • createBox et createBall : destinées à créer des objets de type « box » et « ball » ;
  • createBody : appelée par les deux précédentes pour la construction des objets et de leurs propriétés physiques.

Complétons maintenant le corps de ces méthodes. Penchons-nous dans un premier temps sur la méthode createWorld :

 
Sélectionnez
createWorld : function(context) {
         var world = new b2World(
             new b2Vec2(0, 10),     // gravité
            true                   // doSleep
        );

         // Définir la méthode d'affichage du debug
         var debugDraw = new b2DebugDraw();
         // Définir les propriétés d'affichage du debug
         debugDraw.SetSprite(context);          // contexte
         debugDraw.SetFillAlpha(0.3);           // transparence
         debugDraw.SetLineThickness(1.0);       // épaisseur du trait
         // Affecter la méthode d'affichage du debug au monde 2dbox
         debugDraw.SetFlags(b2DebugDraw.e_shapeBit | b2DebugDraw.e_jointBit);
         world.SetDebugDraw(debugDraw);

         return world;
}

Qu'avons-nous fait ici ?
Nous avons créé une instance de box2DWorld en lui assignant deux propriétés :

  • un vecteur définissant la gravité : les valeurs 0, 10 sont couramment utilisées car elles permettent de simuler la gravité terrestre qui est de 9,8 m.s2 ;
  • doSleep : qui permet d'indiquer si l'on autorise les objets du monde 2D à passer au repos lorsqu'ils n'ont plus d'interaction physique avec l'extérieur. Cela permet de nettement améliorer les performances.

Ensuite, nous avons défini les propriétés d'affichage du mode debug. Box2d n'est pas une bibliothèque graphique dans le sens où elle n'a pas pour objectif d'afficher des éléments graphiques à l'écran mais de simuler les propriétés physiques de ces éléments. Cependant, elle propose un mode debug qui offre un rendu visuel de notre simulation. Nous configurons donc le mode debug (b2DebugDraw) et l'assignons à l'objet world.

Passons maintenant à la création des objets. Nous avons défini deux méthodes de création d'objets : createBox pour créer des cubes, et createBall pour créer des sphères. Les corps de ces méthodes sont très proches :

 
Sélectionnez
createBox : function(world, x, y, width, height, fixed, userData) {
        // Définir les dimensions de la box
        var dimensions = {
                        width: width,
                        height: height
        };
        // Appel à createBody()
        return this.createBody('box', world, x, y, dimensions, fixed, userData);
},

createBall : function(world, x, y, radius, fixed, userData) {
        // Définir les dimensions de la ball
        var dimensions = {
                radius: radius  
        };
        // Appel à createBody()
        return this.createBody('ball', world, x, y, dimensions, fixed,             userData);
}

En fait, il existe très peu de différences entre ces deux objets, si ce n'est leur forme. On va donc faire appel à une méthode commune pour les créer, en spécifiant le type d'objet à créer et les dimensions souhaitées. Dans le cas d'une box : la largeur et la hauteur, dans le cas d'une ball : le rayon.

Voyons maintenant ce que contient la méthode createBody :

 
Sélectionnez
createBody : function(type, world, x, y, dimensions, fixed, userData) {
        // Par défaut, l'objet est statique
        if (typeof(fixed) == 'undefined') {
                fixed = true;
        }
        // Créer l'élément Fixture
        var fixDef = new b2FixtureDef();
        fixDef.userData = userData;             // attribuer les propriétés spécifiques de l'objet
        // Dessiner l'objet en fonction de son type : sa forme et ses dimensions
        switch (type) {
                case 'box':
                        fixDef.shape = new b2PolygonShape();
                        fixDef.shape.SetAsBox(dimensions.width,                                 dimensions.height);
                        break;
                case 'ball':
                        fixDef.shape = new b2CircleShape(dimensions.radius);
                        break;
        }
        // Créer l'élément Body
        var bodyDef = new b2BodyDef();
        // Affecter la position à l'élément Body
        bodyDef.position.x = x ;
        bodyDef.position.y = y;
        if (fixed) {
                // élément statique
                bodyDef.type = b2Body.b2_staticBody;
        } else {
                // élément dynamique
                bodyDef.type = b2Body.b2_dynamicBody;
                fixDef.density = 1.0;
                fixDef.restitution = 0.5;
        }
        // Assigner l'élément fixture à l'élément body et l'ajouter au monde 2dbox
        return world.CreateBody(bodyDef).CreateFixture(fixDef);
}

Là, ça se complique un peu. Nous avons instancié deux classes : b2BodyDef et b2FixtureDef afin de créer notre objet.
Le Body est l'objet qui sera ajouté au monde 2dbox. On lui attribue les propriétés suivantes :

  • position : en lui spécifiant des coordonnées x et y ;
  • type : état statique ou dynamique (en fonction du paramètre « fixed » passé).

Le body en l'état ne possède pas plus de caractéristiques. Il ne sait pas comment s'afficher et encore moins comment réagir aux lois physiques de notre monde 2dBox. C'est le rôle des Fixtures de définir les propriétés physiques d'un objet. On attribue à la fixture les propriétés suivantes :

  • shape : la forme de l'objet, sphère ou cube en fonction du « type », et en spécifiant les dimensions ;
  • density : la densité de l'objet. Elle permettra au moteur physique de calculer la masse de l'objet en fonction de sa taille, et ainsi de déterminer son comportement dans le monde 2D ;
  • restitution : le coefficient de restitution de l'objet. Sans entrer dans les détails, cela permet de gérer les rebonds et les collisions. Je vous laisse vous référer à Wikipédia si vous aimez les formules mathématiques. Sachez tout de même que cette valeur doit être comprise entre 0 et 1 ;
  • userData : permet de stocker un ensemble d'autres propriétés propres à notre application ou jeu. On peut y stocker n'importe quoi ! Très utile lorsque l'on souhaite affecter un comportement particulier à un objet ou modifier son état en réaction à ce qui se passe à l'écran.

Pour finir, nous assignons donc la Fixture au Body et ajoutons le Body à l'univers 2D.

Et voilà, nous avons réalisé notre classe utilitaire pour manipuler tous les objets box2d qui vont nous servir dans ce tutoriel. Il est temps maintenant d'exploiter tout ça !

IV-D. Le fichier gip.js

Le fichier gip.js est le cœur de l'application (on ne peut pas encore parler de jeu). C'est ici que nous allons créer l'environnement 2D, y ajouter des objets et lancer la simulation. Le code à insérer dans ce fichier est le suivant :

 
Sélectionnez
var box2dUtils;         // classe utilitaire
var world;              // "monde" 2dbox
var canvas;             // notre canvas
var canvasWidth;        // largeur du canvas
var canvasHeight;       // hauteur du canvas
var context;            // contexte 2D

// Initialisation
$(document).ready(function() {
        init();
});

// Lancer à l'initialisation de la page
this.init = function() {
        box2dUtils = new Box2dUtils();  // instancier la classe utilitaire

        // Récupérer la canvas, ses propriétés et le contexte 2D
        canvas = $('#gipCanvas').get(0);
        canvasWidth = parseInt(canvas.width);
        canvasHeight = parseInt(canvas.height);
        context = canvas.getContext('2d');

        world = box2dUtils.createWorld(context); // box2DWorld

        // Créer le "sol" de notre environnement physique
        ground= box2dUtils.createBox(world, canvasWidth / 2, canvasHeight - 10, canvasWidth / 2, 10, true, 'ground');

        // Créer 2 box statiques
        staticBox = box2dUtils.createBox(world, 600, 450, 50, 50, true, 'staticBox');
        staticBox2 = box2dUtils.createBox(world, 200, 250, 80, 30, true, 'staticBox2');

        // Créer 2 ball statiques
        staticBall = box2dUtils.createBall(world, 50, 400, 50, true, 'staticBall');
        staticBall2 = box2dUtils.createBall(world, 500, 150, 60, true, 'staticBall2');

        // Créer 30 éléments ball dynamiques de différentes tailles
        for (var i=0; i<30; i++) {
                var radius = 45;
                if (i < 10) {
                        radius = 15;
                } else if (i < 20) {
                        radius = 30;
                }
                // Placer aléatoirement les objets dans le canvas
                box2dUtils.createBall(world,
                                Math.random() * canvasWidth,
                                Math.random() * canvasHeight - 400,
                                radius, false, 'ball'+i);
        }

        // Créer 30 éléments box dynamiques de différentes tailles
        for (var i=0; i<30; i++) {
                var length = 45;
                if (i < 10) {
                        length = 15;
                } else if (i < 20) {
                        length = 30;
                }
                // Placer aléatoirement les objets dans le canvas
                box2dUtils.createBox(world,
                                Math.random() * canvasWidth,
                                Math.random() * canvasHeight - 400,
                                length, length, false, 'ball'+i);
        }

        // Exécuter le rendu de l'environnement 2D
        window.setInterval(update, 1000 / 60);
}

// Mettre à jour le rendu de l'environnement 2D
this.update = function() {
       // effectuer les simulations physiques et mettre à jour le canvas
        world.Step(1 / 60,  10, 10);
        world.DrawDebugData();
        world.ClearForces();
}

Rien de vraiment compliqué ici, puisque l'on se contente de faire appel aux méthodes de notre classe utilitaire Box2dUtils pour créer l'environnement et les objets 2D (de tailles variables, avec un positionnement aléatoire).

Revenons quand même sur quelques bouts de code :

 
Sélectionnez
canvas = $('#gipCanvas').get(0);
context = canvas.getContext('2d');
world = box2dUtils.createWorld(context); // box2DWorld

Dans ce premier bout de code, nous récupérons l'élément canvas et le contexte 2D. Ce qu'il faut retenir ici c'est qu'un élément canvas dispose d'un contexte, c'est-à-dire une zone dans laquelle il est possible de dessiner et d'afficher des éléments graphiques. À savoir qu'il existe également un contexte 3D appelé « webgl » sur lequel je ne m'attarderai pas. Ici, nous récupérons le contexte 2D afin de spécifier à l'objet b2DebugDraw dans quel espace il doit dessiner (voir le contenu de la fonction createWorld).

Ensuite, attardons-nous sur ceci :

 
Sélectionnez
window.setInterval(update, 1000 / 60);
/** ... **/
this.update = function() {
        // effectuer les simulations physiques et mettre à jour le canvas
        world.Step(1 / 60,  10, 10);
        world.DrawDebugData();
        world.ClearForces();
}

C'est ici que la magie opère. La fonction setInterval de l'objet window permet de lancer un traitement répété à intervalle régulier. Ici nous appelons la méthode update qui va permettre de simuler les équations physiques de notre environnement. L'intervalle est défini en millisecondes.
Les calculs compliqués sont à la charge de la bibliothèque box2D, nous n'avons pas à nous en préoccuper. Configurons simplement l'appel au moteur de rendu via la méthode Step. Les paramètres passés sont les suivants :

  • timeStep : le taux de rafraîchissement, fixé ici à 60 images par seconde, ce qui correspond à l'intervalle d'appel à la fonction update. La vie est bien faite !
  • velocityIterations : compteur d'itérations pour le calcul de la vitesse ;
  • positionInterations : compteur d'itérations pour le calcul de la position.

Les calculs physiques sont effectués en deux phases : le calcul de la vitesse et le calcul de la position. Dans la première phase, le moteur recalcule les forces et contraintes physiques pour faire correctement bouger les objets dans l'environnement. Dans la seconde phase, le moteur ajuste le positionnement des objets dans l'espace.
La valeur 10 semble communément utilisée pour ces deux paramètres. Évidemment, plus le nombre d'itérations est élevé, plus le nombre de calculs à effectuer est important. Donc, réaliser moins d'itérations améliore les performances au détriment de la justesse de la simulation alors que, à l'inverse, augmenter le nombre d'itérations augmente la qualité de la simulation au détriment des performances.

Il ne reste plus qu'à effectuer les deux manipulations suivantes à chaque step :

  • DrawDebugData : mettre à jour l'affichage du mode debug ;
  • ClearForces : mettre à jour les forces physiques calculées afin de ne pas les recalculer à chaque Step.

IV-E. Tests et résolution des problèmes

Nous sommes maintenant prêts à tester. Ouvrons notre navigateur favori et observons le résultat. Si vous avez correctement suivi ce tutoriel, vous devriez obtenir quelque chose comme ça. Hum, cela n'est qu'en partie satisfaisant. Nous avons bien un monde 2D avec des plates-formes fixes et des objets qui tombent et rebondissent dans tous les sens… mais qu'est-ce que c'est lent ! Tout se passe au ralenti !

Essayons de comprendre d'où peut venir ce problème et de trouver une solution. En fait, la cause de tous nos soucis n'est pas très difficile à trouver. Il suffit de consulter la FAQ de box2d. Comme nous l'avons vu plus haut dans cet article, box2d n'est pas une bibliothèque graphique et ne doit pas être utilisée pour afficher des choses à l'écran. Son job est de calculer les positions, rotations et interactions physiques des éléments à l'écran, et pour se faire, box2d utilise comme unité de mesure le mètre et non le pixel. La FAQ nous prévient également que les objets mobiles ne devraient pas excéder une taille de dix mètres.

Nous allons donc revoir un peu notre code afin de répondre à ces spécifications en effectuant une mise à l'échelle de tous nos objets. Il existe une règle plus ou moins établie selon laquelle 1 m = 30 pixels. Appliquons donc cela à notre environnement.

Dans un premier temps, nous ajoutons l'échelle comme variable de classe à Box2dUtils :

 
Sélectionnez
Box2dUtils = function() {
        this.SCALE = 30;        // Définir l'échelle
}

Ensuite, nous appliquons cette échelle à tous nos objets. Reprenons un peu le corps de la fonction createBody. Les modifications s'appliquent à la définition de la taille et du positionnement de nos objets :

 
Sélectionnez
fixDef.shape.SetAsBox(dimensions.width / this.SCALE, dimensions.height / this.SCALE);
fixDef.shape = new b2CircleShape(dimensions.radius / this.SCALE);
bodyDef.position.x = x / this.SCALE;
bodyDef.position.y = y / this.SCALE;

Enfin, et pour que le rendu du mode debug reste inchangé, appliquons cette même échelle à l'objet b2DebugDraw :

 
Sélectionnez
debugDraw.SetDrawScale(30.0);           // échelle

Et voilà !

V. Game over

Ce tutoriel est maintenant terminé. Si tout s'est déroulé comme prévu vous devriez obtenir ce résultat. Et je vous rappelle également que les sources sont à votre disposition.

Je vous rappelle que ce tutoriel est extrait de mon blog, sur lequel je publierai régulièrement de nouveaux articles et qui, je l'espère, seront également sujets à une publication sur Developpez.com.

VI. Remerciements

Je souhaite remercier LittleWhite qui m'a donné l'opportunité de publier ce tutoriel sur le site Developpez.com et qui m'a encadré dans ce périlleux exercice.

Je remercie également ClaudeLELOUP pour la relecture et les corrections apportées à cet article.