Aprende a crear juegos en HTML5 Canvas

viernes, 25 de mayo de 2012

Fondo en movimiento

La anterior vez vimos como crear un fondo básico para nuestro juego de naves espaciales, creando estrellas al azar. Ahora usaremos una imagen de fondo, la cual se desplazará hacia abajo conforme avance la nave, de forma similar que las estrellas en la lección pasada.

Uno de los mayores problemas al crear u obtener imágenes para el fondo de nuestros juegos, es que si deseamos nuestro fondo se mueva, debemos asegurarnos que las imágenes del fondo se puedan repetir continuamente sin que se vea donde acaba y donde empieza de nuevo. A este tipo de imágenes se les conoce como "imágenes de Mosaico" (Tile Image).

Si deseas crear tus propias imágenes de mosaico, en la sección "Tips para artistas: Creando imagenes en mosaico" subiré pronto encontrarás un breve tutorial que explica una forma sencilla para crearlas. Sin embargo, para el presente ejemplo tomaré prestada una imagen de Internet.

Tras buscar un largo rato por Internet, encontré esta página, que incluye 8 maravillosas imágenes de mosaico con temática del espacio: http://webtreats.mysitemyway.com/tileable-classic-nebula-space-patterns/. Estas imágenes son perfectas para un juego de naves espaciales, y como su uso es permitido libremente para cualquier tipo de proyecto, por lo que no tenemos que preocuparnos al usarlas. Puedes usar una de este sitio para tu juego , o la imagen que tú desees; solo asegúrate que sea una imagen de mosaico.

Para no usar una imagen tan grande, yo he escalado la mía a una altura más propia. Solo asegúrate que la imagen sea cuando menos de la altura del canvas (O de la anchura, en caso que el desplazamiento de tu juego sea horizontal). Para el presente ejemplo, usaré  la mostrada a continuación:

[Nebula Background]

Para empezar, necesitaremos una variable que indique la posición de nuestro fondo:
    var bgTimer=0;
En nuestro ciclo, aumentaremos en uno la variable cada turno, y si el valor es mayor de 0, se le restará la altura de nuestra imagen:
            bgTimer++;
            if(bgTimer>0)
                bgTimer-=background.height;
Por último, dibujaremos nuestra imagen dos veces. La primera, en la posición marcada por nuestra variable, y la segunda, restando nuestra variable a la altura de nuestra imagen. Esto hará el efecto de que nuestro fondo se desplaza de forma infinita:
        if(background.width){
            ctx.drawImage(background,0,bgTimer);
            ctx.drawImage(background,0,background.height+bgTimer);
        }
Para comprender mejor como ocurre efecto, te recomiendo que comentes primero una de las dos líneas y observes su comportamiento. Posteriormente, haz lo mismo con la otra imagen. Al complementarse el movimiento de las dos imágenes, logramos el efecto de un fondo sin fin.

Como nota, dado que la imagen de fondo cubre toda la imagen al dibujarse, ya no es necesario llamar a un fillRect que cubra todo el fondo al comienzo de nuestro ciclo de dibujado. Sin embargo, toma en cuenta que si la imagen no carga, no habrá nada que limpie el lienzo antes de dibujar en él de nuevo, por lo que te recomiendo le incluyas en un "else", en caso que la imagen de fondo no tenga un ancho.

PNG con fondos transparentes.


Ahora que estamos usando una imagen de fondo, si estabas usando imágenes con fondos negros como los usados en este tutorial, notarás que su sólido secreto ha quedado al descubierto. Para crear imágenes PNG con fondos transparentes, necesitarás un editor más avanzado, como Photoshop, PaintShop Pro, GIMP, Paint.net, u otros similares (Los últimos dos son gratuitos, por si aun no tienes un editor avanzado).

Pero si tienes una computadora antigua y lenta, o eres amante de Paint a morir que no lo soltarías por nada, les pasaré una herramienta ultraligera que hace la función exclusiva de remover fondos de una imagen PNG.

PNG Background Remover (pngbgr.zip)

Saca los dos archivos del ZIP y copialos en la carpeta donde se encuentre tu imagen. Haz doble clic sobre el archivo BAT (El archivo EXE solo abre y se cierra inmediatamente, no te asustes si eso pasa), y escribe el nombre de tu imagen, con la extensión ".png". Con esto, el color ubicado en el primer pixel de tu imagen, se convertirá en el fondo transparente de tu imagen.

El programa tiene funciones más avanzadas para espeficar el color de fondo a convertir de la imagen, pero por ahora, esta información ha de ser suficiente (Puedes ver las demás opciones presionando enter sin escribir el nombre de archivo a convertir dentro del programa).

Ahora con imágenes con fondos transparentes, podemos disfrutar de un juego de mejor calidad con una imagen de fondo.

Desplazamiento en paralaje.


Aprovechando que estamos viendo imágenes con fondos, aprovecharé para tocar este tema. Un desplazamiento en paralaje (Parallax scrolling) crea un efecto visual en el cual los objetos "más lejanos" se desplazan más lento que los objetos cercanos. Se puede ver un claro ejemplo de este efecto en Wikipedia: http://en.wikipedia.org/wiki/Parallax_scrolling#Example (Haz clic en la última imagen para reproducir la animación).

Para hacer este efecto, solo hay que dibujar una segunda imagen de fondo encima de la primera que se desplace más rápido. Puedes crear una nueva variable para almacenar su desplazamiento, pero también puedes crearlo con una simple fórmula; debes multiplicar la posición de la primer imagen tantas veces como quieras sea más rápida la segunda sobre la primera, y sacar el módulo de la altura de la imagen, para posicionarle en su lugar correcto. Si queremos que la segunda imagen vaya al doble de velocidad de la primera, se haría de esta forma:
        if(background2.width){
            ctx.drawImage(background2,0,(bgTimer*2)%background.height);
            ctx.drawImage(background2,0,background.height+(bgTimer*2)%background.height);
        }
Puedes ejecutar la misma fórmula con tantas imágenes en paralaje como desees. Para el presente ejemplo no vamos a incluir esta técnica, pero puedes hacerlo en tu juego si así lo deseas, además que te servirá para muchos juegos en el futuro.

Con esto, concluimos el tema de imágenes de fondo.

Código final:

[Canvas not supported by your browser]
(function(){
    'use strict';
    window.addEventListener('load',init,false);
    var KEY_ENTER=13;
    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 pause=true;
    var gameover=true;
    var score=0;
    var multishot=1;
    var aTimer=0;
    var bgTimer=0;
    var player=new Rectangle(90,280,10,10,0,3);
    var shots=[];
    var enemies=[];
    var powerups=[];
    var messages=[];
    var spritesheet=new Image();
    var background=new Image();
    spritesheet.src='assets/spritesheet.png';
    background.src='assets/nebula.jpg';

    function random(max){
        return ~~(Math.random()*max);
    }

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

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

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

    function reset(){
        score=0;
        multishot=1;
        player.x=90;
        player.y=280;
        player.health=3;
        player.timer=0;
        shots.length=0;
        enemies.length=0;
        powerups.length=0;
        messages.length=0;
        enemies.push(new Rectangle(30,0,10,10,0,2));
        enemies.push(new Rectangle(70,0,10,10,0,2));
        enemies.push(new Rectangle(110,0,10,10,0,2));
        enemies.push(new Rectangle(150,0,10,10,0,2));
        gameover=false;
    }

    function act(deltaTime){
        if(!pause){
            // GameOver Reset
            if(gameover)
                reset();
            
            // Move Player
            //if(pressing[KEY_UP])
            //    player.y-=10;
            if(pressing[KEY_RIGHT])
                player.x+=10;
            //if(pressing[KEY_DOWN])
            //    player.y+=10;
            if(pressing[KEY_LEFT])
                player.x-=10;

            // Out Screen
            if(player.x>canvas.width-player.width)
                player.x=canvas.width-player.width;
            if(player.x<0)
                player.x=0;
            
            // New Shot
            if(lastPress==KEY_SPACE){
                if(multishot==3){
                    shots.push(new Rectangle(player.x-3,player.y+2,5,5));
                    shots.push(new Rectangle(player.x+3,player.y,5,5));
                    shots.push(new Rectangle(player.x+9,player.y+2,5,5));
                }
                else if(multishot==2){
                    shots.push(new Rectangle(player.x,player.y,5,5));
                    shots.push(new Rectangle(player.x+5,player.y,5,5));
                }
                else
                    shots.push(new Rectangle(player.x+3,player.y,5,5));
                lastPress=null;
            }
            
            // Move Shots
            for(var i=0,l=shots.length;i<l;i++){
                shots[i].y-=10;
                if(shots[i].y<0){
                    shots.splice(i--,1);
                    l--;
                }
            }
            
            // Move Messages
            for(var i=0,l=messages.length;i<l;i++){
                messages[i].y+=2;
                if(messages[i].y<260){
                    messages.splice(i--,1);
                    l--;
                }
            }
            
            // Move PowerUps
            for(var i=0,l=powerups.length;i<l;i++){
                powerups[i].y+=5;
                // Powerup Outside Screen
                if(powerups[i].y>canvas.height){
                    powerups.splice(i--,1);
                    l--;
                    continue;
                }
                
                // Player intersects
                if(player.intersects(powerups[i])){
                    if(powerups[i].type==1){ // MultiShot
                        if(multishot<3){
                            multishot++;
                            messages.push(new Message('MULTI',player.x,player.y));
                        }
                        else{
                            score+=5;
                            messages.push(new Message('+5',player.x,player.y));
                        }
                    }
                    else{ // ExtraPoints
                        score+=5;
                        messages.push(new Message('+5',player.x,player.y));
                    }
                    powerups.splice(i--,1);
                    l--;
                }
            }
            
            // Move Enemies
            for(var i=0,l=enemies.length;i<l;i++){
                if(enemies[i].timer>0)
                    enemies[i].timer--;
                
                // Shot Intersects Enemy
                for(var j=0,ll=shots.length;j<ll;j++){
                    if(shots[j].intersects(enemies[i])){
                        score++;
                        enemies[i].health--;
                        if(enemies[i].health<1){
                            enemies[i].x=random(canvas.width/10)*10;
                            enemies[i].y=0;
                            enemies[i].health=2;
                            enemies.push(new Rectangle(random(canvas.width/10)*10,0,10,10,0,2));
                        }
                        else{
                            enemies[i].timer=1;
                        }
                        shots.splice(j--,1);
                        ll--;
                    }
                }
                
                enemies[i].y+=5;
                // Enemy Outside Screen
                if(enemies[i].y>canvas.height){
                    enemies[i].x=random(canvas.width/10)*10;
                    enemies[i].y=0;
                    enemies[i].health=2;
                }
                
                // Player Intersects Enemy
                if(player.intersects(enemies[i])&&player.timer<1){
                    player.health--;
                    player.timer=20;
                }
                
                // Shot Intersects Enemy
                for(var j=0,ll=shots.length;j<ll;j++){
                    if(shots[j].intersects(enemies[i])){
                        score++;
                        enemies[i].health--;
                        if(enemies[i].health<1){
                            // Add PowerUp
                            var r=random(20);
                            if(r<5){
                                if(r==0)    // New MultiShot
                                    powerups.push(new Rectangle(enemies[i].x,enemies[i].y,10,10,1));
                                else        // New ExtraPoints
                                    powerups.push(new Rectangle(enemies[i].x,enemies[i].y,10,10,0));
                            }
                            enemies[i].x=random(canvas.width/10)*10;
                            enemies[i].y=0;
                            enemies[i].health=2;
                            enemies.push(new Rectangle(random(canvas.width/10)*10,0,10,10,0,2));
                        }
                        else{
                            enemies[i].timer=1;
                        }
                        shots.splice(j--,1);
                        ll--;
                    }
                }
            }
            
            // Timers
            aTimer+=deltaTime;
            if(aTimer>3600)
                aTimer-=3600;
            bgTimer++;
            if(bgTimer>0)
                bgTimer-=300;
            
            // Damaged
            if(player.timer>0)
                player.timer--;
            
            // GameOver
            if(player.health<1){
                gameover=true;
                pause=true;
            }
        }
        // Pause/Unpause
        if(lastPress==KEY_ENTER){
            pause=!pause;
            lastPress=null;
        }
    }

    function paint(ctx){
        if(background.width){
            ctx.drawImage(background,0,bgTimer);
            ctx.drawImage(background,0,300+bgTimer);
        }
        else{
            ctx.fillStyle='#000';
            ctx.fillRect(0,0,canvas.width,canvas.height);
        }
        
        ctx.strokeStyle='#0f0';
        if(player.timer%2==0)
            //player.fill(ctx);
            player.drawImageArea(ctx,spritesheet,(~~(aTimer*10)%3)*10,0,10,10);
        for(var i=0,l=powerups.length;i<l;i++){
            if(powerups[i].type==1){
                ctx.strokeStyle='#f90';
                powerups[i].drawImageArea(ctx,spritesheet,50,0,10,10);
            }
            else{
                ctx.strokeStyle='#cc6';
                powerups[i].drawImageArea(ctx,spritesheet,60,0,10,10);
            }
            //powerups[i].fill(ctx);
        }
        for(var i=0,l=enemies.length;i<l;i++){
            if(enemies[i].timer%2==0){
                ctx.strokeStyle='#00f';
                enemies[i].drawImageArea(ctx,spritesheet,30,0,10,10);
            }
            else{
                ctx.strokeStyle='#fff';
                enemies[i].drawImageArea(ctx,spritesheet,40,0,10,10);
            }
            //enemies[i].fill(ctx);
        }
        ctx.strokeStyle='#f00';
        for(var i=0,l=shots.length;i<l;i++)
            //shots[i].fill(ctx);
            shots[i].drawImageArea(ctx,spritesheet,70,(~~(aTimer*10)%2)*5,5,5);
        
        ctx.fillStyle='#fff';
        for(var i=0,l=messages.length;i<l;i++)
            ctx.fillText(messages[i].string,messages[i].x,messages[i].y);
        ctx.fillText('Score: '+score,0,20);
        ctx.fillText('Health: '+player.health,150,20);
        //ctx.fillText('Last Press: '+lastPress,0,20);
        //ctx.fillText('Shots: '+shots.length,0,30);
        if(pause){
            ctx.textAlign='center';
            if(gameover)
                ctx.fillText('GAME OVER',100,150);
            else
                ctx.fillText('PAUSE',100,150);
            ctx.textAlign='left';
        }
    }

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

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

    function Rectangle(x,y,width,height,type,health){
        this.x=(x==null)?0:x;
        this.y=(y==null)?0:y;
        this.width=(width==null)?0:width;
        this.height=(height==null)?this.width:height;
        this.type=(type==null)?1:type;
        this.health=(health==null)?1:health;
        this.timer=0;
    }

    Rectangle.prototype.intersects=function(rect){
        if(rect!=null){
            return(this.x<rect.x+rect.width&&
                this.x+this.width>rect.x&&
                this.y<rect.y+rect.height&&
                this.y+this.height>rect.y);
        }
    }
    
    Rectangle.prototype.fill=function(ctx){
        ctx.fillRect(this.x,this.y,this.width,this.height);
    }

    Rectangle.prototype.drawImageArea=function(ctx,img,sx,sy,sw,sh){
        if(img.width)
            ctx.drawImage(img,sx,sy,sw,sh,this.x,this.y,this.width,this.height);
        else
            ctx.strokeRect(this.x,this.y,this.width,this.height);
    }

    function Message(string,x,y){
        this.string=(string==null)?'?':string;
        this.x=(x==null)?0:x;
        this.y=(y==null)?0:y;
    }

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

12 comentarios:

  1. Me gusta esto de desplazar el fondo automaticamente, da mucha sensación de realismo.

    Me has dado una idea, a ver si consigo hacer que en en vez de mover el jugador, muevo el fondo para la direccion que haya dado el user en el teclado...aunque el jugador debería estar siempre centrado en el medio de la pantalla.

    pufff...aunque se supone que también debería desplazar todos los elementos que hubiese en pantalla, si lo consiguiese hacer sería original pero...trabajo habría para rato, pues nada yo lo intento y a ver que me duran los ánimos hasta que me canse y lo deje jaja xD

    Pd: Gracias por la url de las img en mosaico!!

    ResponderBorrar
    Respuestas
    1. La técnica que intentas hacer es muy popular en juegos de plataformas, tales como Mario, Sonic, Megaman... Más tarde se verá una forma sencilla de crear una "cámara" que haga este efecto, pero es posible que dicho tutorial tarde en aparecer. Éxito y logras implementarlo por tu propia cuenta, que echando a perder también se aprende y mucho ;)

      Borrar
    2. Lo he estado probando pero a medida que tenía más elementos en pantalla se hacía pesadísimo, mover se mueve pero el problema es que es demasiado código y que los fps se bajan bastante...vamos que el juego va bastante lento.

      Si encontrase una solución más liviana te cuento...pero por ahora creo que dejo esta idea de lado, hasta que tenga más conocimientos.

      Borrar
    3. ¿Que resolución usas? Mientras más grandes son las imágenes, mas pesadas resultan y menos fluidas se vuelven, claro está. Todo trata de encontrar el balance necesario. Hay varias técnicas de optimización, pero requieren ser específicas de cada necesidad. Puede que encuentres alguna que se ajuste a tus necesidades. ¡Exito!

      Borrar
    4. Pues en principio tengo 600x800 pero vamos estaba pensando bajarsela y utilizar sprites para acelerarlo todo un poco. También vi por ahí que le comentaste a un compañero la forma de cargar en 1er lugar las img y hasta que no se carguen no se ejecute el juego.

      Son pequeños detalles que pueden ayudar a optimizarlo todo, bueno aburrido no voy a estar esta semana hasta que avance más jaja

      Borrar
    5. Las optimizaciones de mapas de sprites y precarga sirven para descargar más veloz, pero su eficiencia en cuadros por segundo no se ve afectado por eso. Para ello se necesitan técnicas distintas. ¡Suerte con ello!

      Borrar
  2. Estaría bueno que crearas un tuto de sistemas de particulas, ya que los que hay estan en inglés y confunden un poco.
    Saludos!

    ResponderBorrar
    Respuestas
    1. ¡Lo tendré en cuenta! Se que en algún lugar ya tengo una versión incompleta del mismo, solo será cuestión de encontrarlo, terminarlo y publicarlo ;)

      Borrar
    2. Espero te ayude en tu búsqueda:
      http://juegoscanvas.blogspot.com/2013/08/sistemas-de-particulas.html

      ¡Felices códigos!

      Borrar
  3. Copiado y pegado el codigo no me funciona en IE10, pero si el ejemplo de esta misma página... alguna idea de que puede ser?

    ResponderBorrar
    Respuestas
    1. ¿Incluiste las imágenes? Veo que Internet Explorer regresa un error fatal si no encuentra alguna de las imágenes...

      Borrar