Aprende a crear juegos en HTML5 Canvas

lunes, 23 de abril de 2012

Naves enemigas

Con las habilidades que has adquirido hasta ahora, debes ser ya capaz de hacer esto por tu cuenta. Sin embargo, cubriré este paso de igual forma antes de continuar.

Agreguemos las variables necesarias, y la función random que ya hemos usado en el juego pasado:
    var pause=true;
    var gameover=true;
    var score=0;
    var enemies=[];

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

La función reset a la que llamaremos cada vez que haya que reiniciar el juego:
    function reset(){
        score=0;
        player.x=90;
        player.y=280;
        shots.length=0;
        enemies.length=0;
        enemies.push(new Rectangle(10,0,10,10));
        gameover=false;
    }
Recordemos llamar a reset en cuanto comience el juego, si estamos en GameOver (Y con ello también, al inicio del juego):
    function act(){
        if(!pause){
            // GameOver Reset
            if(gameover)
                reset();
Y antes de entrar con la lógica del juego para las naves enemigas, agreguemos de una vez la función para dibujarlas:
        ctx.fillStyle='#00f';
        for(var i=0,l=enemies.length;i<l;i++)
            enemies[i].fill(ctx);

Ahora que ya tenemos todo listo, terminemos por agregar el movimiento de las naves enemigas, y el análisis de colisión con nuestra nave:
            // Move Enemies
            for(var i=0,l=enemies.length;i<l;i++){
                enemies[i].y+=10;
                if(enemies[i].y>canvas.height){
                    enemies[i].x=random(canvas.width/10)*10;
                    enemies[i].y=0;
                }
           
                // Player Intersects Enemy
                if(player.intersects(enemies[i])){
                    gameover=true;
                    pause=true;
                }
            }
El movimiento de las naves es idéntico al de los disparos, solo que el lugar de removerlo, lo reciclamos, regresándole a la parte superior y asignándole una nueva posición en X. Con respecto a su intersección con nuestra nave, es el mismo comportamiento que la serpiente cuando choca contra su cuerpo: Se pone el juego en estado de Game Over y se pausa.

Lo que podría representar un poco de reto, es la interacción de las naves con los disparos, así que me dentendré un poco más con ello. Para empezar, este análisis se hará dentro del for de las naves, con un segundo for dentro del primero para el análisis de los disparos. De esta forma, se comparará cada nave con cada disparo para encontrar cuales colisionan entre sí. A esto se le llama "un for anidado".

Es importante recalcar, que este for no puede llevar el contador i, pues ya lo estamos usando para las naves enemigas, por lo que usaremos la letra j en este caso (Asegúrate siempre de usar la j para los disparos y la i para las naves dentro de este for anidado, pues en caso de equivocarte, recibirás comportamientos extraños y no deseados).

Ahora que podemos comparar ambos arreglos entre sí, en caso de colisionar uno con el otro, subiremos el score en 1, removeremos el disparo que colisionó con la nave, y reciclaremos la nave una vez más, en lugar de removerla. Para agregar un poco más de reto, he agregado que se añada una nueva nave en pantalla cada vez que se elimina una. El código queda entonces de esta forma:
                // Shot Intersects Enemy
                for(var j=0,ll=shots.length;j<ll;j++){
                    if(shots[j].intersects(enemies[i])){
                        score++;
                        enemies[i].x=random(canvas.width/10)*10;
                        enemies[i].y=0;
                        enemies.push(new Rectangle(random(canvas.width/10)*10,0,10,10));
                        shots.splice(j--,1);
                        ll--;
                    }
                }
Con esto quedaría concluida la lección de ahora, pero al probar el juego, notaremos que a veces, el disparo no afecta a la nave enemiga. Esto se debe a un peculiar efecto causado por que el desplazamiento de los objetos en el juego, es igual a la altura de ellos; explicaré este efecto a continuación.

Si el disparo y la nave enemiga tienen un cuadro de distancia de separación, al siguiente turno, el disparo subirá un cuadro, y la nave bajará un cuadro, colisionando ambos en ese encuentro, como se muestra a continuación:
M
     ->    X
A
Sin embargo, si el disparo y la nave enemiga están uno enfrente del otro, al siguiente turno el disparo subirá a la posición de la nave, y la nave bajará su cuadro correspondiente, posteriormente se efectuará el análusis de colisión, pero para entonces, ninguno de los dos objetos estará colisionando:
M          A
A    ->    M
Hay varias soluciones a este problema. La más práctica, aunque quizá no la mas óptima, es leer la colisión de ambos objetos dos veces: una justo antes de mover la nave enemiga, y la otra posterior a dicho movimiento, como lo estamos efectuando ahora.

Por ahora, implementaré dicha solución para el ejemplo presente. De igual forma, no es común en los juegos que se usen imágenes tan pequeñas desplazándose a estas velocidades, pero es importante prestar a esta clase de detalles, ya que son los que frecuentemente provocan bugs en los juegos cuando salen al público.

Con esto concluimos por ahora. Si tienes soluciones alternas o comentarios al respecto, no dudes en dejarlos en la sección de comentarios.

Actualización: Luis Trecet ha dejado un comentario donde explica dos alternativas más optimizadas para detectar la colisión entre la bala y el enemigo, cuando sus velocidades son más rápidas que sus respectivas alturas. Aunque posteriormente modificaremos este código en particular  haciendo estas medidas innecesarias para el presente ejemplo, es muy buena idea tenerlos en consideración para futuros proyectos que puedan desarrollar.

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);
    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;
        shots.length=0;
        enemies.length=0;
        enemies.push(new Rectangle(10,0,10,10));
        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++){
                // Shot Intersects Enemy
                for(var j=0,ll=shots.length;j<ll;j++){
                    if(shots[j].intersects(enemies[i])){
                        score++;
                        enemies[i].x=random(canvas.width/10)*10;
                        enemies[i].y=0;
                        enemies.push(new Rectangle(random(canvas.width/10)*10,0,10,10));
                        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;
                }
                
                // Player Intersects Enemy
                if(player.intersects(enemies[i])){
                    gameover=true;
                    pause=true;
                }
                
                // Shot Intersects Enemy
                for(var j=0,ll=shots.length;j<ll;j++){
                    if(shots[j].intersects(enemies[i])){
                        score++;
                        enemies[i].x=random(canvas.width/10)*10;
                        enemies[i].y=0;
                        enemies.push(new Rectangle(random(canvas.width/10)*10,0,10,10));
                        shots.splice(j--,1);
                        ll--;
                    }
                }
            }
        }
        // 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';
        player.fill(ctx);
        ctx.fillStyle='#00f';
        for(var i=0,l=enemies.length;i<l;i++)
            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('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){
        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;
    }

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

8 comentarios:

  1. gracias por la clase excelente, como puedo agregar unas estrellas que prendan y apaguen cada cierto tiempo, o poner imagenes de fondo que cambien cada cierto tiempo?. muchas gracias aprecio la ayuda

    ResponderEliminar
    Respuestas
    1. Eso se haría a través de una animación. En dos entradas más, veremos como usar animaciones para la nave espacial, y podrás hacer lo mismo para las estrellas.

      Eliminar
  2. Hola Karl. Muchas GRacias por compartir el conocimiento. Sabes estoy creando un juego donde caen desde el cielo huesos y el perro los debe comer antes que caigan al suelo. Estoy usando este ejemplo para hacer caer los huesos desde la coordenada y=0 y la coordenada x aleatorio.
    La pregunta es: Como puedo hacer para que empieze a caer desde una coordenada x distinta y como puedo hacer para que la cantidad de huesos sea menor y no empiecen a caer huesos por toda la pantalla. No logro hacer eso. Como nota indicar que la pantalla del canvas es de 1024x768. Mil Gracias, Saludos!

    ResponderEliminar
    Respuestas
    1. Disculpa el retraso en responder. Me temo no puedo imaginar el problema que tienes en tu caso particular. ¿Tienes el ejemplo disponible en algún lado para poder ver el problema que tienes y ayudarte? Puedes usar JSFiddle en caso opuesto.

      Eliminar
  3. Muy buenos tutoriales. Muy utiles. Muy amenos. Muy... en fin, buenísimos.

    He estado pensando aquello de las colisiones no detectadas.

    He hallado dos soluciones:

    1- //Shots instersects enemy
    if (shots[j].x>enemy[i].x && shots[j].x + shots[j].width shots[j].y) {
    //El enemigo ha sido tocado y aqui se realizan los calculos.
    }



    2- //Primero se mueven los enemigos

    //Shots intersects enemy
    shot_gosth = (new Rectangle shots[j].x,shots[j].y+ v_bala, shots[j].width,shots[j].height);
    if (shots[j].intersects(enemies[i]) || shot_gosth.intersects(enemies[i])) {
    //El enemigo ha sido tocado y aqui se realizan los calculos.
    }

    //Despues se moverán los disparos


    Explico:

    El primer caso es muy util para este juego si los enemigos siempre van para abajo. Por que compara que se puedan colisionar en el eje "x" y si el disparo esta por encima del enemigos significa que ha pasado de largo sin detectar colision.

    En el segundo caso tenemos algo un poco mas complejo. He puesto una variable que genera un objeto de uso temporal, "gosth_shot". En este objeto el parametro "y" es manipulado para obtener la prediccion de donde estará la bala cuando la movamos. Hay que recordar que la variable "v_bala" es la velocidad de desplazamiento de los shots. En resumen: movemos los enemigos, comparamos si tiene colisiones con los disparos o con la prediccion que hacemos de su posicion futura y despues movemos los disparos.

    Un saludo y gracias por enseñar como funciona todo esto.

    ResponderEliminar
    Respuestas
    1. ¡Vaya! Éste ha sido una de las mejores respuestas que he leído sobre opciones para el código. Creo que el primer ejemplo ha sido truncado, pero se entiende la intensión en esta técnica.

      Redirigiré a los lectores a tu respuesta al final de la entrada, para que conozcan tus propuestas para la optimización de este código. ¡Excelentes ideas!

      Eliminar
  4. como puedo hacer para que cuando dispare espere un tiempo para volver a disparar

    ResponderEliminar
    Respuestas
    1. Esta tarea se trata posteriormente en el tema Botones en Pantalla (http://juegos.canvas.ninja/2013/09/botones-en-pantalla.html); busca donde se utiliza la variable shootTimer para que veas como se utiliza.

      Eliminar