Hoy hemos visto en el Google I/O la noticia de que Angry Birds estará disponible para el navegador Google Chrome. ¿Cómo es esto posible? Gracias al objeto canvas :D Ahora que HTML5 ha llegado para quedarse, y que incluso Internet Explorer ofrece soporte para el elemento canvas, poco a poco se irán popularizando las animaciones y juegos en el navegador, sin necesidad de Flash. Uno de los elementos clave son los motores de física; consiste en una serie de bibliotecas que permiten modelar sistemas donde exista gravedad, y se puedan definir objetos rígidos o dinámicos, con coeficientes de fricción, elasticidad, etc. Suena bien, ¿verdad? :)

Uno de los motores más populares es box2d. De hecho, es el que utiliza el propio Angry Birds. Y por supuesto existen ports de esta biblioteca para muchísimos lenguajes de programación, incluido, claro está, el pequeño gran JavaScript. En concreto la versión JavaScript está extraída automáticamente de la versión de ActionScript. Si quieres ver algunos ejemplos en vivo, entra a la web de box2d-js. Lo que ahora vamos a hacer, lo puedes ver aquí abajo (prueba a hacer clic sobre el lienzo).

Dejemos de hablar, y vamos a mancharnos las manos :)

Aviso sobre este artículo

El código en el que he basado este artículo está extraído de savagelook.com. Te recomiendo que entres y lo revises, ya que su ejemplo es más completo que la versión que yo aquí expongo.

Del mismo modo, es muy recomendable el manual de uso de box2d que han hecho en Box2DFlash.

Paso 1 – Preparando el entorno

Lo primero que tenemos que hacer es crear el código HTML. box2djs depende de dos bibliotecas:

  1. Prototype, como framework javascript.
  2. excanvas, para añadir soporte canvas a Internet Explorer.

Además, crearemos ya el objeto canvas, al que le estableceremos el ancho y alto, y sus propiedades CSS. Nos quedará el siguiente código:

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<!--[if IE]><script type="text/javascript" src="lib/excanvas.js"></script><![endif]-->
<script src="lib/prototype-1.6.0.2.js"></script>
<script src="lib/box2djs.min.js"></script>
</head>
<body>
<canvas id="canvas-world" style="position:absolute; top:0; left:0;background-color:#F5F5F5;width:800px; height:500px;"></canvas>
</body>
</html>

Ver demo. Como vemos, no ocurre gran cosa. Tan sólo vemos una gran caja gris

Paso 2 – Crear el mundo

En este paso vamos a crear el mundo en el que todo sucederá. Vamos a ver el código JavaScript que hace lo define:

            var world;
            var ctx;
            var canvasWidth;
            var canvasHeight;

            // Función que crea el mundo.
            function createWorld() {
                var worldAABB = new b2AABB();
                worldAABB.minVertex.Set(-1000, -1000);
                worldAABB.maxVertex.Set(1000, 1000);
                var gravity = new b2Vec2(0, 300);
                var doSleep = true;
                world = new b2World(worldAABB, gravity, doSleep);

                return world;
            }

            // Punto de entrada principal. Cuando se cargue la ventana:
            Event.observe(window, 'load', function() {
                world = createWorld();
       		ctx = $('canvas-world').getContext('2d');

       		var canvasElm = $('canvas-world');
       		canvasWidth = parseInt(canvasElm.width);
       		canvasHeight = parseInt(canvasElm.height);
       		var canvasTop = parseInt(canvasElm.style.top);
       		var canvasLeft = parseInt(canvasElm.style.left);
            });

En primer lugar definimos cuatro variables globales que utilizaremos en el resto de la aplicación. Después, definimos la función createWorld(), y finalmente vinculamos el evento “load” de la ventana, al código de la función anónima. Este código por ahora hace una llamada a createWorld(), extrae el lienzo 2d del canvas, y calcula su ancho/alto y sus coordenadas. Pero vamos a ver qué hace createWorld():

La primera línea crea un nuevo objeto de tipo b2AABB. Estos objetos definen un espacio en 2 dimensiones. En las dos líneas siguientes establecemos los extremos donde este espacio termina. En concreto nuestro espacio, worldAABB, va desde las coordenadas (-1000,-1000) hasta las coordenadas (1000,1000).

Después creamos un vector gravitacional. El primer valor, 0, corresponde a la gravedad horizontal. El segundo, 300, la gravedad vertical. Es decir, podemos crear una gravedad lateral :D

Otra opción que definimos es la posibilidad de que los objetos se “duerman” cuando entren en reposo. Esto lo hacemos asignando true a la variabel doSleep.

Finalmente creamos el objeto mundo, de la clase b2World, pasándole como parámetros al constructor las variables que acabábamos de crear.

Ver demo. En este caso, de nuevo no ocurre nada. Tan sólo hemos definido el modelo virtual, sin atarlo al canvas.

Paso 3 – Crear un suelo

En nuestro ejemplo va a haber un suelo. En resumen, tan sólo es una caja con posición fija ocupando toda la parte inferior visible. Este es el código que lo define:

            // Añade un suelo al mundo
            function createGround(world) {
                var groundSd = new b2BoxDef();
                groundSd.extents.Set(400, 30);
                groundSd.restitution = 0.0;
                var groundBd = new b2BodyDef();
                groundBd.AddShape(groundSd);
                groundBd.position.Set(400, 470);
                return world.CreateBody(groundBd);
            }

Además, añadimos una llamada a createGround(world) antes del return de la función createWorld(). Vamos a ver en qué consiste la creación del suelo:

Primero creamos un objeto b2BoxDef, que no es más que un objeto que define una forma rectangular. A continuación establecemos su tamaño (400 x 30), y su elasticidad (0.0). El factor de elasticidad 0.0 es el mínimo. Cuanto más alto, más elástico será el material de la forma que estamos definiendo.

A continuación creamos un cuerpo físico, al que le añadimos la forma que acabamos de crear. Por último establecemos las coordenadas en las que lo colocamos (400, 470), y lo añadimos al mundo.

Ver demo. Todavía no ocurre nada. Tan sólo hemos añadido la definición y las características del suelo, pero nuestro mundo todavía no se ha arrancado, ni se ha vinculado al canvas.

Paso 4 – Dibujando en el lienzo

Bien, ya vamos avanzando :) En este paso vamos a implementar el código encargado de dibujar en el canvas nuestro mundo virtual. Veamos cómo es:

            function drawWorld(world, context) {
                for (var b = world.m_bodyList; b; b = b.m_next) {
                    for (var s = b.GetShapeList(); s != null; s = s.GetNext()) {
                        drawShape(s, context);
                    }
                }
            }

            function drawShape(shape, context) {
                context.strokeStyle = '#ffffff';
                context.fillStyle = "black";
                context.beginPath();
                switch (shape.m_type) {
                    case b2Shape.e_polyShape:
                        {
                            var poly = shape;
    				        var tV = b2Math.AddVV(poly.m_position, b2Math.b2MulMV(poly.m_R, poly.m_vertices[0]));
                            context.moveTo(tV.x, tV.y);

                            for (var i = 0; i < poly.m_vertexCount; i++) {
                                var v = b2Math.AddVV(poly.m_position, b2Math.b2MulMV(poly.m_R, poly.m_vertices[i]));
                                context.lineTo(v.x, v.y);
                            }
                            context.lineTo(tV.x, tV.y);
                        }
                        break;
                }

                context.fill();
                context.stroke();
            }

Si nunca has trabajado con canvas, este es un buen momento para que revises algún artículo. Por ejemplo, este de Mozilla, o este de Thinkvitamin.

Por un lado tenemos la función drawWorld, que se encarga de recorrer cada uno de los objetos del mundo, y cada una de las formas que contienen los objetos, para finalmente llamar a la función drawShape, que dibujará en el lienzo canvas la forma.

Por otro lado tenemos drawShape, que es la función que dibuja cada forma. En primer lugar establecemos los colores de línea (stroke) y de relleno (fill). Posteriormente indicamos que vamos a comenzar a dibujar en el lienzo. A continuación dibujamos la forma que corresponda; por ahora tan sólo dibujamos polígonos.

Para cada polígono, calculamos las coordenadas de su primer vértice. A continuación, dibujamos líneas entre cada uno de los siguientes vértices que encontremos, hasta finalmente, cerrar el polígono.

Y por último indicamos al contexto del lienzo que renderice el relleno y las líneas.

Ver demo. ¡Cómo es posible! ¡Todavía no ocurre nada! Esto es porque falta la función que da vida y que arranca al sistema. ¡Vamos al siguiente paso!

Paso 5 – Arrancando el sistema

Ya ha llegado el momento; vamos a unir nuestro sistema, con nuestro canvas, y a hacer que funcione en el tiempo. Para ello vamos a añadir una nueva función:

            function step(cnt) {
                var timeStep = 1.0/60;
                var iteration = 1;
                world.Step(timeStep, iteration);
                ctx.clearRect(0, 0, canvasWidth, canvasHeight);
                drawWorld(world, ctx);
                setTimeout('step(' + (cnt || 0) + ')', 10);
            }

Y vamos a hacer que la función anónima que se ejecuta al cargar la página, invoque a esta función, añadiendo al final:

step();

¿Qué hace step()? Cada vez que se invoca, realiza una nueva iteración en el sistema. En primer lugar establece los frames per second del sistema físico. Por convenio suele utilizarse a 60Hz, es decir, 1.0/60. En cada uno de estos instantes, se realizará una simulación con las equaciones físicas.

A continuación definimos el número de iteraciones por cada instante. Cuanto más bajo sea el valor, más precisión, así como mayor consumo de recursos.

En la tercera línea, indicamos al mundo que avance un nuevo paso. Es en este momento cuando se realizan todos los cálculos para ver en qué estado se encuentra cada objeto del sistema.

Después, limpiamos el lienzo, ya que en la siguiente línea hacemos una llamada a drawWorld para dibujar todo el sistema.

Finalmente establecemos un timeout para volver a ejecutar un paso (step()) en los siguientes 10 milisegundos.

Ver demo. ¡Viva! Al fin se ve algo :D No es gran cosa, pero ya tenemos dibujado el suelo en nuestro lienzo.

Paso 6 – Añadiendo más objetos al mundo

El paso anterior ha estado bien, pero vamos a hacer que nuestro mundo sea más rico en detalles. En concreto vamos a añadir unos polígonos que escriban el mensaje “Hola Mundo!” :) Esta es la función que lo crea:

            function createBox(world, x, y, width, height, fixed) {
                if (typeof(fixed) == 'undefined') fixed = true;
                var boxSd = new b2BoxDef();
                if (!fixed) boxSd.density = 1.0;
                boxSd.restitution = 0.0;
                boxSd.friction = 1.0;
                boxSd.extents.Set(width, height);
                var boxBd = new b2BodyDef();
                boxBd.AddShape(boxSd);
                boxBd.position.Set(x,y);
                return world.CreateBody(boxBd);
            }

            function createHelloWorld() {
                // H
                createBox(world, 50, 420, 10, 20, false);
                createBox(world, 90, 420, 10, 20, false);
                createBox(world, 70, 395, 30, 5, false);
                createBox(world, 50, 370, 10, 20, false);
                createBox(world, 90, 370, 10, 20, false);

                // O
        	createBox(world, 140, 435, 20, 5, false);
        	createBox(world, 155, 405, 5, 25, false);
       		createBox(world, 125, 405, 5, 25, false);
        	createBox(world, 140, 375, 20, 5, false);

        	// L
       		createBox(world, 200, 435, 20, 5, false);
       		createBox(world, 185, 400, 5, 30, false);

       		// A
       		createBox(world, 240, 410, 5, 30, false);
       		createBox(world, 278, 425, 5, 15, false);
       		createBox(world, 265, 405, 20, 5, false);
       		createBox(world, 280, 390, 5, 10, false);
       		createBox(world, 260, 375, 25, 5, false);

        	// M
        	createBox(world, 390, 355, 40, 5, false);
       		createBox(world, 360, 400, 10, 40, false);
       		createBox(world, 420, 400, 10, 40, false);
       		createBox(world, 390, 400, 5, 40, false);

       		// U
       		createBox(world, 460, 435, 20, 5, false);
       		createBox(world, 445, 405, 5, 30, false);
       		createBox(world, 475, 405, 5, 30, false);

       		// N
                createBox(world, 495, 415, 5, 30, false);
                createBox(world, 525, 415, 5, 30, false);
        	createBox(world, 510, 375, 20, 5, false);

       		// D
       		createBox(world, 558, 435, 18, 5, false);
       		createBox(world, 545, 405, 5, 25, false);
       		createBox(world, 575, 405, 5, 25, false);
       		createBox(world, 558, 375, 18, 5, false);

       		// O
        	createBox(world, 610, 435, 20, 5, false);
        	createBox(world, 595, 405, 5, 25, false);
       		createBox(world, 625, 405, 5, 25, false);
       		createBox(world, 610, 375, 20, 5, false);

       		// !
       		createBox(world, 650, 430, 10, 10, false);
       		createBox(world, 650, 380, 10, 40, false);
            }

Definimos en primer lugar la función createBox(). A partir de 6 parámetros crea una nueva caja en el mundo. En concreto los parámetros son: (1) objeto mundo, (2) coordenada x, (3) coordenada y, (4) ancho, (5) altura, y (6) si tiene o no posición fija.

Su código es prácticamente equivalente al código que usamos para crear el objeto suelo. Además, establecemos la densidad de los objetos que no tienen posición fija, y un coeficiente de rozamiento.

Luego tenemos la función createHelloWorld, que tan sólo crea rectángulos.

Por último, vamos a añadir una llamada a createHelloWorld(); antes de step(), en la función anónima que se ejecuta al cargar la ventana.

Ver demo. La cosa ya va cambiando :) Ahora tenemos ya un montón de cajas, que hasta vemos cómo se tambalean.

Paso 7 – Creando círculos

Nuestro programa ya sabe cómo crear rectángulos en el sistema (createBox()), y cómo dibujarlos en el canvas. Ahora vamos a añadir la opción de crear círculos, y de dibujarlos. Primero, vamos a añadir esta función:

            function createBall(world, x, y) {
                var ballSd = new b2CircleDef();
                ballSd.density = 1.0;
                ballSd.radius = 20;
                ballSd.restitution = 0.5;
                ballSd.friction = 0.5;
                var ballBd = new b2BodyDef();
                ballBd.AddShape(ballSd);
                ballBd.position.Set(x,y);
                return world.CreateBody(ballBd);
            }

Como vemos, es prácticamente igual que createBox(). En este caso al crear el objeto forma, no hemos elegido la clase b2BoxDef, sino b2CircleDef. Y además no tenemos la opción de especificar el tamaño, sino que todas las esferas tendrán el mismo radio, como vemos en “ballSd.radius = 20;”.

El sistema ya sabe añadir y reconocer círculos. Ahora vamos a indicarle cómo dibujarlos. En el switch de la función drawShape() que añadimos en el paso 4, vamos a añadir un nuevo caso:

                    case b2Shape.e_circleShape:
                        {
            				var circle = shape;
            				var pos = circle.m_position;
            				var r = circle.m_radius;
            				var segments = 16.0;
            				var theta = 0.0;
            				var dtheta = 2.0 * Math.PI / segments;
    				        context.moveTo(pos.x + r, pos.y);

    				        for (var i = 0; i <= segments; i++) {
            					var d = new b2Vec2(r * Math.cos(theta), r * Math.sin(theta));
            					var v = b2Math.AddVV(pos, d);
            					context.lineTo(v.x, v.y);
            					theta += dtheta;
                            }
                            context.moveTo(pos.x, pos.y);
                            var ax = circle.m_R.col1;
                            var pos2 = new b2Vec2(pos.x + r * ax.x, pos.y + r * ax.y);
                            context.lineTo(pos2.x, pos2.y);
                        }
                        break;

Ahora en la función drawShape(), en caso de que le proporcionen un círculo, sabrá cómo dibujarlo. En concreto lo hará dividiéndolo en 16 segmentos, y con un poco de trigonometría, se calculará el vector de cada segmento, y sus coordenadas finales.

Además, tras dibujar el círculo, se añadirá un radio, que nos permitirá observar cómo realmente gira el círculo.

Ver demo. Nada nuevo, ya que todavía no hemos creado en ningún momento un círculo.

Paso 8 – Creando objetos con el ratón

Ahora vamos a ver cómo crear nuevos objetos al vuelto, haciendo clic con el ratón. Veremos también cómo impactan con los que ya hay, y cómo efectivamente se ve que se respetan las leyes físicas ;)

Vamos a añadir este código antes de la llamada a step() en la función anónima:

                Event.observe('canvas-world', 'click', function(e) {
                    if (Math.random() > 0.5) {
                        createBox(world, e.clientX, e.clientY, 10, 10, false);
                    } else {
                        createBall(world, Event.pointerX(e), Event.pointerY(e));
                    }
                });

Al hacer clic en el canvas, habrá un 50% de probabilidades de que se cree o bien una caja, o bien un círculo. Lo interesante es que se creará donde hayamos hecho clic, y desde ahí caerá :)

Ver demo final. Si quieres, también puedes descargarte el código completo, con el ejemplo final documentando cada línea.

Por supuesto, ahora se pueden añadir imágenes de fondo al canvas, o añadir sprites a nuestros objetos. Y desde luego también se pueden añadir sonidos. Aquí tienes un ejemplo prácticamente igual que el que acabamos de hacer, pero con sonidos y texturas: