Tutorial de Tiles 2ª parte, el héroe

Escrito por el 7 Mayo, 2012

Bienvenidos al segundo capítulo de la serie. En el primer capítulo vimos los conceptos básicos para crear un mapa basado en tiles. Pero un juego sin un héroe que acabe con los malos, no es un juego ni es nada. Así que el capítulo que estamos a punto de comenzar tratará sobre cómo crear a nuestro protagonista principal, a cómo embellecerlo usando sprites animados, y a moverlo por el mapa detectando las colisiones con las paredes infranqueables. En uno de los comentarios del blog me acusaban de crear artículos demasiado largos. Pues toma dos tazas, para que sepan con quién están tratando.

Antes de empezar, como siempre, veámos el resultado que conseguiremos si seguimos éste tutorial:

Como viene siendo habitual, aquí teneis un enlace para verlo en movimiento, y aquí un zip para que lo descargues y lo pruebes en local.

Gestión de imágenes y Sprites

De momento ignora los botones que hay debajo del canvas. Seguro que lo primer que te ha llamado la atención, es el movimiento sexy de nuestro héroe calvo. Veámos cómo se hace.
Como ya he dicho en anteriores ocasiones, para hacer un nuevo tutorial yo parto de la idea de que ya habeis leído los otros tutoriales. En el tutorial de cómo programar un primer videojuego en JavaScript, en la última lección, ya hablé sobre cómo usar sprites en el juego, haciéndolo algo más bonito para la vista. Ahora vamos a seguir prácticamente la misma filosofía, aunque mejorando lo presente.

Si recordais, en aquel tutorial teníamos un array con las imagenes (que estaban en archivos independientes), y también una variable con el tiempo en milisegundos que debía transcurrir para pintar la siguiente imagen (como sólo eran dos por cada alien, se iban alternando). Aquello estaba hecho para salir del paso. Los juegos en 2D tienen personajes con movimientos muy elaborados, es decir, con 4, 5 o 6 imágenes para cada acción (ej, caminar, saltar, disparar, etc). Así se consigue una animación mucho más suave. Asi que fácilmente, para animar todas las acciones que puede realizar un personaje, se necesitarían 30-40 imágenes, y tenerlas todas en ficheros independientes, es una locura. Lo que hacen los profesionales es empaquetarlas en una imagen única. Como nuestro juego además se ejecuta en un navegador, tener todas las imágenes en un sólo archivo mejora mucho el tiempo de descarga de la página. De hecho, las páginas hechas por diseñadores profesionales, suelen meter todas las imágenes (iconos, bordes redondeados, imagenes de background, etc) de su web en un único archivo (excepto aquellas que por su tamaño o importancia deban estar separadas, como por ejemplo las fotos de una galería). Asi que aquí, en la izquierda, tenemos nuestro sprite del calvo.

Como podeis ver, sólo tenemos imágenes para mover el personaje en las cuatro direcciones, y a pesar de ello, son ¡32!.

El siguiente paso es ver cómo “troceamos” la imagen para extraerle cada uno de los sprites. Afortunadamente el autor de dicha imagen tuvo el acierto de incluir todas las imágenes pertenecientes a una acción, en la misma fila, y las distribuyó también por columnas. Es decir, la imagen está compuesta por 32 rectángulos del mismo tamaño. Si partimos la imagen en 4 filas, y 8 columas, obtendremos todos los sprites. Para ilustrarlo mejor, a mano alzada he trazado unas líneas rojas sobre la imagen; pero imaginad que todas están a la misma distancia unas de otras, y que los rectángulos que forman tienen el mismo tamaño.

Como podeis observar, aún dividiendo la imagen en 32 rectángulos iguales, el personaje tiene demasiado “espacio vacío” a su alrededor, algo que no nos interesa en absoluto. Si pintáramos cada rectángulo en el juego, cuando el personaje choca con la pared, en realidad estaría topando ese espacio vacío, y el juego se quedaría algo feo. Hay que recortar un poco más a cada imagen, y para hacer más sencilla la tarea, he creado una clase nueva, Sprite.

Veamos la clase con detenimiento:

function Sprite(img){
    this.img=img;
    this.direcciones=[];
}
Sprite.prototype.setDireccion=function(direccion,coordenadas){
    this.direcciones[direccion]=coordenadas;
};
Sprite.prototype.createDireccion=function(filas, columnas, ajustear,ajusteab,ajusteiz,ajustede,direcciones){
    var ancho=this.img.width/columnas;
    var alto=this.img.height/filas;
   
    for (direccion in direcciones)
    {
        this.direcciones[direccion]=[];
        for (var i=0;i<columnas;i++)
        {
            this.direcciones[direccion].push([i*ancho+ajusteiz,direcciones[direccion]*alto+ajustear,(i+1)*ancho-ajustede,(direcciones[direccion]+1)*alto-ajusteab]);
        }
    }
};
Sprite.prototype.getNumSprites=function(direccion){
    return this.direcciones[direccion].length;
};
Sprite.prototype.dibujar=function(contexto,ancho,alto,direccion,indice){
    contexto.drawImage(this.img, this.direcciones[direccion][indice][0],
                                this.direcciones[direccion][indice][1],
                                this.direcciones[direccion][indice][2]-this.direcciones[direccion][indice][0],
                                this.direcciones[direccion][indice][3]-this.direcciones[direccion][indice][1],
                                -ancho/2, -alto/2, ancho, alto);
};

Su constructor sólo necesita un objeto Image, que será obviamente la imagen que contenga todos los sprites. Un par de sus métodos, setDireccion y createDireccion, serán los que troceen la imagen y la almacenen por el tipo de acción (en este caso, como el personaje sólo se puede mover, lo llamo direccion).

El primero de esos métodos, setDireccion, sirve para trocear manualmente la imagen, indicando por cada movimiento-accion-direccion, un array con una serie de coordenadas (vértice superior izquierdo, vértice inferior derecho) que definen el rectángulo a recortar. En definitiva, se crea un conjunto ordenado de imágenes donde toda ellas pertenecen a la misma animación que se quiere representar). Éste método es útil para cuando los sprites están repartidos de forma irregular por la imagen

El segundo método, createDireccion, automatiza muchísimo la tarea: Le decimos cuántas filas y columnas ha de cortar, y la cantidad en píxeles de ajuste (por arriba, por abajo, por la derecha y por la izquierda) que hay que realizar a cada uno de los rectángulos finales (ojo, a todos se les realiza el mismo ajuste). Éste método es útil para el ejemplo del sprite calvo, ya que todas las imágenes están perfectamente alineadas unas con otras. Veámos cómo se usan:

    var spriteJugadorTrans=new Sprite(Imagenes.get("calvo"));
    spriteJugadorTrans.setDireccion("arriba",[[12,233,42,287],[64,233,94,287],[116,233,145,287],[166,233,196,287],[217,233,247,287],[270,233,300,287],[321,233,350,287],[374,233,404,287]]);
    spriteJugadorTrans.setDireccion("abajo",[[12,15,45,70],[64,15,95,70],[113,15,145,70],[164,15,195,70],[217,15,250,70],[272,15,305,70],[321,15,354,70],[373,15,404,70]]);
    spriteJugadorTrans.setDireccion("derecha",[[12,156,37,213],[68,156,94,213],[114,156,140,213],[164,156,191,213],[218,156,244,213],[272,156,303,213],[322,156,355,213],[376,156,402,213]]);
    spriteJugadorTrans.setDireccion("izquierda",[[19,84,38,140],[61,84,89,140],[120,84,145,140],[170,84,197,140],[222,84,244,140],[270,84,300,140],[322,84,354,140],[377,84,402,140]]);

De momento olvida el Imagenes.get, te basta con saber que ejecutando ese método de esa clase te devuelve un objeto Image con la imagen del sprite del calvo, que es justo lo que necesitamos.

Ésta forma requiere de un arduo trabajo por tu parte, ya que para cada acción hay que indicar manualmente y en el orden adecuado (cualquier editor de imágenes te ayudará) las coordenadas de los rectángulos que queremos extraer. Como puedes ver, hemos creado 4 conjuntos de imágenes (rectángulos), uno para cada dirección donde el personaje puede moverse. Cada conjunto tiene 8 sprites, y cada uno de esos sprites está identificado como un array de 4 posiciones que contienen 2 coordenadas: [vertice superior izquierdo X, vertice superior izquierdo Y, vertice inferir derecho X, vertice inferior derecho Y].

Y tiene un problema que hay que tener en cuenta: Si los rectángulos tienen diferentes tamaños, y se utilizan para rellenar un rectángulo en el canvas que tiene siempre el mismo tamaño, la imagen se estirará o se encogerá según el caso, afeando la animación. En el ejemplo en vivo, puedes verlo si aprietas en el botón de “Sprite Punto a Punto”, y caminas hacia la izquierda o hacia la derecha.

    var spriteJugadorTrans=new Sprite(Imagenes.get("calvo"));
    spriteJugadorTrans.createDireccion(4,8,15,2,11,7,{"arriba":3,"abajo":0,"derecha":2,"izquierda":1});

Ésta es mucho más cómoda de utilizar, a cambio de perder precisión. Le especificamos el número de filas, el número de columnas, el número de píxeles que hay que descartar por arriba, por abajo, por la derecha y por la izquierda, y un array asociativo donde relacionamos las acciones-animaciones-direcciones que tiene la imagen con el número de la fila donde están contenidas (cuidado, la primera fila es la número 0, no la 1).

Volvamos a la clase Sprite, que aún nos quedan por ver dos métodos.

El getNumSprites, indicándole una acción-direccion-animación, nos devuelve el número de imágenes que tiene ese conjunto, para que así, quien la utilice, no se exceda con índice del sprite a utilizar.

Por último, y la más importante de todas, el método dibujar, que pintará la imagen en pantalla. Requiere de 5 parámetros: El primero, contexto, es el contexto del canvas sobre el que tiene que dibujarse. El segundo y el tercero, dicen el ancho y el alto que tienen que tener el sprite final, la dirección (tendría que haberle llamado acción, pero bueno) es el identificador del conjunto de imágenes que sirven para animar una acción, que quiero utilizar. E índice, es la imagen determinada que quiero recuperar de ese conjunto. El método getNumSprites sirve para que sepámos cuál es el índice máximo que podemos utilizar para la accion indicada.

El método del canvas 2D drawImage viene explicado perfectamente en éste enlace (concretamente, en la sección llamada Slicing), asi que poniendo sus argumentos correctamente, obtendremos el trozo de imagen deseado.

Y aquí viene una de las primeras novedades que vamos a seguir para éste juego: El centro de la imagen SIEMPRE se pintará en la coordenada orígen (0,0) del canvas. Por lo tanto, el vértice superior izquierdo, que es el que hay que indicar al drawImage para empezar a pintar, es -ancho/2 para su X y -alto/2 para su Y. El porqué de esta locura sin sentido que contradice todo lo que hemos estado haciendo hasta ahora, lo explicaremos a su debido tiempo.

También utilizaremos un almacén de sprites, concepto que ya vimos en otro tutorial:

function AlmacenSprites(){
    //La lista de sprites
    this.lista=[];
}
AlmacenSprites.prototype.add=function(id,sprite){
    this.lista[id]=sprite;
};
AlmacenSprites.prototype.get=function(id){
    return this.lista[id];
};

Para quien no lo recuerdo, señalar que el almacén (de cualqueir cosa) sirve para no crear instancias repetidas del mismo recurso. Por ejemplo, un sprite, un sonido, un video, etc. Un montón de enemigos pueden utilizar el mismo sprite (aunque pintándolo en posiciones diferentes), asi que si metemos los sprites en una lista, identificando a cada uno de ellos con un id único, luego podemos recuperarlos fácilmente. Sencillo y para toda la familia.

Bien, todo ésto de los sprites está muy bien, pero parémonos a pensar un momento. ¿Qué es lo más importante que necesita la clase sprite para funcionar? Las imágenes.

Podemos crear el objeto Imagen justo antes de asignársela al sprite, asignándole su SRC como ya hicimos en el Space Invaders, pero… ¿Y si queremos usar la misma imagen en sprites diferentes? Sí, al igual que con los sprites, las imágenes están pidiendo a gritos su propio almacén. Asi que démosles el gusto.

function AlmacenImagenes(){
    //La lista de objetos Image
    this.lista=[];
    this.esperadas=0;
    this.cargadas=0;
}
AlmacenImagenes.prototype.cargar=function(lista){
    var self=this;
    this.esperadas=lista.length;
    for (var i=0;i<this.esperadas;i++)
    {
        var img=new Image();
        img.src=lista[i][1];
        img.onload=function(){
            self.imagenCargada();
        };
        this.lista[lista[i][0]]=img;
    }
};
AlmacenImagenes.prototype.imagenCargada=function(){
    this.cargadas++;
    if (this.cargadas==this.esperadas)
    {
        this.completado();
    }
};
AlmacenImagenes.prototype.get=function(id){
    return this.lista[id];
};
AlmacenImagenes.prototype.completado=function(){};

Bueno, este almacen es un poco más complicado que el de los sprites. ¿Por qué? Pues porque tienes en tus ojos una maravilla de la ingeniería moderna. Este almacén de recursos (en concreto imágenes) no sólo nos vale para almacenar todas las imágenes, evitando duplicarlas, si no que además nos servirá para precargarlas, es decir, nos avisará cuando todas las imágenes hayan sido descargadas en el navegador. Así podremos lanzar el juego cuando todas las imágines disponibles, evitando así la embarazosa situación de que en el canvas no se vea nada, a pesar de estar el juego ejecutándose (algo que pasaba durante unas milésimas de segundo al arrancar el Space Invaders de la quinta lección). En juegos con pocas imágenes, realmente no es algo importante, pero cuando tus juegos crezcan y tengas megabytes y megabytes de imágenes, utilizar un precargador te será imprescindible.

Su uso es sencillísimo. Su constructor no requiere argumentos. Con el método cargar, le pasamos un array bidimensional con el id y la ruta de cada una de las imágenes que queremos cargar. Con un bucle recorremos dicha lista, creamos el objeto Image indicándole su SRC, lo metemos en la lista (asociativa) según el ID indicado, y controlando su evento onLoad, disparamos nuestro método imagenCargada para que vaya contando las imágenes que se han cargado. Cuando se hayan cargado todas, se disparará el método completado, que inicialmente estamos declarando vacío. Efectívamente, ese método tendrás que sobreescribirlo en tu aplicación. Por ejemplo, yo lo he utilizado para arrancar el juego, algo que ya veremos más adelante. Veámos un ejemplo sencillo de uso:

    Imagenes=new AlmacenImagenes();
    Imagenes.cargar(
        [
            ["calvo","img/sprite_player.png"],
            ["mario","img/mario.png"]
        ]);
    Imagenes.completado=function(){
        alert("Todas las imagenes han sido cargadas");
    };

Como nota, destacar que el almacén de imágenes no sólo sirve para los juegos. Si eres diseñador de páginas webs, tambien puede serte útil para precargar las imágenes de tu sitio. Por ejemplo, la típica imagen puesta por CSS en el background que se muestra cuando pones el ratón encima de algún elemento. Si no la precargas, la primera vez que tenga que mostrarse, deberá descargarse primero; y si la imagen es pesada, o el servidor está saturado, pueden pasar varios interminables segundos y causar una mala impresión al visitante. Si nada mas entrar a la página la precargas con esta clase (aunque no la utilices aún en ningún sitio), luego, cuando el navegador deba usarla, mirará en su caché si la ruta de dicha imagen ya ha sido descargada para esa página, y utilizará la que ya tiene en memoria, en vez de descargarla otra vez.

Y con ésto, ya tenemos declaradas todas las clases útiles para la gestión de sprites e imágenes.

La clase principal del juego, y el control de eventos de teclado

Para empezar, habrá que crear la estructura básica sobre la que tiene que funcionar el juego, la clase principal. Por ejemplo, la llamaremos Mundo. Veámosla por partes en todo su esplendor:

function Mundo(idCanvas, idBoton){
    this.canvas=document.getElementById(idCanvas);
    this.contexto=this.canvas.getContext('2d');
   
    this.anchoCelda=60;
    this.altoCelda=60;
   
    this.conjuntoTiles=[new Tile(this.anchoCelda,this.altoCelda,true,"white"),new Tile(this.anchoCelda,this.altoCelda,false,"black")];
   
    this.mapa=
    [
        [1, 1, 1, 1, 1, 1, 1, 1],
        [1, 0, 0, 0, 0, 0, 0, 1],
        [1, 0, 1, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 0, 1, 0, 1],
        [1, 0, 0, 0, 0, 0, 0, 1],
        [1, 1, 1, 1, 1, 1, 1, 1]
    ];
   
    this.canvas.width=this.anchoCelda*this.mapa[0].length;
    this.canvas.height=this.altoCelda*this.mapa.length;
   
    this.jugador;
    this.iniciarJugador();
   
    var self=this;
    this.tiempoTranscurrido=new Date().getTime();
    this.intervalo=setInterval(function(){self.loop()},25);
   
    this.boton=document.getElementById(idBoton);
    this.boton.onclick=function(){
        self.detener();
    };
}
Mundo.prototype.detener=function(){
    clearInterval(this.intervalo);
};

Su constructor es muy parecido al del capítulo anterior. Esta vez, no recibiremos en ancho y el alto de cada celda, será un valor que pondremos nosotros adrede. Usaremos cuadrados de 60 píxeles de lado, para que se vea mejor el mundo y el sprite del personaje.

Como antes, declaramos el conjunto de tiles que vamos a usar, y a continuación describimos el mapa y el tamaño del canvas, acorde al número de filas y columnas del mapa.

Luego iniciamos a nuestro personaje. Como el personaje requiere también que le enlacemos los eventos de teclado, encapsulamos todo el código en un método, para tenerlo separado y mejor organizado.

Por último, iniciamos las variables útiles para controlar nuestro querido tiempo delta. Antes teníamos el intervalo fuera de la clase principal, siendo el INDEX.HTML el que lo iniciaba y paraba (con un botón). Ahora será la clase Mundo quien controle su propio intervalo, y el click del ratón sobre el botón de parar, como debe de ser.

Mundo.prototype.iniciarJugador=function(){
    this.jugador=new Jugador(this,30, 30, 2.5, 1.5);
    var self=this;
   
    document.body.onkeydown=function(e){
        switch(e.keyCode)
        {
            case 38: //Arriba
                e.preventDefault();
                self.jugador.arriba=true;
                break;
            case 40: //Abajo
                e.preventDefault();
                self.jugador.abajo=true;
                break;
            case 39: //Derecha
                e.preventDefault();
                self.jugador.derecha=true;
                break;
            case 37: //Izquierda
                e.preventDefault();
                self.jugador.izquierda=true;
                break;
           
        }
    };
    document.body.onkeyup=function(e){
        switch(e.keyCode)
        {
            case 38: //Arriba
                e.preventDefault();
                self.jugador.arriba=false;
                break;
            case 40: //Abajo
                e.preventDefault();
                self.jugador.abajo=false;
                break;
            case 39: //Derecha
                e.preventDefault();
                self.jugador.derecha=false;
                break;
            case 37: //Izquierda
                e.preventDefault();
                self.jugador.izquierda=false;
                break;
           
        }
    };
};

El método iniciarJugador se limita a llamar a su constructor con los argumentos adecuados, que ya veremos después, y a enlazar los eventos de teclado (en particular, los cursores) con el jugador, mediante la técnica de notificación que ya explicamos en su día. El preventDefault sirver para que si la página tiene scroll, y apretamos los cursores de arriba o abajo, no ejecute la acción por defecto, que sería bajar o subir dicho scroll. Quedaría un poco feo que la página entera se moviera, en vez de simplemente nuestro personaje.

Mundo.prototype.dibujarMapa=function(){
    var y=this.mapa.length;
    var x=this.mapa[0].length;
    for (var yi=0;yi<y;yi++)
    {
        for (var xi=0;xi<x;xi++)
        {
            this.conjuntoTiles[this.mapa[yi][xi]].dibujar(this.contexto,xi,yi);
        }
    }
};

Este sencillo método, que es el mismo que el del capítulo anterior, pintará el mapa en el canvas. Y con él, ha llegado el momento de explicar la terrible verdad que se esconde detrás de éste tutorial: Las posiciones relativas.

Un alto en el camino: Posiciones absolutas VS posiciones relativas

Hasta el momento, para dibujar cada elemento de nuestro juego seguíamos una máxima: La coordenada (0,0) es el vértice superior izquierdo del canvas. Todos los elementos los colocábamos teniendo esa coordenada como referencia, es decir, usábamos coordenadas absolutas. De hecho, en este mismo tutorial, estamos dibujando el mapa de la misma forma. Sabemos que los tiles son cuadrados, con lados de 60 píxeles. Así que el bucle de arriba dibujará el primer cuadrado en la coordenada (0,0). Luego dibujará el siguiente, a su derecha, en la (60,0), despues (120,0). Y así sucesivamente hasta saltar a la siguiente fila, que comenzará en (0,60).

Podríamos pintar a los personajes siguiendo el mismo sistema de posiciones absolutas, como hicimos con la nave, aliens y disparos del Space Invaders, y no habría ningún problema. Pero… ¿Y si el jugador, en vez de ser un simple rectángulo, fuera una figura más compleja? por ejemplo, unos 5 cuadrados pintados en forma de damero (como un 5 de un dado). Si quisieramos mover dicha figura, habría que calcular las posiciones de cada uno de los cuadrados que la forman. Bueno, no parece tan difícil… ¿Pero y si (oh dios mío) quisiéramos ROTAR al jugador por ejemplo 30 grados? Vaya, pues habría que implementar una función que mueva las coordenadas de los vértices según una posición y un ángulo indicado… ¿Pero y si la figura está compuesta por cuadrados, triángulos y círculos? ¿Implementarías decenas de funciones específicas para cada tipo de figura y cada tipo de movimiento o rotación?

Nada de eso, mejor usar una solución ideal: Posiciones relativas. ¿Y qué es eso? Pues lo más sencillo del mundo. Pintar siempre el objeto mirando hacia arriba (si el personaje fuera rotable, si no, no importa), y haciendo coincidir su centro con el origen de coordenadas (0,0). Es decir, independientemente de donde esté el personaje, o su rotación, o lo que sea, las órdenes que lo dibujan en la pantalla son exactamente las mismas, con los mismos valores en los argumentos.

¿Y para qué diantres quiero dibujar todos los personajes en la coordenada (0,0), apelotonados unos encima de otros? Ahí está la gracia: El contexto 2D tiene comandos para desplazar el “cursor actual”, hacer translaciones y rotaciones, guardar su estado acual, y restaurar su última versión guardada. Podemos leer más sobre ello en el magnífico tutorial en español de Mozilla.

El cursor actual es la posición donde se va a dibujar sobre el canvas. Es decir, en pantalla siempre se muestra la misma zona (el vértice superior izquierdo del canvas es el(0,0) absoluto). Pero para actuar sobre el canvas, se utilizan posiciones relativas al “cursor”. Inicialmente, dicho cursor está en la (0,0), asi que si no lo tocas, es como si trabajaras con posiciones absolutas.
Para mover el cursor, se utiliza la orden “contexto.translate(x, y);”. Así movemos el cursor a otra posición absoluta, sumándole las componentes X e Y ( que si son negativas, restarán) a las coordenadas X e Y actuales del cursor. Pero luego, cuando dibujemos algo indicando coordenadas, se tomará nueva la posición actual como si fuera la (0,0).

Hay mas tranformaciones del contexto que afectan al cursor, como rotaciones, y escalados y transformaciones compuestas, pero como no los utilizo en éste juego, y dada su dificultad, las dejaremos para otro día.

Los lectores más avezados se habrán dado cuenta de un pequeño detalle… Si movemos el cursor, ¿cómo mantenemos las posiciones de los personajes de la escena? Antes los teníamos de forma absoluta…

Nada de eso, las posiciones absolutas de los personajes se quedan como están. Pero antes de dibujar al siguiente personaje, hay que deshacer los movimientos que hizo el cursor, para volver al punto origen (0,0) absoluto. Es decir, si hacemos un contexto.translate(20,40), luego cuando terminemos de dibujar el personaje, tendremos que hacer un contexto.translate(-20,-40). Si hacemos varias translaciones, no hay problema alguno en dehacer los pasos en cualquier orden. Pero si mezclamos tranlaciones y rotaciones… el orden en el que deshagamos los pasos es fundamental. Habría que seguir una especie de pila LIFO (último en entrar, primero en salir) para deshacer las transformaciones, es decir, deshacer primero la última transformación, luego la penúltima, etc. ¿No hay ninguna forma de hacer ésto más fácilmente? ¡Si!, con save y restore.

Si ejecutamos contexto.save(); en cualquier momento, se guardará en memoria la posición actual del cursor (entre otras muchas cosas; en realidad se guardan todos las variables de configuración del canvas, como son el color de la brocha que hemos utilizado por última vez, el último ancho de líneas especificado, etc). Y en cuanto hagamos un contexto.restore(), se recuperarán todos esos valores almacenados.

Así que a partir de ahora, para dibujar personajes daremos los siguientes pasos:

  1. Guardar el estado del contexto.
  2. Hacer las trasformaciones del contexto adecuadas (por ejemplo, una translación).
  3. Dibujar en el canvas todas las figuras que hagan falta.
  4. Restaurar el último estado guardado del contexto.

Pero ¿Cómo afectará todo ésto a nuestro juego? Pues para empezar, la coordenada de referencia de nuestros personajes ya no será su vértice superior izquierdo, si no su centro. ¿Recuerdas cómo pintábamos los sprites que representan a los personajes? Ya empieza a encajar todo. Si adaptamos la lista anterior a nuestro juego, quedaría así:

  1. Guardar el estado del contexto.
  2. Trasladamos el cursor del canvas a la posición del personaje que vamos a pintar.
  3. Pintar el sprite del personaje.
  4. Restaurar el último estado guardado del contexto.

¿Y cómo guardamos la coordenada central de cada personaje? De forma absoluta al canvas, por supuesto. Pero no en cantidad de píxeles en X e Y, como antes. Vamos a hacer algo parecido a lo que hemos hecho para pintar un tile. En el Space Invaders, el método de dibujar de las entidades se basaba en una coordenada X e Y absoluta, que junto al ancho y alto de la entidad, nos indicaba dónde y con qué tamaño dibujar el sprite de los aliens, disparos, etc. Sin embargo para dibujar los tiles, como ya hemos visto, no le pasábamos píxeles, si no el número de la fila y el número de la columna donde está.

Para los personajes, haremos algo parecido. Le pasaremos un par de números, que representan la fila y la columna donde están. Probablemente te preguntes… Pero si el personaje es mucho más pequeño que un tile, ¿cómo podemos movernos dentro de un tile sin “saltar” al tile siguiente? Fácil, usando la parte decimal de esos números. La parte entera, sin redondeo, nos servirá para saber en qué fila y columa de tiles está. Y la parte decimal, indicará en qué zona de ese tile se encuentra. Por ejemplo, la posición (2.5, 3.333) nos dice que el centro del personaje está en la columna 2, fila 3. Y dentro de ese tile, está exactamente en el centro horizontal (0.5), y aun tercio de su parte superior en el eje vertical (0.333).

Regresando a la clase Mundo

¿Por qué lo hacemos así? Para calcular las colisiones de los personajes con el mapa mucho más rápido. Si detectáramos las colisiones como hacíamos antes, tendríamos que calcular para cada personaje si colisiona con algún tile mediante un bucle que fuera haciendo dicha comprobación con cada uno de los tiles. Sin embargo, teniendo las posiciones de los personajes guardadas así, recuperaríamos el tile sobre el que se tiene que posicionar cada vértice de la imagen, y comprobar si son caminables, todo de forma inmediata. En caso afirmativo, nos podemos mover. Si alguno no es caminable, entonces es que se está produciendo una colisión con el mapa.

Mundo.prototype.casillaCaminable=function(px,py){
    var x=parseInt(px);
    var y=parseInt(py);
    return this.conjuntoTiles[this.mapa[y][x]].caminable;
};

Este método de la clase Mundo será el informe si el tile al que pertenece la coordenada (en el formato decimal recién explicado) determinada. El método parseInt de JavaScript no redondea, sólo elimina la parte decimal del número pasado como parámetro. Y como dije, al tener las tiles en una matriz bidimensional, el acceso al tile adecuado es inmediato.

Mundo.prototype.loop=function(){
    var delta=(new Date().getTime()) - this.tiempoTranscurrido;
    this.tiempoTranscurrido=new Date().getTime();
   
    this.moverPersonajes(delta);
    this.dibujarMapa();
    this.dibujarPersonajes();
};

Mundo.prototype.moverPersonajes=function(delta){
    this.jugador.mover(delta);
};
Mundo.prototype.dibujarPersonajes=function(){
    this.jugador.dibujar(this.contexto);
};

El método loop tiene la forma de siempre. Calculamos el tiempo delta, movemos a los personajes, y dibujamos todo en pantalla. Para no convertir el loop en un monstruo, hemos repartido las tareas en varios métodos independientes. Mover y dibujar jugador, llamarán a los respectivos métodos de la clase Jugador, que veremos después.

La clase abstracta de Personaje

En vista de que en un futuro no muy lejano querremos meter enemigos al juego para darle chicha, necesitamos crear una buena base fácilmente extensible. Y una de las formas más populares de resolver este problema es usar jerarquías de clases y herencia. Vamos a crear una clase abstracta (clase que sólo sirve para heredarla, jamás tendremos instancias de ella) llamada personaje, con los métodos comunes a todos los posibles actores del juego (protagonistas, amigos, enemigos, etc). Así que pensemos. ¿Qué tendrán todos ellos en común? Bueno, se me ocurren un par de cosas: Todos tienen que dibujarse usando sprites, y todos tendrán la posibilidad de moverse por el mapa, por lo que también habrá que controlar sus colisiones con los tiles.

function Personaje(mundo, ancho, alto, x, y, sprite){
    this.mundo=mundo;
   
    this.ancho=ancho;
    this.alto=alto;
    this.x=x;
    this.y=y;
   
    this.dx=0;
    this.dy=0;
   
    this.velocidad=0.001;
   
    this.sprite=sprite;
    this.direccion="abajo";
    this.spriteindice=0;
    this.transicionSprite=50;
    this.transicion=0;
}

El constructor de la clase personaje aceptará como es habitual un puntero hacia el mundo, para tener acceso al mapa, y posteriormente poder notificarle algun suceso interesante como una colisión con un disparo, o lo que sea. También le indicamos en ancho y alto en píxeles, la coordenada X e Y de su centro (formateada en el sistema con decimales que expliqué antes), y el sprite que lo representa.

Los atributos dx y dy pueden contener un -1, un 0 o un 1. Un cero, es que en ese eje, el personaje no se moverá. Un -1, quiere decir que retrocederá. Un 1, que avanzará. La velocidad del personaje está efectuada de forma relativa al tamaño de los tiles, por milisegundo. 0.001 (sobre 1) equivale a un movimiento del 0.1% de lo que mida el tile por cada milisegundo transcurrido.

Dirección contendrá el último tipo de acción que realizó el personaje, útil para saber cuándo cambiar al siguiente sprite de la misma animación. Ésto se hace comprobando si ha transcurrido transicionSprite milisegundos desde el último movimiento. En caso afirmativo, avanzamos spriteIndice, que es el índice de sprite a dibujar del conjunto de sprites indicado en direccion. El tiempo transcurrido lo vamos acumulando en transicion.

Personaje.prototype.dibujar=function(contexto){
    contexto.save();
    contexto.translate(this.x*mundo.anchoCelda,this.y*mundo.altoCelda);
   
   
    this.sprite.dibujar(contexto,this.ancho,this.alto,this.direccion,this.spriteindice);
   
    contexto.restore();
};

Si se ha entendido todo lo que expliqué antes, dibujar el personaje es muy sencillo. Guardamos el contexto del canvas, movemos el cursor a la coordenada donde está el centro del personaje, dibujamos su sprite pasándole los datos que necesita el dibujarse. Por último, restauramos el canvas.

Personaje.prototype.posicionValida=function(px,py){
    var ancho=this.ancho/(2*mundo.anchoCelda);
    var alto=this.alto/(2*mundo.altoCelda);
   
    if (!mundo.casillaCaminable(px-ancho,py-alto))
    {
        return false;
    }
    if (!mundo.casillaCaminable(px+ancho,py-alto))
    {
        return false;
    }
    if (!mundo.casillaCaminable(px-ancho,py+alto))
    {
        return false;
    }
    if (!mundo.casillaCaminable(px+ancho,py+alto))
    {
        return false;
    }
    return true;
   
};

El método posicionValida nos dirá si podemos movernos a una posición indicada, antes de realizar realmente el movimiento. Para ello, y como dije antes, comprueba para cada vértice del rectángulo que contendrá el sprite del personaje, si está en un tile que no es caminable. Como la coordenada de referencia es su posición central, los vértices los obtenemos mediante la combinación de sumarle y/o restarle la mitad del ancho y la mitad de alto a su respectiva coordenada. Pero ojo, ese ancho y ese alto NO DEBEN SER en términos de píxeles, si no relativos al tamaño del tile, igual que antes. Con una sencilla regla de tres, obtenemos la mitad de dichos tamaños respecto a los del tile.

Si anchoTile -> 1 entonces anchoPersonaje -> X
X = (anchoPersonaje * 1) / anchoTile = anchoPersonaje / anchoTile
Como queremos la mitad del ancho:
X = anchoPersonaje / anchoTile / 2 = anchoPersonaje / (anchoTile * 2)

Con la altura, sería la misma fórmula.

Personaje.prototype.mover=function(delta){
    var nuevaDireccion="";
    if (this.dx==0 && this.dy==0) return;
    var px=this.x+this.dx*this.velocidad*delta;
    var py=this.y+this.dy*this.velocidad*delta;
   
    if (!this.posicionValida(px,this.y)) px=this.x;
    if (!this.posicionValida(this.x,py)) py=this.y;
   
    if (this.x==px && this.y==py) return;
   
    this.x=px;
    this.y=py;
    if (this.dx>0)
    {
        nuevaDireccion="derecha";
    }
    if (this.dx<0)
    {
        nuevaDireccion="izquierda";
    }
    if (this.dy>0)
    {
        nuevaDireccion="abajo";
    }
    if (this.dy<0)
    {
        nuevaDireccion="arriba";
    }
    if (this.direccion!=nuevaDireccion)
    {
        this.transicion=0;
        this.spriteindice=0;
        this.direccion=nuevaDireccion;
    }
    this.transicion+=delta;
    if (this.transicion>this.transicionSprite)
    {
        this.transicion=0;
        this.spriteindice=(this.spriteindice+1)%this.sprite.getNumSprites(nuevaDireccion);
    }
};

El método mover es el más largo de todos, pero no más complicado. Si dx y dy están a cero, el personaje no va a moverse, y salimos del método con un return. En caso contrario, almacenamos en dos variables locales la nueva posición central donde irá a parar el personaje si aplicamos el movimiento. Luego comprobamos de forma separada, para cada eje, si podemos movernos en dicho eje. En caso afirmativo, guardamos en una variable local la posición final para ese eje. Lo hacemos por partes para que si nos estrellamos contra una pared, mientras pulsamos por ejemplo arriba y derecha,en caso de que el personaje no pueda moverse hacia arriba, pero sí a la derecha, se mueva sólo hacia la derecha. Si comprobáramos directamente la posición final, el personaje no se movería nada.

Si después de hacer todo ésto, las coordenadas finales son las mismas que las iniciales, es que no podíamos movernos en ninguna de las direcciones indicadas, asi que salimos del método con otro return.

En caso contrario, actualizamos la posición del centro del personaje con las variables locales calculadas, guardamos la dirección con la que nos estamos moviendo, y comprobamos si es la misma que la que teníamos la última vez. Si la dirección es la misma, y ha transcurrido el tiempo necesario, incrementamos en índice del sprite en una unidad, utilizando la operación módulo (%) para que no nos pasemos.

Si la dirección es otra, entonces reseteamos las variables encargadas de la gestión del sprite.

El héroe

Y sólo queda la parte más dura de todas, programar la clase que representará al jugador. Entiendo que después de tanto rollo estéis agotados, pero hay que hacer el último esfuerzo, la meta está cerca:

function Jugador(mundo, ancho, alto, x, y){
    Personaje.call(this, mundo, ancho, alto, x, y, Sprites.get("jugador"));
    this.arriba=false;
    this.abajo=false;
    this.derecha=false;
    this.izquierda=false;
}

Jugador.prototype=new Personaje;

Jugador.prototype.mover=function(delta){
    this.dx=0;
    this.dy=0;
    if (this.arriba)
    {
        this.dy-=1;
    }
    if (this.abajo)
    {
        this.dy+=1;
    }
    if (this.izquierda)
    {
        this.dx-=1;
    }
    if (this.derecha)
    {
        this.dx+=1;
    }
    Personaje.prototype.mover.call(this,delta);
};

¡El Jugador apenas tiene nada! Todas las tareas ya han sido resueltas por alguna de las clases que hemos visto. El Jugador hereda de Personaje, y los únicos atributos nuevos que de momento necesita son las variables donde almacenar si una tecla concreta está siendo pulsada o no en el momento en el que se necesitan, la misma técnica que hemos usado siempre. Su método mover, simplemente lee si alguna de las teclas está pulsada, y actualiza el dx o dy en consecuencia, antes de llamar al método de mover de la clase padre.

Sólo nos queda el HTML:

<html>
    <head>
        <title>Ejemplo Tile 02</title>
        <script type="text/javascript" src="js/AlmacenImagenes.js"></script>
        <script type="text/javascript" src="js/Sprites.js"></script>
        <script type="text/javascript" src="js/Tile.js"></script>
        <script type="text/javascript" src="js/Personaje.js"></script>
        <script type="text/javascript" src="js/Jugador.js"></script>
        <script type="text/javascript" src="js/Mundo.js"></script>
        <script>
            var mundo;
            var Sprites;
            var Imagenes;
           
            window.onload=function(){
                Imagenes=new AlmacenImagenes();
                Sprites=new AlmacenSprites();
               
                Imagenes.cargar(
                    [
                        ["calvo","img/sprite_player.png"],
                        ["mario","img/mario.png"]
                    ]);
                Imagenes.completado=function(){
                    var spriteJugadorTrans=new Sprite(Imagenes.get("calvo"));
                    spriteJugadorTrans.createDireccion(4,8,15,2,11,7,{"arriba":3,"abajo":0,"derecha":2,"izquierda":1});
                    Sprites.add("jugador",spriteJugadorTrans);
                    mundo=new Mundo("micanvas","parar");
                   
                    document.getElementById("transparente").onclick=function(){
                        mundo.detener();
                       
                        var spriteJugadorTrans=new Sprite(Imagenes.get("calvo"));
                        spriteJugadorTrans.createDireccion(4,8,15,2,11,7,{"arriba":3,"abajo":0,"derecha":2,"izquierda":1});
                        Sprites.add("jugador",spriteJugadorTrans);
                       
                        mundo=new Mundo("micanvas","parar");
                    };
                    document.getElementById("puntoapunto").onclick=function(){
                        mundo.detener();
                       
                        var spriteJugadorTrans=new Sprite(Imagenes.get("calvo"));
                        spriteJugadorTrans.setDireccion("arriba",[[12,233,42,287],[64,233,94,287],[116,233,145,287],[166,233,196,287],[217,233,247,287],[270,233,300,287],[321,233,350,287],[374,233,404,287]]);
                        spriteJugadorTrans.setDireccion("abajo",[[12,15,45,70],[64,15,95,70],[113,15,145,70],[164,15,195,70],[217,15,250,70],[272,15,305,70],[321,15,354,70],[373,15,404,70]]);
                        spriteJugadorTrans.setDireccion("derecha",[[12,156,37,213],[68,156,94,213],[114,156,140,213],[164,156,191,213],[218,156,244,213],[272,156,303,213],[322,156,355,213],[376,156,402,213]]);
                        spriteJugadorTrans.setDireccion("izquierda",[[19,84,38,140],[61,84,89,140],[120,84,145,140],[170,84,197,140],[222,84,244,140],[270,84,300,140],[322,84,354,140],[377,84,402,140]]);
                        Sprites.add("jugador",spriteJugadorTrans);
                       
                        mundo=new Mundo("micanvas","parar");
                    };
                    document.getElementById("coloreado").onclick=function(){
                        mundo.detener();
                       
                        var spriteJugadorTrans=new Sprite(Imagenes.get("mario"));
                        spriteJugadorTrans.createDireccion(4,6,5,0,6,6,{"arriba":0,"abajo":2,"derecha":1,"izquierda":3});
                        Sprites.add("jugador",spriteJugadorTrans);
                       
                        mundo=new Mundo("micanvas","parar");
                    };
                };
                           
            };
            function log(texto,nueva){
                var capa=document.getElementById("log");
                if (nueva)
                    capa.innerHTML+="<br/>"+texto;
                else
                    capa.innerHTML=texto;
            }
        </script>
    </head>

    <body>
        <canvas id="micanvas"></canvas>
        <div id ="log"></div>
        <button id="transparente">Sprite transparente</button><button id="puntoapunto">Sprite Punto a punto</button><button id="coloreado">Sprite coloreado</button><button id="parar">Parar</button>
    </body>
</html>

El HTML es como siempre. En el Script podemos ver cómo usamos el almacén de imágenes, sobreescribiendo su método completado, para iniciar el juego. Así nos aseguramos de que el juego arrancará cuando todas las imágenes estén disponibles.

También iniciamos el almacén de sprites, con el sprite del calvo y con id “jugador”. Como puedes recordar, la clase Jugador utilizará ese almacén para recuperar el sprite asociado con esa misma id “jugador”.

Le hemos añadido tres botones. El primero, es el mismo que se ejecuta cuando lo arrancamos por primera vez: Usa el sprite del calvo iniciado con el método rápido, que parte la imagen por filas y columnas, con una serie de pequeños ajustes.

El segundo botón, carga el mismo sprite, pero declarado manualmente coordenada a coordenada. Como dije, al tener cada imagen un tamaño diferente, se estirará y encogerá cuando el personaje se mueva, quedando algo feo la animación. Para utilizarlo bien, habría que tener cuidado con declarar cada imagen con el mismo tamaño que todas las otras.

Por ejemplo, mira la siguiente imagen:

Como puedes ver, los sprites no están divididos por filas y columas repartidas uniformemente, asi que los sprites habría que extraerlos manualmente, agrupándolos por el tipo de animación que van a representar. Por eso, no está de más el método setDireccion. Aunque como ya dije antes, tiene un mal nombre; probablemente le cambie el nombre a setAccion o setAnimacion en futuros tutoriales.

El tercero es para mostrar la importancia de usar sprites con transparencias (formato PNG o GIF). El sprite de Mario es un jpg, por lo tanto, no es transparente. Su fondo es verde, así que cuando andemos sobre una sueprficie que no sea verde, se quedará horrible. Usar jpg’s como sprites debería estar penado por la ley con varios años de cárcel.

Y punto final, más de 5400 palabras después, ya tenemos a nuestro héroe dándose vueltas por el mapa, ansioso de enfrentarse al peligro, liquidar a los malos y rescatar a la princesa. En el próximo episodio, verémos que nuevas aventuras le esperan a nuestro particular héroe.

Volver al índiceIr al siguiente tutorial

Etiquetas: , , , , , , , ,

Comentarios (8)

  • De nuevo felicidades, muy bien explicado y impaciente por otra entrega, supongo que también meterás profundidad al mapa, encontré esto http://www.project-cyan.com/2011-07-sistema-colisiones-superposicion-capas-html5-canvas/ y sobre setInterval como lo usaste con el Invader encontré esto http://www.project-cyan.com/2012-05-requestanimationframe-adios-al-setinterval/, también habla algo de un buffer http://www.project-cyan.com/2011-07-utilizar-un-elemento-canvas-como-buffer-para-evitar-el-parpadeo-en-html5/ no se si tiene algo que ver con lo que tienes pensado hacer, es para saber tu opinión un saludo y de nuevo gracias.

  • Muchas gracias! Muy interesante y facil para entenderlo, tendras pronto el siguiente tutorial??

  • Pues espero tenerlo para la semana que viene. El código ya está hecho, tratará sobre cómo cambiar de mapa, usar una ventana de scroll (en vez de mostrar todo el mapa, sólo mostrar una parte pequeña alrededor del nuestro personaje), y la introducción de enemigos con una inteligencia artificial muy tonta.

    De hecho, antes de escribir el tutorial, me emocioné y seguí programando, e hice un editor de mapas básico para que sea más sencillo crear nuevos mapas, el cual probablemente también incluya en el siguiente tutorial; y también modifiqué la forma de pintar el juego para que se vea en pseudo-3D, es decir, unsando la perspectiva isométrica, que probablemente será el motivo principal del cuarto tutorial. Aunque antes tengo que resolver el problema del solapamiento entre los personajes y el mapa al ser dibujados (pues ya no se puede pintar primero el mapa, y luego los personajes; todos tienen que ser pintados atendiendo a algún orden, en cronceto, a la “profundidad” de donde estén).

  • Muy bien, esto cada vez suena mas interesante! xD
    Aun me siento algo verde en la programacion, pero poco a poco con ayudas como estas que nos aportas me serviran(y a otros tambien…).
    Gracias por compartirnos todo esto y Felicidades por todo tu trabajo!

  • Gracias por los tutoriales. Son, de lejos, los mejores que he visto de javaScript.

  • Muy bueno el tutorial. De lo mejor que he visto. Estoy preparando un juego similar y me viene de perlas. Aunque mi juego debe funcionar también en móviles y en Internet Explorer 9.

    Para el tema del que no funciona en Explorer he descargado esta clase:

    https://code.google.com/p/explorercanvas/source/browse/trunk/silverlight/excanvas.js?r=48

    Luego con añadirla tan solo en el html inicial ya funciona el dibujado del mapa(de la parte I del tutorial) en IE.

    El caso es que la parte II del tutorial se queda completamente bloqueada en IE y no sé exactamente que lo provoca, la clase sprite quizás, pero no lo sé. Me gustaría encontrarlo para reescribir el juego y hacerlo totalmente compatible.

    ¿Alguien sabe qué es lo que falla?

  • Bueno, he encontrado varias cosas que no funcionan en dispositivos móviles y en IE. Por un lado la forma de manejar los Sprites, por otro el redibujado del mapa mediante las propiedades del canvas.

    Estoy reescribiendo el código entero para hacerlo compatible con todos los dispositivos, os iré dejando los cambios.

  • Casi lloro viendo estos maravillosos tutoriales.
    No tiene precio lo que acabas de enseñarme , te mereces el cielo.
    Gracias por todo tu trabajo y empeño , deberiamos de darte todo el sueldo de esos funcionarios llamados “profesores de informatica” a ti por hacer más por la comunidad que ellos mismos.

¿Tienes algo que decir?

Gestionado con Wordpress y Stripes Theme Entradas (RSS) | Comentarios (RSS)