Aprende a crear juegos en HTML5 Canvas

domingo, 6 de septiembre de 2015

Mapas con colindancia automatizados

En esta ocasión, les traigo un truco que hará sus juegos de plataformas mucho más atractivos de forma sencilla. Quizá ya hayas notado algunos juegos donde no se conforman con sólo una imagen por cada tipo de bloque, si no que agregan distintos tipos de imágenes por cada tipo de bloque para agregarles más detalle. Esto es posible hacerlo de forma sencilla aunque tardada de forma manual, pero algunos de estos detalles también se pueden automatizar, como por ejemplo, agregar un tipo de imagen distinta dependiendo los bloques del mismo tipo que colindan con él. Tomemos la siguiente imagen por ejemplo:
En esta imagen podemos apreciar 16 tipos de bloques con los que podemos ensamblar un mapa mucho más vistoso, dependiendo de los bloques del mismo tipo que tenga encima, a los lados, o debajo de cada uno. Quizá especificar el aspecto de cada bloque individual dependiendo los que le rodean, podría llevarte mucho tiempo, y siempre podrías llegar a cometer un error en alguno de ellos. Pero hoy les enseñaré cómo realizar una función que se encargue de ello en forma automatizada.

Comencemos por declarar la variable que contendrá esta nueva imagen, y asignar su origen en la función "init":
        spritemap.src = 'assets/platformer-automap.png';
Como nuestra imagen contiene 4 bloques en lo horizontal y 4 bloques en lo vertical, adaptaremos la función que dibuja las paredes para que dibuje la imagen correspondiente de acuerdo a su tipo:
            wall[i].drawImageArea(ctx, cam, spritemap, wall[i].type % 4 * 16, ~~(wall[i].type / 4) * 16, 16, 16);
Finalmente, adaptaremos el código de la función "setMap" que agrega los bloques al arreglo "wall", para que todos los valores "1" en el mapa sean agregados calculando su tipo conforme a una nueva función "getMapTypeFor":
                if (map[row][col] === 1) {
                    rect = new Rectangle2D(col * blockSize, row * blockSize, blockSize, blockSize, true);
                    rect.type = getMapTypeFor(map, col, row);
                    wall.push(rect);
                }
Esta nueva función "getMapTypeFor" es la que se encargará de la magia que nos permetirá tener nuestros mapas dibujados de acuerdo a la proximidad de los valores vecinos. ¿Cómo es que actúa esta función? En realidad es muy sencillo; Si en la posición inmediata superior hay otro objeto del mismo valor, sumamos 1, si es en la posición inmediata derecha, sumamos 2, si es en la inferior sumamos 4, y si es en la izquierda sumamos 8. Esto nos dará valores del 0 al 15, que corresponden a cada una de las posibles iteraciones de acuerdo a los objetos solidos que rodeen al punto actual.

Hay que tomar en cuenta que, si estamos por ejemplo revisando el punto superior, considerar antes que no estemos revisando un punto en el mapa de la primera fila, pues de lo contrario intentará comparar con la fila "-1", y esto provocará un error. Tomando este ejemplo, la primer comparación sería de esta forma:
    function getMapTypeFor(map, col, row) {
        var type = 0;
        if (row > 0 && map[row - 1][col] === 1) {
            type += 1;
        }
Algo que he notado comunmente, es que es preferible en la mayoría de los casos que los puntos en las esquinas, parecieran colindar con algo más allá de lo visible, haciendo parecer que el suelo y las paredes se extienden más adelante hacia el infinito. Por tanto, en lugar de validar que no exista nada antes de la comparación actual para evitar el error, validaremos si el valor anterior al actual está precisamente afuera del mapa, como alternativa a buscar si dicho valor colinda con un objeto sólido, para agregar el valor correspondiente:
        if (row - 1 < 0 || map[row - 1][col] === 1) {
            type += 1;
        }
Tomando en cuenta esta última condicional, la función "getMapTypeFor" quedaría finalmente de esta forma:
    function getMapTypeFor(map, col, row) {
        var type = 0;
        if (row - 1 < 0 || map[row - 1][col] === 1) {
            type += 1;
        }
        if (col + 1 >= map[row].length || map[row][col + 1] === 1) {
            type += 2;
        }
        if (row + 1 >= map.length || map[row + 1][col] === 1) {
            type += 4;
        }
        if (col - 1 < 0 || map[row][col - 1] === 1) {
            type += 8;
        }
        return type;
    }
Con esta función terminada, ya tendremos un valor único para cada iteración posible. Tan solo necesitaremos una imagen de sprites donde cada posición corresponda al valor obtenido, como se muestra en el siguiente ejemplo:
Por supuesto, esta imagen es bastante confusa, y es muy posible que el artista a cargo tenga problemas para adaptar los sprites a la posición correcta. ¿No sería más sencillo para los dos si se pudiera usar, por ejemplo, la imagen que vimos al comienzo?

¡Claro que es posible! Todo lo que necesitamos hacer, es crear una matriz de ajuste, que cambie los valores obtenidos por los correspondientes en la primer imagen. Tras un rato de prueba y error, he conseguido dicha matriz:
    var mapAdjustment = [
            0, 12, 1, 13,
            4, 8, 5, 9,
            3, 15, 2, 14,
            7, 11, 6, 10
        ];
Ya con esta matriz, podemos simplemente regresar su correspondiente al final de la función "getMapTypeFor" en lugar del valor original:
        return mapAdjustment[type];
Con esto, tendremos mapas en nuestro juegos cuyas paredes se adapten de acuerdo a los valores que colinden con estas mismas.

Código final:

[Canvas not supported by your browser]
/*jslint bitwise: true, es5: true */
(function (window, undefined) {
    'use strict';
    var KEY_ENTER = 13,
        KEY_LEFT = 37,
        KEY_UP = 38,
        KEY_RIGHT = 39,
        KEY_DOWN = 40,
        
        canvas = null,
        ctx = null,
        lastPress = null,
        pressing = [],
        pause = false,
        gameover = true,
        onGround = false,
        worldWidth = 0,
        worldHeight = 0,
        elapsed = 0,
        cam = null,
        player = null,
        wall = [],
        spritesheet = new Image(),
        spritemap = new Image(),
        mapAdjustment = [
            0, 12, 1, 13,
            4, 8, 5, 9,
            3, 15, 2, 14,
            7, 11, 6, 10
        ],
        map0 = [
            [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
            [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
            [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
            [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
            [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 1],
            [1, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 1],
            [1, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
            [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
            [1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 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, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
        ];

    function Camera() {
        this.x = 0;
        this.y = 0;
    }

    Camera.prototype = {
        constructor: Camera,
        
        focus: function (x, y) {
            this.x = x - canvas.width / 2;
            this.y = y - canvas.height / 2;

            if (this.x < 0) {
                this.x = 0;
            } else if (this.x > worldWidth - canvas.width) {
                this.x = worldWidth - canvas.width;
            }
            if (this.y < 0) {
                this.y = 0;
            } else if (this.y > worldHeight - canvas.height) {
                this.y = worldHeight - canvas.height;
            }
        }
    };
    
    function Rectangle2D(x, y, width, height, createFromTopLeft) {
        this.width = (width === undefined) ? 0 : width;
        this.height = (height === undefined) ? this.width : height;
        if (createFromTopLeft) {
            this.left = (x === undefined) ? 0 : x;
            this.top = (y === undefined) ? 0 : y;
        } else {
            this.x = (x === undefined) ? 0 : x;
            this.y = (y === undefined) ? 0 : y;
        }
        this.scale = {x: 1, y: 1};
    }
    
    Rectangle2D.prototype = {
        constructor: Rectangle2D,
        left: 0,
        top: 0,
        width: 0,
        height: 0,
        vx: 0,
        vy: 0,
        type: 0,
        
        get x() {
            return this.left + this.width / 2;
        },
        set x(value) {
            this.left = value - this.width / 2;
        },
        
        get y() {
            return this.top + this.height / 2;
        },
        set y(value) {
            this.top = value - this.height / 2;
        },
        
        get right() {
            return this.left + this.width;
        },
        set right(value) {
            this.left = value - this.width;
        },
        
        get bottom() {
            return this.top + this.height;
        },
        set bottom(value) {
            this.top = value - this.height;
        },
        
        intersects: function (rect) {
            if (rect !== undefined) {
                return (this.left < rect.right &&
                    this.right > rect.left &&
                    this.top < rect.bottom &&
                    this.bottom > rect.top);
            }
        },
        
        fill: function (ctx) {
            if (ctx !== undefined) {
                if (cam !== undefined) {
                    ctx.fillRect(this.left - cam.x, this.top - cam.y, this.width, this.height);
                } else {
                    ctx.fillRect(this.left, this.top, this.width, this.height);
                }
            }
        },
        
        drawImageArea: function (ctx, cam, img, sx, sy, sw, sh) {
            if (ctx !== undefined) {
                if (img.width) {
                    ctx.save();
                    if (cam !== undefined) {
                        ctx.translate(this.x - cam.x, this.y - cam.y);
                    } else {
                        ctx.translate(this.x, this.y);
                    }
                    ctx.scale(this.scale.x, this.scale.y);
                    //ctx.rotate(this.rotation * Math.PI / 180);
                    ctx.drawImage(img, sx, sy, sw, sh, -this.width / 2, -this.height / 2, this.width, this.height);
                    ctx.restore();
                } else {
                    if (cam !== undefined) {
                        ctx.strokeRect(this.left - cam.x, this.top - cam.y, this.width, this.height);
                    } else {
                        ctx.strokeRect(this.left, this.top, this.width, this.height);
                    }
                }
            }
        }
    };

    document.addEventListener('keydown', function (evt) {
        if (!pressing[evt.which]) {
            lastPress = evt.which;
        }
        pressing[evt.which] = true;
        
        if (evt.which >= 37 && evt.which <= 40) {
            evt.preventDefault();
        }
    }, false);

    document.addEventListener('keyup', function (evt) {
        pressing[evt.which] = false;
    }, false);

    function getMapTypeFor(map, col, row) {
        var type = 0;
        if (row - 1 < 0 || map[row - 1][col] === 1) {
            type += 1;
        }
        if (col + 1 >= map[row].length || map[row][col + 1] === 1) {
            type += 2;
        }
        if (row + 1 >= map.length || map[row + 1][col] === 1) {
            type += 4;
        }
        if (col - 1 < 0 || map[row][col - 1] === 1) {
            type += 8;
        }
        return mapAdjustment[type];
    }

    function setMap(map, blockSize) {
        var col = 0,
            row = 0,
            columns = 0,
            rows = 0,
            rect = null;
        wall.length = 0;
        for (row = 0, rows = map.length; row < rows; row += 1) {
            for (col = 0, columns = map[row].length; col < columns; col += 1) {
                if (map[row][col] === 1) {
                    rect = new Rectangle2D(col * blockSize, row * blockSize, blockSize, blockSize, true);
                    rect.type = getMapTypeFor(map, col, row);
                    wall.push(rect);
                } else if (map[row][col] > 0) {
                    rect = new Rectangle2D(col * blockSize, row * blockSize, blockSize, blockSize, true);
                    rect.type = map[row][col];
                    wall.push(rect);
                }
            }
        }
        worldWidth = columns * blockSize;
        worldHeight = rows * blockSize;
    }

    function reset() {
        player.left = 48;
        player.top = 16;
        player.vx = 0;
        player.vy = 0;
        gameover = false;
    }

    function paint(ctx) {
        var i = 0,
            l = 0;
        
        // Clean canvas
        ctx.fillStyle = '#9cf';
        ctx.fillRect(0, 0, canvas.width, canvas.height);

        // Draw player
        ctx.strokeStyle = '#0f0';
        if (!onGround) {
            player.drawImageArea(ctx, cam, spritesheet, 16, 16, 16, 32);
        } else if (player.vx === 0) {
            player.drawImageArea(ctx, cam, spritesheet, 0, 16, 16, 32);
        } else {
            player.drawImageArea(ctx, cam, spritesheet, (~~(elapsed * 10) % 2) * 16, 16, 16, 32);
        }

        // Draw walls
        ctx.strokeStyle = '#999';
        for (i = 0, l = wall.length; i < l; i += 1) {
            wall[i].drawImageArea(ctx, cam, spritemap, wall[i].type % 4 * 16, ~~(wall[i].type / 4) * 16, 16, 16);
        }

        // Debug last key pressed
        ctx.fillStyle = '#fff';
        //ctx.fillText('Last Press: ' + lastPress, 0, 20);
        
        // Draw pause
        if (pause) {
            ctx.textAlign = 'center';
            if (gameover) {
                ctx.fillText('GAMEOVER', canvas.width / 2, canvas.height / 2);
            } else {
                ctx.fillText('PAUSE', canvas.width / 2, canvas.height / 2);
            }
            ctx.textAlign = 'left';
        }
    }

    function act(deltaTime) {
        var i = 0,
            l = 0;
        
        if (!pause) {
            // GameOver Reset
            if (gameover) {
                reset();
            }

            // Set vectors
            if (pressing[KEY_RIGHT]) {
                player.scale.x = 1;
                if (player.vx < 10) {
                    player.vx += 1;
                }
            } else if (player.vx > 0) {
                player.vx -= 1;
            }
            if (pressing[KEY_LEFT]) {
                player.scale.x = -1;
                if (player.vx > -10) {
                    player.vx -= 1;
                }
            } else if (player.vx < 0) {
                player.vx += 1;
            }

            // Gravity
            player.vy += 1;
            if (player.vy > 10) {
                player.vy = 10;
            }

            // Jump
            if (onGround && lastPress === KEY_UP) {
                player.vy = -10;
            }

            // Move player in x
            player.x += player.vx;
            for (i = 0, l = wall.length; i < l; i += 1) {
                if (player.intersects(wall[i])) {
                    if (player.vx > 0) {
                        player.right = wall[i].left;
                    } else {
                        player.left = wall[i].right;
                    }
                    player.vx = 0;
                }
            }
            
            // Move player in y
            onGround = false;
            player.y += player.vy;
            for (i = 0, l = wall.length; i < l; i += 1) {
                if (player.intersects(wall[i])) {
                    if (player.vy > 0) {
                        player.bottom = wall[i].top;
                        onGround = true;
                    } else {
                        player.top = wall[i].bottom;
                    }
                    player.vy = 0;
                }
            }

            // Out Screen
            if (player.x > worldWidth) {
                player.x = 0;
            }
            if (player.x < 0) {
                player.x = worldWidth;
            }

            // Bellow world
            if (player.y > worldHeight) {
                gameover = true;
                pause = true;
            }

            // Focus player
            cam.focus(player.x, player.y);

            // Elapsed time
            elapsed += deltaTime;
            if (elapsed > 3600) {
                elapsed -= 3600;
            }
        }
        
        // Pause/Unpause
        if (lastPress === KEY_ENTER) {
            pause = !pause;
        }
    }

    function repaint() {
        window.requestAnimationFrame(repaint);
        paint(ctx);
    }

    function run() {
        setTimeout(run, 50);
        act(0.05);

        lastPress = null;
    }

    function init() {
        // Get canvas and context
        canvas = document.getElementById('canvas');
        ctx = canvas.getContext('2d');
        canvas.width = 240;
        canvas.height = 160;
        worldWidth = canvas.width;
        worldHeight = canvas.height;
        
        // Load assets
        spritesheet.src = 'assets/platformer-sprites.png';
        spritemap.src = 'assets/platformer-automap.png';
        
        // Create camera and player
        cam = new Camera();
        player = new Rectangle2D(48, 16, 16, 32, true);

        // Set initial map
        setMap(map0, 16);
        
        // Start game
        run();
        repaint();
    }

    window.addEventListener('load', init, false);
}(window));
Regresar al índice

19 comentarios:

  1. Es interesante ver el rendimiento de la iteracion, y ahi estuve haciendo los bloques destructibles. Esta cool. :)

    ResponderEliminar
  2. esta bien cool ... tengo una duda querria saber si es posible agregarle a la funcion setMap la posibilidad de crear un mapa visualmente encima de otro es decir capas? y de ser asi si es posible alguna idea?

    ResponderEliminar
    Respuestas
    1. ¡Claro que es posible! Muchos videojuegos clásicos usaban esta técnica para las capas de fondo.

      Antes que nada ¿Cuál es tu intensión con estas capas? Para saber si esta técnica es la más conveniente para lo que deseas hacer.

      Eliminar
    2. bueno deseo crear un rpg y para algunas que otras partes desearia poder dibujarle mas de un sprite en un mismo sitio por asi decirlo

      Eliminar
    3. ¿Algo como dibujar un fondo de pasto y caminos, y en una capa encima dibujar objetos sólidos como árboles, rocas y edificios? Sin duda es posible, y para tal caso, quizá te serviría más usar la función fillMap que se menciona al comienzo del tema "Proyección Isométrica":

      http://juegos.canvas.ninja/2014/07/proyeccion-isometrica.html

      Eliminar
  3. hmmm si es algo asi pero quiero usarlo en ortagonal y segun lei el tema ese indexado en z no sirve solo para isometrico? o me estoy perdiendo de algo? y disculpa tantas preguntas xD

    ResponderEliminar
    Respuestas
    1. El primer ejemplo al comienzo de aquel tema, explica cómo se dibuja un mapa directo en el canvas, antes de entrar al tema de cómo aplicarle en isométrico, así que debería funcionar sin problema para lo que tú deseas.

      Eliminar
    2. bueno por ahora solo veo 1 problema solo se dibuja la pantalla? osea pareciese que lo que esta fuera del alcanze de esta no esta

      Eliminar
    3. ¿Te refieres a la cámara? La verdad no he podido comprender tu pregunta.

      Eliminar
  4. Respuestas
    1. La función de ejemplo no incluye la cámara por lo que veo, así que tendrás que restar los valores de la misma en los ejes X y Y para que se mueva junto con ella, de la misma forma que se ha hecho con los demás objetos.

      Eliminar
  5. Hola digamos que estoy interesado en crear un juego de varios niveles, pero jugador deja de jugar por algun motivo y al regresar quiere empezar donde lo dejo ¿como se podria hacer un archivo de guardado? o ¿cargar partida?

    ResponderEliminar
    Respuestas
    1. Revisa el tema de Almacenamiento Local, seguro te ayudará con lo que buscas.

      http://juegos.canvas.ninja/2013/07/almacenamiento-local-y-altos-puntuajes.html

      Eliminar
  6. Bueno tengo una duda desde siempre y aun no he podido respondermela y a la hora de crear una plataforma con relieve o no se como podria llamarla pero aqui dejo un ejemplo:

    http://i3.ytimg.com/vi/lDb6QD79hJ0/mqdefault.jpg

    tengo tiempo intentando hacerlo mas aun no lo he logrado. Seria de mucha ayuda si a alguien se le a ocurrido alguna forma de lograrlo

    ResponderEliminar
    Respuestas
    1. Si supieras que me he estado enfrentando al mismo problema durante los últimos meses, sin éxito aún en los resultados...

      He conseguido hacer un par de soluciones parciales, pero no me han dado el resultado deseado, especialmente buscando algo fácil de replicar y de generaciones semi-automatica como lo he visto en este tema... Te podría compartir lo que tengo para ver si te es de ayuda, pero aún dista del resultado que deseo. Creeme, si logro conseguirlo, haré un tutorial en forma de cómo realizarlo para todos los lectores del blog.

      Eliminar
    2. Bueno si te parece yo también he estado investigado y he hecho varias pruebas mas no me gusta el resultado lo que podemos hacer es un tema donde la comunidad pueda compartir información referente a este tema ya que es algo un poco mas complejo de lograr y la idea es que podamos aprender todos juntos si te parece bien.

      Por los momentos yo lo mejorcito que he encontrado es esto https://youtu.be/Pen8djqS2JQ?list=LL4SzkwMQBz0uU7HMxxH8cfA

      Eliminar
    3. Me parece muy buena idea la que propones. Prepararé lo poco que tengo al respecto, y abriré el tema en el sitio, esperando entre la misma comunidad podamos encontrar la mejor solución a este caso.

      Eliminar
    4. super, estaré atento a la subida del tema

      Eliminar