Aprende a crear juegos en HTML5 Canvas

domingo, 22 de diciembre de 2013

Animación condicional

Mucha gente parece no estar muy segura de como dibujar una animación, solo cuando el personaje está en movimieno. En realidad esta es una técnica bastante sencilla, se trata tan solo de una animación condicional. Pero para evitar dudas, veremos a detalle como hacer dicha técnica.

Para empezar, dado que haremos una animación, cambiaremos nuestra imagen por una hoja de sprites. Para no editarla más tarde, les entregaré de una vez la hoja de sprites completa que usaremos para el juego en este curso:


Importemos la imagen. Aprovechando que estamos trabajando ya con imágenes, agreguemos de paso la imagen de fondo de nuestro juego:
    var spritesheet=new Image();
    var background=new Image();
    spritesheet.src='assets/asteroids.png';
    background.src='assets/nebula2.jpg';
Para la imagen de fondo en esta ocasión, usaré otra de las opciones que vimos en el tema Fondo en movimiento. Esta vez he elegido la siguiente imagen:


Dibujar la imagen de fondo de nuestro juego no representará mayor reto:
        if(background.width)
            ctx.drawImage(background,0,0);
Para dibujar la animación de la nave, necesitamos para empezar, tener nuestra función personalizada "drawImageArea" dentro de la función "Circle", soportando la rotación de imágenes:
    Circle.prototype.drawImageArea=function(ctx,img,sx,sy,sw,sh){
        if(img.width){
            ctx.save();
            ctx.translate(this.x,this.y);
            //ctx.scale(this.scale,this.scale);
            ctx.rotate(this.rotation*Math.PI/180);
            ctx.drawImage(img,sx,sy,sw,sh,-img.width/2,-img.height/2,img.width,img.height);
            ctx.restore();
        }
        else
            this.stroke(ctx);
    }
Dado que en el juego presente no necesitamos escalar la imagen de ningún objeto, he comentado dicha variable tanto en su creación, como en su dibujado. Si en algún juego futuro la necesitas, siéntete libre de volverla a descomentar. Ahora que esta función está lista, ya podemos dibujar la animación del jugador.

Ya que deseamos que el jugador sea animado únicamente cuando esté acelerando, estableceremos la condicional al presionar la tecla de arriba (Que usaremos para aceleración en un momento), y en caso contrario, dibujaremos una imagen sin animar. Para la animación, usaremos la variable "aTimer" como la vimos en el tema Hojas de sprites y animaciones, y la dibujaremos de la siguiente forma:
        ctx.strokeStyle='#0f0';
        if(pressing[KEY_UP])
            player.drawImageArea(ctx,spritesheet, (~~(aTimer*10)%3)*10,0,10,10);
        else
            player.drawImageArea(ctx,spritesheet, 0,0,10,10);
Ahora que ya animamos nuestra nave al acelerar, falta agregar dicha aceleración. Para podernos desplazar de acuerdo al ángulo de rotación de la imagen, agregaremos en la función "Circle" una variable "speed", así como la función "move" aprendida en el tema Desplazamiento angular. Posteriormente, agregamos la aceleración y movimiento del jugador:
        // Set Acceleration
        if(pressing[KEY_UP]){
            if(player.speed<5)
                player.speed++;
        }
        if(pressing[KEY_DOWN]){
            if(player.speed>-5)
                player.speed--;
        }
     
        // Move Player
        player.move((player.rotation-90)*Math.PI/180,player.speed);
Nota que, debido a que el juego se desarrolla en el espacio, no hemos agregado desaceleración para la nave. Con esto tendremos nuestra nave animada y en movimiento para nuestro juego.

Aprovechemos que estamos con ella para agregar sus disparos. Esto ya lo sabemos desde el tema Municiones dinámicas; solo nos falta adaptarlo para su movimiento angular. Para ello, asignaremos al disparo creado su rotación, su velocidad mas 10, y un temporizador de 15 ciclos, cuya variable agregaremos antes a la variable círculo. Por tanto, la creación de un nuevo disparo quedaría como se muestra a continuación:
        // New Shot
        if(lastPress==KEY_SPACE){
            var s=new Circle(player.x,player.y,2.5);
            s.rotation=player.rotation;
            s.speed=player.speed+10;
            s.timer=15;
            shots.push(s);
        }
Posteriormente movemos cada disparo, y lo removemos del arreglo cuando su temporizador llegue a cero:
        // Move Shots
        for(var i=0,l=shots.length;i<l;i++){
            shots[i].timer--;
            if(shots[i].timer<0){
                shots.splice(i--,1);
                l--;
                continue;
            }
            
            shots[i].move((shots[i].rotation-90)*Math.PI/180,shots[i].speed);
        }
Finalmente, solo resta dibujar los disparos animados, que ya teníamos incluidos en la hoja de sprites:
        ctx.strokeStyle='#f00';
        for(var i=0,l=shots.length;i<l;i++)
            shots[i].drawImageArea(ctx,spritesheet, 30,(~~(aTimer*10)%2)*5,5,5);
Como podrás ver, todo el conocimiento para lo hecho en esta entrada ya lo teníamos de antes. Solo falta usar la creatividad para aplicar lo ya conocido en nuevas formas. Con esto tendremos ya nuestra nave espacial moviéndose en distintos ángulos, y disparando en la dirección a la que viaja.

Código final:


[Canvas not supported by your browser]
(function(){
    'use strict';
    window.addEventListener('load',init,false);
    var KEY_SPACE=32;
    var KEY_LEFT=37;
    var KEY_UP=38;
    var KEY_RIGHT=39;
    var KEY_DOWN=40;
    
    var canvas=null,ctx=null;
    var lastPress=null;
    var pressing=[];
    var player=new Circle(150,100,5);
    var aTimer=0;
    var shots=[];
    var spritesheet=new Image();
    var background=new Image();
    spritesheet.src='assets/asteroids.png';
    background.src='assets/nebula2.jpg';

    function init(){
        canvas=document.getElementById('canvas');
        ctx=canvas.getContext('2d');
        canvas.width=300;
        canvas.height=200;
        
        run();
        repaint();
    }

    function run(){
        setTimeout(run,50);
        act(0.05);
    }

    function repaint(){
        requestAnimationFrame(repaint);
        paint(ctx);
    }

    function act(deltaTime){
        // Set Rotation
        if(pressing[KEY_RIGHT]){
            player.rotation+=10;
        }
        if(pressing[KEY_LEFT]){
            player.rotation-=10;
        }
        // Set Acceleration
        if(pressing[KEY_UP]){
            if(player.speed<5)
                player.speed++;
        }
        if(pressing[KEY_DOWN]){
            if(player.speed>-5)
                player.speed--;
        }
        
        // Move Player
        player.move((player.rotation-90)*Math.PI/180,player.speed);
        
        // New Shot
        if(lastPress==KEY_SPACE){
            var s=new Circle(player.x,player.y,2.5);
            s.rotation=player.rotation;
            s.speed=player.speed+10;
            s.timer=15;
            shots.push(s);
        }
        
        // Move Shots
        for(var i=0,l=shots.length;i<l;i++){
            shots[i].timer--;
            if(shots[i].timer<0){
                shots.splice(i--,1);
                l--;
                continue;
            }
            
            shots[i].move((shots[i].rotation-90)*Math.PI/180,shots[i].speed);
        }
        
        aTimer+=deltaTime;
        if(aTimer>3600)
            aTimer-=3600;
        
        lastPress=null;
    }

    function paint(ctx){
        ctx.fillStyle='#000';
        if(background.width)
            ctx.drawImage(background,0,0);
        else
            ctx.fillRect(0,0,canvas.width,canvas.height);
        
        ctx.strokeStyle='#f00';
        for(var i=0,l=shots.length;i<l;i++)
            shots[i].drawImageArea(ctx,spritesheet, 30,(~~(aTimer*10)%2)*5,5,5);
        
        ctx.strokeStyle='#0f0';
        if(pressing[KEY_UP])
            player.drawImageArea(ctx,spritesheet, (~~(aTimer*10)%3)*10,0,10,10);
        else
            player.drawImageArea(ctx,spritesheet, 0,0,10,10);
        
        ctx.fillStyle='#fff';
        ctx.fillText('Rotation: '+player.rotation,0,20);
    }

    document.addEventListener('keydown',function(evt){
        lastPress=evt.keyCode;
        pressing[evt.keyCode]=true;
    },false);

    document.addEventListener('keyup',function(evt){
        pressing[evt.keyCode]=false;
    },false);

    function Circle(x,y,radius){
        this.x=(x==null)?0:x;
        this.y=(y==null)?0:y;
        this.radius=(radius==null)?0:radius;
        //this.scale=1;
        this.rotation=0;
        this.speed=0;
        this.timer=0;
    }

    Circle.prototype.distance=function(circle){
        if(circle!=null){
            var dx=this.x-circle.x;
            var dy=this.y-circle.y;
            return (Math.sqrt(dx*dx+dy*dy)-(this.radius+circle.radius));
        }
    }
        
    Circle.prototype.move=function(angle,speed){
        if(speed!=null){
            this.x+=Math.cos(angle)*speed;
            this.y+=Math.sin(angle)*speed;

            // Out Screen
            if(this.x>canvas.width)
                this.x=0;
            if(this.x<0)
                this.x=canvas.width;
            if(this.y>canvas.height)
                this.y=0;
            if(this.y<0)
                this.y=canvas.height;
        }
    }

    Circle.prototype.stroke=function(ctx){
        ctx.beginPath();
        ctx.arc(this.x,this.y,this.radius,0,Math.PI*2,true);
        ctx.stroke();
    }

    Circle.prototype.drawImageArea=function(ctx,img,sx,sy,sw,sh){
        if(img.width){
            ctx.save();
            ctx.translate(this.x,this.y);
            //ctx.scale(this.scale,this.scale);
            ctx.rotate(this.rotation*Math.PI/180);
            ctx.drawImage(img,sx,sy,sw,sh,-this.radius,-this.radius,this.radius*2,this.radius*2);
            ctx.restore();
        }
        else
            this.stroke(ctx);
    }

    window.requestAnimationFrame=(function(){
        return window.requestAnimationFrame || 
            window.webkitRequestAnimationFrame || 
            window.mozRequestAnimationFrame || 
            function(callback){window.setTimeout(callback,17);};
    })();
})();

7 comentarios:

  1. Hola!! Antes que nada felicitarte por los tutoriales, estan muy claros y son fáciles de seguir.

    Al probar el código que indicas arriba. Me esta escalando la imagen de la Nave.
    El motivo de dicho escalado, es que se esta indicando las dimensiones del spritesheet en ves de las dimensiones de la nave

    ctx.drawImage(img, sx, sy, sw, sh, -img.width/2, -img.height/2, img.width, img.height);

    Para resolver esto, he encontrado dos posibles soluciones.

    1.- Trabajar con los valores pasados por parámetro
    ctx.drawImage(img, sx, sy, sw, sh, -sw/2, -sh/2, sw, sh);

    2.- Trabajar con el rádio del circulo
    ctx.drawImage(img, sx, sy, sw, sh, -this.radius, -this.radius, this.radius*2, this.radius*2);

    Ambas soluciones son validas y me han funcionado.

    ResponderBorrar
    Respuestas
    1. Tienes mucha razón. Temo que mientras actualizaba el código de la lección anterior, he olvidado reemplazar esta función para que funcione con hojas de sprites, curioso, porque en la siguiente lección este error ya estaba arreglado.

      Ya he corregido este detalle para que nadie más pase por este problema, muchas gracias por hacermelo notar. ¡Y muchas felicidades habiendo encontrado las soluciones para este problema! Ambas son igualmente válidas en su forma básica, pero tienen funciones diversas en técnicas avanzadas, ya que el primero permite colisiones fantasma (Gran arma de doble filo, muy útil para quien sabe manejarles, aunque puede causar problemas inesperados si no lo sabes), y el segundo permite imágenes escaladas, como se ve en el tema siguiente, aunque hay quienes no saben hacer buen uso de esta técnica y terminan creando imágenes poco optimizadas, abusando de las capacidades de esta segunda técnica.

      ¡Excelente trabajo! Y gracias una vez más.

      Borrar
  2. Me ha gustado mucho su trabajo es genial, pero como se podría modifificarla para que las imagenes de naves, disparos y asteroides, se ingresen por separado.

    ResponderBorrar
    Respuestas
    1. Originalmente se había aprendido cómo ingresar cada imagen por separado (http://juegos.canvas.ninja/2012/02/el-juego-de-la-serpiente.html), pero posteriormente aprendimos cómo usar hojas de sprite y sus ventajas (http://juegos.canvas.ninja/2012/05/hojas-de-sprites-y-animaciones.html)

      La única ocación donde no recomendaría usar hojas de sprite, es de forma local durante el desarrollo. Espero esto te sirva. ¡Felices códigos!

      Borrar
    2. gracias, me has ayudado mucho.

      Borrar
    3. gracias, me has ayudado mucho, pero quisiera que los asteroides no se separaran en 3, como lo hago.

      Borrar
    4. ¿Hablas del tema siguiente? Esto ocurre en estas líneas:

      if(enemies[i].radius>5){
      for(var k=0;k<3;k++){
      var e=new Circle(enemies[i].x,enemies[i].y,enemies[i].radius/2);
      e.rotation=shots[j].rotation+120*k;
      enemies.push(e);
      }
      }

      Si las remueves, los asteroides se eliminarán de forma inmediata, sin fragmentarse.

      Borrar