Dibujando en pantalla
Una de las piezas más importantes en el desarrollo de video juegos es el lugar donde mostrar el juego en sí. Un lienzo donde podamos dibujar y animar los gráficos o mostrar texto con el que el usuario pueda interactuar. Con HTML5, ese lienzo se transforma en un nuevo tag HTML llamado canvas.
...
«canvas . . .» «/canvas»
... (*)
Este tag posee dos atributos con los que podremos trabajar. Estos atributos (Width y Height) nos permitirá especificar las dimensiones del lienzo y por lo tanto, el tamaño visual de nuestro juego.
Es importante tener en cuenta que este tag representa los píxeles de nuestros gráficos y de nuestro juego, por lo que a mayor tamaño, mayor cantidad de píxeles deberán ser creados, lo que requerirá, entre otras cosas, más memoria y capacidad de procesamiento, por lo que deberemos ser cuidadosos al momento de dimensionar el tag.
El siguiente paso, para poder trabajar con el tag canvas, será obtenerlo y manipularlo desde Javascript.
...
«canvas width="640" height="480" id="canvas"» «/canvas»
...
«script language=”Javascript”»
var canvas = document.getElementById("canvas");
var contexto = canvas.getContext("2d");
«/script»
El objeto canvas, ahora contenido en la variable canvas del código JavaScript nos servirá como referencia del tag en el DOM HTML. Pero de este necesitaremos obtener un contexto de dibujo mediante el uso de la función getContext(“MODO”). El modo del contexto retornado representa la forma con la que trabajaremos con el mismo, en el caso de 2d (Dos dimensiones), nos proveerá, entre otras particularidades, un eje de coordenadas X e Y con el que desplazaremos los diferentes elementos del juego. Actualmente HTML5 solo cuenta con el modo 2d de forma nativa, pero es seguro que en un futuro se agreguen nuevos modos.
Teniendo el contexto de dibujo, para corroborar que esté funcionando correctamente, escribiremos un mensaje en el mismo.
...
context.fillText("Juegos en HTML5!", 300, 240);
...
El texto es dibujado en el objeto canvas
Hasta aquí hemos visto como crear un lienzo de dibujo mediante el tag canvas. Este tag representa un conjunto de píxeles y cuenta con funciones para dibujar elementos sobre este. Finalmente escribimos algo de texto para probar su funcionamiento.
A medida que avancemos, iremos agregando cada una de las partes que componen el juego: animaciones fluidas, interacción con el usuario, sonido y demas.
Dibujando imágenes
A diferencia de lo que hicimos mas arriba, los videojuegos se caracterizan por mostrar imágenes para representar cada uno de los elementos del juego y reservamos el texto para mensajes con los que el usuario podrá conocer más sobre los acontecimientos e historia del juego. Entonces, es momento de que dibujemos nuestra primera imagen sobre el lienzo.
Dibujar imágenes es una tarea relativamente sencilla, primero, debemos contar con la imagen en cuestión, para luego colocarla en un eje de coordenadas específico. Y para hacer esto existen diferentes técnicas, por ejemplo, podríamos utilizar una imagen declarada en el DOM mediante el tag
«script language=”Javascript”»
var yoda = new Image();
yoda.src = "yoda.jpg";
var canvas = document.getElementById("canvas");
var context = canvas.getContext("2d");
context.drawImage(yoda, 10, 10);
«/script»
La función drawImage nos permite tomar una imagen y dibujarla sobre el lienzo en unas coordenadas específicas. En nuestro ejemplo, la imagen se dibujará a 10 píxeles en el eje X y 10 píxeles en el eje Y.
Curiosamente, si ejecutamos el código veremos que no se muestra ninguna imagen.
- - -
Esto se debe a que la carga de la imagen en memoria se realiza después de que el código de dibujado se ejecuta, por lo tanto, el objeto imagen aun se encuentra vacio. Será necesario hacer algunas modificaciones al código para asegurarnos que solo se dibuje la imagen luego de que se haya cargado correctamente. Veamos:
...
var yoda = new Image();
yoda.onload = function () {
context.drawImage(yoda, 10, 10);
};
yoda.src = "yoda.png";
...
El evento onload de la imagen nos avisará cuando el objeto haya terminado con la carga. Es en este momento cuando deberemos realizar el dibujado.
Una vez cargada la imagen se muestra en el canvas.
Es importante que tengamos en cuenta el formato de nuestras imágenes. Podemos usar cualquiera de los tres formatos más populares de Internet (Gif, Jpg y Png). Cada uno tiene sus ventajas y desventajas. El formato Png, para nuestro caso, es el que posee un balance entre pros y contras. Pudiendo soportar gran cantidad de colores, una buena compresión sin perder calidad y lo más importante, transparencias.
Moviendo gráficos
El siguiente paso es mover los gráficos del juego. Para iniciarnos, lo haremos de forma automática por el canvas, esto quiere decir, que no tendremos la intervención del usuario mediante el mouse o las teclas.
Los videojuegos cuentan con una particularidad que, si estamos acostumbrados al desarrollo de aplicaciones para la Web, celulares o de escritorio, puede que no hayamos notado que, a nivel del sistema operativo, funcionan de forma similar a los videojuegos. En todo caso, esta particularidad es la que le da vida al videojuego, la que posibilita el mover los gráficos y generar las animaciones. Y es que los videojuegos requieren redibujar la pantalla del juego constantemente. Cada vez que vemos un gráfico desplazarse, una explosión, una barra de vida cambiar su estado, el código del juego está redibujando todo el contenido del mismo para poder mostrar estos cambios. Por lo tanto, es necesario contar con algo de código que garantice el redibujado constante.
Podríamos, por ejemplo, escribir el siguiente código.
...
for ( ; ; ) {
//Calcular todos los cambios.
//Redibujar la pantalla.
}
...
El código anterior es funcional, pero requeriría de nuestra parte algo de esfuerzo adicional para poder sincronizar cada cuadro. Tengamos en cuenta que el cerebro humano requiere de cierta continuidad en la acción, en este caso en los cuadros del juego, para poder percibir movimiento, por lo que normalmente, para que un juego tenga cierta fluidez, es necesario un mínimo aceptable de 24 cuadros por segundo, y esto requeriría ser sincronizado dentro del bucle, para que se adapte a diferentes equipos consumidores de nuestro juegos; con diferentes características de memoria, de tarjetas gráficas, procesadores, etc.
Un camino ligeramente más simple, en Javascript, es haciendo uso de la función setInterval([Funcion], [Tiempo en milisegundos]), la que nos permite disparar otra función en ciclos basados en los milisegundos dados.
...
var yoda = new Image();
yoda.onload = function () {
setInterval(dibujar, 1000 / 33);
};
yoda.src = "yoda.png";
function dibujar() {
context.drawImage(yoda, 10, 10);
}
...
Para este caso, definimos que el tiempo que debería tomarse entre llamadas a la función dibujar() sea de 1000 dividido 33, lo que sería un aproximado a 33 veces en 1 segundo. Por supuesto, JavaScript hará todo lo posible por cumplir esta meta, por lo que la cantidad de cuadros por segundo podría verse afectada en base a la cantidad de objetos a dibujar en pantalla.
El resultado no difiere del código visto hasta ahora, pero internamente se está redibujando constantemente.
Ahora que ya tenemos una función que vuelve a dibujar los objetos en pantalla, para moverlos, será tan simple como cambiar cualquiera de los ejes de coordenadas donde el gráfico es dibujado, teniendo en cuenta que, mientras más variación ejerzamos sobre el eje en cuestión, más rápido parecerá moverse el objeto.
...
var x = 0;
function dibujar() {
context.drawImage(yoda, x, 10);
x++;
}
...
Ahora nuestro personaje se desplaza hacia la derecha, pero deja rastros.
Podemos ver, al ejecutar el código, que nuestro personaje efectivamente se mueve hacia la derecha de la pantalla, pero deja un halo detrás de sí. Esto se debe a que, por cada cuadro, el objeto canvas no está limpiando el contenido previo, sino que simplemente se limita a dibujar el nuevo objeto sobre el lienzo. Es nuestra tarea, entonces, limpiar el lienzo cada vez que necesitemos redibujar todo en la pantalla.
function dibujar() {
context.clearRect(0, 0, canvas.width, canvas.height);
context.drawImage(yoda, x, 10);
x++;
}
La función clearRect() nos permite eliminar una parte del contenido dibuja en el objeto canvas. En este caso, tomamos toda la pantalla como destino y así eliminamos cualquier rastro del anterior dibujo.
Ahora, nuestro personaje, no deja rastros detrás de él.
Es necesario aclarar que esta técnica no es la más óptima, ya que estamos dibujando cada uno de los elementos directamente en el objeto que tiene contacto con la pantalla. Esto puede acarrear costos computacionales que, en juegos de alta gama, sean la diferencia entre una animación fluida y una mala animación. Para subsanar este problema, se suele usar una técnica llamada de buffer doble.
Doble buffer
Si podemos colocar más gráficos en pantalla y de forma más rápida, podremos también, utilizar el tiempo ganado para realizar cálculos adicionales que enriquezcan nuestro video juego.
Esta técnica la habíamos denominado de buffer doble (Double Buffer), técnica bastante simple, pero eficiente. La técnica de doble buffer consiste, en pocas palabras, en tener una copia de la memoria de video, eso que vamos a mostrar en pantalla, pero en memoria, realizar todos los cambios en esta (Mover gráficos, realizar animaciones, etc.) y luego, con todo esto resuelto, volcar de una sola vez este modelo de memoria a la pantalla.
Tengamos en cuenta que el dibujar un solo pixel en pantalla es una tarea costosa a nivel de procesos de datos. Ya que no es simplemente determinar el punto o coordenada donde queramos depositar este píxel, si no que el mismo debe viajar desde la computadora hasta la pantalla para transformarse en un punto de luz que solo el monitor puede representar. Por lo tanto, para lograr esta tarea, es necesario que la información sea sincronizada, manipulada y procesada por diferentes dispositivos, desde el CPU pasando por la tarjeta de video y hasta el monitor.
Si nuestro código simplemente dibuja cada elemento directamente en el canvas, esto hace que cada una de las acciones de dibujado sean enviadas tras terminar con ellas, por lo que todo el trabajo de sincronización y cómputos será necesario tras cada uno de los envíos.
Imaginemos que tenemos 10 personajes en nuestro juego, aunque intentemos crear una cantidad de cuadros por segundo, dentro de cada cuadro estaríamos enviando 10 veces (1 por cada personaje) información hasta el monitor, por lo que todo ese tiempo de procesado nos jugaría en contra a la hora de conseguir más cuadros por segundo, o de necesitar dibujar más elementos o simplemente detectar colisiones o cualquier otro tipo de cálculo.
Por lo tanto, si dibujamos estos 10 elementos en memoria, y luego volcamos todos esos píxeles en una sola pasada al monitor, solo tendríamos un proceso de sincronización y demás acciones.
...
var canvas = document.getElementById("canvas");
var context = canvas.getContext("2d");
var canvasBuffer = document.createElement("canvas");
canvasBuffer.width = canvas.width;
canvasBuffer.height = canvas.height;
var contextBuffer = canvasBuffer.getContext("2d");
...
Lo primero, entonces, será crear nuestro buffer en memoria. Como vemos en el código anterior, simplemente se crea un elemento canvas en memoria, se le asigna el mismo ancho y alto que el objeto canvas original y finalmente se toma un contexto de dibujo. El siguiente paso, por lo tanto, es dibujar sobre este contexto.
...
function dibujar() {
walkFrame = (walkFrame + 1) % 2;
contextBuffer.fillRect(0, 0, canvasBuffer.width, canvasBuffer.height);
contextBuffer.drawImage(yoda,
walkFrame * 42, 1, 42, 39,
x, 10, 42, 39);
x++;
context.drawImage(canvasBuffer, 0, 0);
}
...
Aquí vemos que todo el dibujo se realiza sobre contextBuffer, para luego tomar el objeto canvas en memoria y utilizarlo como si fuese una imagen completa, copiando todos sus píxeles al canvas destino.
Existen muchas otras técnicas para optimizar el rendimiento en video juegos, esta es, posiblemente, una de las más antiguas que aun hoy se utiliza.
Animaciones
Es momento de darle vida a los gráficos del juego mediante animaciones de los diferentes objetos. Siendo que, salvo algunos ítems o partes de la interfaz del juego suelen ser los únicos estáticos, todos los demás elementos cuentan con por lo menos algún efecto de animación que va más allá del simple hecho de desplazar el objeto por la pantalla.
En un juegos de dos dimensiones como el que estamos creando, es necesario contar con una serie de gráficos que simulen la animación cuadro a cuadro. Si buscamos un parámetro de referencia, este es el de los dibujos animados. En el caso de los dibujos animados, cada cuadro de película requiere se dibujado, y cada movimiento de un protagonista necesita de cada uno de los dibujos que le dan vida.
Cada cuadro del caminar es dibujado
De la misma forma, para obtener una animación en un video juego es necesario dibujar cada uno de estos cuadros en cada uno de los objetos. Luego, se usarán estos cuadros para que, en cada frame de animación del juego, se represente uno de los gráficos que forman parte de la animación del personaje.
Mediante el uso de alguna herramienta de diseño gráfico, entonces, podemos crear esta serie de gráficos para animar a nuestro personaje.
Yoda solo moverá los pies ligeramente para dar la sensación de animación
Como vemos en la imagen, se traza una cuadrícula que dividirá cada cuadro de la animación. Esta cuadrícula, los píxeles de separación entre imagen e imagen, son importantes debido a que de esta forma será más simple, a nivel de programación, poder obtener cada uno de los cuadros de esta tira de gráficos, también llamada Sprite Sheet.
Si tenemos en cuenta que cada cuadro de nuestro personaje posee un ancho de 42 píxeles, la cuadrícula será dividida por este tamaño dejando un espacio de 42 píxelespara cada animación.
Con los gráficos en su lugar solo nos queda poder intercambiar, entre cada cuadro dibujado, el sprite que corresponda de la Sprite Sheet y así obtener el efecto de caminar deseado.
yoda.src = "yodaspritesheet.png";
var x = 0;
var walkFrame = 0;
function dibujar() {
walkFrame = (walkFrame + 1) % 2;
context.clearRect(0, 0, canvas.width, canvas.height);
context.drawImage(yoda,
walkFrame * 42, 1, 42, 39,
x, 10, 42, 39);
x++;
}
Hemos cambiado algunas líneas desde el ejemplo anterior. La más notable es la referente al dibujado de la imagen en el canvas. La función drawImage, ahora posee nuevos parámetros los que nos ayudan a poder elegir la sección origen de nuestra Sprite Sheet y el destino en el canvas. La nueva definición de drawImage es la siguiente:
drawImage(Imagen, X origen, Y origen, ancho origen, alto origen, X destino, Y destino, ancho destino, alto destino)
Esto quiere decir que podemos especificar un rectángulo en la imagen origen, tomar dicho rectángulo y dibujar solo esa parte en el destino, esto es, nuestro canvas. En el caso del ancho y alto del destino, si variamos estos valores, la imagen cambiará su tamaño de forma automática. Esto resulta una buena forma de escalar gráficos sin tener que realizar cálculos matemáticos complejos.
La segunda línea llamativa es la que especifica el cuadro a dibujar:
walkFrame = (walkFrame + 1) % 2;
Esta es una forma simple de recorrer todos los cuadros de la animación. Teniendo el cuadro actual, podemos obtener el siguiente valor de cuadro a mostrar o volver el contador a cero. La formula es como sigue:
[FRAME ACTUAL] = ([FRAME ACTUAL] + [SIGUIENTE FRAME]) % [CANTIDAD DE FRAMES EN LA ANIMACION]
Si ejecutamos el código veremos que la animación se realiza de forma muy rápida. Esto se debe a que contamos con pocos cuadros de animación por la cantidad de cuadros por segundo con los cuales se refresca toda la pantalla.
A continuación veremos cómo sincronizar estos casos para que, teniendo menos cuadros de animación podamos tener una animación fluida.
Sincronizando animaciones
Ejecutando el juego pudimos notar que nuestro personaje modificaba sus gráficos de forma muy rápida no pudiéndose apreciar la animación.
Este comportamiento se debe, en principio, a que el juego está disparando más de 30 cuadros de redibujado por segundo y tras cada cuadro nuestro código cambia el gráfico a mostrar, por lo que solo se ven algunos píxeles que cambian de lugar en vez de tener una animación algo más apreciable. Simplemente, resulta una animación pobre, incluso para dos cuadros de animación.
Para mejorar esto tendremos que encontrar una forma de disparar el cambio de cuadro en nuestro personaje cada cierta cantidad de cuadros dibujados en pantalla.
Una solución rápida podría ser contar cuantos cuadros se han dibujado y luego cambiar al siguiente gráfico de nuestro personaje. Si bien es una alternativa, esto puede causar que una variación en la cantidad de cuadros posibles a ser dibujados en pantalla haga que nuestra animación se dispare más veces por segundo, o menos, dependiendo de cuál sea esta variación, lo que nos daría una sensación de que el juego a veces se acelera y a veces se hace más lento.
Otra posibilidad, y es la que veremos, es la de contar tiempo entre cuadros. Esto es, contabilizar cuanto tiempo ha pasado entre un cuadro y el otro, y luego de llegar a determinada suma, realizar el cambio. Esto nos garantizará que el cambio sea constante en el tiempo, incluso si contamos con problemas en la cantidad de cuadros dibujados por segundo. Por supuesto, esta técnica no puede ser considerada como infalible, ya que a baja performance del equipo que ejecute el juego, también veremos ciertos problemas, pero salvaremos esto en equipos con mayor capacidad.
Necesitaremos algunas variables para calcular el tiempo actual y la diferencia en relación al cuadro actual.
...
var lastFrame = 0;
var counter = 0;
function dibujar() {
var thisFrame = new Date().getTime();
var delta = (thisFrame - lastFrame) / 1000;
lastFrame = thisFrame;
...
Cada vez que un nuevo cuadro es dibujado se calcula el tiempo en milisegundos (Podría ser cualquier otra unidad) en el cual se dibujó el cuadro anterior y el tiempo actual. Esto nos dará un valor delta (Diferencia en el cambio entre el valor anterior y el actual) con el que podremos realizar nuestros cálculos.
...
counter += delta;
if (counter > 0.4) {
walkFrame = (walkFrame + 1) % 2;
counter = 0;
}
...
Sumando este valor delta a un contador iremos acumulando las diferencias. Cuando la misma llegue a un valor determinado, entonces será el momento de realizar el cambio del gráfico a mostrar, logrando una animación más agradable.
En Frameworks avanzados este valor delta suele ir encapsulado con otras propiedades, pero siempre podremos encontrarlo ya que es vital no solo para este tipo de cálculos, sino que resulta también de utilidad en cálculos de trayectorias, proyectiles, colisiones y otros.
Detectando colisiones
Detectar colisiones en un videojuego es una tarea común, no solo para poder saber si dos objetos del juego se tocan, como por ejemplo, una bala disparada por nuestro personaje impactando contra un enemigo, sino que además, la interacción con el puntero del ratón, o los dedos en una pantalla táctil para acceder a un determinado menú del juego también representa una colisión.
Existen muchos métodos para detectar que dos objetos se están tocando, cada uno de estos más eficientes que los otros en determinados casos. Podemos encontrar colisiones en forma de caja (La que usaremos para el ejemplo), por píxeles, verificando que solo píxeles de color de un objeto estén tocando los píxeles del otro objeto, mediante proyección de líneas (ray casting), y muchos otros métodos.
La colisión por cajas, entonces, es una de las más simples y puede resultar efectiva si nuestro juego no cuenta con gran cantidad de elementos para detectar si están en colisión o no, ya que si bien no es dependiente de este tipo de colisión, se suele hacer un escaneo por todos los elementos del juego y verificar, uno a uno, si nuestro objeto están en contacto con otro.
Para entender la mecánica de esta forma de detectar colisiones, pensemos en nuestro objeto de juego al cual le dibujamos una caja que lo contenga. Esta caja poseerá tanto las coordenadas X e Y donde se sitúa, además de las dimensiones de ancho y alto. Luego, teniendo el objeto contra el cual probar la colisión, dibujaremos otra caja con datos similares. Cuando ambas cajas entran en contacto, entonces entendemos que existe una colisión.
Este tipo de colisiones no promete detectar correctamente la misma si las formas son irregulares.
Como vemos en la imagen, si las formas (Gráficos) son irregulares, al crearse una caja para contenerlo, puede quedar mucho espacio sin uso, pero que será tomado como válido para la colisión. Para mejorar este comportamiento, una colisión por píxel sería más adecuada, ya que la misma detectará solo aquellos píxeles de color y no los transparentes en nuestro sprite, por supuesto, realizar el cálculo para dicha detección resulta algo más costosa en cálculos computacionales.
Entonces, para detectar mediante la colisión por cajas necesitamos saber las coordenadas de los dos objetos además de sus dimensiones. Una excelente solución es la que nos propone Matthew Casperson con su clase Rectangle en JavaScript.
function Rectangle()
{
this.left = 0;
this.top = 0;
this.width = 0;
this.height = 0;
this.startupRectangle = function(left, top, width, height)
{
this.left = left;
this.top = top;
this.width = width;
this.height = height;
return this;
}
this.intersects = function(other)
{
if (this.left + this.width < other.left)
return false;
if (this.top + this.height < other.top)
return false;
if (this.left > other.left + other.width)
return false;
if (this.top > other.top + other.height)
return false;
return true;
}
}
La sección más importante está dentro de la función intersects, la cual toma los dos rectángulos y compara los mismos para saber que una parte del primero se encuentre en alguna parte del segundo.
Para este ejemplo, dibujaremos un rectángulo en el trayecto de nuestro personaje. Cuando el mismo toque dicho rectángulo, este dejará de avanzar.
contextBuffer.save();
contextBuffer.fillStyle = "black";
contextBuffer.fillRect(300, 10, 30, 40);
contextBuffer.restore();
A continuación del dibujado de nuestro personaje, dibujamos un rectángulo de color negro. Podemos notar en el código que estamos utilizado dos funciones, save y restore. Estas funciones sirven para guardar el estado del lienzo de dibujo en ese momento, poder realizar cualquier modificación, agregar elementos, y luego retornar el lienzo al estado anterior. De esta forma, el cambio de color mediante fillStyle no afectará otras funciones que usen este color para rellenar formas.
El bloque negro se interpone en el camino de nuestro personaje.
El siguiente paso, para detectar la colisión será verificar el estado de los dos objetos tras cada actualización del juego.
var boxBlock = new Rectangle();
boxBlock.startupRectangle(300, 10, 30, 40);
var boxYoda = new Rectangle();
Debido a que sabemos de antemano la posición del rectángulo negro, no es necesario actualizarlo cada vez que el juego se modifica, por lo que escribiremos el código anterior fuera del bucle principal del juego. Finalmente, en el bucle, comprobaremos la posición actual del personaje y su intersección con el bloque.
boxYoda.startupRectangle(x, 10, 42, 39);
if (!boxYoda.intersects(boxBlock)) {
x++;
}
Solo si el personaje no está en colisión con el bloque, podrá avanzar un píxel.
Como decíamos, este tipo de comprobación suele ser costosa, pero eficiente para darnos una solución rápida para interpretar la colisión en nuestros juegos.
(*) En algunos ejemplos se han reemplazado los simbolos <, > por «, ».