Caso de éxito: Onslaught! Estadio

Introducción

En junio de 2010, notamos que la publicación local Boing Boing estaba llevando a cabo una competencia sobre desarrollo de juegos. Consideramos que esta era una excusa perfectamente buena para crear un juego rápido y simple en JavaScript y <canvas>, así que nos pusimos a trabajar. Después de la competencia, teníamos muchas ideas y queríamos terminar lo que empezamos. Este es el caso de éxito del resultado, un pequeño juego llamado Onslaught! Arena.

El estilo retro y pixelado

Era importante que nuestro juego se viera como un juego retro de Nintendo Entertainment System, dada la premisa del concurso para 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 de creación de elementos y su atractivo natural para los gamers nostálgicos.

¡Ataque! Tamaños de píxeles de arena
Aumentar el tamaño de los píxeles puede disminuir el trabajo de diseño gráfico.

Dada lo pequeños que son estos objetos, decidimos duplicar nuestros píxeles, lo que significa que un objeto de 16 × 16 ahora sería de 32 × 32 píxeles, y así sucesivamente. Desde el principio, duplicamos la creación de elementos en lugar de hacer que el navegador se encargue del trabajo pesado. Simplemente fue más fácil de implementar, pero también tenía algunas ventajas de apariencia definidas.

Consideramos el siguiente escenario:

<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 objetos de 1 x 1 en lugar de duplicarlos en la creación de elementos. A partir de ahí, CSS se encarga de cambiar el tamaño del lienzo. Nuestras comparativas revelaron que este método puede ser casi dos veces más rápido que la renderización de imágenes más grandes (duplicadas). Sin embargo, el cambio de tamaño de CSS incluye la suavizado de contorno, algo que no pudimos encontrar una forma de evitarlo.

Opciones de cambio de tamaño del lienzo
Izquierda: Los recursos de píxeles perfectos se duplicaron en Photoshop. Derecha: El cambio de tamaño de CSS agregó un efecto borroso.

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 contorno es apropiado para tu proyecto, podrías considerar este enfoque por razones de rendimiento.

Trucos divertidos de lienzo

Todos sabemos que <canvas> es la nueva funcionalidad, pero, a veces, los desarrolladores aún recomiendan usar DOM. Si no sabes qué usar, aquí tienes un ejemplo de cómo <canvas> nos ahorró mucho tiempo y energía.

Cuando un enemigo es golpeado en Onslaught! Arena, parpadeará en rojo y mostrará brevemente una animación de “doloz”. Para limitar la cantidad de gráficos que tuvimos que crear, solo mostramos a los enemigos con "dolora" en la dirección hacia abajo. Esto parece aceptable en el juego y te ahorró mucho tiempo de creación de objetos. Sin embargo, para los monstruos jefes, era impactante ver cómo un objeto grande (de 64 x 64 píxeles o más) se ajustaba desde la orientación hacia la izquierda o hacia arriba hasta hacia abajo de repente para el punto de dolor.

Una solución obvia sería establecer un punto débil para cada jefe en cada una de las ocho direcciones, pero esto hubiera llevado mucho tiempo. Gracias a <canvas>, pudimos resolver este problema en el código:

¡Observador que está sufriendo daños en Onslaught! Estadio
Los efectos interesantes se pueden crear con context.globalcomposeOperation.

Primero, dibujamos el monstruo a un "búfer" <canvas> oculto, lo superponemos con rojo y, luego, presentamos el resultado de nuevo en la pantalla. El código debería ser similar al siguiente:

// 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);

Bucle de juego

El desarrollo de juegos tiene algunas diferencias notables con respecto al desarrollo web. En la pila web, es común reaccionar a los eventos que ocurrieron a través de los objetos de escucha de eventos. Por lo tanto, es posible que el código de inicialización no haga más que escuchar eventos de entrada. La lógica de un juego es diferente, ya que es necesario actualizarse constantemente. Si, por ejemplo, un jugador no se movió, eso no debería impedir que los duendes lo consigan.

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 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, las cosas tienen que suceder en orden cronológico para que fluyan 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, con la certeza de que cumplirá con el 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 todo esté calculado, ¡es hora de volver a dibujar la pantalla! En DOM-land, el navegador controla esta elevación pesada. Sin embargo, cuando se usa <canvas>, es necesario volver a dibujar manualmente cada vez que ocurre algo (que suele ser cada uno de los fotogramas).

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 objetos en función de 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 objetos se muevan a velocidades constantes.

Para usar el modelado basado en el tiempo, debemos capturar el tiempo transcurrido desde que se dibujó el último fotograma. Debemos aumentar la función update() del bucle de juego para realizar 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 debe moverse un objeto determinado en cada fotograma. Primero, tendremos que hacer un seguimiento de algunos aspectos de un objeto de objeto: la posición actual, la velocidad y la dirección.

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, a continuación, se muestra cómo moveríamos una instancia de la clase de objeto 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 han sido posiblemente el mayor obstáculo durante el desarrollo de Onslaught! Arena. La primera demostración 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. Aunque es un poco intuitivo y fácil de entender, esto hizo que el juego casi no se pudiera jugar en niveles más difíciles. Con decenas de enemigos y proyectiles volando hacia el jugador en cualquier momento, es fundamental poder relacionarse entre los malos mientras se dispara en cualquier dirección.

A fin de hacer una comparación con juegos similares de su género, agregamos compatibilidad con un mouse para controlar un retículo, que el personaje usaría para atacar. El carácter aún podía moverse con el teclado, pero después de este cambio podría activar simultáneamente en cualquier dirección de 360 grados. Los jugadores expertos valoraron esta función, pero tuvo el desafortunado efecto secundario de frustrar a los usuarios del panel táctil.

¡Ataque! modal de controles de arena (obsoleto)
Un viejo control o una ventana modal de "cómo jugar" en Onslaught. Arena.

Para adaptarse a los usuarios del panel táctil, actualizamos los controles de las teclas de flecha hacia atrás, esta vez para permitir que se activen en las direcciones que se presionan. Si bien sentíamos que íbamos a satisfacer a todo tipo de jugadores, también introdujimos demasiada complejidad en nuestro juego, sin saberlo. Para nuestra sorpresa, más tarde nos enteramos de que algunos jugadores no sabían sobre los controles opcionales del mouse (o teclado) para atacar, a pesar de las modales de instructivo, que se ignoraban en gran medida.

¡Ataque! Instructivo de controles de arena
La mayoría de los jugadores ignoran la superposición del instructivo, por lo que prefieren jugar y divertirse.

También tenemos la fortuna de tener algunos fans europeos, pero nos han frustrado que ellos no tengan teclados QWERTY típicos y que no puedan usar las teclas WASD para los movimientos direccionales. Los jugadores zurdos expresaron reclamos 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 disponible en Android, iPad y otros dispositivos táctiles (en los que no hay teclado). Una de las principales fortalezas de HTML5 es su portabilidad, por lo que la incorporación del juego a estos dispositivos es factible; solo tenemos que resolver muchos problemas (en particular, los controles y el rendimiento).

Para abordar estos problemas, comenzamos a jugar con un método de juego de una sola entrada que solo involucra la interacción con el mouse (o táctil). Los jugadores hacen clic en la pantalla o la tocan, y el personaje principal camina hacia la ubicación presionada y ataca automáticamente al tipo malo más cercano. El código debería ser similar al siguiente:

// 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 facilitar el juego en algunas situaciones, pero creemos que simplificar las cosas para el jugador tiene muchas ventajas. Surgen otras estrategias, como colocar 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 los gamers, especialmente cuando se juega con un juego acelerado como el nuestro.

Otros problemas incluyen el evento "progress" no se activa, que podría hacer que el flujo de carga del juego se bloquee de forma indefinida. Por estas razones, adoptamos lo que llamamos un método "fall-forward", donde, si Flash no se carga, cambia al audio HTML5. El código debería ser similar al siguiente:

/*
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 sea compatible con navegadores que no reproducen 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 es posible jugar al estilo arcade de los juegos de disparos sin puntuaciones altas. Sabíamos que necesitaríamos conservar algunos de los datos de nuestros juegos y, si bien podríamos haber usado una solución antigua como las galletas, queríamos profundizar en las nuevas y divertidas tecnologías de HTML5. Ciertamente, hay muchas opciones, como almacenamiento local, almacenamiento de sesión y bases de datos web de SQL.

ALT_TEXT_HERE
Se guardan las puntuaciones altas y tu lugar en el juego después de derrotar a cada jefe.

Decidimos usar localStorage porque es nuevo, increíble y fácil de usar. Admite el guardado de pares clave-valor básicos, que es todo lo que necesitamos. 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 algunas trampas que hay que tener en cuenta. Sin importar 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 manejan todo lo que necesita un desarrollador de juegos, desde gráficos hasta cómo guardar el estado del juego. Si bien hay algunos problemas crecientes (como problemas con la etiqueta <audio>), los desarrolladores de navegadores avanzan rápidamente y con las cosas ya tan buenas como son, el futuro se ve brillante para los juegos creados en HTML5.

¡Ataque! Estadio con logotipo HTML5 oculto
Puedes obtener un escudo HTML5 si escribes "html5" cuando juegues Onslaught. Arena.