Motor de animaciones para aplicaciones HTML (Web + Windows Store)


En artículos anteriores he descrito cómo realizar animaciones de forma óptima utilizando JavaScript y la nueva función requestAnimationFrame. Los ejemplos que daba eran bastante sencillos, pero cuando hacemos una aplicación más grande, como por ejemplo un juego, debemos mover muchos elementos distintos, cada uno con su propia lógica de movimiento.
Para facilitarnos el trabajo y evitar repetir cada vez lo mismo podemos crear un motor de animaciones sencillo y reutilizable.

TL;DR

Este artículo tiene cantidades ingentes de código JavaScript, pero para qué leerlo si puedes ejecutarlo y jugar con él en Fiddle o ver cómo usarlo en una app Windows 8.
El código de ejemplo contiene los siguientes objetos:

  • Animation: un objeto que contiene una lista de elementos a animar y se encarga de gestionar el bucle de animación.
  • Planet y Moon: dos objetos de ejemplo que demuestran cómo se calcula la animación de cada elemento.

Loop Engine

Ahora que ya habéis jugado y destrozado el código os explico qué está haciendo. Para empezar, como quería un código lo más estándar posible, pero que me sea fácil de usar dentro de una aplicación Windows Store, definiré un espacio de nombres sin depender de WinJS:

(function initializeLoop(global) {
    "use strict";

    //espacio de nombres Loop
    global.Loop = global.Loop || {};
})(this);

Una vez tenemos definido nuestro espacio de nombres Loop vamos con el objeto principal del motor.

El objeto Animation

En un alarde de originalidad he definido un objeto llamado Animation. Dicho objeto gestiona el famoso ciclo update + draw dentro de una llamada requestAnimationFrame y además contiene una matriz con los elementos a animar. Así, para que los objetos se animen nos bastará con incorporarlos a la matriz y el objeto se encargará de llamar a las funciones de cálculo y pintado de cada uno.

(function initializeAnimationObject(Loop) {
    "use strict";

    //objeto Animation
    Loop.Animation = function(canvas) {
        this.canvas = canvas;
        this.context2d = canvas.getContext("2d");

        this.animationElements = [];
        this.size = [canvas.width, canvas.height];
        this.delta = 0;

        this._oldTime = undefined;
        this._lastAnimationFrame = undefined;
        this._enabled = false;
        this._paused = false;
    };

El objeto incorpora las funciones start, stop, pause y resume para manejar la animación.

    //empieza la animación
    Loop.Animation.prototype.start = function() {
        if (!this._enabled) {
            this._enabled = true;
            this._loop();
        }
    };

    //para la animación y vuelve a poner el tiempo a 0
    Loop.Animation.prototype.stop = function() {
        this._enabled = false;
        this._paused = false;

        if (this._lastAnimationFrame) {
            cancelAnimationFrame(this._lastAnimationFrame);
        }
        this.animationElements.forEach(function(e) {
            if (e.stop) {
                e.stop();
            }
        });

        this._oldTime = undefined;
        this.update(0);
        this.draw(0);
        this._oldTime = undefined;
    };

    //pausa la animación
    Loop.Animation.prototype.pause = function() {
        if (this._enabled) {
            this._paused = true;
            this.animationElements.forEach(function(e) {
                if (e.pause) {
                    e.pause();
                }
            });
        }
    };

    //continua la animación a partir de la pausa
    Loop.Animation.prototype.resume = function() {
        this._paused = false;
        this.animationElements.forEach(function(e) {
            if (e.resume) {
                e.resume();
            }
        });
    }

La lista de elementos debe contener objetos que pueden tener definidas (o no) las siguientes funciones:

  • update: la función donde se realizan todos los cálculos de posición del elemento
  • draw: en esta función debemos procurar tenerlo todo listo para que sólo tengamos que dibujar
  • pause: sólo la implementamos si tenemos que realizar algo especial al pausar la animación
  • resume: la implementamos en el caso de que debamos reaccionar de alguna manera a la reanudación de la animación.

De esta manera se comunica a los objetos qué deben hacer en cada momento, si es que quieren saberlo.

Desde la función start se llama a una función privada llamada _loop. Esta crea el bucle de llamadas requestAnimationFrame. Como estas llamadas funcionan como un callback, no se ejecutan dentro del contexto del objeto, pero nosotros queremos poder llamar a las funciones this.update y this.draw. El truco utilizado aquí es crear una closure que nos almacene el objeto this dentro de la variable that.

    //Definición de las funciones de Animation
    Loop.Animation.prototype._loop = function() {
        //ciclo de animación básico
        var that = this;
        var insideLoop = function(loopTime) {
            if (that._enabled) {
                that._lastAnimationFrame = requestAnimationFrame(insideLoop);
                that.update(loopTime);
                that.draw(loopTime);
            }
        };
        requestAnimationFrame(insideLoop);
    };

Desde el bucle se llama a las funciones update y draw, que a su vez llaman a las funciones homónimas de los elementos de la lista.

    //update: donde se realizan los cálculos, se llama antes del draw.
    Loop.Animation.prototype.update = function (animationTime) {
        if (animationTime === undefined) {
            animationTime = Date.now();
        }

        if (this._oldTime === undefined) {
            this._oldTime = animationTime;
        }
        this.delta = (animationTime - this._oldTime);
        this._oldTime = animationTime;
        if (!this._paused) {
            this.animationElements.forEach(function (e) {
                if (e.update) {
                    e.update(animationTime);
                }
            });
        }
    };

    //draw: una vez realizados los cálculos se pueden dibujar los elementos en el canvas
    //borra el canvas antes de empezar a pintar el nuevo.
    Loop.Animation.prototype.draw = function (animationTime) {
        var context2d = this.context2d;
        context2d.clearRect(0, 0, this.canvas.width, this.canvas.height);

        this.animationElements.forEach(function (e) {
            if (e.draw) {
                e.draw(animationTime);
            }
        });
        if (this.showFPS) {
            context2d.strokeStyle = '#4f4';
            context2d.strokeText("FPS: " + (Math.round(1000 / this.delta)).toString(), 10, 10);
        }
    };
})(this.Loop);

¿Cómo utilizamos este motor?

Para utilizar el motor necesitaremos por una parte una página HTML con un canvas sobre el que animar:

<canvas style="position:absolute;background-color:black" id="animationCanvas"></canvas>
<div style="position:absolute; top:10px; width:100%">
    <div style="width:100px;margin-left:auto;margin-right:auto;">
        <button class="roundButton" id="playPauseButton" >║</button>
        <button class="roundButton" id="stopButton">■</button>
    </div>
</div>

Y en el script de la página esperaremos a que cargue para llamar a la función start de nuestra animación:

this.onresize = resizeCanvas;
var animation;

function start() {
    resizeCanvas();
    animation = new Loop.Animation(animationCanvas);
    createPlanets(animation);
    animation.start();
}

function resizeCanvas() {
    animationCanvas.height = window.innerHeight;
    animationCanvas.width = window.innerWidth;
}

document.addEventListener("visibilitychange", function() {
    animation.pause();
    if (animation.isPaused) {
        playPauseButton.innerHTML = "►";
    }
}, false);

this.onload = function () {
    playPauseButton.onclick = function () {
        if (!animation.isEnabled) {
            animation.start();
            playPauseButton.innerHTML = "&#x2551";
        }
        else if (animation.isPaused) {
            animation.resume();
            playPauseButton.innerHTML = "║";
        }
        else {
            animation.pause();
            playPauseButton.innerHTML = "►";
        }
    };

    stopButton.onclick = function () {
        animation.stop();
        playPauseButton.innerHTML = "►";
    }

    start();
};

Animando una lista de planetas

Una vez tenemos el motor, nos queda añadir los elementos a animar. Vamos a crear una animación de planetas muy sencilla. Para ello necesitaremos definir los objetos Planet con sus métodos update y draw:

(function initializePlanetsNS(global) {
    "use strict";

    //espacio de nombres Loop
    global.Planets = global.Planets || {};
})(this);

(function createPlanetsObjects(Planets) {
    var x = 0,
        y = 1;
    var pi2 = Math.PI * 2;

    Planets.Planet = function(animation) {
        if (animation) {
            this.animation = animation;

            var canvas = this.animation.canvas;
            this.speed = 0;
            this.rotationCenter = [canvas.width / 2, canvas.height / 2];
            this._rotation = 0;
            this.rotationRadius = 0;
            this.position = [this.rotationCenter[x] + this.rotationRadius, this.rotationCenter[y]];
            this.radius = 50;
            this.foreColor = "#FF0";
        }
    }

    Planets.Planet.prototype.update = function(time) {
        var delta = this.animation.delta;
        this._rotation += delta * this.speed / 1000;
        if (Math.abs(this._rotation) > pi2) this._rotation -= pi2 * (this._rotation / this._rotation);

        this.position[x] = this.rotationCenter[x] + this.rotationRadius * Math.cos(this._rotation);
        this.position[y] = this.rotationCenter[y] + this.rotationRadius * Math.sin(this._rotation);
    };
    Planets.Planet.prototype.draw = function(time) {
        var context2d = this.animation.context2d;
        context2d.fillStyle = this.foreColor;
        context2d.beginPath();
        context2d.arc(this.position[x], this.position[y], this.radius, 0, pi2);
        context2d.fill();
    };

    Planets.Planet.prototype.stop = function() {
        this._rotation = 0;
    };

Para darle más vida a la animación vamos a crear también satélites a los planetas. En este caso la función de draw no hace falta definirla, reaprovechamos la del objeto Planet:


    Planets.Moon = function(animation, planet) {
        Planets.Planet.apply(this, arguments);
        this.planet = planet;
    }
    Planets.Moon.prototype = new Planets.Planet();

    //store the update from the Planet object to avoid repeating code
    Planets.Moon.prototype.updateBase = Planets.Moon.prototype.update;

    //change update function to take planet position as rotation center
    Planets.Moon.prototype.update = function(time) {
        this.rotationCenter = this.planet.position;
        this.updateBase(time);
    };
})(this.Planets);

Y en el script principal creamos los planetas con sus lunas:

function createPlanets(animation) {
    var sun = new Planets.Planet(animation);
    sun.radius = 40;
    animation.animationElements.push(sun);
    for (var i = 0; i < 1+rnd(8,2); i++) {
        var planet = new Planets.Planet(animation);
        planet.rotationCenter = [animationCanvas.width / 2, animationCanvas.height / 2];
        planet.radius = 10 + rnd(20);
        planet.speed = rnd(90, 1) * Math.PI * 2 / 360;
        planet.rotationRadius = sun.radius * 2 + i * 150 + rnd(100);
        planet.foreColor = "rgb(" + rnd(255) + "," + rnd(255) + "," + rnd(255) + ")";
        animation.animationElements.push(planet);

        for (var m = 0; m < rnd(5); m++) {
            var moon = new Planets.Moon(animation, planet);
            moon.radius = 5 + rnd(planet.radius / 3);
            moon.speed = rnd(180, 1) * Math.PI * 2 / 360 * (Math.random() > 0.5 ? -1 : 1);
            moon.rotationRadius = planet.radius + moon.radius + 10 + rnd(40);
            moon.foreColor = "rgb(" +
                (50 + rnd(205)) + "," +
                (50 + rnd(205)) + "," +
                (50 + rnd(205)) + ")";
            animation.animationElements.push(moon);
        }
    }
}

function rnd(max, min) {
    if (!min)
        min = 0;
    return Math.round(min+( Math.random() * (max == undefined ? 1 : (max-min))));
}

Windows 8

El código anterior funcionará directamente en una aplicación Windows Store, pero para mejorar un poco su funcionamiento podemos aplicar algunos trucos como la reacción a los eventos de suspensión para pausar la animación.

Ya estamos preparados para probarlo en un navegador moderno o en una app Windows 8.

Nota: este ejercicio es sólo un ejemplo, no es un motor optimizado ni preparado para funcionar bien en todos los navegadores. Aún así funciona en la mayoría de navegadores, contiene código fallback para los navegadores que no dispongan todavía de la versión definitiva de requestAnimationFrame e incluso funciona en algunos navegadores móviles.

Conclusiones

Hemos visto que gracias a las nuevas características de HTML5 y JavaScript podemos crear animaciones suaves y definir un modelo de objetos que nos permita crear objetos que controlan su propio movimiento.
El ejemplo funciona tanto en navegadores como dentro de una aplicación Windows Store de Windows 8.

Anuncios

Responder

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Cerrar sesión / Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Cerrar sesión / Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Cerrar sesión / Cambiar )

Google+ photo

Estás comentando usando tu cuenta de Google+. Cerrar sesión / Cambiar )

Conectando a %s