Aprende a crear juegos en HTML5 Canvas

lunes, 7 de mayo de 2012

Mejoras

Algo muy característico de los juegos, en especial de los juegos de naves, son las mejoras (power-ups) que pueden ser soltadas al azar por los enemigos al ser destruidos. Estas pueden darte más puntos, nuevas habilidades, recuperar tu vida, y muchas otras características.

Crearlas es muy sencillo con nuestros conocimientos actuales, pues no difieren mucho de las naves enemigas o nuestros disparos. Pero de igual forma, tocaremos el tema para dejarlo claro. Para este ejemplo, se crearán dos tipos de mejoras: Uno que simplemente nos de más puntos, y otro que nos permita lanzar múltiples disparos a la vez en lugar de uno solo. Comencemos declarando la variable que los almacenarán:
    var powerups=[];
    var multishot=1;
Además de nuestro contenedor, he agregado una variable que nos permite saber cuantos disparos se crearán cada vez que presionamos la barra espaciadora.

Para poder diferenciar entre las distintas mejoras, necesitamos una forma de identificar el tipo de mejora que es. Para eso, se agregará una nueva variable a nuestro objeto Rectangle:
    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;
También podríamos haber creado un arreglo para cada tipo de mejora, pero es buena práctica almacenar juntos objetos que comparten una serie de características. Esto permite además una mayor escalabilidad, ya que agregar nuevas mejoras será relativamente más sencillo de esta forma, que teniendo que declarar una nueva variable para cada mejora. Además, puedes asignar tipos ahora a otros arreglos, por ejemplo, para identificar distintos tipos de enemigos con comportamientos específicos de cada uno.

Pero antes de aventurarnos más a otras ideas, continuemos con la lección actual. Notarás en la declaración de la función Rectangle, que he dado mayor prioridad a "type" que a "health". Esto es por que las posibilidades de que un objeto tenga salud en un juego, son menores a las de necesitar ser identificadas con un tipo único (Esto con excepción del jugador, que es único en el juego, y requiere de un medidor de salud).

Debido a este cambio, todas las declaraciones previas de un rectángulo con salud, ahora deben ser agregadas con un 0 previo a su salud. Por ejemplo, el jugador:
    var player=new Rectangle(90,280,10,10,3);
Ahora debe ser declarado así:
    var player=new Rectangle(90,280,10,10,0,3);
Y debemos aplicar este cambio para cada creación de un rectángulo, que en nuestro código actual, son todas excepto la creación de nuevos disparos (Que no tienen tipo ni salud).

Después de dar mantenimiento a nuestro código, prosigamos con la lección. Al momento de destruir una nave enemiga, decidiremos al azar si este suelta alguna mejora. 15 de cada 20, no soltará nada. De los restantes, 4 serán de puntos extra, y solo uno será el multi-disparo. Como es la mejora que cambia más el balance del juego, es lógico que sea la más difícil en aparecer.
                        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));
                            }
No olvides dibujar las mejoras. En este caso, pondré los puntos extra de amarillo, y el doble disparo de naranja:
        for(var i=0,l=powerups.length;i<l;i++){
            if(powerups[i].type==1)
                ctx.fillStyle='#f90';
            else
                ctx.fillStyle='#cc6';
            powerups[i].fill(ctx);
        }
Probamos el código, y vemos que las mejoras son generadas al azar, aun sin acción alguna. Pasemos a agregar el movimiento a las mejoras:
            // Move PowerUps
            for(var i=0,l=powerups.length;i<l;i++){
                powerups[i].y+=10;
                // Powerup Outside Screen
                if(powerups[i].y>canvas.height){
                    powerups.splice(i--,1);
                    l--;
                    continue;
                }
Para todas las mejoras, se agrega el mismo tipo de movimiento que los demás objetos. Nota que si la mejora está fuera de la pantalla, damos una instrucción "continue" tras ello. Esto indica que se deje de leer el código dentro de nuestro ciclo, y se pase al siguiente elemento. Cada que eliminemos un elemento, hay que especificar esta instrucción si posterior a su posible eliminación, hay más instrucciones, esto por que no deseamos que se sigan comparando instrucciones si el elemento con el cual las íbamos a comparar, ya ha sido eliminado.

Por ejemplo, anteriormente hacíamos algo similar con los disparos. Pero la última instrucción del disparo, era eliminar si salía de pantalla, y posterior a ello ya no había más instrucciones sobre ese elemento. En el caso de las mejoras, en caso de no estar fuera de pantalla, todavía vamos a comparar si ha colisionado con el jugador, instrucciones que agregaremos a continuación:
                // Player intersects
                if(player.intersects(powerups[i])){
                    if(powerups[i].type==1){ // MultiShot
                        if(multishot<3){
                            multishot++;
                        }
                        else{
                            score+=5;
                        }
                    }
                    else{ // ExtraPoints
                        score+=5;
                    }
                    powerups.splice(i--,1);
                    l--;
                }
Cuando una mejora choca con el jugador, comparamos su tipo; si es uno (Multi-disparo) y el multi-disparo es menor de 3 sumamos en uno el multi-disparo, y de lo contrario, agregamos 5 puntos extra, ya que dicha mejora no tiene otro efecto después de obtener triple disparo, así que recompensamos al jugador a cambio por tomar de nuevo la mejora. Si se trata de otro tipo de mejora (Puntos extras), simplemente se agregan los 5 puntos extra. Después de analizar las condiciones de la mejora, se remueve esta.

Ahora que las mejoras están programadas, agregaremos las diferencias para el multi-disparo. Antes que nada hay que desactivar el multi-disparo dentro del reset, regresándolo a valor 1, para que al comenzar un nuevo juego, la mejora empiece desactivada. Después, modificaremos la creación de disparos, para incluir el multi-disparo:
            // 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;
            }
Probamos el código. Ahora, al obtener un doble disparo, los enemigos mueren instantáneamente, ya que son atacados con dos disparos a la vez en lugar de uno solo, removiendo su vida entera con este ataque. Y al obtener un triple disparo, podemos dañar a los enemigos laterales, no solo los que tenemos enfrente.

Un problema con el código actual, es que resulta difícil saber con certeza cuando hemos recogido un poder, o cual es su efecto. Los juegos clásicos resolvían este problema con un efecto de sonido y un pequeño mensaje que indicaba el logro obtenido. El primero ya sabes como hacerlo, así que dedicaré un momento más para ayudar con el segundo

Crearemos un objeto especial para los mensajes de nombre "Message", al que le asignaremos un texto y una posición:
    function Message(string,x,y){
        this.string=(string==null)?'?':string;
        this.x=(x==null)?0:x;
        this.y=(y==null)?0:y;
    }
Almacenaremos los mensajes en un arreglo:
    var messages=[];
Y agregaremos el mensaje correspondiente cada que agarremos una mejora:
                // 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--;
                }
Ahora, desplazaremos cada mensaje dos pixeles hacia arriba por 10 turnos, antes de removerlo:
            // 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--;
                }
            }
Por último, dibujaremos los textos de los mensajes:
        ctx.fillStyle='#fff';
        for(var i=0,l=messages.length;i<l;i++)
            ctx.fillText(messages[i].string,messages[i].x,messages[i].y);
Con esto, tendremos mejoras en nuestro juego. ¿Que otras mejoras planeas agregarle a tu juego?

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 player=new Rectangle(90,280,10,10,0,3);
    var shots=[];
    var enemies=[];
    var powerups=[];
    var messages=[];

    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();
    }

    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(10,0,10,10,0,2));
        gameover=false;
    }

    function act(){
        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+=10;
                // 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+=10;
                // 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--;
                    }
                }
            }
            
            // 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){
        ctx.fillStyle='#000';
        ctx.fillRect(0,0,canvas.width,canvas.height);
        
        ctx.fillStyle='#0f0';
        if(player.timer%2==0)
            player.fill(ctx);
        for(var i=0,l=powerups.length;i<l;i++){
            if(powerups[i].type==1)
                ctx.fillStyle='#f90';
            else
                ctx.fillStyle='#cc6';
            powerups[i].fill(ctx);
        }
        for(var i=0,l=enemies.length;i<l;i++){
            if(enemies[i].timer%2==0)
                ctx.fillStyle='#00f';
            else
                ctx.fillStyle='#fff';
            enemies[i].fill(ctx);
        }
        ctx.fillStyle='#f00';
        for(var i=0,l=shots.length;i<l;i++)
            shots[i].fill(ctx);
        
        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);
    }

    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);};
    })();
})();

10 comentarios:

  1. jajaja, quedo bueno, cuando ya se esta avanzado el mapa queda lleno de azul, mi record es 1290 puntos, xD

    ResponderEliminar
  2. Hola, cómo podría incluir un sonido en el juego?

    ResponderEliminar
    Respuestas
    1. Esto se aprendió en el tema de "El juego de la serpiente", en la sección de "Media":
      http://juegoscanvas.blogspot.mx/2012/02/el-juego-de-la-serpiente.html

      Eliminar
  3. Muy buenos los tutoriales, he llegado hasta aquí pero...
    No entiendo la parte del continue, me la podrías aclarar un poco?

    ResponderEliminar
  4. ah, ya entendi lo del continue, es que pensé que después del continue ya se acababa el bucle y no le veía sentido. gracias!

    ResponderEliminar
  5. Muchas gracias por estos tutoriales!
    Te queria haces una consulta, si yo quisiera cambiar la fuente para el ctx.fillText(), la unica forma que conozco es utilizando primero el ctx.font=" ". Pero este solo me permite utilizar la fuentes que estar por predeterminado. Se puede cargar de alguna forma una fuente externa, ya sea por url o local?
    Gracias!

    ResponderEliminar
    Respuestas
    1. Para ello, necesitas precargar la fuente que deseas usar mediante CSS con la propiedad font-face ( https://www.google.com.mx/search?q=font-face ). Una vez que la tengas de esta forma, podrás asignarla a ctx.font de igual forma a como asignas las demás fuentes.

      No olvides probarla en un dispositivo que no tenga instalada esta fuente, para asegurar que haz realizado esta tarea con éxito. ¡Suerte!

      Eliminar
  6. if(player.timer%2==0)

    ESTO QUE SIGNIFICA ME DA ERROR

    NO SE PORQUE Y PORQUE SE PONE EL %2

    GRACIAS

    ResponderEliminar
    Respuestas
    1. % es la operación de módulo, o lo que es igual, te devuelve el residuo de la división entre los dos números. Sin embargo, no hay razón por la que esta operación resulte en un error como tal; imagino que tal vez el problema venga de la variable player, o player.time... ¿Que error te devuelve la consola?

      Eliminar
    2. Gracias

      me faltaba en la declaracion del Rectangle(....,Type,...)

      Eliminar