Aprende a crear juegos en HTML5 Canvas

domingo, 15 de septiembre de 2013

Botones en pantalla

Ahora que estamos desarrollando para móviles, necesitamos encontrar una nueva forma de controlar el juego, dado que no disponemos de un teclado. Una de las soluciones populares, aprovechando las capacidades multi-toque de estos dispositivos, es simular botones en la pantalla.

Para este ejemplo, nos basaremos en el código de Municiones Dinámicas. Comenzaremos agregando una función para crear botones:
    function Button(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;
    }

    Button.prototype.touch=function(){
        for(var i=0,l=touches.length;i<l;i++){
            if(touches[i]!=null){
                if(this.x<touches[i].x&&
                    this.x+this.width>touches[i].x&&
                    this.y<touches[i].y&&
                    this.y+this.height>touches[i].y){
                    return true;
                }
            }
        }
        return false;
    }

    Button.prototype.stroke=function(ctx){
        ctx.strokeRect(this.x,this.y,this.width,this.height);
    }
Como podrás ver, esta función es bastante similar a la función rectángulo, con la diferencia principal en la función touch, que analiza todos los toques, y si encuentra uno de los toques contenidos en su área, regresa verdadero; de lo contrario, regresa falso al final.

Crearemos cuatro botones, para los movimientos izquierda, derecha, disparo y pausa. No olvides tomar en cuenta el botón de pausa, ya que ahora no contamos con teclado en lo absoluto:
    var btnLeft=new Button(10,270,20,20);
    var btnRight=new Button(40,270,20,20);
    var btnShoot=new Button(170,270,20,20);
    var btnPause=new Button(90,0,20,20);
Para hacer uso de los botones, solo debemos llamar a su función touch:
            // Move Rect
            if(btnRight.touch()) //RIGHT
                player.x+=10;
            if(btnLeft.touch()) //LEFT
                player.x-=10;
Para efectuar una acción "tap", es decir, un golpecito en lugar de la acción de dejar presionado el botón, como es la acción esperada con el botón de pausa, sólo debe compararse si acaba de iniciar un toque, y si el botón está en acción touch al mismo tiempo:
        if(lastPress==1&&btnPause.touch()){
            pause=!pause;
            lastPress=null;
        }
Para el botón de disparo, ¿Se debería usar touch, o tap? En el código original, se usaba el equivalente a tap, pero puede resultar incómodo este movimiento repetido en la pantalla móvil. Sin embargo, usar touch, crea más disparos de los que realmente son deseados. Una forma de solucionar esto, es creando un temporizador. Comencemos por declararlo:
    var shootTimer=0;
Ya sabes usar temporizadores. Se compara si el temporizador está activo, de ser así, se resta su valor, y de lo contrario, se busca si es tiempo de realizar otro disparo, en cuyo caso, se vuelve a activar el temporizador:
            // New Shot
            if(shootTimer>0){
                shootTimer-=deltaTime;
            }
            else if(btnShoot.touch()){
                shots.push(new Rectangle(player.x+3,player.y,4,4));
                shootTimer=0.1;
            }
De esta forma, podemos controlar la cantidad de disparos por segundo, en caso que el jugador deje presionado el botón. Si prefiere hacer tap en lugar de dejar presionado el touch, la acción funciona de igual forma. No olvides dibujar los botones en pantalla:
        ctx.strokeStyle='#fff';
        btnRight.stroke(ctx);
        btnLeft.stroke(ctx);
        btnShoot.stroke(ctx);
        btnPause.stroke(ctx);
Con esto, tendremos botones virtuales en nuestra pantalla. He movido el jugador un poco más arriba, para que los dedos no obstruyan su posición en la pantalla; este es un factor muy importante a considerar cuando se usan botones virtuales en pantalla.

También he desactivado la pantalla completa en modo paisaje, ya que para este juego no aplica. Creo que puedes saber como hacerlo, pero en caso de tener duda, revisa el código final. Quizá también puedas tener una idea más original de que hacer en este caso.

Finalmente, dado que los tiempos de actualización suelen ser menores en los navegadores de dispositivos móviles, he cambiado la forma de regular el requestAnimationFrame de la forma que hemos usado hasta ahora, a usar el deltaTime. Esto ya quedó explicado en el tema RequestAnimationFrame: Regulando el tiempo entre dispositivos, pero si tienes dudas en su aplicación, el código final incluye ya dicho formato.

De igual forma, cualquier duda que no quede clara, puedes dejarla en los comentarios.

Código Final:


Ver el ejemplo: http://canvas.ninja/mobile3.html

(function(){
    'use strict';
    window.addEventListener('load',init,false);
    window.addEventListener('resize',resize,false);
    var canvas=null,ctx=null;
    var scaleX=1,scaleY=1;
    var touches=[];
    var lastPress=null;
    var pause=false;
    var time=0;
    var shootTimer=0;
    var shots=[];
    var player=new Rectangle(100,260,10,10);
    var btnLeft=new Button(10,270,20,20);
    var btnRight=new Button(40,270,20,20);
    var btnShoot=new Button(170,270,20,20);
    var btnPause=new Button(90,0,20,20);

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

    function resize(){
        if(window.innerWidth>window.innerHeight){
            canvas.style.position='';
            canvas.style.top='';
            canvas.style.left='';
            canvas.style.width='';
            canvas.style.height='';
            scaleX=1;
            scaleY=1;
        }
        else{
            canvas.style.position='fixed';
            canvas.style.top='0';
            canvas.style.left='0';
            canvas.style.width='100%';
            canvas.style.height='100%';
            scaleX=canvas.width/window.innerWidth;
            scaleY=canvas.height/window.innerHeight;
        }
    }

    function run(){
        requestAnimationFrame(run);
        
        var now=Date.now();
        var deltaTime=(now-time)/1000;
        if(deltaTime>1){
            deltaTime=0;
        }
        time=now;
        
        act();
        paint(ctx);
    }

    function act(deltaTime){
        if(!pause){
            // Move Rect
            if(btnRight.touch()) //RIGHT
                player.x+=120*deltaTime;
            if(btnLeft.touch()) //LEFT
                player.x-=120*deltaTime;

            // 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(shootTimer>0){
                shootTimer-=deltaTime;
            }
            else if(btnShoot.touch()){
                shots.push(new Rectangle(player.x+3,player.y,4,4));
                shootTimer=0.1;
            }
            
            // Move Shots
            for(var i=0,l=shots.length;i<l;i++){
                shots[i].y-=120*deltaTime;
                if(shots[i].y<0){
                    shots.splice(i--,1);
                    l--;
                }
            }
        }
        // Pause/Unpause
        if(lastPress==1&&btnPause.touch()){
            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='#f00';
        for(var i=0,l=shots.length;i<l;i++)
            shots[i].fill(ctx);
        
        ctx.fillStyle='#fff';
        ctx.fillText('Last Press: '+lastPress,0,20);
        ctx.fillText('Shots: '+shots.length,0,30);
        if(pause){
            ctx.textAlign='center';
            ctx.fillText('PAUSE',95,150);
            ctx.textAlign='left';
        }
        
        ctx.strokeStyle='#fff';
        btnRight.stroke(ctx);
        btnLeft.stroke(ctx);
        btnShoot.stroke(ctx);
        btnPause.stroke(ctx);
        
        // Touch Debug
        ctx.fillStyle='#999';
        for(var i=0,l=touches.length;i<l;i++){
            if(touches[i]!=null){
                ctx.fillRect(touches[i].x-1,touches[i].y-1,2,2);
            }
        }
    }

    function enableInputs(){
        canvas.addEventListener('touchstart',function(evt){
            var t=evt.changedTouches;
            for(var i=0;i<t.length;i++){
                var x=~~((t[i].clientX-canvas.offsetLeft)*scaleX);
                var y=~~((t[i].clientY-canvas.offsetTop)*scaleY);
                touches[t[i].identifier%100]=new Vtouch(x,y);
                lastPress=1;
            }
        },false);
        canvas.addEventListener('touchmove',function(evt){
            evt.preventDefault();
            var t=evt.changedTouches;
            for(var i=0;i<t.length;i++){
                if(touches[t[i].identifier%100]){
                    touches[t[i].identifier%100].x=~~((t[i].clientX-canvas.offsetLeft)*scaleX);
                    touches[t[i].identifier%100].y=~~((t[i].clientY-canvas.offsetTop)*scaleY);
                }
            }
        },false);
        canvas.addEventListener('touchend',function(evt){
            var t=evt.changedTouches;
            for(var i=0;i<t.length;i++){
                touches[t[i].identifier%100]=null;
            }
        },false);
        canvas.addEventListener('touchcancel',function(evt){
            var t=evt.changedTouches;
            for(var i=0;i<t.length;i++){
                touches[t[i].identifier%100]=null;
            }
        },false);
        
        canvas.addEventListener('mousedown',function(evt){
            evt.preventDefault();
            var x=~~((evt.clientX-canvas.offsetLeft)*scaleX);
            var y=~~((evt.clientY-canvas.offsetTop)*scaleY);
            touches[0]=new Vtouch(x,y);
            lastPress=1;
        },false);
        document.addEventListener('mousemove',function(evt){
            if(touches[0]){
                touches[0].x=~~((evt.clientX-canvas.offsetLeft)*scaleX);
                touches[0].y=~~((evt.clientY-canvas.offsetTop)*scaleY);
            }
        },false);
        document.addEventListener('mouseup',function(evt){
            touches[0]=null;
        },false);

        function Vtouch(x,y){
            this.x=x||0;
            this.y=y||0;
        }
    }

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

    function Button(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;
    }

    Button.prototype.touch=function(){
        for(var i=0,l=touches.length;i<l;i++){
            if(touches[i]!=null){
                if(this.x<touches[i].x&&
                    this.x+this.width>touches[i].x&&
                    this.y<touches[i].y&&
                    this.y+this.height>touches[i].y){
                    return true;
                }
            }
        }
        return false;
    }

    Button.prototype.stroke=function(ctx){
        ctx.strokeRect(this.x,this.y,this.width,this.height);
    }

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

16 comentarios:

  1. hay manera de saber que identifier se le asigno a que boton, esto por que necesito una palanca como sale en nova3 o modern combat 4, entonces ya la coloque (la base y la palanca) se cuendo se toco, pero no se como obtener el toque de la palanca para capturar los cambios de posicion y mover la palanca para q valla en direccion al toque, y por supuesto ejecutar una funcion u otra segun el valor de "Y" en mi caso (solo necesito subir y bajar)

    ResponderBorrar
    Respuestas
    1. Cuando crees el Vtouch, asignale dos nuevas variables: originx y originy, a los cuales le asignaras x y y respectivamente, pero estos no los modifiques nunca. Asi, la diferencia para originx y x, será la dirección del eje x donde se mueve el personaje; ocurre igual para el eje y.

      ¡Suerte con ello! ¡Felices códigos!

      Borrar
    2. No pude, me resigne a colocar dos botones uno para subir y otro para bajar, por otro lado necesito escuchar los eventos de teclado y se pone muy lento o no escucha en keydown y keyUp, sabes a q se debe? quite todo el code de botones en pantalla y wuala, funciono ok el control desde teclado,

      Borrar
    3. Revisa si no hay un error en un for. Ese es un problema común. Si gustas, muestrame el código que usas para ayudarte con ello.

      Borrar
    4. jaja, lo de la palanca, lo olvide, pero logre saber por que es lento, resulta que colocas el escuchador de cada toque para un boton en todos los loops, me explico

      act(){
      if(btnPause.touch()){pauseEljuego();}
      }
      paint(){

      }

      lo anterior llama en cada ciclo al metodo "touch" del boton pause

      lo cambie asi:

      function hacer(){
      if(btnPause.touch()){pauseEljuego();}
      }
      function act(){

      }
      function paint(){

      }

      canvas.addEventListener('touchstart',function(evt){
      var t=evt.changedTouches;
      for(var i=0;i<t.length;i++){
      var x=~~((t[i].pageX-canvas.offsetLeft)*scaleX);
      var y=~~((t[i].pageY-canvas.offsetTop)*scaleY);
      touches[t[i].identifier]=new Vtouch(x,y);
      lastPress=1;
      hacer();//fijate aqui

      }
      },false);

      esto hace que solo cuando se ejecuta el evento se pregunte si un boton fue tocado,

      para hacer lo contrario:

      function noHacer(){
      if(!btnPause.touch()){continueEljuego();}
      }

      function act(){

      }
      function paint(){

      }
      //---

      canvas.addEventListener('touchend',function(evt){
      var t=evt.changedTouches;
      for(var i=0;i<t.length;i++){
      touches[t[i].identifier]=null;
      }
      noHacer();//fijate aqui
      },false);

      Nota: pues la verdad no se si sera buena practica pero me funciono, estoy haciendo un juego multiplataforma, por eso necesito escuchar los dos tipos de eventos(tactiles y tradicionales).

      Borrar
    5. Esa que dices, parece ser muy buena practica de optimización. Pero tengo un conflicto con ello: ¿Como haces para cambiar de dirección (arriba/abajo) sin tener que levantar y presionar de nuevo la pantalla?

      Borrar
    6. Este comentario ha sido eliminado por el autor.

      Borrar
    7. ¿Eso es dentro de la función "hacer"? Más mi interés era la relacion entre el touchdown y la lectura del botón.

      Sobre el cual por cierto, es mas óptimo hacer un:
      if(botonSubir//...
      else if(botonBajar//...
      else {//hacer ambos falsos

      Borrar
  2. ahh, ya lo de la palanca, asi lo hice:

    var subiendo=false;var bajando=false;
    var protagonista={x:0,y:0}


    if(botonSubir.touch()){subiendo=true;bajando=false;}
    if(botonBajar.touch()){subiendo=false;bajando=true;}

    if(!botonBajar.touch() || !botonSubir.touch()){
    subiendo=false;bajando=false;
    }

    act(){
    if(subiendo){protagonista.y=parseInt(protagonista.y)-5}//*subir
    if(bajando){protagonista.y=parseInt(protagonista.y)+5}//*bajar
    //si subieno y bajando son false pues es que esta quieto
    }

    paint(){
    ctx.clearRect(xxxxxx);
    ctx.drawImage(pImagen,parseInt(protagonista.x)parseInt(protagonista.y));
    //obviamente falta operaciones para la imagen omo centrarla saber si
    //esta puera de la pantalla, lo anterior es para
    //mostrar como lo hago yo

    ResponderBorrar
  3. Hola, no soy un conocedor en el tema, solo soy curioso y he estado trabajando en un pequeño proyecto que hasta el momento funciona fantástico en casi todos los dispositivos, pero me he encontrado con un problema y es que cuando abro la página en Safari o Chrome para IOS (que son en términos generales el mismo browser) el browser se congela completamente (no solo la página sino todo el browser)... leí que parece ser porque este navegador no puede soportar más de determinada cantidad de tareas en javascript, pero me gustaría saber si tiene alguna solución al respecto, de antemano disculpas por las molestias y felices fiestas, para la referencia el link es http://www4.avaya.com/dg-test/asb2.html

    ResponderBorrar
    Respuestas
    1. Disculpa no haya respondido antes, pero tuve que hacer extensas pruebas para encontrar el origen del problema y solución de lo que comentabas.

      Lo que causa el error son los eventos de toque, o mas específicamente, la forma distinta en que Safari los maneja. A diferencia de los otros, que los reciclan, Safari crea un nuevo identificador cada evento, que crea eventualmente valores desorbitantes, los cuales al ser asignados a un arreglo, provocan un error fatal. Ya he corregido los códigos para prevenir este error, usando un módulo del identificador para que este problema no ocurra.

      Gracias por avisarme de este problema, y espero la solución sea suficiente para casos futuros. ¡Felices fiestas!

      Borrar
  4. Al contrario, un millón de gracias, es increible la amabilidad de tomarse un tiempo en días de fiesta para solucionar esto, ha funcionado de maravilla y he aprendido mucho con sus excelentes tutoriales, aunque falta mucho por aprender ha sido una gran guía y es completamente recomendable, mil gracias y un feliz año :)

    ResponderBorrar
  5. Buenas...
    He estado probando algunas cosas y quisiera saber como se podría hacer para dibujar una linea haciendo uso del toque(por ejemplo un trazado, al estilo de fruit ninja). He visto con tu ejemplo que hace un fill de rectangulo, pero este desaparece, tengo dudas en como se puede dejar ese recuadro o linea, como si fuera un trazado...

    Espero haber expresado bien mi duda...

    Gracias...

    ResponderBorrar
    Respuestas
    1. Para mantener algo en pantalla por más tiempo que el ciclo actual, debes guardarlo en una variable global. Me imagino por el ejemplo que pones del Fruit Ninja, que lo que desearías sería un arreglo que guarde las últimas 3 o 5 posiciones del ratón, e ir descartando las anteriores conforme pase el movimiento. ¿Es algo así lo que tienes en mente?

      Borrar
  6. Hola que no sería más fácil crear un canvas.addEventListener("click",function(e){if(e.layerX>=0&&e.layerX<=50&&e.clientY<=50&&e.clientY>=0){//code}},false); Y así yo creo que sería más fácil un saludo y buen tutorial

    ResponderBorrar
    Respuestas
    1. Usar "click" serviría sólo para acciones que requieren el comportamiento entero de presionar y soltar dentro del área (y dado que el área es todo el canvas, las mismas limitantes aplican), en el caso de los videojuegos, es más común querer controlar los distintos casos en diversas formas de presionar, dejar presionado y soltar, por eso usamos estos dos métodos en su lugar.

      Borrar