Aprende a crear juegos en HTML5 Canvas

lunes, 30 de abril de 2012

Vida y daño

Hasta ahora, nuestro juego termina al entrar en contacto con un enemigo. Este comportamiento era simple y lógico para el juego de la serpiente, pero ahora que desarrollamos un juego de naves, esta acción es muy pronta y precipitada. Para poder alargar más el juego, asignaremos una variable de salud al jugador, así nuestra nave podrá ser dañada varias veces antes de terminar el juego.

Comencemos declarando la variable necesaria. Empezaremos asignándole tres puntos de salud:
    var health=3;
En la función paint, agregaremos la línea que nos indique los puntos de salud que tenemos:
        ctx.fillText('Health: '+player.health,150,20);
Por último, cambiaremos la intersección entre nuestra nave y una nave nave enemiga, para que solo reste la variable de salud:
                // Player Intersects Enemy
                if(player.intersects(enemies[i])){
                    health--;
                }
Y moveremos al final de nuestro código la condicional que nos permitirá saber si la salud del personaje ha terminado y, por tanto, el juego ha acabado:
            // GameOver
            if(health<1){
                gameover=true;
                pause=true;
            }
Al probar el código, veremos que funciona al comienzo, aunque es difícil saber cuando hemos sido dañados sin estar viendo el contador de salud. Además, cuando está más avanzado el juego, hay veces que las naves enemigas nos hacen más daño del debido, incluso llegando a matarnos de un solo golpe con la salud llena.

Esto último se debe a que, aunque no podamos verles, suele ocurrir que dos o más naves enemigas vienen una encima de la otra, por lo que recibimos daño por cada una de ellas.

Para corregir estos dos problemas, crearemos una segunda variable, que nos permitirá saber cuando hemos sido dañados, y siendo esto algo reciente, darnos unos momentos de inmunidad antes de poder ser dañados de nuevo. Comencemos declarando esta variable:
    var damaged=0;
Ahora, modifiquemos la intersección de esta forma para otorgarnos inmunidad temporal:
                // Player Intersects Enemy
                if(player.intersects(enemies[i])&&damaged<1){
                    health--;
                    damaged=20;
                }
Esto creará un breve temporizador que nos hará inmunes por 20 tiempos, ya que hemos modificado la condición de intersección para que solo sea valida si la variable damaged es menor a 1. Por último, si esta variable es mayor a 0, iremos restando su valor en uno, para eventualmente salir del estado de inmunidad. Esto lo pondremos justo antes de la condición de fin de juego:
            // Damaged
            if(damaged>0)
                damaged--;
¿Cómo saber si hemos sido dañados y estamos en estado de inmunidad? Existen varias formas de hacer este efecto. Dependiendo del juego, muchos optan por dibujar una imagen alterna de daño durante este tiempo, pero el caso más común (Y también el más sencillo de hacer), es dibujar de forma intermitente nuestra imagen actual. Para hacer este efecto, solo debemos dibujar nuestra nave cuando el residuo de la variable damaged sea igual a cero:
        if(damaged%2==0)
            player.fill(ctx);
¡No olvides re-asignar los valores iniciales de health y damaged en la función reset!

De esta forma tendremos nuestra nave resistente a varios impactos, y con esto, concluimos la lección de hoy.





Pero... ¿Por qué concluir ahora? ¿Por qué no ir un poco más lejos? ¿Qué tal si asignamos vida también a las naves enemigas?

Por supuesto, crear una variable para cada nave sería muy difícil de mantener. Por ello, asignaremos las propiedades health y damaged a nuestro objeto rectángulo, asignándole recibir un quinto valor para poder pasar la resistencia inicial de nuestros objetos desde su creación:
    function Rectangle(x,y,width,height,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.health=(health==null)?1:health;
        this.timer=0;
He renombrado la variable damaged a timer, que nos servirá como un temporizador genérico de múltiples funciones en nuestro rectángulo, además de su función actual dentro de player. Este es un buen momento para convertir todas las variables health y damaged en player.health y player.timer, así como borrar sus declaraciones iniciales que ya no usaremos.

Cuando creemos nuestro jugador, asignemos el valor inicial de su salud en tres:
    var player=new Rectangle(90,280,10,10,3);
De igual forma, cada vez que creemos una nueva nave enemiga, la asignaremos con una salud inicial de dos:
        enemies.push(new Rectangle(10,0,10,10,2));
Cuando la nave enemiga haga intersección con el disparo, en lugar de reciclarla inmediatamente, restaremos su salud en uno, y ya en el caso que su vida haya terminado, será cuando la reciclemos y crearemos una nueva nave:
                        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,2));
                        }
Es difícil tener certeza de si hemos golpeado a una nave enemiga a menos que creemos una referencia visual para ello. Como no deseamos otorgar inmunidad a la nave enemiga, y la referencia del disparo solo la necesitamos por un instante, asignaremos el valor de uno a la variable timer de la misma, en caso de que no la hayamos reciclado aun:
                        else{
                            enemies[i].timer=1;
                        }
Antes de pasar al dibujado de la nave dañada, agreguemos el restar de la variable timer de las naves enemigas, justo al tiempo que se entra en su ciclo for:
            for(var i=0,l=enemies.length;i<l;i++){
                if(enemies[i].timer>0)
                    enemies[i].timer--;
Tampoco olvides que debes modificar ambas condiciones de intersección entre el disparo y la nave enemiga (En la entrega pasada tuvimos que repetir dos veces esta condicional para corregir un bug), y que al reciclar nuestra nave cuando llegue al punto más bajo, también se regrese su salud a 2.

Ahora sí, pasemos al dibujado. Hacer trasparente la nave enemiga cuando es dañada como lo hacemos con nuestra nave, es poco efectivo, ya que su desaparición temporal puede confundir al jugador, o no ser notada en la acción del movimiento de todos los objetos en pantallas. Por eso, se necesita otra técnica para ello. Yo me he decidido por el intercambio de imágenes que hablé al comienzo, o en este caso que manejamos puros rectángulos de colores, el cambiarle a un color contrastante: El color blanco.
        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);
        }
Probemos el código actual, y podremos notar con satisfacción que nuestro código se desarrolla de forma natural y sin problemas, teniendo tanto nuestra nave como las enemigas, su respectiva resistencia antes de ser destruidos.

Esto nos da la posibilidad de incluir variedad para crear diferentes tipos de naves enemigas con propiedades diversas, así como los clásicos jefes finales de nivel.

Si alguna sección te pareció confusa, compara con el código final para esclarecer tus dudas:

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

    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;
        player.x=90;
        player.y=280;
        player.health=3;
        player.timer=0;
        shots.length=0;
        enemies.length=0;
        enemies.push(new Rectangle(10,0,10,10,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){
                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 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,2));
                        }
                        else{
                            enemies[i].timer=1;
                        }
                        shots.splice(j--,1);
                        ll--;
                    }
                }
                
                enemies[i].y+=10;
                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){
                            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,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=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';
        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,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.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);
    }

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

6 comentarios:

  1. Otro estupendo tutorial, como todos los demás.

    Me gustaría comentarte algo más sobre el código, pero no puedo decir nada, se entiende todo perfectamente, lo explicas de una forma que haces que no tenga dudas.

    Pues nada, a cargarme otro tuto ^^

    ResponderEliminar
    Respuestas
    1. El comentario de saber que todo fluye sin problemas, me permite saber que he logrado mi cometido con este blog. De igual forma, toda forma de retroalimentación es siempre bien recibida. ¡Muchas gracias, y éxito en los que sigue!

      Eliminar
  2. Hola, ya debo tenerte cansado con las preguntas, pero esto más que pregunta es un comentario para saber s entendí bien;
    ese temporizador que hiciste, hace que nuestra nave tenga 400 milésimas de inmunidad porque el "game()" se ejecuta cada 20 milésimas, multiplicadas por las 20 de nuestro timer llegan a 400, es así?
    Lo otro es el dibujado de de la nave, se dibuja cuando el resto de player.timer/2 sea igual a 0, como nuestro timer empieza en cero el resto es cero, por eso se dibuja, cuando tocamos la nave, el timer queda en 20, y ahí se sigue dibujando, pero cuando lo restamos en el if(player.timer > 0) player.timer-- queda en 19 como el resto no es 0, no va a dibujarse, hasta llegar a cero. Luego intersecta con el enemigo y vuelve a comenzar el ciclo. Es así?
    Bueno eso es todo, disculpa por tantas molestias, pero es que quiero entenderlo perfectamente para poder seguir con firmeza en el desarrollo.
    Saludos!!!

    ResponderEliminar
    Respuestas
    1. Lo has entendido perfectamente! Solo un detalle: El timer es de 50 milésimas por ciclo, no 20, así que en realidad se ejecuta a 50*20=1000 milésimas, o un segundo. De ahí fuera, todo ha quedado perfectamente comprendido.

      ¡Es un placer ayudarte! Sigue así en tu progreso ;)

      Eliminar
    2. Ahhh, yo creía que setTimeout se le pasaba como parámetro la cantidad de frame por segundos (por eso lo de 20 milésimas (1000/50)) no la cantidad de milésimas, muchas gracias por responder tan pronto.
      Saludos!

      Eliminar
  3. una pregunta no me dibuja los cuadros
    aparece en negro la pantalla

    ResponderEliminar