`

Como crear tu videojuego desde 0 en la web con HTML5 y JavaScript - Capítulo 2

Buenas!

Antes de empezar con el capítulo 2, vamos a resolver el challenge que nos dejó el capítulo 1.

Como verán, cambiamos también de editor, CodeSandbox nos va a permitir crear carpetas y múltiples archivos para tener todo mucho más ordenado que en CodePen, así que de ahora en más voy a estar subiendo todo acá.

El uso del mismo es hiper fácil, a la derecha tienen los archivos y pueden ir cambiando entre ellos.

Lo que hicimos fue agregar las 2 urls como constantes

const urlPoster = "https://i.imgur.com/NXIjxr8.png";
const urlTree = "https://i.imgur.com/wIK2b9P.png";

Y dentro de la función renderMap agregar las mismas junto con el texto

context.drawImage(imageTree, 150, 150);
context.drawImage(imagePoster, 150, 20);

context.font = "12pt Helvetica";
context.fillStyle = "white";
context.fillText("Challenge 1!", 185, 50);

Antes de empezar y como ya saben, todo contenido va a ser 100% gratuito, con lo único que me ayudarían es dándome un follow en Twitter: @DamianCatanzaro  y compartiendo el contenido para que más gente lo pueda usar 😁.

Ahora si! Pasemos al capítulo 2.

Como todo juego, yo quiero poder interactuar con el mundo o el personaje así que vamos a hacer que el personaje se pueda mover por todo el mapa.

Para esto, vamos a declarar una constante con la primera información que va a tener el usuario que va a ser la posición donde está parado.

const user = {
    pos: {
        x: 100,
        y: 100
    }
};

Y vamos a hacer uso del evento "keydown", que nos va a tomar cualquier tecla que escribamos en el teclado, como en este caso queremos agarrar las flechas, vamos a tener que declararlas, cada tecla del teclado tiene asignado un código que se llama codigo ASCII, así que vamos a declararlas

const keys = {
    arrowUp: 38,
    arrowDown: 40,
    arrowLeft: 37,
    arrowRight: 39
};

Una vez declaradas, nos resta usar el evento de "keydown"

document.addEventListener("keydown", e => {
    //Cada vez que presionemos una tecla, se va a activar este evento
    //e.keyCode nos va a dar el numero de la tecla que presionemos
});

Así que lo que vamos a hacer es según la tecla presionada, vamos a sumarle o restarle posición al usuario que declaramos arriba, y como nuestro juego se basa de tiles de 32px vamos a usar ese número para sumar o restar posición.

document.addEventListener("keydown", e => {
    switch (e.keyCode) {
        case keys.arrowUp:
            user.pos.y -= sizeTile;
            break;
        case keys.arrowDown:
            user.pos.y += sizeTile;
            break;
        case keys.arrowLeft:
            user.pos.x -= sizeTile;
            break;
        case keys.arrowRight:
            user.pos.x += sizeTile;
            break;
        default:
            break;
    }

    context.drawImage(imageCharacter, user.pos.x, user.pos.y);
});

Lo que hicimos fue si presiona la tecla para arriba, se le resta 32 a la posición en eje-Y, si es para abajo se le suma. Y por último, necesitamos renderizar el personaje nuevamente con la nueva posición y acá nos encontramos con el siguiente problema.

via Gfycat

En el canvas lo que estamos haciendo es dibujar todo el tiempo cosas nuevas, pero nunca limpiamos lo anterior por lo que si dejamos así el código el personaje se nos va a duplicar por toda la pantalla.

Para limpiar el canvas se usa la función clearRect.

context.clearRect(x, y, width, height)

Nos pide una posición en X e Y de inicio y un ancho y un alto, lo que vamos a hacer es usar todo el tamaño de nuestro canvas y limpiarlo por completo.

context.clearRect(0, 0, mapSize.x * sizeTile, mapSize.y * sizeTile);

Y lo vamos a poner arriba de donde hacemos el renderiza de nuestro personaje antes de moverlo, entonces nos aseguramos que siempre antes que movamos el personaje estamos limpiando toda la pantalla.

context.clearRect(0, 0, mapSize.x * sizeTile, mapSize.y * sizeTile);
context.drawImage(imageCharacter, user.pos.x, user.pos.y);

Y qué pasa con esto? Acá tenemos otro error, el personaje se va a dibujar pero estamos perdiendo todo el resto de lo que teníamos antes, pasto, árboles, carteles, cualquier cosa que no sea el personaje.

via Gfycat

Cómo lo solucionamos? Podríamos decir que si renderizamos todo el tiempo todo a la hora de mover el personaje ya solucionamos el tema y en parte es verdad.

Si lo vemos funcionando, ya se renderiza todo y no perdemos nada a la hora de limpiar nuestro canvas.

via Gfycat

Pero antes de seguir y vamos a explicar como tenemos el código ordenado actualmente, se si pudieron dar cuenta, cambiamos nuestro archivo y lo llamamos engine.js, lo mismo dentro, creamos una clase Engine y acá vamos a meter todo el motor gráfico de nuestro juego. Dejo material abajo por si no están familiarizados con las clases en JavaScript!

Lo primero que hacemos es declarar en el constructor de la clase, todas las variables que vamos a necesitar en nuestro Engine, que no es más que las constantes que teníamos declaradas antes.

constructor(context) {
    this.ctx = context;

    this.keys = {
        arrowUp: 38,
        arrowDown: 40,
        arrowLeft: 37,
        arrowRight: 39
    };

    this.mapSize = {
        x: 10,
        y: 10
    };

    this.user = {
        pos: {
            x: 100,
            y: 100
        }
    };

    this.sizeTile = 32;

    this.urls = {
        tile: "https://i.imgur.com/fqG34pO.png",
        character: "https://i.imgur.com/ucwvhlh.png",
        poster: "https://i.imgur.com/NXIjxr8.png",
        tree: "https://i.imgur.com/wIK2b9P.png"
    };

    this.images = {};
}

Seguido a esto, tenemos nuestra función initialize, acá vamos a poner todas las funciones y procesos que nuestro juego tiene que correr para iniciarse.

async initialize() {
    await this.loadImages();
    this.renderMap();
    this.renderEnvironment();
    this.renderCharacter();

    this.initializeKeys();
}

Y vamos a pasar a explicar una por una que estamos haciendo, como primera medida, necesitamos tener descargadas todas las imágenes.

async loadImages() {
    for (let nameUrl in this.urls) {
        const url = this.urls[nameUrl];

        const imageLoaded = await this.loadImage(url);
        this.images[nameUrl] = imageLoaded;
    }
}

Para esto, la función loadImages va a recorrer todas las urls que teníamos declaradas en el constructor y una vez descargadas meterlas en un objeto nuevo que creamos this.images.

Si lo ven, es lo mismo que hacíamos descargando las imágenes una por una y asignándolas a una variable, pero ahora están metidas todas dentro de un objeto.

Haciendo que loadImages sea una función asyncronica y gracias al await de cada this.loadImage(url) podemos esperar a que se descarguen todas las imágenes para recién pasar a la siguiente función.

La siguiente función que tenemos es renderMap, no es muy diferente a lo que teníamos antes, solo que en vez de agarrar la imagen del tile con la constante imageTile, la vamos a agarrar con this.images.tile.

renderMap() {
    for (let y = 0; y <= this.mapSize.y; y++) {
        for (let x = 0; x <= this.mapSize.x; x++) {
            this.ctx.drawImage(
                this.images.tile,
                x * this.sizeTile,
                y * this.sizeTile
            );
        }
    }
}

En renderEnvironment vamos a poner todos los assets que queramos cargar en el juego, como árboles, carteles, piedras, etc. Lo mismo agarrando las imágenes con this.images.tree o this.images.poster según el nombre que les hayamos asignado.

renderEnvironment() {
    this.ctx.drawImage(this.images.tree, 150, 150);
    this.ctx.drawImage(this.images.poster, 150, 20);

    this.ctx.font = "12pt Helvetica";
    this.ctx.fillStyle = "white";
    this.ctx.fillText("Capítulo 2", 190, 45);
}

En renderCharacter por ahora solo vamos a tener el renderizado del personaje.

renderCharacter() {
    this.ctx.drawImage(
        this.images.character,
        this.user.pos.x,
        this.user.pos.y
    );
}

Y como último nos queda initializeKeys que es el mismo evento de "keyDown" que teníamos antes con la diferencia que en vez de renderizar solo el personaje, vamos a limpiar y renderizar todo cada vez que nos movamos.

initializeKeys() {
    document.addEventListener("keydown", e => {
        switch (e.keyCode) {
            case this.keys.arrowUp:
                this.user.pos.y -= this.sizeTile;
                break;
            case this.keys.arrowDown:
                this.user.pos.y += this.sizeTile;
                break;
            case this.keys.arrowLeft:
                this.user.pos.x -= this.sizeTile;
                break;
            case this.keys.arrowRight:
                this.user.pos.x += this.sizeTile;
                break;
            default:
                break;
        }

        this.clearCanvas();
        this.renderMap();
        this.renderCharacter();
        this.renderEnvironment();
    });
}

Si se fijan al final, limpiamos el canvas, renderizamos el mapa, renderizamos el personaje y renderizamos los assets del ambiente, cada vez que nos movamos vamos a generar todo ese proceso y nos va a dar el resultado de tener siempre todas las partes del juego renderizadas.

Si nos ponemos a pensar, porque estamos dibujando todo el tiempo el mapa si no cambia, siempre es el mismo piso de pasto que tenemos y aun asi lo estamos limpiando y dibujando cada vez que nos movemos, acá pasamos a entender el concepto de capas.

Nosotros podemos tener múltiples canvas uno encima del otro y nos va a dar como beneficio poder dibujar algo estático en uno y en el otro poder estar limpiando y dibujando cada vez que nos movamos, para que hacemos esto? Por temas de rendimiento, es mucho menos costoso para el navegador no tener que recorrer los 2 for del mapa y renderizar los tiles, con que los rendericemos una vez ya nos alcanza.

Para hacer esto, vamos a agregar un canvas nuevo y los vamos a renombrar.

background, va a ser el canvas que se va a dedicar solamente a renderizar el pasto o el suelo que tengamos.

foreground, se va a dedicar mostrar nuestro personaje y los assets del ambiente, como los árboles o carteles, en este si vamos a estar limpiando cada vez que nos movamos.

Creamos los 2 canvas en nuestro HTML y les asignamos el ID correspondiente a cada uno para después poder agarrarlos con JavaScript.

<div class="canvas">
    <canvas id="background" width="320" height="320"></canvas>
    <canvas id="foreground" width="320" height="320"></canvas>
</div>

A ambos le vamos a dar una posición absoluta en CSS para que esté uno encima del otro y generar estas capas que queremos.

Del lado del código vamos a pasar a tener 2 context diferentes, antes teníamos uno porque teníamos un canvas solo, ahora vamos a crear un objeto y poner todos los canvas que tengamos ahí, en este caso 2.

const background = document.getElementById("background");
const foreground = document.getElementById("foreground");

const context = {
    background: background.getContext("2d"),
    foreground: foreground.getContext("2d")
};

const engine = new Engine(context);

Y procedemos a aplicarlos al código, lo primero que queremos hacer es que el piso del mapa se dibuje en el background, así que vamos a ir a la función renderMap y cambiar el context que teníamos por el context del background.

this.ctx.background.drawImage(
    this.images.tile,
    x * this.sizeTile,
    y * this.sizeTile
);

Ahí ya tenemos el piso dibujado en el canvas del background, o sea en el que está detrás de todo, lo que nos falta es cambiar el context del personaje y environments y texto para que se dibujen en el canvas de foreground.

renderEnvironment() {
    this.ctx.foreground.drawImage(this.images.tree, 150, 150);
    this.ctx.foreground.drawImage(this.images.poster, 150, 20);

    this.ctx.foreground.font = "12pt Helvetica";
    this.ctx.foreground.fillStyle = "white";
    this.ctx.foreground.fillText("Capítulo 2", 190, 45);
}

renderCharacter() {
    this.ctx.foreground.drawImage(
        this.images.character,
        this.user.pos.x,
        this.user.pos.y
    );
}

clearCanvas() {
    this.ctx.foreground.clearRect(
        0,
        0,
        this.mapSize.x * this.sizeTile,
        this.mapSize.y * this.sizeTile
    );
}

Tanto renderEnvironment como renderCharacter como clearCanvas van a estar trabajando en el canvas de foreground.

Y en la función de initializeKeys solo vamos a dejar estos 3, ya que no necesitamos más renderizar el mapa cada vez que nos movemos porque nunca lo limpiamos, ahora está en una capa diferente.

this.clearCanvas();
this.renderCharacter();
this.renderEnvironment();

Para hacer un resumen hasta acá porque tenemos muchísima información. Cambiamos el script.js que teníamos por un engine.js y a este lo transformamos todo en una clase Engine donde toda la lógica de nuestro motor gráfico va a pasar por acá. Creamos 2 canvas diferentes para usarlos de capas, una capa superior (foreground) donde se va a estar dibujando el personaje, los assets del ambiente y los textos  y una capa inferior (background) donde se va a estar dibujando el suelo, esto lo hicimos para optimizar nuestro juego, solo vamos a renderizar el suelo 1 vez (la primera vez que se carga nuestro engine) y despues lo unico que vamos a estar limpiando y renderizando es la capa de foreground donde están los personajes y cosas que se pueden llegar a mover.

Ya nuestro juego va tomando forma, pero como todo juego quiero poder tener diferentes mapas, poder editar mi mapa y poder tener tiles diferentes, como agua, pasto, desierto o lava entre otros, así que vamos a pasar a hacer eso.

Para esto vamos a crear un archivo JSON donde va a estar la lógica de nuestro mapa (si lo ven adentro de CodeSandbox está en maps/city.json), este city.json va a ser 2 arrays donde un array va a representar el eje-Y y el otro array el eje-X, lo mismo que usamos para recorrer y crear nuestros tiles en el mundo.

[
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
]

Imaginense que vamos a tener esa matriz, esta matriz es nuestro mundo actual de 10x10, cada fila es el eje-Y y cada número de adentro es el eje-X.

Si lo queremos pasar a código eso sería:

const map = [
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], //Agarró el primer numero de la Y = 0 X = 0
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
]

let Y = 0;
let X = 0;

console.log(map[Y][X]); //0

Otro ejemplo:

const map = [
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], //Agarró el cuarto numero de la Y = 2 X = 3
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
]

let Y = 2;
let X = 3;

console.log(map[Y][X]); //3

Para poder dibujar un mapa con esa matriz vamos a tener que darle más información de que queremos dibujar, así que cada uno de esos números X vamos a ponerles un objeto

{
    "background": "grass"
}

Ese nombre "grass" va a representar el nombre de la url que estemos cargando, si se fijan en nuestras urls, tenemos el mismo objeto llamado "grass" al que le estamos cargando la url para posteriormente descargarla y incluirla en la variable this.images.

this.urls = {
    grass: "https://i.imgur.com/fqG34pO.png",
    character: "https://i.imgur.com/ucwvhlh.png",
    poster: "https://i.imgur.com/NXIjxr8.png",
    tree: "https://i.imgur.com/wIK2b9P.png"
};

Mapa creado, ahora falta hacer la lógica para que nuestra función renderMap lo agarre y dibuje, así que vamos a modificarla un poco.

async renderMap() {
    const response = await fetch("/maps/city.json");
    const result = await response.json();

    for (let y = 0; y <= this.mapSize.y - 1; y++) {
        for (let x = 0; x <= this.mapSize.x - 1; x++) {
            const tile = result[y][x];

            this.ctx.background.drawImage(
                this.images[tile.background],
                x * this.sizeTile,
                y * this.sizeTile
            );
        }
    }
}

Lo primero que hacemos es descargar el archivo del mapa city.json

const response = await fetch("/maps/city.json");
const result = await response.json();

fetch es una función interna de JavaScript para hacer descargas o enviar data, abajo voy a dejar información extra, así que descargamos el mapa, lo convertimos a json y ya tenemos todo el mapa listo para cargar en nuestra constante result.

Como agarramos el tile? Como dijimos arriba result[Y][X]

const tile = result[y][x];

Ese tile como habíamos declarado, va a tener un objeto que va a ser {background: "grass"}.

Así que vamos a reemplazar nuestra manera de cargar las imágenes para que tome dinámicamente el valor de "background" que viene de la constante tile.

this.ctx.background.drawImage(
    this.images[tile.background],
    x * this.sizeTile,
    y * this.sizeTile
);

Esto nos va a permitir crear diferentes tipos de tiles y crear un mapa super personalizado con el tipo de terreno que quieran.

Información para procesar? Muchísima, no se frustren si no pudieron entender todo, es mucha mucha mucha información de golpe que aprendieron y cambiamos todo lo que veníamos haciendo en el capítulo 1.

Así como ya sabemos, ahora tocan los challenges! En este capítulo vamos a tener 3 challenges, con dificultades de menor a mayor.

Primer challenge: Tienen que reemplazar el pasto por agua en las últimas 2 filas del mapa, acá les dejo la url de tile del agua.

Url: https://i.imgur.com/4BZGw0M.png

Segundo challenge: Se tienen que poder cargar los objetos del mapa (el árbol y el cartel) desde el city.json

Tercer challenge: El agua tiene que tener bloqueos, el personaje no puede pisar el agua (Tip: los bloqueos se van a manejar desde el city.json)

Material de lectura:

Clases: link

Fetch: link

Y eso es todo por este capítulo!

Muchas gracias por el aguante y apoyo que le están dando al blog! Si pueden resolver los challenges no duden en compartirlos, los voy a estar leyendo en mi Twitter @DamianCatanzaro

Tambien se pueden suscribir al blog o seguirme en Twitter para recibir todas las notificaciones de cuando suba nuevos capítulos!

Nos vemos en el siguiente capítulo!

Follow @DamianCatanzaro