Tutorial de Tiles 3ª parte: Cambiar de mapa, aparición de enemigos y scroll.

Escrito por el 31 mayo, 2012

En el anterior capítulo del tutorial de Tiles dejamos al protagonista dando vueltas a un diminuto y desértico mapa, donde no hay nada que hacer. Asi que probablemente nuestro juego no será lider de ventas este mes. Necesitamos mapas más grandes, laberínticos, donde nos suponga un reto buscar la salida. Y enemigos. Decenas de enemigos. Cientos de enemigos. Miles de enemigos. Enemigos que nos acosen el resto de nuestra vida virtual. Asi que ése va a ser el objetivo de esta nueva entrega de tutoriales de valor incalculable, como empiezan a darse cuenta alguno de los lectores que se atreven a mancillarme el blog dejando sus comentarios.

Como novedad para el tutorial, pondré aquí unos enlaces con los que poder probar la última versión del juego, además del ZIP para correrlo en local:

Aquí tienes en enlace para jugar, y aquí el ZIP (con el editor de mapas que veremos más adelante).

El objetivo del juego es llegar a la casilla verde sano y salvo, esquivando por el camino a las temibles arañas mecánicas asesinas del infierno laberíntico bicolor (estoy pensando un nombre mejor para la versión de pago). Las casillas rojas son puntos por los que cambiar de mapa.

Sin embargo, esta versión tiene metido el manejo del Scroll, asi que para hacer menos traumático el seguimiento del tutorial, antes de introducir el scroll, programaremos ésta versión previa donde se muestra el mapa entero: Versión previa sin scroll.

Si eres un pobre de esos que viven debajo de un puente y no tienes un monitor de 23 flamantes pulgadas como yo, el mapa no te cabrá entero y aparecerán las típicas barras de scroll a la derecha y en la parte inferior del navegador. He ahí la indispensable necesidad de implementar un sistema de scroll propio en juegos con mapas grandes. Si no sabes lo que es scroll, cuando habras las dos versiones del juego, lo entenderás enseguida.

Además, el movimiento del jugador está acelerado, para que sea más fácil esquivar a los enemigos y alcanzar la casilla de cambio de mapa.

La página principal

Como no sabía muy bien por dónde empezar, he decidido hacerlo por el tejado, es decir, enseñando el index.html. Tiene esta bellísima apariencia:

<html>
    <head>
        <title>Ejemplo Tile 03</title>
        <script type="text/javascript" src="js/sprites/AlmacenImagenes.js"></script>
        <script type="text/javascript" src="js/sprites/Sprites.js"></script>
        <script type="text/javascript" src="js/tiles/Tile.js"></script>
        <script type="text/javascript" src="js/tiles/TilePuerta.js"></script>
        <script type="text/javascript" src="js/tiles/TileMeta.js"></script>
        <script type="text/javascript" src="js/personajes/Personaje.js"></script>
        <script type="text/javascript" src="js/personajes/Jugador.js"></script>
        <script type="text/javascript" src="js/personajes/Enemigo.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"],
                        ["enemigo","img/enemy.png"]
                    ]);
                Imagenes.completado=function(){
                    var sprite=new Sprite(Imagenes.get("calvo"));
                    sprite.createDireccion(4,8,15,2,11,7,{"arriba":3,"abajo":0,"derecha":2,"izquierda":1});
                    Sprites.add("jugador",sprite);
                    sprite=new Sprite(Imagenes.get("enemigo"));
                    sprite.createDireccion(4,3,15,20,8,8,{"arriba":3,"abajo":0,"derecha":2,"izquierda":1});
                    Sprites.add("enemigo",sprite);
                   
                    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="parar">Parar</button>
    </body>
</html>

La página donde se mostrará el juego es prácticamente la misma de siempre. He eliminado los botones del tutorial anterior (los que cargaban los sprites de distintas formas), y el código javascript lo que hace es meter en el almacen de imágenes las dos imágenes que de momento tiene el juego: El tan entrañable como calvorota protagonista y el del horripilante arácnido maquinal homicida del averno blanquinegrel (sigo trabajando en el nombre). Cuando el almacen haya cargado las imágenes, creará el almacen de sprites e iniciará el juego. La imagen del sprite de la araña está en el formato de 4 filas y 3 columnas similar al que ya conoces, y por supuesto, descargado de la internet violando todos los posibles derechos de autor habidos y por haber.

Los archivos de JavaScript los he metido en tres subcarpetas (Tiles, Personajes y Sprites), para tener mejor organizado el código.

Cambiando de mapa

Un buen juego tiene que tener un mapa bien diseñado, que le proponga retos al jugador. Grande, para que el juego dure lo máximo posible. Y bonito, porque lo que entra por el ojo, gusta de forma inmediata. Que esté bien diseñado y que sea bonito es algo que se escapa a la programación, aunque puedo dar consejos generales. El diseño del mapa depende de las habilidades que tenga el protagonista y las que tengan los enemigos. Por ejemplo, en este juego, el jugador no puede disparar; y las arañas se mueven más rápido que el jugador. Así que no puedo hacer pasillos estrechos y muy largos, ya que si se mete una araña en ellos, será muy difícil que el jugador pueda cruzarlo sin morir. También quiero que para ganar, el jugador tenga que alcanzar una casilla determinada. Asi que para obligarle a recorrerse todo el mapa, lo he diseñado en forma de laberinto.

Pero por ejemplo, si el jugador pudiera disparar, quizás convendría hacer habitaciones más anchas, para evitar que los enemigos se apelotonen en una zona con poco espacio haciendo que sea más fácil abatirlos a balazos. En cuanto a que sea bonito, es cuestión de utilizar Tiles con multitud de Sprites-imágenes diferentes, para que al avanzar por el mapa no de la sensación de que todas las habitaciones son iguales. Es decir, todo lo contrario a lo que he hecho yo.

En cuanto a la última característica, que sea grande, se abren dos posibilidades: Tener un único mapa gigantesco, o tener muchos mapas más pequeños. A éste tipo de juegos con muchos mapas, también se les conoce como niveles. Ambos tienen sus ventajas, y sus inconvenientes. El mapa único gigantesco, tiene como gran ventaja el no tener que gestionar posibles cambios de mapa. Pero tiene varios inconvenientes. El primero, es el desperdicio de tiles que se quedarán vacíos. Obviamente tu mapa tendrá que ser rectangular (al estar contenido en una matriz bidimensional), pero probablemente el escenario caminable tenga forma de “serpiente”, visto desde la distancia. Asi que quedarían un montón de posiciones de Tiles con información irrelevante (las casillas de color negro por las que nunca podrás caminar):

Otro inconveniente, es que para pintar todas las casillas el algoritmo tardará más, y enlentecerá el juego, si tu mapa es realmente gigantesco. Aunque ésto tiene solución: Usando un sistema de scroll, como veremos después. El último inconveniente es muy similar. Tienes que gestionar a la vez a todos los personajes (jugador, enemigos, items) que estén en en tu juego. Tienes que moverlos aunque no se vean, y detectar las colisiones que se producen entre ellos, haciendo el juego más lento cuanto más personajes tengas.

Así que usaremos muchos mapas. En algunos juegos sencillos, como el Space Invaders, no era necesario. Pero para hacer juegos más complejos, cambiar de mapa (o nivel) es indispensable. Además de no desperdiciar memoria en Tiles que no sirven para nada, sólo tendrás que manejar los enemigos que estén en ese mapa o nivel.

¿Y qué necesitamos para ello? Un nuevo tipo de Tile: el Tile Puerta.

function TilePuerta(ancho, alto, color, mapa, dx, dy){
    Tile.call(this, ancho, alto, true, color);
    this.mapa=mapa;
    this.dx=dx;
    this.dy=dy;
}
TilePuerta.prototype=new Tile;

El nuevo tipo de Tile es muy sencillo, hereda del anterior todos sus atributos y métodos, y declara tres métodos nuevos: El mapa y las coordenadas a donde te llevará cuando cruces la puerta. Es decir, cuando pises un tile de ese tipo, se te “transportará” al mapa indicado en la posición de la coordenada indicada (con el mismo formato de siempre; la parte entera es la fila o columna, la parte decimal es la posición relativa dentro de ese Tile destino). Una puerta debe ser caminable (para poder entrar y atravesarla), asi que le mando un true en el argumento correspondiente.

Y ya que estamos con los Tiles, veámos el otro tipo de Tile nuevo.

function TileMeta(ancho, alto, color, mapa, dx, dy){
    Tile.call(this, ancho, alto, true, color);
}
TileMeta.prototype=new Tile;

Es muy sencillo, sólo hereda de Tile. Cuando esté en una casilla del tipo TileMeta, sabré que he llegado a la casilla final y que he ganado la partida. Por supuesto, la meta es una casilla caminable.

Veámos cómo cambia el constructor de la clase Mundo. He dejado sólo la parte involucrada en la gestión de mapas.

function Mundo(idCanvas, idBoton){
    /*Código de carga del canvas...*/
   
    this.conjuntoTiles=[
        new Tile(this.anchoCelda,this.altoCelda,true,"white"), //0
        new Tile(this.anchoCelda,this.altoCelda,false,"black"), //1
        new TilePuerta(this.anchoCelda,this.altoCelda,"red",1,1.1,14.5), //2
        new TilePuerta(this.anchoCelda,this.altoCelda,"red",0,24.9,14.5), //3
        new TilePuerta(this.anchoCelda,this.altoCelda,"red",2,22.5,16.9), //4
        new TilePuerta(this.anchoCelda,this.altoCelda,"red",1,22.5,1.1), //5
        new TileMeta(this.anchoCelda,this.altoCelda,"green") //6
    ];
   
    this.mapa=[];

    this.mapa.push( //0
    [
        [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
        [1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 0, 0, 1],
        [1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 1, 0, 0, 1],
        [1, 1, 1, 1, 1, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 1],
        [1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 0, 0, 1],
        [1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 1],
        [1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 1],
        [1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1],
        [1, 1, 0, 1, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 1, 1],
        [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 1, 1, 1, 1, 1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1],
        [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 2], /*pta*/
        [1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1],
        [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
    ]);
    this.mapa.push( //1
    [                                                                   /*pta*/
        [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 4, 1, 1, 1],
        [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
        [1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
        [1, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 1, 0, 0, 1],
        [1, 1, 1, 1, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 1, 1, 1, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
        [1, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 1, 0, 0, 1, 0, 0, 1, 1, 1],
        [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 0, 1, 0, 1, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
        [1, 1, 1, 1, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 1, 1, 0, 0, 1],
/*pta*/ [3, 0, 0, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
        [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
    ]);
    this.mapa.push( //2
    [
        [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
        [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
        [1, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1],
        [1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1],
        [1, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
        [1, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 1],
        [1, 0, 0, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 0, 0, 1, 1, 1],
        [1, 0, 1, 1, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 1],
        [1, 0, 0, 1, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 1, 1, 0, 0, 0, 0, 1],
        [1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 0, 0, 1, 1, 1, 0, 0, 1],
        [1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 6, 1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 1],
        [1, 1, 0, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 0, 0, 0, 0, 1],
        [1, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 1, 1],
        [1, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 1],
        [1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0, 1],
        [1, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
        [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 5, 1, 1, 1]
    ]);                                                                 /*pta*/
   
    this.personajes=[];
   
    this.mapaActual=0;
    this.jugador;

    /*Mas codigo de otras inicializaciones*/
    this.iniciarJugador();
    this.cambiarMapa(0,2.5,1.5);

}

Como en el ejemplo del tutorial anterior, el código comienza declarando el conjunto de tipos de tiles que vamos a usar. Como antes, necesitaré 2 del tipo Tile, que me servirán como las zonas no caminables (en negro), y las zonas caminables (en blanco). A continuación, declaro 4 puertas (1 para el primer mapa, 2 para el segundo, y otra para el tercero). ¿Por qué cuatro, y no dos? Si te paras a pensarlo, sólo deberían de haber 2 puertas. Una para pasar del mapa 1 al mapa 2, y otra para pasar del 2 al mapa 3. La explicación es que he añadido 2 puertas adicionales para poder regresar al mapa anterior. Si mi puerta está a la derecha del todo, en la fila 5, debería llevarme a otro mapa apareciendo por la parte izquierda (pero no necesariamente en la fila 5; imagina dos mapas que forman una T acostada). Otra curiosidad es que en algunas de las coordenadas, uso (x.1 y x.9) o (y.1 y y.9). Así da la sensación de que esto “entrando” en ese mapa.

Por ejemplo, la puerta del mapa 0 está en la fila 14, última columna. Asi que esa puerta me llevará al mapa 1, en la fila 14 pero en la primera columna. En cuanto a su parte decimal, está en la y.5 y en la x.1, así aparecera en mitad de la celda en su eje vertical, pero pegada a la izquierda en el eje horizontal.

Alguno pensará ¿Y por qué no lo pongo directamente encima de la puerta del otro mapa, en vez de ponerlo en la casilla siguiente? Pues porque para saber si cambiar de mapa se detecta cuando el jugador está contenido completamente en una tile del tipo PuertaTile. Asi que si lo transportáramos a encima de una puerta, ésta lo mandaría a una posición determinada de vuelta al mapa anterior, que también sería otra puerta; por lo que se volvería a cambiar de mapa, consiguiendo un precioso bucle infinito. Podríamos poner código para controlar esa situación, ¿pero realmente es necesario?.

Volvamos al código. Después del conjunto de tipos de Tiles, vienen declarados los mapas, como matrices bidimensionales, como ya conoces. Situar una puerta es fácil. Pero luego, lo que cuesta un poco más es situar la coordenada de donde te lleva esa puerta utilizando la lógica. Con lo ya explicado antes, y sabiendo que he puesto comentarios /*pta*/ cerca de las puertas para que las veas claramente, podeis comprobar cómo están colocadas y se corresponden con los datos pasados al constructor de los TilePuerta. Además, en el último mapa, he colocado un 6 que corresponde con el MetaTile.

Por último, llamamos a la función cambiarMapa, pasándole como argumentos el mapa que quiero cargar, y la posición donde apareceré en dicho mapa. Como ya te estás imaginando, los PuertaTile tienen como atributos precisamente esos argumentos, asi que resultará evidente que esa función también será la que usaremos para cambiar de mapa al cruzar una puerta.

Mundo.prototype.detener=function(mapa,px,py){
    clearInterval(this.intervalo);
    this.intervalo=null;
};
Mundo.prototype.reanudar=function(){
    var self=this;
    this.tiempoTranscurrido=new Date().getTime();
    this.intervalo=setInterval(function(){self.loop()},25);
};
Mundo.prototype.cambiarMapa=function(mapa,px,py){
    this.detener();
   
    this.mapaActual=mapa;
    this.canvas.width=this.anchoCelda*this.mapa[this.mapaActual][0].length;
    this.canvas.height=this.altoCelda*this.mapa[this.mapaActual].length;
   
    this.jugador.situar(this.mapaActual,px,py);
   
    this.personajes=[];
    this.personajes.push(this.jugador);
   
    this.reanudar();
};

Para parar y poner el marcha el intervalo, he creado un par de métodos nuevos. No necesitan de más explicaciones.

El método de cambiar de mapa es muy sencillo. Ponemos el índice del mapa actual al mapa al que vamos a cambiar, y utilizamos dicho índice para poner el tamaño del canvas correctamente para que quepa el nuevo escenario. Despoués situamos al jugador en la posición correspondiente, utilizando un nuevo método el cual veremos en el siguiente apartado, cuando hable sobre cómo meter enemigos. Y por último metemos al jugador en la lista de personajes del juego que hay en el mapa actual. Utilizaremos dicha lista para mover, pintar y detectar las colisiones de los personajes con un simple FOR. Por supuesto, mientras hacemos todo ésto, hemos detenido el intervalo de tiempo. En cuanto terminemos de cambiar de mapa, volvemos a activarlo.

El resto de los métodos de Mundo, no cambian sustancialmente.

¿Y cómo afecta el cambio de mapa al personaje?

Cambios a las clases de Personaje y Jugador

Bueno, ahora el protagonista puede cambiar de mapa, y si se pone encima de una casilla del tipo MetaTile, se escapa del laberinto y gana la partida. Además, para complicar las cosas, infestaremos de enemigos el mapeado.

¿Cómo cambia la clase Personaje para manejar todo ésto? Veámosla:

function Personaje(mundo, ancho, alto, sprite){
    this.mundo=mundo;
   
    this.ancho=ancho;
    this.alto=alto;
    this.x=0;
    this.y=0;
   
    this.mapa=0;
    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;
   
    this.tilesProhibidos=[];
}
Personaje.prototype.situar=function(mapa,x,y){
    this.mapa=mapa;
    this.x=x;
    this.y=y;
};

El constructor apenas cambia. Le hemos sacado los parámetros X e Y que le pasábamos antes, pues como el jugador puede cambiar de mapa, nos interesa asignarle esos valores a través de un método nuevo, que hemos llamado situar. Como vamos a tener varios mapas, también guardamos el índice del mapa de donde estamos, para poder acceder a él para detectar las colisiones con las paredes del escenario (los Tiles negros que no son caminables).

Además tenemos un atributo del tipo array nuevo. ¿Para qué sirve tilesProhibidos? Pues sencillo, para indicarle al juego que ese personaje en cuestión no puede caminar sobre los tipos de Tiles que le digamos. Es decir, si a un tipo de Tile le ponemos un false en el caminable, ningún personaje podrá caminar sobre él. Sin embargo, a veces nos interesa que algunos tiles sean no caminables sólo para un tipo de personaje. En éste caso, nos interesará que los enemigos no puedan caminar sobre las puertas

Personaje.prototype.esTileProhibido=function (px,py){
    var n=this.tilesProhibidos.length;
    for (var i=0;i<n;i++)
    {
        if (this.mundo.isTipoTile(this.tilesProhibidos[i],this.mapa,px,py))
        {
            return true;
        }
    }
    return false;
};
Personaje.prototype.posicionValida=function(px,py){
    var ancho=this.ancho/(2*this.mundo.anchoCelda);
    var alto=this.alto/(2*this.mundo.altoCelda);
   
    if (!this.mundo.casillaCaminable(this.mapa,px-ancho,py-alto) || this.esTileProhibido(px-ancho,py-alto))
    {
        return false;
    }
    if (!this.mundo.casillaCaminable(this.mapa,px+ancho,py-alto) || this.esTileProhibido(px+ancho,py-alto))
    {
        return false;
    }
    if (!this.mundo.casillaCaminable(this.mapa,px-ancho,py+alto) || this.esTileProhibido(px-ancho,py+alto))
    {
        return false;
    }
    if (!this.mundo.casillaCaminable(this.mapa,px+ancho,py+alto) || this.esTileProhibido(px+ancho,py+alto))
    {
        return false;
    }
    return true;
   
};

A continuación declaramos un método, esTileProhibido, donde comprobamos si una casilla dada es de alguno de los tipos prohibidos para ese personaje. Para ello usamos otro nuevo método de la clase Mundo isTipoTile, ya que es en la clase Mundo donde tenemos acceso directo al array de tipos de tiles.

En el método posicionValida, tenemos en cuenta también si la casilla es de un tipo de tile prohibido.

Personaje.prototype.dentroTile=function(){
    var tx=parseInt(this.x);
    var ty=parseInt(this.y);
    var ancho=this.ancho/(2*this.mundo.anchoCelda);
    var alto=this.alto/(2*this.mundo.altoCelda);
   
    if (tx!=parseInt(this.x-ancho) || ty!=parseInt(this.y-alto))
    {
        return false;
    }
    if (tx!=parseInt(this.x+ancho) || ty!=parseInt(this.y-alto))
    {
        return false;
    }
    if (tx!=parseInt(this.x-ancho) || ty!=parseInt(this.y+alto))
    {
        return false;
    }
    if (tx!=parseInt(this.x+ancho) || ty!=parseInt(this.y+alto))
    {
        return false;
    }
    return true;
};

Este nuevo método nos servirá para saber si el personaje está completamente dentro de una casilla. Nos servirá para cambiar de mapa, si el jugador está completamente encima de una casilla del tipo PuertaTile, o si ha ganado la partida, si está en un MetaTile. Es obvio que para saber si el personaje está dentro, como las posición x-y que tenemos del personaje es su centro, tenemos que sumarle y/o restarle el ancho y alto (relativos al tamaño de la casilla), como ya expliqué en el tutorial anterior.
Para saber si el personaje está totalmente dentro de una casilla, la parte entera de su X e Y de todas sus “esquinas” tiene que ser la misma que la que tiene su centro. En caso de que alguna de las coordenadas no coincida, es que el personaje está ocupando al menos 2 Tiles.

Personaje.prototype.colisiona=function(otro){
    var tasaPenetracion=0.4;
    var anchoThis=this.ancho/(2*this.mundo.anchoCelda)*(1-tasaPenetracion);
    var altoThis=this.alto/(2*this.mundo.altoCelda)*(1-tasaPenetracion);
    var anchoOtro=otro.ancho/(2*this.mundo.anchoCelda)*(1-tasaPenetracion);
    var altoOtro=otro.alto/(2*this.mundo.altoCelda)*(1-tasaPenetracion);
   
    if (this.x + anchoThis < otro.x - anchoOtro) {
        return;
    }
    if (this.y + altoThis < otro.y - altoOtro) {
        return;
    }
    if (this.x - anchoThis > otro.x + anchoOtro) {
        return;
    }
    if (this.y - altoThis > otro.y + altoOtro) {
        return;
    }
   
    this.colisionado(otro);
    otro.colisionado(this);
};

Personaje.prototype.colisionado=function(otro){};

Por fin nuestro querido método de comprobar colisiones entre los personajes. Con el escenario, ya tenemos claro cómo se realizan. ¿Pero y entre los personajes? Pues exactamente igual que hacíamos en el Space Invaders, aunque adaptando el algoritmo al sistema relativo de coordenadas que usamos ahora. Además, como sabemos que el personaje y las arañas no llenan completamente el rectángulo que se usará para las colisiones, los haremos más pequeños (un 60% –> 0.6 = 1-0.4). Así nos estamos asegurando que para que ocurra una colisión, los sprites están chocando con una buena profundidad (es decir, un sprite está bastante encima de otro, en vez de simplemente tocándose las esquinas).

Y como en el Space Invaders, cuando detectemos que se ha producido una colisión, ejecutamos el método colisionado de ambos personajes. Ahora lo declaramos vacío, pero quien herede de personaje, si le interesa, deberá sobreescribirlo.

Personaje.prototype.dibujar=function(contexto){
    contexto.save();
    contexto.translate(this.x*this.mundo.anchoCelda,this.y*this.mundo.altoCelda);
   
    this.sprite.dibujar(contexto,this.ancho,this.alto,this.direccion,this.spriteindice);
   
    contexto.restore();
};
Personaje.prototype.mover=function(delta){
    var nuevaDireccion="";
    if (this.dx==0 && this.dy==0) return false;
    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 false;
   
    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);
    }
    if (this.dentroTile())
    {
        this.sobreTile(this.mundo.getTile(this.mapa,this.x,this.y));
    }
    return true;
};
Personaje.prototype.sobreTile=function(tile){};

Dibujar no ha cambiado, el personaje se pinta en el canvas de la misma forma.

En cuanto a mover, sólo ha cambiado ligeramente hacia el final. Cada vez que el personaje se mueva, tendrá que comprobar si está completamente dentro de un tile, usando el nuevo método que hemos visto hace unos instantes. Si lo está, llamamos al método sobreTile pasándole el Tile sobre el que estamos. Y al igual que con colisionado, el método sobreTile deberá ser sobreescrito por las clases que heredan de Personaje, siempre que les interese hacer alguna acción cuando el personaje está completamente encima de un Tile. Además, ahora este método devuelve un booleano, indicando si el personaje se ha podido mover o no. Este booleano nos será muy útil para gestionar las arañas enemigas.

Y ahora veámos los cambios que hay que hacerle al jugador:

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

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);
};
Jugador.prototype.sobreTile=function(tile){
    if (this.mundo.isTipoTile(TilePuerta,this.mapa,this.x,this.y))
    {
        this.mundo.cambiarMapa(tile.mapa,tile.dx,tile.dy);
    }
    else if (this.mundo.isTipoTile(TileMeta,this.mapa,this.x,this.y))
    {
        this.mundo.jugadorMeta();
    }
};
Jugador.prototype.colisionado=function(otro){
    this.mundo.jugadorMuerto();
};

El constructor es el mismo, pero ya no recibe los argumentos X e Y. Ahora utilizarán el método situar de la clase padre. El método mover es el mismo, no tiene cambios.

Más interesante son las funciones sobreTile y colisionado, que estamos sobrescribiendo, porque nos interesa, y mucho, utilizarlas.

La primera será la que se ejecute cuando el jugador esté completamente encima de un tile. Comprobamos el tipo de tile en el que estamos, usando el nuevo método de la clase Mundo que veremos después. Si el Tile es de la clase TilePuerta, llamaremos al método de la clase Mundo que cambia de mapa, con los datos que definimos en sus atributos (el mapa, y la posición x e y donde aparecer en ese mapa).

Si está sobre un TileMeta, entonces le notificamos a la clase Mundo que hemos ganado la partida.

El método colisionado trabaja igual que en el Space Invaders. Cuando se dispare es que hemos topado con un enemigo (ya que de momento no hay más clases de personajes, sólo nosotros, Jugador, y las arañas, de clase Enemigo). Asi que notificamos al juego que hemos muerto.

Pero he cometido un pequeño error, ahora, escribiendo el tutorial, me he dado cuenta. Cuando llegamos a la meta y cuando colisionamos con un enemigo, nos comunicamos con el juego con notificaciones. Es decir, avisamos a la clase Mundo que en la siguiente vuelta del loop, gestione la “incidencia” y haga lo que tenga que hacer. Y eso es lo correcto. Es el método loop el que debe cambiar el “estado” del juego.

Sin embargo, para cambiar de mapa, no lo hago con una notificación: Lo estoy cambiando directamente. Asi que cuando la ejecución regrese al loop, moverá los personajes restantes que haya en la lista de personajes (que estarán situados después del jugador). ¿Pero y si el mapa al que he cambiado no tiene enemigos? Darán errores en la consola, ya que el tamaño de la lista en esa ejecución del loop estaba calculado para el mapa anterior. Así que nada. Lo correcto es usar notificaciones. Por ejemplo, crearle a la clase mundo nuevos atributos, como por ejemplo “notificacionCambiarMapa”, un booleano que ponemos a false inicialmente, pero que desde la clase Jugador en vez de llamar a cambiarMapa, la ponemos a TRUE. Y en un una zona del loop donde estemos seguros de que podemos hacerlo sin errores, usualmente en la última linea del loop, cambiamos de mapa si esa variable está a TRUE.

Y ha llegado el momento de presentaros al temible arácnido asesino que nos hará la vida imposible.

La clase Enemigo y el primer intento de crear una inteligencia artificial

Con la introducción de enemigos, se abre una nueva puerta en la programación: Inteligencia artificial. A los enemigos hay que dotarlos de un código para que interactuen con el mundo. En el Space Invaders, los enemigos no tenían inteligencia. Sólo se movían siguiendo un patrón cíclico. Se movían de lado a lado, y bajaban un poquito. Pero las arañas deben de moverse por un mapa lleno de obstáculos, con lo que hay que dotarlas con algún tipo de algoritmo que hagan que se muevan inteligentemente.

Antes de empezar, quiero destacar que soy un profano en programación de IA, asi que es posible que meta la pata hasta el fondo. Aunque como aproximación amateur, supongo que no iré tan desencaminado.

La inteligencia artificial es, en esencia, una serie de algoritmos que sirven para hacer que una entidad actúe inteligentemente respetando ciertas reglas. Por ejemplo, cómo moverse por el mapa, atacar al jugador, interactuar con el escenario, etc. Es un mundo muy amplio y sería imposible abordar todos los aspectos en un tutorial como éste. Así que de momento me centraré en la inteligencia artificial para el movimiento dentro de un mapa.

Los algoritmos de inteligencia artificial para el movimiento pienso que se pueden dividir en dos categorías: Los que conocen todo el mapa en el que se mueven (obstáculos, la coordenada del enemigo, etc), y los que no conocen nada, sólo lo que puede ver desde su posición. Para los primeros se suele utilizar algoritmos de búsqueda de caminos (llamados algoritmos A*), y para los segundos, hay muchas posibilidades. Desde unos costosos algoritmos de exploración usando heurísticas (una especie de reglas-suposiciones sobre las que se basa para elegir una acción), hasta un simple tirada de dados para escoger la nueva dirección a tomar.

En este tutorial, haremos una inteligencia artificial muy básica, basada en el último tipo de algoritmo que expliqué en el párrafo anterior. La araña no conoce el mapa, sólo su posición. Ni siquiera conoce que clase de casilla tiene alrededor suya. Haremos que se mueva en una dirección generada al azar, y cuando se tope con una pared, volverá a calcular otra dirección al azar. Para hacer un poco más complicado para el jugador predecir los movimientos de la araña, también haremos que cambie de dirección cada X milisegundos, aunque no haya chocado con ninguna pared. Y para evitar que las arañas se atraviesen, cuando se detecten colisiones entre ellas, también les cambiaremos la dirección.

Veámos la nueva clase Enemigo.

function Enemigo(mundo, ancho, alto){
    Personaje.call(this, mundo, ancho, alto, Sprites.get("enemigo"));
    this.velocidad=0.0015;
    this.tilesProhibidos=[TilePuerta];
   
    this.tiempoAcumulado=0;
    this.tiempoCambio=2000;
}
Enemigo.prototype=new Personaje;

Su constructor es muy sencillo. Llama al constructor del padre con los argumentos necesarios, le indica una velocidad algo superior a la que tiene el personaje (para que no podamos moviendonos simplemente en linea recta), y añadimos el tipo de tile TilePuerta a los tipos de tile que este personaje no puede atravesar. Así evitamos que una araña se meta en una puerta (que aunque no cambiaría de mapa, sí que molestaría al jugador).

Además manejamos dos atributos nuevos donde acumular el tiempo transcurrido desde el último cambio de dirección. Cuando se superen los milisegundos almacenados en tiempoCambio, se procederá a calcular una nueva dirección al azar.

Enemigo.prototype.calcularDireccion=function(alAzar){
    if (alAzar)
    {
        var azar=Math.round(Math.random()*3);
        switch (azar)
        {
            case 0:
                this.dx=-1;
                this.dy=0;
                break;
            case 1:
                this.dx=1;
                this.dy=0;
                break;
            case 2:
                this.dx=0;
                this.dy=-1;
                break;
            case 3:
                this.dx=0;
                this.dy=1;
                break;
        }
    }
    else
    {
        this.dx*=-1;
        this.dy*=-1;
    }
};
Enemigo.prototype.mover=function(delta){
    if (!Personaje.prototype.mover.call(this,delta) || this.tiempoAcumulado>this.tiempoCambio)
    {
        this.tiempoAcumulado=0;
        this.calcularDireccion(true);
    }
    else
    {
        this.tiempoAcumulado+=delta;
    }
};
Enemigo.prototype.colisionado=function(otro){
    this.calcularDireccion(false);
};

El método calcularDireccion será el que genere una dirección al azar. Para ello simplemente obtenemos un número redondeado entre 0 y 3 con Math.random(Math.random()*3) y movemos al personaje en una de las direcciones básicas. Además, a veces, nos interesa mover a la araña en justamente la dirección contraria a la que llevaba, por ejemplo cuando chocamos con otra araña. Así evitamos que casualmente las direcciones obtenidas al azar sean las mismas que llevaban ambas arañas, porque en ese caso, colisionarían otra vez.

Su mover llamará al mover del padre, y si la araña no se ha podido mover, o se ha escedido el tiempo acumulado, calcularemos una nueva dirección al azar. En caso contrario, nos basta con actualizar el tiempo transcurrido desde el último cambio de dirección.

Finalmente su método colisionado (el cual será llamado cuando la araña choque con otro personaje, como ya hemos visto en la clase Personaje) hará que la araña cambie de dirección a exactamente la contraria de la que llevaba.

Y con estas 4 tonterías, ya tenemos una primera inteligencia artificial creada, que si bien probablemente no pueda de momento destruir a la raza humana, sí que te pondrá las cosas jodidas en el juego (sobre todo cuando hayan muchas en poco espacio).

¿Y cómo metemos a las arañas en el escenario?
Volvamos a la clase Mundo. Habrá que cambiar más cosillas.

Añadiendo enemigos y Estados de juego a la clase Mundo

Bueno, ahora el jugador puede morir, y ganar. Asi que habrá que controlar esos posibles estados y mostrar algún mensajito que nos anime a seguir jugando.
Como son tantos los cambios, supongo que será mejor empezar a verla entera desde el principio.

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"), //0
        new Tile(this.anchoCelda,this.altoCelda,false,"black"), //1
        new TilePuerta(this.anchoCelda,this.altoCelda,"red",1,1.1,14.5), //2
        new TilePuerta(this.anchoCelda,this.altoCelda,"red",0,24.9,14.5), //3
        new TilePuerta(this.anchoCelda,this.altoCelda,"red",2,22.5,16.9), //4
        new TilePuerta(this.anchoCelda,this.altoCelda,"red",1,22.5,1.1), //5
        new TileMeta(this.anchoCelda,this.altoCelda,"green") //6
    ];
   
    this.mapa=[];
    this.enemigosInicio=[];
   
    this.mapa.push( //0
    [
        [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
        [1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 0, 0, 1],
        [1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 1, 0, 0, 1],
        [1, 1, 1, 1, 1, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 1],
        [1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 0, 0, 1],
        [1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 1],
        [1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 1],
        [1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1],
        [1, 1, 0, 1, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 1, 1],
        [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 1, 1, 1, 1, 1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1],
        [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 2], /*pta*/
        [1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1],
        [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
    ]);
    this.enemigosInicio.push( // mapa 0
    [
        [2.5,10.5],
        [2.5,15.5],
        [6.5,9.5],
        [12.5,15.5],
        [11.5,5.5],
        [19.5,6.5],
        [23.5,9.5],
        [23.5,14.5]
    ]);
    this.mapa.push( //1
    [                                                                   /*pta*/
        [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 4, 1, 1, 1],
        [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
        [1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
        [1, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 1, 0, 0, 1],
        [1, 1, 1, 1, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 1, 1, 1, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
        [1, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 1, 0, 0, 1, 0, 0, 1, 1, 1],
        [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 0, 1, 0, 1, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
        [1, 1, 1, 1, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 1, 1, 0, 0, 1],
        /*pta*/ [3, 0, 0, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
        [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
    ]);
    this.enemigosInicio.push( // mapa 1
    [
        [4.5,16.5],
        [2.5,10.5],
        [3.5,6.5],
        [1.5,1.5],
        [7.5,2.5],
        [8.5,6.5],
        [7.5,9.5],
        [8.5,13.5],
        [13.5,12.5],
        [12.5,8.5],
        [13.5,5.5],
        [14.5,1.5],
        [18.5,4.5],
        [17.5,7.5],
        [18.5,11.5],
        [17.5,16.5],
        [21.5,15.5],
        [23.5,11.5],
        [22.5,6.5],
        [22.5,2.5],
        [23.5,2.5]
    ]);
    this.mapa.push( //2
    [
        [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
        [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
        [1, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1],
        [1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1],
        [1, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
        [1, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 1],
        [1, 0, 0, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 0, 0, 1, 1, 1],
        [1, 0, 1, 1, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 1],
        [1, 0, 0, 1, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 1, 1, 0, 0, 0, 0, 1],
        [1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 0, 0, 1, 1, 1, 0, 0, 1],
        [1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 6, 1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 1],
        [1, 1, 0, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 0, 0, 0, 0, 1],
        [1, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 1, 1],
        [1, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 1],
        [1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0, 1],
        [1, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
        [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 5, 1, 1, 1]
    ]);                                                                 /*pta*/
    this.enemigosInicio.push( // mapa 2
    [
        [24.5,15.5],
        [21.5,10.5],
        [21.5,7.5],
        [24.5,4.5],
        [18.5,1.5],
        [13.5,2.5],
        [8.5,1.5],
        [4.5,2.5],
        [2.5,4.5],
        [1.5,9.5],
        [1.5,13.5],
        [4.5,16.5],
        [11.5,15.5],
        [16.5,16.5],
        [18.5,13.5],
        [19.5,6.5],
        [16.5,4.5],
        [10.5,5.5],
        [6.5,4.5],
        [4.5,7.5],
        [5.5,10.5],
        [7.5,13.5],
        [11.5,12.5],
        [14.5,13.5],
        [16.5,9.5],
        [13.5,8.5],
        [7.5,10.5],
        [9.5,7.5]
    ]);
   
    this.personajes=[];
   
    this.mapaActual=0;
    this.jugador;
    this.intervalo=null;
    this.mapaCambiado=true;
    this.finalPartida=false;
    this.jMuerto=false;
   
    this.iniciarJugador();
    this.cambiarMapa(0,2.5,1.5);
   
    var self=this;
    this.boton=document.getElementById(idBoton);
    this.boton.onclick=function(){
        self.detener();
    };
}

El constructor trae muchos atributos nuevos, que nos servirán para gestionar los estados del juego. Al principio obtenemos en objeto canvas, y establecemos un tamaño a los Tiles, como en el tutorial anterior. Despues declaramos el conjunto de Tiles que necesitamos ahora, como dije en un apartado anterior. A continuación vienen las matrices bidimensionales que contienen los mapas y atención, justo debajo de cada unoa, declaramos un nuevo array donde almacenamos otra lista con las coordenadas X e Y (de forma relativa como siempre) de cada enemigo que habrá en ese mapa. Para no complicarnos mucho la vida, hemos puesto las arañas en el centro de cada Tile [x.5, y.5].

Después creamos la lista donde meteremos a los personajes (jugador y enemigos) para moverlos, dibujarlos y detectar colisiones de forma genérica. Aunque también nos interesa mantener un puntero al jugador, para comunicarlos con él sin tener que buscarlo en el array de personajes.

A continuación, tenemos varios atributos para manejar los estados del juego, como son el cambio de mapa, la muerte del jugador, y el final de la partida.

Y por último, iniciamos el jugador y cambiamos de mapa al primero, a una casilla caminable cualquiera que no tenga enemigos demasiado cerca (para no morir nada más empezar).

Mundo.prototype.detener=function(mapa,px,py){
    clearInterval(this.intervalo);
    this.intervalo=null;
};
Mundo.prototype.reanudar=function(){
    var self=this;
    this.tiempoTranscurrido=new Date().getTime();
    this.intervalo=setInterval(function(){self.loop()},25);
};
Mundo.prototype.cambiarMapa=function(mapa,px,py){
    this.detener();
   
    this.mapaCambiado=!this.mapaCambiado;
    this.mapaActual=mapa;
   
    this.jugador.situar(this.mapaActual,px,py);
   
    this.personajes=[];
    this.personajes.push(this.jugador);
    var n=this.enemigosInicio[this.mapaActual].length;
    var enemigo;
    for (var i=0;i<n;i++)
    {
        enemigo=new Enemigo(this, 30, 30);
        enemigo.situar(this.mapaActual,this.enemigosInicio[this.mapaActual][i][0],this.enemigosInicio[this.mapaActual][i][1]);
        this.personajes.push(enemigo);
    }
    this.finalPartida=false;
    this.jMuerto=false;
    this.reanudar();
};

Detener y reanudar el intervalo no han cambiado.

En cuanto a cambiarMapa, ahora también debe recorrer el array de enemigos que tiene ese mapa, y tiene que ir creándolos, situándolos en la casilla indicada, y añadiéndolos al array de personajes. Por supuesto, los estados de fin de partida y de jugador muerto comienzan a False.

Mundo.prototype.iniciarJugador=function(){
    this.jugador=new Jugador(this,30, 30);
    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;
            case 13: //Reanudar
                e.preventDefault();
                if (self.intervalo==null)
                {
                    self.cambiarMapa(0,2.5,1.5);
                }
                break;
        }
    };
};
Mundo.prototype.casillaCaminable=function(mapa,px,py){
    var x=parseInt(px);
    var y=parseInt(py);
    return this.conjuntoTiles[this.mapa[mapa][y][x]].caminable;
};

El método que inicia al jugador no ha cambiado. Bueno, salvo una cosilla. Si el temporizador no existe es que el juego está parado (muerte del jugador, o victoria), asi que empezamos una nueva partida si pulsamos ENTER. Y el método que nos devuelve si una casilla es caminable, tampoco.

Mundo.prototype.isTipoTile=function(tipo,mapa,px,py){
    return (this.conjuntoTiles[this.mapa[mapa][parseInt(py)][parseInt(px)]] instanceof tipo);
};
Mundo.prototype.getTile=function(mapa,px,py){
    return this.conjuntoTiles[this.mapa[mapa][parseInt(py)][parseInt(px)]];
};

Después vienen un par de metos útiles para el mover del personaje. GetTile devuelve el Tile que hay en una posición de un mapa dados. isTipoTile nos dice si una casilla de un mapa es del tipo de Tile concreto.

Mundo.prototype.dibujarMapa=function(){
    var y=this.mapa[this.mapaActual].length;
    var x=this.mapa[this.mapaActual][0].length;
    for (var yi=0;yi<y;yi++)
    {
        for (var xi=0;xi<x;xi++)
        {
            this.conjuntoTiles[this.mapa[this.mapaActual][yi][xi]].dibujar(this.contexto,xi,yi);
        }
    }
};
Mundo.prototype.moverPersonajes=function(delta){
    var n=this.personajes.length;
    for (var i=0;i<n;i++)
    {
        this.personajes[i].mover(delta);
        if (this.mapaCambiado)
        {
            break;
        }
    }
    this.mapaCambiado=false;
};
Mundo.prototype.dibujarPersonajes=function(){  
    var n=this.personajes.length;
    for (var i=0;i<n;i++)
    {
        this.personajes[i].dibujar(this.contexto);
    }
};
Mundo.prototype.comprobarColisiones=function(){
    var n=this.personajes.length;
    for (var i=0;i<n-1;i++)
    {
        for (var j=i+1;j<n;j++)
        {
            this.personajes[i].colisiona(this.personajes[j]);
        }
    }
};

Aquí tenemos las 3 típicas funciones que forman el loop: Dibujar el mapa, mover a los personajes, y dibujar a los personajes.

El método para mover personajes recorre el array de personajes, y ejecuta el mover sobre cada uno, no tiene complicación. Aunque sí una chapuza. Como no usé notificaciones para cambiar de mapa, ya comenté el problema que había en el bucle loop. Asi que cuando se cambie de mapa, se pondrá el atributo mapaCambiado a true. Asi que cuando movamos al jugador (y se cambie de mapa) hacemos un break para evitar mover a supuestos personajes que quizá no existan a posiciones que quizá el nuevo mapa no tenga.

Las otras, pues más de lo mismo, llaman a sus respectivos métodos pasándole los argumentos necesarios.

Merece la pena destacar el método para comprobar las colisiones. Es un doble bucle, pero sabiendo que si A colisiona con B, no es necesario volver a comprobar que B colisiona con A, le cambiamos los índices de inicio y fin a ambos FOR para evitar trabajo innecesario.

Mundo.prototype.jugadorMuerto=function(){
    this.jMuerto=true;
};
Mundo.prototype.finPartidaDerrota=function(){
    this.detener();
    var mensaje="MUERTO!";
    this.contexto.font = "bold 60px monospace";
    this.contexto.fillStyle="green";
    this.contexto.fillText(mensaje,this.canvas.width/2-115,this.canvas.height/2+15);
    this.contexto.fillStyle="red";
    this.contexto.fillText(mensaje,this.canvas.width/2-112,this.canvas.height/2+18);
};

Mundo.prototype.jugadorMeta=function(){
    this.finalPartida=true;
};
Mundo.prototype.finPartidaVictoria=function(){
    this.detener();
    var mensaje="VICTORIA!";
    this.contexto.font = "bold 60px monospace";
    this.contexto.fillStyle="green";
    this.contexto.fillText(mensaje,this.canvas.width/2-135,this.canvas.height/2+15);
    this.contexto.fillStyle="red";
    this.contexto.fillText(mensaje,this.canvas.width/2-132,this.canvas.height/2+18);
};

Aquí tenemos los métodos encargados de gestionar las peticiones de fin de partida y muerte del jugador, junto a los métodos que pintarán les mensajitos en la pantalla (hasta que se pulse la tecla ENTER). Nada destacable.

Mundo.prototype.loop=function(){
    var delta=(new Date().getTime()) - this.tiempoTranscurrido;
    this.tiempoTranscurrido=new Date().getTime();
   
    this.moverPersonajes(delta);
    this.dibujarMapa();
    this.dibujarPersonajes();
   
    this.comprobarColisiones();
   
    if(this.finalPartida)
    {
        this.finPartidaVictoria();
    }
    else if(this.jMuerto)
    {
        this.finPartidaDerrota();
    }
};

Y por último en bucle principal del juego, donde manejamos el tiempo delta, dibujamos y gestionamos a todos los personajes del juego, y controlamos los estados. Sin novedad en el frente.

Editando mapas

Habreis notado que crear mapas es una tarea horrible, sobre todo si el mapa es gigantesco. Y situar enemigos en una posición concreta, contando el número de filas y columnas para ponerlo en el lugar exacto que queremos, una pesadilla.

Asi que, ¿por qué no crear un editor de mapas tiles donde además poder situar fácilmente a los enemigos? Dicho y hecho:

Link al increíble editor de mapas de Tiles.

Su uso es muy poco intuitivo, pero muy sencillo. Para empezar, SIEMPRE se ha de crear el conjunto de Tiles. Por cada Tile, sólo tenemos que indicarle el color que tiene. Es muy buena idea respetar el orden que tienes en tu Mundo.JS. Por ejemplo, el conjunto de Tiles para éste tutorial está compuesto por 7 Tiles. El tipo de cada uno no importa para el editor.
El primero es blanco, asi que añado un “white” (tile caminable). El segundo un negro. “Black” (tile no caminable). Lo añado también. Los cuatro siguientes son RED (las puertas), y el último es GREEN (la meta). Si te equivocas al poner el Tile, puedes eliminarlo pinchando sobre él (se pone la fila roja).

A continuación tengo dos opciones:

Si quiero crear un nuevo mapa, especificio el número de columnas y filas que quiero que tenga, y le doy al botón que se llama “generar mapa”. De inicio, se crearán todas las casillas del primer tipo de tile que hayas creado. Debajo del canvas donde se dibuja la miniatura del mapa, hay un select donde puedo elegir el tipo de TILE con el que pintar sobre el canvas. Inicialmente se carga con el primer tipo de Tile que has creado. Pero ahora, por ejemplo, elige el negro. Ya puedes pintar sobre el CANVAS, como si de un pincel se tratara (pincha el raton y arrastra). Así es mucho más fácil crear mapas. El botón derecho del ratón sirve además para añadir o eliminar enemigos (añade o borra un asterisco a la casilla sobre la que pulsas). Cuando hayas creado el mapa de tus sueños, pulsa el botón que pone código. En el TextArea de abajo aparecerá el código que tienes que pegar en el constructor. Atención, utiliza el método PUSH, asi que el orden en el que los pongas en la clase Mundo es muy importante.

Además, para que te sea más fácil crear puertas (ya que tienes que poner a mano las coordenas donde aparecerás al cruzar dicha puerta), debajo del canvas también aparece la fila y columna donde está el ratón.

La otra opción es la de cargar mapas ya creados.
Por ejemplo, si pegas en el TEXTAREA ésto:

    this.mapa.push( //0
    [
        [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
        [1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 0, 0, 1],
        [1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 1, 0, 0, 1],
        [1, 1, 1, 1, 1, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 1],
        [1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 0, 0, 1],
        [1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 1],
        [1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 1],
        [1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1],
        [1, 1, 0, 1, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 1, 1],
        [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 1, 1, 1, 1, 1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1],
        [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 2], /*pta*/
        [1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1],
        [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1],
        [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
    ]);
    this.enemigosInicio.push( // mapa 0
    [
        [2.5,10.5],
        [2.5,15.5],
        [6.5,9.5],
        [12.5,15.5],
        [11.5,5.5],
        [19.5,6.5],
        [23.5,9.5],
        [23.5,14.5]
    ]);

Y le das al botón “cargar mapa” que está abajo del todo, se dibujará ese canvas en el mapa. Pero repito, antes tienes que tener el conjunto de Tiles creado. Si en la matriz de abajo apunta a una posición que no existe en el conjunto de Tiles, ese tile no se dibujará en el canvas (aparecerá gris).

Cuando tengas el mapa cargado, puedes dibujar sobre él perfectamente, y añadir o borrar enemigos. Y cuando estés contento con los cambios, pinchas en “generar código” para obtener el código con los cambios, y lo sustituyes en el Mundo.js de tu juego.

Éste editor no es definitivo, sólo es un ejemplo de cómo crear tus propias herramientas para trabajar sobre tu juego, tal y como hacen los profesionales del sector en la realidad. Nadie programa niveles directamente con código.

Cómo está programado el editor queda fuera del objetivo de éste tutorial, aunque si lo veo necesario, es posible que haga un tutorial sobre él, aunque nada os impide ver el código fuente y entenderlo por cuenta propia. La mayor complicación que tiene es el enlazamiento de los elementos HTML del DOM con métodos concretos de la clase Editor.

Añadiendo SCROLL

El mapa del juego es gigante. Y aún puede ser mucho más grande. Asi que pintarlo todo en la pantalla puede ser inviable para tu juego. ¿Cómo resolvemos el problema? Pues utilizando el viejo sistema de SCROLL para mapas 2D. Sólo vemos al personaje, normalmente justo en el centro de la pantalla, y una pequeña porción rectangulas que lo envuelve.

Así mejoramos la eficiencia del código, pues en vez de dibujar en pantalla todo el mapa, con el gasto de tiempo que eso conlleva, sólo dibujamos una pequeña porción. Sin embargo, los personajes hay que seguir moviéndolos y calculando colisiones, aunque no se vean, o dará la sensación de ser un mapa muy estático. Aunque nada te impide, adaptando ligeramente el código, hacer que los personajes sólo se muevan cuando sean visibles,como siempre, todo depende del tipo de juego que estés programando.

Para que se vea más claro, veámos una imagen con lo que queremos conseguir. El rectángulo rojo es la ventana de SCROLL, lo que hay dentro del rectángulo es la zona visible, y lo único que debe pintarse en pantalla. Así que hay que jugar con todas las coordenadas para hacer que el (0,0) absoluto ya no sea el vértice superior izquierdo del del primer tile del mapa, si no el vértice superior izquierdo de la ventana de Scroll:

Bueno, vamos al lío.

Para meter un sistema de scroll en nuestro juego, para empezar, necesitaremos un par de atributos nuevos para la clase Mundo, donde almacenar el número de filas y casillas que tendrá la “ventana”.

function Mundo(idCanvas, idBoton){
   
    this.tilesvisiblesAncho=8;
    this.tilesvisiblesAlto=5;

}

Y sólo necesitamos cambiar los métodos que dibujan cosas en la pantalla. Ahora comprobamos que el objeto o Tile que se ha de pintar, está en la zona visible.
Veámos primero el método que pinta los Tiles:

Mundo.prototype.dibujarMapa=function(){
    var desfaseX=this.jugador.x-this.tilesvisiblesAncho/2;
    var desfaseY=this.jugador.y-this.tilesvisiblesAlto/2;
    var yIni=Math.max(0,parseInt(desfaseY));
    var yFin=Math.min(this.mapa[this.mapaActual].length,parseInt(this.jugador.y+this.tilesvisiblesAlto/2)+1);
    var xIni=Math.max(0,parseInt(desfaseX));
    var xFin=Math.min(this.mapa[this.mapaActual][0].length,parseInt(this.jugador.x+this.tilesvisiblesAncho/2)+1);
    this.contexto.fillStyle="orange";
    this.contexto.fillRect(0,0,this.canvas.width,this.canvas.height);
    for (var yi=yIni;yi<yFin;yi++)
    {
        for (var xi=xIni;xi<xFin;xi++)
        {
            this.conjuntoTiles[this.mapa[this.mapaActual][yi][xi]].dibujar(this.contexto,xi,yi,desfaseX,desfaseY);
        }
    }
};

Necesitamos un par de variables nuevas, desfaseX y desfaseY. Queremos que el jugador esté siempre en el centro de la ventana de scroll. Pero nuestro vértice superior derecho del canvas debe ser la coordenada (0,0) absoluta. ¿Cómo solucionamos el problema? Bueno, el juego siempre se moverá tomando la coordenada (0,0) como el vértice superior izquierdo del mapa. Pero para dibujarlo, podemos crear coordenadas relativas. Tenemos que hacer que el vértice superior izquierdo de la ventana de scroll sea la posición absoluta (0,0) cuando se dibuje en el canvas. ¿Y cómo hacemos eso? Pues restándole a todos los elementos, a la hora de dibujarse, la distancia que hay desde la posición (0,0) al vértice superior izquierdo de la ventana de scroll. Esa distancia en el eje X la llamaremos DesfaseX. Y la del eje Y, DesfaseY. Notar que esos desfases también nos valen como coordenadas, ya que una coordenada es en realidad la distancia que hay entre un punto y el origen (coordenada 0,0). ¿Y cómo obtenemos sabemos dónde está la coordenada del vértice superior derecho de la ventana de scroll? Sencillo, si sabemos que el jugador estará siempre en el centro, y que la ventana tiene el tamaño especificado en dos valores, restándole la mitad del tamaño de cada lado a la coordenada correspondiente del jugador, la obtenemos.

¿Y por qué obtenemos la coordenada en modo relativo, en vez de obtener el número de píxeles finales (que sería multiplicar esa coordenada relativa por el tamaño del TIle? Pues porque dichos desfases también nos servirán para hacer un FOR que recorra sólo la parte visible del mapa, sacando en (Xini, Yini) las coordenas relativas de la ventana (vértice superior izquierdo. Así nos ahorramos tener que recorrer entero el array bidimensional del mapa. Por si acaso, comprobamos que dicha coordenada de desfase no se sale del mapa (por ejemplo, cuando el personaje esté pegado a la izquierda del todo en el mapa).

Y haciendo una operación similar, pero sumando ésta vez la mitad del ancho y el alto de la ventana de scroll, obtenemos el vértice inferior derecho de la ventana de scroll (xFin, yFin). Esa coordenada sirve para decirle a los bucles FOR hasta dónde recorrer el mapa pintando Tiles. Además le sumamos uno. Imagina que por ejemplo, xFin es 13.2 . Ese .2 de diferencia debería dibujarse, pero el FOR recorre enteros. Así que debemos redondear al alza. Simplemente hay que sumarle uno al resultado antes de hacer el parseInt.

Por último, llamamos al dibujar de cada TILE, pasándole los desfases. Por supuesto, dicho método también ha cambiado para tener los desfases en cuenta:

Tile.prototype.dibujar=function(contexto,x,y,desfaseX,desfaseY){
    contexto.fillStyle = "#444";
    contexto.fillRect(this.ancho*(x-desfaseX),this.alto*(y-desfaseY),this.ancho,this.alto);
    contexto.fillStyle = this.color;
    contexto.fillRect(this.ancho*(x-desfaseX)+1,this.alto*(y-desfaseY)+1,this.ancho-2,this.alto-2);
};

Simplemente le resta el desfase a la Y y a la X para que quede colocado correctamente.

En cuanto a los personajes, hacemos algo parecido:

Mundo.prototype.dibujarPersonajes=function(){
    var desfaseX=this.jugador.x-this.tilesvisiblesAncho/2;
    var desfaseY=this.jugador.y-this.tilesvisiblesAlto/2;
    var yIni=Math.max(0,parseInt(desfaseY));
    var yFin=Math.min(this.mapa[this.mapaActual].length,parseInt(this.jugador.y+this.tilesvisiblesAlto/2)+1);
    var xIni=Math.max(0,parseInt(desfaseX));
    var xFin=Math.min(this.mapa[this.mapaActual][0].length,parseInt(this.jugador.x+this.tilesvisiblesAncho/2)+1);
    var n=this.personajes.length;
    for (var i=0;i<n;i++)
    {
        if(this.personajes[i].dentroRango(xIni,yIni,xFin,yFin))
        {
            this.personajes[i].dibujar(this.contexto,desfaseX,desfaseY);
        }
    }
};

Calculamos los desfases y las coordenadas de la ventana de scroll de la misma forma. Pero ésta vez, los personajes están metidos en una lista simple, asi que hay que comprobar uno a uno si el personaje está dentro de dicho marco.

Si lo está, lo dibujamos pasándole el desfase.

Personaje.prototype.dentroRango=function(xIni,yIni,xFin,yFin){
    return (this.x >= xIni && this.x <= xFin && this.y >= yIni && this.y <= yFin);
};
Personaje.prototype.dibujar=function(contexto,desfaseX,desfaseY){
    contexto.save();
    contexto.translate((this.x-desfaseX)*mundo.anchoCelda,(this.y-desfaseY)*mundo.altoCelda);
   
    this.sprite.dibujar(contexto,this.ancho,this.alto,this.direccion,this.spriteindice);
   
    contexto.restore();
};

El método dentroRango de Personaje es muy sencillo, sólo comprieba si su x e y están dentro de esos rangos de valores. El dibujar es muy parecido a la vieja versión, solo que ahora, también le restamos el desfase a cada coordenada relativa.

¡Y ya está todo terminado! Es hora de que empieces a practicar creando nuevos mapas y haciendo algunas modificaciones al código para experimentar por tu cuenta.

En el próximo capítulo, veremos como cambiar los métodos de dibujar para crear un motor gráfico pseudo 3D usando una perspectiva isométrica. Con unas cuantas líneas nuevas, conseguirás que el juego mejore mucho a la vista.

Volver al índice

Etiquetas: , , , , ,

Comentarios (9)

  • Buenas me gustaría contactar con el o los responsables del blog, para una oferta de un proyecto.

  • Tendré que hacer una página “sobre el autor” algún día.

  • Muy buen blog, me sirve de mucho, no dejes de actualizarlo

  • “En el próximo capítulo, veremos como cambiar los métodos de dibujar para crear un motor gráfico pseudo 3D usando una perspectiva isométrica. Con unas cuantas líneas nuevas, conseguirás que el juego mejore mucho a la vista.”
    Esperando ese capítulo =)

  • Hola jgrc, una simple pregunta : ¿ las añaras esas siguen siempre el mismo movimiento o pueden cambiar al azar ? gracias

  • En el tutorial está todo explicado; las arañas se mueven en una dirección al azar cada X milisegundos. Si en algún momento topan con una pared o contra otra araña, entonces van en dirección opuesta, hasta que en X milisegundos vuelvan a cambiar.

  • Bueno.
    En primer lugar quería agradecerle por compartir esto con la comunidad. Hoy dia hay muchos “programadores” y muchos programadores (estos últimos, los que son de verdad programadores) que la verdad cada vez que aparece algo, y si hace algo hace una “moda” comienzan a aparecer blogs de mala muerte con nombres sugestivos o tentadores como “css3yHtml5”, “canvasHtml5”, “nodejsfulano”, y un largo etc. Y por supuesto, estos “programadores” en si solo dejan en claro que no saben nada, absolutamente nada, solo hablan de usar framewords, o incluso sobre cómo usar una extensión para jquery con la que podemos hacer fácilmente un lindo “hola mundo”.
    Bueno, resumiendo, digo que es grato ver que todavía aparecen blogs donde tratan los temas como se debe, y sobre todo, que no tratan solo temas de moda. Por esto le agradezco.
    Ahora unas palabras con respecto al programa que usted expone:
    Suele ocurrir un error no muy a menudo, en donde al tratar de acceder a un tile en especial, la función encargada produce un error al recorrer el array bidimensional
    Con “try – catch” se soluciona, pero creo que habría que verificar si esto tiene un impacto en el rendimiento
    Otra cosa
    Con respecto a la representación isométrica:
    Para conseguir representación isométrica yo hago que las “entidades” se registren en un objeto, usando su valor de eje y para formar la clave = “key” + y Value;
    Como es muy probable que otros objetos tengan exactamente el mismo valor de coordenadas, cada campo del objeto contiene en realidad un array en el que se almacena una referencia al objeto (basándonos que en javascript los objetos se pasan como referencias y no como valores o copias)
    Por medio de un for in se recorren los campos del primer nivel (si, ya sé que es más lento. Pero si solo se registran las entidades visibles dentro del canvas no tendremos problemas de rendimiento), y con un for tradicional los campos del segundo
    De esta forma básicamente disparo los métodos draw de cada entidad segun su profundidad
    En cada vuelta del loop borro el contenido del almacén de referencias
    Comento el método solo para saber qué opina, y si cree que se puede mejorar
    Saludos

  • en verdad buen tutorial sobre el manejo de estas tecnologías pero estoy intentando poner la funcion para matar a las arañas con disparos y no me sale no se si podrias ayudarme en ese sentidoo !!!

  • hola… te mereces uno de los mejores premios de nobel… de la solidaridad.. estoy leyendo mucho de lo q as echo..y tal vez apruebe un semestre en la universidad.. gracias a tu tutoriales…Done estabas.. por que no te apareciste… y creeme cuando pueda te pago.. aun no tengo visa..

¿Tienes algo que decir?

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