`

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

Buenas!

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

Como primer challenge, teníamos que cambiar el agua de las últimas 2 filas.

Para esto no es más que cargar la url la variable this.urls

this.urls = {
    grass: "./images/grass.png",
    character: "./images/character.png",
    poster: "./images/poster.png",
    tree: "./images/tree.png",
    water: "./images/water.png"
};

Como verán cambié las imágenes por imagenes locales, en vez de estar cargandolas desde imgur o desde un host directamente las cargamos desde nuestra carpeta local. Y en el city.json cambiamos las ultimas 2 filas que teníamos con "grass" por "water".

"background": "water"

Segundo challenge, cargar los assets como el cartel o el árbol desde el city.json.

Como primero vamos a elegir un random en el city.json y agregar

"foreground": "poster"

Nos va a terminar quedando el objeto algo así si ya tenemos un background anteriormente

{
    "background": "grass",
    "foreground": "poster"
}

Acordemonos que todo lo que era carteles o árboles los vamos a trabajar en el canvas foreground. Como ya los tenemos declarados en el city.json vamos a aplicar la misma lógica que usamos para renderizar el background pero en el foreground, antes de esto, necesitamos cargar el mapa una sola vez y asignarlo a una variable, porque lo vamos a usar tanto en el bucle de renderMap como en un nuevo bucle que vamos a tener en renderEnvironment, asi que vamos a declarar en el constructor un array vacío.

this.map = [];

Y crear una función que le asigne el mapa.

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

    this.map = result;
}

En el renderMap vamos a tener que sacar ese fetch, ya que lo vamos a cargar una sola vez en una función separada y pasar a agarrar el tile desde la variable this.map

const tile = this.map[y][x];

renderEnvironment también va a cambiar, va a pasar a tener un bucle, igual al que tenemos el renderMap solo que adentro vamos a checkear si existe algo del foreground en el objeto y si es así agregarlo al canvas.

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

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

Si ven en el código de CodeSandbox también agregué para que se pueda renderizar los textos desde el city.json

Y con esto pasamos al último challenge que era poder agregar bloqueos en los tiles del agua, estos bloqueos los vamos a agregar como una propiedad más en nuestro objeto del city.json, vamos a agarrar cada tile que tiene agua y ponerle un blocked: true.

{
    "background": "water",
    "blocked": true
}

Con este nuevo parámetro, lo único que vamos a hacer es checkear antes de mover al personaje si el tile de adelante, atrás o de los costados está bloqueado y si es así que no se mueva. Pero antes de esto, acordémonos que nuestro personaje tenía las posiciones en pixeles, ahora van a pasar a ser tiles.

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

Y en el renderCharacter vamos a tener que multiplicar este tile por el valor de nuestro sizeTile, así nos queda igual que antes, pero ahora tenemos la posición de número de tile en el user.

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

(El -32 en el eje-Y lo puse para acomodar el personaje 32 pixeles más arriba, asi quedaba mejor centrado).

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

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

Fijense que en el caso de arrowDown checkeamos que la posición anterior en el eje-Y no tenga bloqueos, si los tiene no va a pasar por el if y no le va a restar un -1.

if (
    !this.map[this.user.pos.y - 1][this.user.pos.x].blocked
) {
    this.user.pos.y--;
}

Challenges del capitulo 2 finalizados!

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 😁.

Y empecemos con el capítulo 3!

En este capítulo vamos a aprender a manejar sprites para que nuestro personaje camine o poder tener diferentes efectos en el mapa, un sprite es básicamente una imagen que tiene múltiples frames (un frame es una imagen que compone un sprite) que juntando todas estas imágenes y renderizandolas en cierta velocidad logramos lo que queremos.

Un ejemplo de un sprite

Como vemos es una bola de fuego que inicia de izquierda a derecha y arriba a abajo, si juntamos cada frame nos quedaría algo así:

Antes de empezar con el código vamos a tener que entender un par de conceptos empezando por los FPS, FPS = Frame Per Second o Fotogramas por segundo el numero de FPS que maneje nuestro juego es igual a la cantidad de veces que se va a redibujar todo.

En este juego vamos a usar 60FPS, para un juego 2D está más que sobrado y se suele usar este número porque es la media de lo que puede ver el ojo humano y la mayoría de los monitores su tasa de refresco es 60Hz (Hz = Hertz) que significa lo mismo, que refrescan el monitor 60 veces por segundo, si bien ya hay monitores de 144Hz o 240Hz se suelen aprovechar solo en juegos 3D que necesiten un gran nivel de detalle.

Genial, vamos a correr nuestro juego en 60FPS por lo que vamos a tener que generar un loop que se ejecute 60 veces por segundo todo el tiempo, para hacer esto tenemos 2 maneras, una nativa que nos da JavaScript y otra media manual, les voy a mostrar las 2.

De manera nativa con JavaScript tenemos el método requestAnimationFrame que espera como parámetro nuestra función.

function loop() {
    //Todo lo que esté acá adentro se va a ejecutar 60 veces por segundo
    requestAnimationFrame(loop);
}

loop();

Y si lo hacemos de la manera manual seria con un setInterval

function loop() {
    //Todo lo que esté acá adentro se va a ejecutar 60 veces por segundo
    console.log("asd")
}

setInterval(loop, 1000 / 60);

1000 = 1 segundo
60 = la cantidad de veces que queremos refrescar la pantalla

Eso nos daría que cada 16.66 milisegundos se va a ejecutar nuestra función loop y hacer algo, en nuestro caso va a ser renderizar el foreground y limpiarlo.

Cual vamos a usar? requestAnimationFrame, por que? Porque está optimizado para hacer este trabajo, previene saltos de frames y se sincroniza con la tasa de refresco de la pantalla.

Así que pasemos a implementarlo en nuestro código.

Como dijimos, vamos a crear el método loop

loop = () => {
    this.calculateFPS();
    this.framesPerSecCounter = this.framesPerSecCounter + 1;

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

    requestAnimationFrame(this.loop);
};

Y migrar los métodos clearCanvas, renderCharacter y renderEnvironment desde el initializeKeys hacia el método loop, este método, se va a estar llamando 60 veces por segundo, por lo que si nos movemos por el mapa y sumamos al valor de user.pos.x o user.pos.y que es la posición de nuestro personaje automáticamente va a pasar por el renderCharacter y dibujarlo en la posición necesaria.

Si ven arriba, también agregué una función que es calculateFPS, la vamos a usar para ver a cuantos FPS se está ejecutando nuestro juego.

calculateFPS() {
    if (this.timestamp() - this.lFrameTimer > 1000) {
        this.FPS = this.framesPerSecCounter;
        this.framesPerSecCounter = 0;
        this.lFrameTimer = this.timestamp();
    }
}

timestamp() {
    return window.performance && window.performance.now
        ? window.performance.now()
    : new Date().getTime();
}

La misma cuenta con 2 partes, la más importante y que vamos a usar más adelante es el método timestamp(), timestamp nos va a devolver un valor de tiempo del momento que se esté ejecutando el juego, porque tenemos window.performance.now y new Date()? Porque window.performance.now() nos va a dar un valor con 5 decimales, muchísimo más exacto que new Date(), aunque ambos van a servir bien y lo ponemos así para que si algún navegador no soporta performance.now() use new Date()

Entonces, que hace el calculateFPS exactamente?

En el loop vemos que estamos sumando un + 1 a la variable framesPerSecCounter, que es la cantidad de veces que va a pasar por el loop, cada vez que pasa una vez se le suma un + 1, si todo está correcto debería pasar 60 veces.

loop = () => {
    this.calculateFPS();
    this.framesPerSecCounter = this.framesPerSecCounter + 1;

Y dentro de calculate FPS preguntamos si timestamp - lFrameTimer es mayor a 1000.

calculateFPS() {
    if (this.timestamp() - this.lFrameTimer > 1000) {
        this.FPS = this.framesPerSecCounter;
        this.framesPerSecCounter = 0;
        this.lFrameTimer = this.timestamp();
    }
}

lFrameTimer es una variable que va a arrancar en 0 y se le va a ir asignando el valor del timestamp cada 1 segundo (1000 = 1 segundo, recuerden que se trabaja en milisegundos), por ende cada 1 segundo se va a cumplir esta condición, vamos a asignar el valor de framesPerSeCounter a la variable FPS y la dibujamos en pantalla para ver el resultado, si todo salió bien vamos a ver 60 FPS que significa que el método loop se ejecutó 60 veces y volvemos todo a 0 para que el bucle comience nuevamente.

Primera parte explicada y funcionando, vamos a entender cómo funcionan los sprites ahora. Para que podamos renderizar un sprite necesitamos información del mismo, en el caso de la bola de fuego vamos a necesitar separar frame por frame en un archivo de configuración.

En este caso, cada frame tiene un ancho y alto de 64px, por lo que nuestra configuración va a ser la siguiente:

{
    "fireBall": {
        "speed": 85,
        "frames": [
            {
                "sX": 0,
                "sY": 0,
                "width": 64,
                "height": 64
            },
            {
                "sX": 64,
                "sY": 0,
                "width": 64,
                "height": 64
            },
            {
                "sX": 128,
                "sY": 0,
                "width": 64,
                "height": 64
            },
            {
                "sX": 192,
                "sY": 0,
                "width": 64,
                "height": 64
            },
            {
                "sX": 256,
                "sY": 0,
                "width": 64,
                "height": 64
            },
            {
                "sX": 0,
                "sY": 64,
                "width": 64,
                "height": 64
            },
            {
                "sX": 0,
                "sY": 128,
                "width": 64,
                "height": 64
            },
            {
                "sX": 0,
                "sY": 192,
                "width": 64,
                "height": 64
            },
            {
                "sX": 0,
                "sY": 256,
                "width": 64,
                "height": 64
            }
        ]
    }
}

Vamos a tener un objeto con un elemento fireBall, que va a tener speed y frames. Con el número speed podemos ir jugando a gusto para que la animación salga más rápida o más lenta y en frames va a ir cada configuración de frame por frame.

{
    "sX": 0,
    "sY": 0,
    "width": 64,
    "height": 64
}

Si empezamos por el primero, vemos que tanto los ejes Y e X son 0, así que los vamos a declarar así y también vamos a declarar el ancho y el alto de cada frame, esto lo hacemos porque en otros sprites podemos tener frames con diferentes medidas, no se suele dar pero puede pasar.

Con la configuración de la fireBall creada pasemos a agregarla a nuestro juego.

Como siempre, cada nueva imagen se agrega a nuestro proyecto, en este caso agregamos la fireBall a las urls

this.urls = {
    ...
    fireBall: "./images/fireBall.png"
};

Y si notan algo, hay una nueva variable en el método loop llamada delta.

loop = () => {
    ...

    this.delta = this.timestamp() - this.lastDelta;
    this.lastDelta = this.timestamp();

    ...
};

La variable delta va a ser una variable hiper importante a la hora de calcular nuestras animaciones, porque no siempre nuestros usuarios van a tener 60FPS constantes, capaz que tienen alguno con una computadora media vieja y el juego les corre a 30FPS o tienen una computadora muy nueva y el juego va a estar en 100FPS, sin importar los FPS nosotros queremos que la animación se vea igual, tanto en 30 como en 100FPS, para esto se calcula la variable delta, que no es más que el tiempo que se ejecuta cada vuelta.

Si tenemos 60FPS, loop se ejecuta 60 veces por segundo por lo que 1000 (1 segundo) / 60 FPS = 16.66ms, delta va a tener un aproximado de 16ms.

En el caso de que nuestro juego esté corriendo a 30FPS van a ver que delta es mayor, 1000 / 30 = 33.33ms

(ms = milisegundos)

Ahora más adelante vamos a ver ejemplos de cómo se usa delta y que pasa si tengo delta con 100FPS o con 60FPS

Como veníamos haciendo, voy a asignar la animación a una parte del city.json así puedo tener animaciones por cualquier parte del mapa

{
    "background": "grass",
    "animation": "fireBall"
}

Y creamos un nuevo método llamado renderAnimation.

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

            if (tile.animation) {
                const animation = this.animations[tile.animation];

                if (typeof tile.frameFxCounter === "undefined") {
                    tile.frameFxCounter = 0;
                }

                tile.frameFxCounter += this.delta / animation.speed;
                let frameFxCounter = Math.floor(tile.frameFxCounter);

                if (frameFxCounter >= animation.frames.length) {
                    tile.frameFxCounter = 0;
                    frameFxCounter = 0;
                }

                const frame = animation.frames[frameFxCounter];

                this.ctx.foreground.drawImage(
                    this.images[tile.animation],
                    frame.sX,
                    frame.sY,
                    frame.width,
                    frame.height,
                    x * this.sizeTile,
                    y * this.sizeTile,
                    frame.width,
                    frame.height
                );
            }
        }
    }
}

Y acá vamos a pasar a explicarlo en detalle porque puede ser un poco confuso al principio.

Lo que tenemos que lograr con el método renderAnimation es que nuestra animación pase por todo el arrays de frames y se vaya dibujando, para esto vamos a recorrer todo el mapa como veníamos haciendo y buscamos si algún tile tiene la propiedad animation, si es así la guardamos

const animation = this.animations[tile.animation];

Anteriormente cargamos un animation.json de igual manera que hicimos con el city.json y se lo asignamos a la variable this.animations.

Esta constante animation va a tener toda la configuración de fireBall que creamos.

{
    "fireBall": {
        "speed": 60,
        "frames": [
            {
                "sX": 0,
                "sY": 0,
                "width": 64,
                "height": 64
            },
            ...
        ]
    }
}

Después de esto, vamos a necesitar crear un contador, este contador va a ir desde 0 hasta la cantidad de frames que tenemos en nuestro array. Como no vamos a poder tener más de una animación por tile, vamos a crear este contador en el tile donde está creada nuestra animación de la siguiente manera.

if (typeof tile.frameFxCounter === "undefined") {
    tile.frameFxCounter = 0;
}

Y acá es donde entra nuestra querida variable delta

tile.frameFxCounter += this.delta / animation.speed;
let frameFxCounter = Math.floor(tile.frameFxCounter);

if (frameFxCounter >= animation.frames.length) {
    tile.frameFxCounter = 0;
    frameFxCounter = 0;
}

Si nuestro juego funciona a 60FPS, delta va a tener un valor aproximado a 16.66ms, así que vamos a usar la speed de nuestra animación, dividirla por delta e irsela sumando a frameFxCounter, esto va a dar algo así

this.delta = 16.66
animation.speed = 85

Primera vuelta:
tile.frameFxCounter += 16.66 / 85 = 0.196
//0.196

Segunda vuelta:
tile.frameFxCounter += 16.66 / 85 = 0.196
//0.392

Sexta vuelta:
tile.frameFxCounter += 16.66 / 85 = 0.196
//1.176

Para qué hacemos esto? Para aprovechar la función Math.floor que nos devuelve el máximo entero menor o igual a un número.

Math.floor(0.196) = 0
Math.floor(0.392) = 0
Math.floor(1.176) = 1

Ese número que nos devuelve Math.floor es el frame que vamos a querer agarrar.

const frame = animation.frames[frameFxCounter];

Bajando el número de speed de la animación, la misma va a ser más rápida, porque va a tardar menos en pasar de frame a frame.

Y como último nos queda explicar el choclo en el que se convirtió drawImage

this.ctx.foreground.drawImage(
    this.images[tile.animation],
    frame.sX,
    frame.sY,
    frame.width,
    frame.height,
    x * this.sizeTile,
    y * this.sizeTile,
    frame.width,
    frame.height
);

Lo que vemos acá se representa así:

ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);

sx = coordenada x de la imagen
sy = coordenada y de la imagen
(es nuestro sX y sY que configuramos en la animación)

sWidth = ancho de la imagen a cortar
sHeight = alto de la imagen a cordar
(es nuestro width y height que configuramos en la animación)

dx = coordenada x en el canvas
dy = coordenada y en el canvas
(Estos los vamos a usar para poner nuestra animación en cualquier parte del mapa)

dWidth = ancho en el canvas
dHeight = alto en el canvas
(Como queremos que sea del mismo alto y ancho que nuestros frames vamos a poner esos parametros)

Y de esta manera podemos crear animaciones en cualquier parte del mapa. Si, es jodido y complicado al principio de entender, intenté explicarlo lo más detallado posible porque en cuanto entramos en los deltas y números se pone un poco denso el asunto.

Acá les voy a dejar 2 videos de un juego funcionando a 200FPS y otro a 60FPS, van a ver que la animación se comporta igual, esto se debe a que calculamos el paso de un frame a otro con el delta.

60FPS

200FPS

Vamos a terminar acá este capítulo, espero que les guste, ya de a poco le vamos dando más funcionalidad a nuestro juego.

Como challenges vamos a dejar 2.

Primer challenge: Tienen que agregar la siguiente antorcha en cualquier parte del mapa, puede ser varias.

Se las dejo subidas por si la quieren seguir incluyendo desde imgur: https://i.imgur.com/Hfi5FkZ.png

Y acá tienen el archivo de configuración del sprite

{
    "torch": {
        "speed": 85,
        "frames": [
            {
                "sX": 0,
                "sY": 0,
                "width": 31,
                "height": 85
            },
            {
                "sX": 31,
                "sY": 0,
                "width": 31,
                "height": 85
            },
            {
                "sX": 62,
                "sY": 0,
                "width": 31,
                "height": 85
            },
            {
                "sX": 93,
                "sY": 0,
                "width": 31,
                "height": 85
            },
            {
                "sX": 124,
                "sY": 0,
                "width": 31,
                "height": 85
            },
            {
                "sX": 155,
                "sY": 0,
                "width": 31,
                "height": 85
            },
            {
                "sX": 186,
                "sY": 0,
                "width": 31,
                "height": 85
            },
            {
                "sX": 217,
                "sY": 0,
                "width": 31,
                "height": 85
            }
        ]
    }
}

Segundo challenge: Agregar el siguiente sprite al personaje como una animación propia, el personaje se tiene que poder mover y la animación va a estar arriba de él todo el tiempo.

Url: https://i.imgur.com/cPVODI6.png

Configuración:

{
    "healing": {
        "speed": 85,
        "frames": [
            {
                "sX": 0,
                "sY": 0,
                "width": 145,
                "height": 145
            },
            {
                "sX": 145,
                "sY": 0,
                "width": 145,
                "height": 145
            },
            {
                "sX": 290,
                "sY": 0,
                "width": 145,
                "height": 145
            },
            {
                "sX": 435,
                "sY": 0,
                "width": 145,
                "height": 145
            },
            {
                "sX": 0,
                "sY": 145,
                "width": 145,
                "height": 145
            },
            {
                "sX": 145,
                "sY": 145,
                "width": 145,
                "height": 145
            },
            {
                "sX": 290,
                "sY": 145,
                "width": 145,
                "height": 145
            },
            {
                "sX": 435,
                "sY": 145,
                "width": 145,
                "height": 145
            },
            {
                "sX": 0,
                "sY": 290,
                "width": 145,
                "height": 145
            },
            {
                "sX": 145,
                "sY": 290,
                "width": 145,
                "height": 145
            },
            {
                "sX": 290,
                "sY": 290,
                "width": 145,
                "height": 145
            },
            {
                "sX": 435,
                "sY": 290,
                "width": 145,
                "height": 145
            },
            {
                "sX": 0,
                "sY": 435,
                "width": 145,
                "height": 145
            },
            {
                "sX": 145,
                "sY": 435,
                "width": 145,
                "height": 145
            },
            {
                "sX": 290,
                "sY": 435,
                "width": 145,
                "height": 145
            },
            {
                "sX": 435,
                "sY": 435,
                "width": 145,
                "height": 145
            }
        ]
    }
}

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