Aprende a crear juegos en HTML5 Canvas

lunes, 21 de mayo de 2012

Estrellas

Un amable lector anónimo preguntó hace un tiempo "¿Cómo crear estrellas que titilen en el fondo del juego?"

Crear estrellas para el fondo del juego realmente es sencillo con lo que hemos aprendido. No tiene mucha diferencia de las naves enemigas. De igual forma, repasaremos lo aprendido con ellas.

Primero, necesitamos un objeto para nuestras estrellas:
    function Star(x,y){
        this.x=(x==null)?0:x;
        this.y=(y==null)?0:y;
    }
Posteriormente, un lugar dónde almacenarlas:
    var stars=[];
Para crearlas, lo haremos en la función "init", asignándoles posiciones al azar dentro de nuestro canvas. Unas 200 serán suficientes:
        for(i=0;i<200;i++)
            stars.push(new Star(random(canvas.width),random(canvas.height)));
Las movemos dentro del ciclo del juego:
            // Move Stars
            for(i=0,l=stars.length;i<l;i++){
                stars[i].y++;
                if(stars[i].y>canvas.height)
                    stars[i].y=0;
            }
Y por último, las dibujamos:
        ctx.fillStyle='#fff';
        for(i=0,l=stars.length;i<l;i++){
            ctx.fillRect(stars[i].x,stars[i].y,1,1);
        }
Nota que las estrellas se dibujan como rectángulos de 1x1 pixeles. Recuerda dibujar las estrellas antes que cualquier otro elemento, para que queden en el fondo.

¡Listo! Probamos el juego y veremos nuestras hermosas estrellas en el fondo. Ahora nuestro fondo es menos aburrido, y así podemos tener un juego más atractivo de forma muy sencilla. ¿Verdad que no fue nada complicado?

Pero ¡Oh!... Nuestro compañero ha preguntado por estrellas que titilen... Bueno, eso hace un poco más complicada la tarea... ¡Pero nada que no podamos realizar! Para comprender como podemos hacer esta tarea, primero hay que aprender como podemos seleccionar colores dinámicos.

Hasta el momento, hemos usado la forma simple para asignar el color con el que deseamos colorear nuestros objetos, en hexadecimal:
ctx.fillStyle='#fff';
Esto podemos hacerlo de una forma más larga, especificando individualmente sus colores RGB en decimal:
ctx.fillStyle='rgb(255,255,255)';
Al hacerlo de esta forma, podemos usar variables para seleccionar nuestro color de forma dinámica:
ctx.fillStyle='rgb('+colorRed+','+colorGreen+','+colorBlue+')';
Asignando los tres colores al mismo valor en distinta escala, podemos crear todos los tonos de grises, técnica que ocuparemos para hacer que nuestras estrellas titilen. Recuerda que RGB permite cualquier número decimal de 0 (el más oscuro) a 255 (el más claro).

Para que no todas las estrellas tengan la misma intensidad al mismo tiempo, haremos de nuevo nuestro objeto, asignándole un tercer valor, que será un temporizador:
    function Star(x,y,timer){
        this.x=(x==null)?0:x;
        this.y=(y==null)?0:y;
        this.timer=(timer==null)?0:timer;
    }
A la hora de crear nuestras estrellas, como tercer valor le asignaremos un número al azar del 0 al 100, para que las estrellas no comiencen con el mismo grado de iluminación y así se vean todas distintas:
        for(i=0;i<200;i++)
            stars.push(new Star(random(canvas.width),random(canvas.height),random(100)));
Al moverlas, aumentaremos además su temporizador en 10, y cuando llegue a ser su valor mayor a 100, se lo restamos:
            // Move Stars
            for(i=0,l=stars.length;i<l;i++){
                stars[i].y++;
                if(stars[i].y>canvas.height)
                    stars[i].y=0;
                stars[i].timer+=10;
                if(stars[i].timer>100)
                    stars[i].timer-=100;
            }
Por último y lo más importante para este efecto: Como dibujarlas. Al máximo valor (255), le restaremos el temporizador en cada valor del RGB. Esto permitirá crear la apariencia que titila:
        for(i=0,l=stars.length;i<l;i++){
            var c=255-stars[i].timer;
            ctx.fillStyle='rgb('+c+','+c+','+c+')';
            ctx.fillRect(stars[i].x,stars[i].y,1,1);
        }
Esta es una técnica sencilla de un solo sentido, que al llegar a su menor brillo, salta de nuevo al brillo máximo. Pero este puede ser un cambio violento, lo cual quizá no sea el mejor efecto para lo que deseamos. Hacer que un temporizador vaya hacia adelante y hacia atrás es un poco más complicado, a continuación lo explicaré paso a paso. Antes, debemos poner el doble del valor máximo del temporizador, así que donde poníamos como valor máximo 100 (creación y movimiento), lo cambiaremos ahora a 200.

La formula para hacer que una animación vaya hacia adelante y hacia atrás, consiste primero en restar a la mitad del valor máximo, el temporizador. Así, si restamos a 100 el valor de nuestro timer, tendremos valores de -100 a 100. Este valor al convertirlo en un valor absoluto con la función "Math.abs" nos dará valores del 100 a 0 y de regreso al 100, con lo que tendremos ya un temporizador de ida y vuelta, el cual restaremos a 255 como hacíamos antes. La formula final quedaría entonces de esta forma:
            var c=255-Math.abs(100-stars[i].timer);
Esta misma técnica funciona también para las animaciones con hojas de sprites, por lo que te será fácil incluir animaciones de ida y vuelta a partir de ahora en tus futuros proyectos.

Con esto, quedarán nuestras estrellas titilando suavemente en el fondo del 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 aTimer=0;
    var player=new Rectangle(90,280,10,10,0,3);
    var shots=[];
    var enemies=[];
    var powerups=[];
    var messages=[];
    var stars=[];
    var spritesheet=new Image();
    spritesheet.src='assets/spritesheet.png';

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

    function init(){
        canvas=document.getElementById('canvas');
        ctx=canvas.getContext('2d');
        canvas.width=200;
        canvas.height=300;
        
        for(var i=0;i<200;i++)
            stars.push(new Star(random(canvas.width),random(canvas.height),random(200)));
        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 Stars
            for(var i=0,l=stars.length;i<l;i++){
                stars[i].y++;
                if(stars[i].y>canvas.height)
                    stars[i].y=0;
                stars[i].timer+=5;
                if(stars[i].timer>200)
                    stars[i].timer-=200;
            }
            
            // 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--;
                    }
                }
            }
            
            // Timer
            aTimer+=deltaTime;
            if(aTimer>3600)
                aTimer-=3600;
            
            // 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);
        
        for(var i=0,l=stars.length;i<l;i++){
            var c=255-Math.abs(100-stars[i].timer);
            ctx.fillStyle='rgb('+c+','+c+','+c+')';
            ctx.fillRect(stars[i].x,stars[i].y,1,1);
        }
        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;
    }

    function Star(x,y,timer){
        this.x=(x==null)?0:x;
        this.y=(y==null)?0:y;
        this.timer=(timer==null)?0:timer;
    }

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

9 comentarios:

  1. muy buena explicacion..hay alguna forma de jugar este juego en un celular con android?

    ResponderEliminar
    Respuestas
    1. En la próxima entrada, precisamente eso se hará. ¡Estate muy atento!

      Gracias por tu comentario Mateo.

      Eliminar
  2. Es muy bueno pero quedaría mejor el efecto si algunas estrellas se mueven mas rápido que otras

    ResponderEliminar
    Respuestas
    1. Puedes agregar una variable extra que indique la velocidad de cada estrella individual. Eso ya no representa reto alguno con los conocimientos aprendidos al día de hoy.

      Eliminar
  3. El resultado es genial, gracias por la entrada, digerible y fácil de seguir.

    Me quedé pensando en aquellos juegos donde al estar el límite de vida la pantalla se torna como en modo "warning", estoy intentando hacer que haga el mismo efecto con:

    ctx.fillStyle = player.health == 1 ? (random(2) == 1 ? "#da3" : "#000" ) : '#000';

    esto al inicio de paint(), cuando pintamos de negro el fondo, sin embargo el resultado es muy "psicodelico-destroza-retinas", sobre todo si no le atinas al color adecuado.

    ¿Algún consejo?

    Saludos.

    ResponderEliminar
    Respuestas
    1. Como tú has dicho, el color es muy contrastante destroza-retinas, por lo que recomiendo un color mas oscuro que contraste menos. Tambien, usa aTimer, que ese controlará mas lineal la animación, a diferencia de un random. Avisame si tiene más dudas; felices códigos!!

      Eliminar
    2. Gracias por responder, con aTimer el asunto ha quedado más digerible a la vista y dejo de parecer "luz de antro en viernes por la noche".

      Saludos.

      Eliminar
  4. Este comentario ha sido eliminado por el autor.

    ResponderEliminar
    Respuestas
    1. Parece ser que encontraste por tu cuenta la parte del código que te faltaba después de todo. De igual forma, si tienes otra duda, no dudes en consultarla.

      ¡Felices códigos!

      Eliminar