Introducción
En junio de 2010, descubrimos que la publicación local Boing Boing estaba organizando una competencia de desarrollo de juegos.
Vimos esto como una excusa perfecta para hacer un juego rápido y sencillo en JavaScript
y <canvas>
, así que nos pusimos a trabajar. Después de la competencia, aún
teníamos muchas ideas y queríamos terminar lo que comenzamos. Este es el caso de éxito del resultado, un pequeño juego llamado Onslaught! Arena.
El aspecto retro y pixelado
Era importante que nuestro juego se viera y se sintiera como un juego retro de Nintendo Entertainment System, dada la premisa del concurso de desarrollar un juego basado en un chiptune. La mayoría de los juegos no tienen este requisito, pero sigue siendo un estilo artístico común (especialmente entre los desarrolladores independientes) debido a su facilidad para crear recursos y su atractivo natural para los jugadores nostálgicos.
Debido a lo pequeños que son estos sprites, decidimos duplicar los píxeles, lo que significa que un sprite de 16 × 16 ahora sería de 32 × 32 píxeles, etcétera. Desde el principio, estábamos duplicamos el trabajo en el lado de la creación de recursos en lugar de hacer que el navegador hiciera el trabajo pesado. Esto fue más fácil de implementar, pero también tenía algunas ventajas de apariencia definidas.
Esta es una situación que consideramos:
<style>
canvas {
width: 640px;
height: 320px;
}
</style>
<canvas width="320" height="240">
Sorry, your browser is not supported.
</canvas>
Este método consistiría en sprites de 1 × 1 en lugar de duplicarlos en el lado de la creación de recursos. A partir de ahí, CSS se encargaría de cambiar el tamaño del lienzo. Nuestras comparativas revelaron que este método puede ser aproximadamente el doble de rápido que renderizar imágenes más grandes (duplicadas), pero, lamentablemente, el cambio de tamaño del CSS incluye el suavizado de contorno, algo que no pudimos evitar.
Esto fue un factor decisivo para nuestro juego, ya que los píxeles individuales son muy importantes, pero si necesitas cambiar el tamaño del lienzo y el suavizado de bordes es adecuado para tu proyecto, puedes considerar este enfoque por motivos de rendimiento.
Trucos divertidos con el lienzo
Todos sabemos que <canvas>
es la nueva tendencia, pero a veces los desarrolladores aún recomiendan usar el DOM. Si no sabes qué usar, aquí tienes un ejemplo de cómo <canvas>
nos ahorró mucho tiempo y energía.
Cuando un enemigo recibe un golpe en Onslaught! Arena, parpadea en rojo y muestra brevemente una animación de "dolor". Para limitar la cantidad de gráficos que tuvimos que crear, solo mostramos enemigos en “dolor” en la dirección hacia abajo. Se ve aceptable en el juego y ahorra mucho tiempo en la creación de sprites. Sin embargo, en el caso de los monstruos jefes, era discordante ver un sprite grande (de 64 x 64 píxeles o más) que cambiaba de orientación de izquierda o arriba a abajo de repente para el fotograma de dolor.
Una solución obvia sería dibujar un marco de dolor para cada jefe en cada una de las ocho direcciones, pero esto habría sido muy lento. Gracias a <canvas>
, pudimos resolver este problema en el código:
Primero, dibujamos el monstruo en un "búfer" oculto <canvas>
, lo superponemos con rojo y, luego, renderizamos el resultado en la pantalla. El código se ve así:
// Get the "buffer" canvas (that isn't visible to the user)
var bufferCanvas = document.getElementById("buffer");
var buffer = bufferCanvas.getContext("2d");
// Draw your image on the buffer
buffer.drawImage(image, 0, 0);
// Draw a rectangle over the image using a nice translucent overlay
buffer.save();
buffer.globalCompositeOperation = "source-in";
buffer.fillStyle = "rgba(186, 51, 35, 0.6)"; // red
buffer.fillRect(0, 0, image.width, image.height);
buffer.restore();
// Copy the buffer onto the visible canvas
document.getElementById("stage").getContext("2d").drawImage(bufferCanvas, x, y);
El bucle de juego
El desarrollo de juegos tiene algunas diferencias notables con el desarrollo web. En la pila web, es común reaccionar a los eventos que ocurrieron a través de objetos de escucha de eventos. Por lo tanto, el código de inicialización no puede hacer nada más que escuchar eventos de entrada. La lógica de un juego es diferente, ya que es necesario que se actualice constantemente. Por ejemplo, si un jugador no se movió, eso no debería impedir que los goblins lo ataquen.
Este es un ejemplo de un bucle de juego:
function main () {
handleInput();
update();
render();
};
setInterval(main, 1);
La primera diferencia importante es que la función handleInput
en realidad no hace nada de inmediato. Si un usuario presiona una tecla en una app web típica, tiene sentido realizar la acción deseada de inmediato. Sin embargo, en un juego, todo debe suceder en orden cronológico para que fluya correctamente.
window.addEventListener("mousedown", function(e) {
// A mouse click means the players wants to attack.
// We don't actually do that yet, but instead tell the rest
// of the program about the request.
buttonStates[e.button] = true;
}, false);
function handleInput() {
// Here is where we respond to the click
if (buttonStates[LEFT_BUTTON]) {
player.attacking = true;
delete buttonStates[LEFT_BUTTON];
}
};
Ahora conocemos la entrada y podemos considerarla en la función update
, sabiendo que se ajustará al resto de las reglas del juego.
function update() {
// Check for collisions, states, whatever else is needed
// If after that the player can still attack, do it!
if (player.attacking && player.canAttack()) {
player.attack();
}
};
Por último, una vez que se haya calculado todo, es hora de volver a dibujar la pantalla.
En el DOM, el navegador controla este levantamiento. Sin embargo, cuando se usa <canvas>
, es necesario volver a dibujar de forma manual cada vez que sucede algo (que suele ser cada fotograma).
function render() {
// First erase everything, something like:
context.clearRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
// Draw the player (and whatever else you need)
context.drawImage(
player.getImage(),
player.x, player.y
);
};
Modelado basado en el tiempo
El modelado basado en el tiempo es el concepto de mover sprites según la cantidad de tiempo transcurrido desde la última actualización de fotogramas. Esta técnica permite que el juego se ejecute lo más rápido posible y, al mismo tiempo, garantiza que los sprites se muevan a velocidades coherentes.
Para usar el modelado basado en el tiempo, debemos capturar el tiempo transcurrido desde que se dibujó el último fotograma. Tendremos que aumentar la función update()
de nuestro bucle de juego para hacer un seguimiento de esto.
function update() {
// NOTE: You'll need to initially seed this.lastUpdate
// with the current time when your game loop starts
// this.lastUpdate = Date.now();
// Calculate elapsed time since last frame
var now = Date.now();
var elapsed = (now - this.lastUpdate);
this.lastUpdate = now;
// Do stuff with elapsed
};
Ahora que tenemos el tiempo transcurrido, podemos calcular qué tan lejos debería moverse un sprite determinado en cada fotograma. Primero, necesitaremos hacer un seguimiento de algunos aspectos en un objeto de sprite: la posición, la velocidad y la dirección actuales.
var Sprite = function() {
// The sprite's position relative to the top left of the game world
this.position = {x: 0, y: 0};
// The sprite's direction. A positive x value indicates moving to the right
this.direction = {x: 1, y: 0};
// How many pixels the sprite moves per second
this.speed = 50;
};
Con estas variables en mente, esta es la forma en que moveríamos una instancia de la clase de sprite anterior con el modelado basado en el tiempo:
// Determine how far this sprite will move this frame
var distance = (sprite.speed / 1000) * elapsed;
// Apply the movement distance to the sprite's current position
// taking into account its direction
sprite.position.x += (distance * sprite.direction.x);
sprite.position.y += (distance * sprite.direction.y);
Ten en cuenta que los valores direction.x
y direction.y
deben estar normalizados, lo que significa que siempre deben estar entre -1
y 1
.
Controles
Los controles fueron posiblemente el mayor obstáculo durante el desarrollo de Onslaught! Arena. La primera demo solo admitía el teclado. Los jugadores movían al personaje principal por la pantalla con las teclas de flecha y disparaban en la dirección en la que estaba mirando con la barra espaciadora. Si bien es algo intuitivo y fácil de entender, esto hizo que el juego fuera casi imposible de jugar en los niveles más difíciles. Con docenas de enemigos y proyectiles volando hacia el jugador en cualquier momento, es fundamental poder moverse entre los malos mientras se dispara en cualquier dirección.
Para comparar con juegos similares en su género, agregamos compatibilidad con el mouse para controlar un retículo de puntería, que el personaje usaría para apuntar sus ataques. El personaje aún se podía mover con el teclado, pero después de este cambio, podía disparar simultáneamente en cualquier dirección de 360 grados. Los jugadores más exigentes apreciaron esta función, pero tuvo el desafortunado efecto secundario de frustrar a los usuarios del panel táctil.
Para los usuarios del panel táctil, recuperamos los controles de teclas de flecha, esta vez para permitir el disparo en las direcciones presionadas. Si bien creíamos que estábamos atendiendo a todos los tipos de jugadores, sin darnos cuenta, también estábamos agregando demasiada complejidad a nuestro juego. Para nuestra sorpresa, más tarde nos enteramos de que algunos jugadores no conocían los controles opcionales del mouse (o del teclado) para atacar, a pesar de los modales del instructivo, que se ignoraban en gran medida.
También tenemos la suerte de tener algunos fans europeos, pero nos comentaron que no tienen teclados QWERTY típicos y no pueden usar las teclas WASD para el movimiento direccional. Los jugadores zurdos expresaron quejas similares.
Con este complejo esquema de control que implementamos, también existe el problema de jugar en dispositivos móviles. De hecho, una de nuestras solicitudes más comunes es hacer Onslaught! Arena está disponible en Android, iPad y otros dispositivos táctiles (donde no hay teclado). Una de las fortalezas principales de HTML5 es su portabilidad, por lo que llevar el juego a estos dispositivos es factible, solo tenemos que resolver los muchos problemas (en particular, los controles y el rendimiento).
Para abordar estos muchos problemas, comenzamos a jugar con un método de entrada única del juego que solo involucra la interacción con el mouse (o la pantalla táctil). Los jugadores hacen clic o presionan la pantalla, y el personaje principal camina hacia la ubicación presionada y ataca automáticamente al villano más cercano. El código se ve así:
// Find the nearest hostile target (if any) to the player
var player = this.getPlayerObject();
var hostile = this.getNearestHostile(player);
if (hostile !== null) {
// Found one! Shoot in its direction
var shoot = hostile.boundingBox().center().subtract(
player.boundingBox().center()
).normalize();
}
// Move towards where the player clicked/touched
var move = this.targetReticle.position.clone().subtract(
player.boundingBox().center()
).normalize();
var distance = this.targetReticle.position.clone().subtract(
player.boundingBox().center()
).magnitude();
// Prevent jittering if the character is close enough
if (distance < 3) {
move.zero();
}
// Move the player
if ((move.x !== 0) || (move.y !== 0)) {
player.setDirection(move);
}
Quitar el factor adicional de tener que apuntar a los enemigos puede hacer que el juego sea más fácil en algunas situaciones, pero creemos que simplificar las cosas para el jugador tiene muchas ventajas. Surgen otras estrategias, como tener que posicionar al personaje cerca de enemigos peligrosos para atacarlos, y la capacidad de admitir dispositivos táctiles es invaluable.
Audio
Entre los controles y el rendimiento, uno de nuestros mayores problemas durante el desarrollo de Onslaught! Arena era la etiqueta <audio>
de HTML5.
Probablemente, el peor aspecto sea la latencia: en casi todos los navegadores, hay una demora entre la llamada a .play()
y la reproducción del sonido. Esto puede arruinar la experiencia de un jugador, en especial cuando se juega con un juego de ritmo rápido como el nuestro.
Otros problemas incluyen que no se active el evento "progress", lo que podría hacer que el flujo de carga del juego se cuelgue de forma indefinida. Por estos motivos, adoptamos lo que llamamos un método de “conmutación por error”, en el que, si no se carga Flash, cambiamos al audio HTML5. El código se ve así:
/*
This example uses the SoundManager 2 library by Scott Schiller:
http://www.schillmania.com/projects/soundmanager2/
*/
// Default to sm2 (Flash)
var api = "sm2";
function initAudio (callback) {
switch (api) {
case "sm2":
soundManager.onerror = (function (init) {
return function () {
api = "html5";
init(callback);
};
}(arguments.callee));
break;
case "html5":
var audio = document.createElement("audio");
if (
audio
&& audio.canPlayType
&& audio.canPlayType("audio/mpeg;")
) {
callback();
} else {
// No audio support :(
}
break;
}
};
También puede ser importante que un juego admita navegadores que no reproduzcan archivos MP3 (como Mozilla Firefox). Si este es el caso, se puede detectar la compatibilidad y cambiar a algo como Ogg Vorbis con un código como el siguiente:
/*
Note: you could instead use "new Audio()" here,
but the client will throw an error if it doesn't support Audio,
which makes using "document.createElement" a safer approach.
*/
var audio = document.createElement("audio");
if (audio && audio.canPlayType) {
if (!audio.canPlayType("audio/mpeg;")) {
// Here you know you CANNOT use .mp3 files
if (audio.canPlayType("audio/ogg; codecs=vorbis")) {
// Here you know you CAN use .ogg files
}
}
}
Cómo guardar datos
No puedes tener un juego de disparos de estilo arcade sin puntuaciones altas. Sabíamos que necesitaríamos que algunos de nuestros datos de juegos persistieran y, si bien podríamos haber usado algo antiguo, como las cookies, queríamos profundizar en las nuevas y divertidas tecnologías de HTML5. No hay escasez de opciones, como el almacenamiento local, el almacenamiento de sesiones y las bases de datos Web SQL.
Decidimos usar localStorage
porque es nuevo, genial y fácil de usar. Admite el guardado de pares clave-valor básicos, que es todo lo que necesita nuestro juego simple. Este es un ejemplo sencillo de cómo usarlo:
if (typeof localStorage == "object") {
localStorage.setItem("foo", "bar");
localStorage.getItem("foo"); // Value is "bar"
localStorage.removeItem("foo");
localStorage.getItem("foo"); // Value is now null
}
Hay algunos "problemas" que debes tener en cuenta. Independientemente de lo que pases, los valores se almacenan como cadenas, lo que puede generar algunos resultados inesperados:
localStorage.setItem("foo", false);
typeof localStorage.getItem("foo"); // Value is "false" (a string literal)
if (localStorage.getItem("foo")) {
// It's true!
}
// Don't pass objects into setItem
localStorage.setItem("bar", {"key": "value"});
localStorage.getItem("bar"); // Value is "[object Object]" (a string literal)
// JSON stringify and parse when dealing with localStorage
localStorage.setItem("json", JSON.stringify({"key": "value"}));
typeof localStorage.getItem("json"); // string
JSON.parse(localStorage.getItem("json")); // {"key": "value"}
Resumen
Es increíble trabajar con HTML5. La mayoría de las implementaciones controlan todo lo que un desarrollador de juegos necesita, desde los gráficos hasta el guardado del estado del juego. Si bien hay algunos problemas iniciales (como los problemas con las etiquetas <audio>
), los desarrolladores de navegadores se mueven con rapidez y, con lo bueno que ya es, el futuro se ve prometedor para los juegos creados en HTML5.