Estudo de caso - Onslaught! Arena

Introdução

Em junho de 2010, soubemos que a publicação local "zine" Boing Boing estava fazendo uma competição de desenvolvimento de jogos. Isso foi uma ótima desculpa para criar um jogo rápido e simples em JavaScript e <canvas>, então começamos a trabalhar. Depois da competição, ainda tínhamos muitas ideias e queríamos terminar o que começamos. Confira o estudo de caso do resultado, um pequeno jogo chamado Onslaught! Arena.

O visual retrô e pixelado

Era importante que nosso jogo parecesse um jogo retro de Nintendo Entertainment System, considerando a premissa do concurso de desenvolver um jogo baseado em um chiptune. A maioria dos jogos não tem esse requisito, mas ainda é um estilo artístico comum (especialmente entre desenvolvedores independentes) devido à facilidade de criação de recursos e ao apelo natural para jogadores nostálgicos.

Ataque! Tamanhos de pixel da Arena
O aumento do tamanho do pixel pode diminuir o trabalho de design gráfico.

Considerando o tamanho desses sprites, decidimos dobrar os pixels, ou seja, um sprite de 16x16 pixels agora seria de 32x32 pixels e assim por diante. Desde o início, estávamos duplicando a criação de recursos em vez de fazer o navegador fazer o trabalho pesado. Isso foi mais fácil de implementar, mas também tinha algumas vantagens definidas.

Confira um cenário que consideramos:

<style>
canvas {
  width: 640px;
  height: 320px;
}
</style>
<canvas width="320" height="240">
  Sorry, your browser is not supported.
</canvas>

Esse método consiste em sprites 1x1, em vez de duplicá-los na criação de recursos. A partir daí, o CSS assumiria o controle e redimensionaria a tela. Nossos comparativos de mercado revelaram que esse método pode ser cerca de duas vezes mais rápido que a renderização de imagens maiores (duplicadas), mas, infelizmente, o redimensionamento do CSS inclui suavização, algo que não conseguimos evitar.

Opções de redimensionamento da tela
À esquerda: recursos com pixels perfeitos duplicados no Photoshop. Certo: o redimensionamento do CSS adicionou um efeito desfocado.

Isso foi um problema para nosso jogo, já que os pixels individuais são muito importantes. No entanto, se você precisar redimensionar a tela e o anti-aliasing for adequado para seu projeto, considere essa abordagem por motivos de desempenho.

Truques divertidos na tela

Todos sabemos que o <canvas> é a nova sensação, mas às vezes os desenvolvedores ainda recomendam o uso do DOM. Se você não sabe qual usar, confira um exemplo de como o <canvas> nos ajudou a economizar tempo e energia.

Quando um inimigo é atingido em Onslaught! Arena, ele pisca em vermelho e mostra brevemente uma animação de "dor". Para limitar o número de gráficos que precisamos criar, mostramos apenas inimigos em "dor" na direção para baixo. Isso parece aceitável no jogo e economiza muito tempo na criação de sprites. No entanto, para os monstros chefes, era estranho ver um sprite grande (64 x 64 pixels ou mais) mudar de direção para a esquerda ou para cima e, de repente, ficar de cabeça para baixo no frame de dor.

Uma solução óbvia seria desenhar um frame de dor para cada chefe em cada uma das oito direções, mas isso teria sido muito demorado. Graças a <canvas>, conseguimos resolver esse problema no código:

O observador está sofrendo dano no Ataque. Arena
É possível criar efeitos interessantes usando context.globalCompositeOperation.

Primeiro, desenhamos o monstro em um <canvas> "buffer" oculto, o sobrepomos com vermelho e renderizamos o resultado de volta à tela. O código fica mais ou menos assim:

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

O loop de jogo

O desenvolvimento de jogos tem algumas diferenças notáveis em relação ao desenvolvimento da Web. Na pilha da Web, é comum reagir a eventos que aconteceram por listeners de eventos. Portanto, o código de inicialização pode não fazer nada além de ouvir eventos de entrada. A lógica de um jogo é diferente, porque ele precisa ser atualizado constantemente. Se, por exemplo, um jogador não se moveu, isso não impede que os goblins o ataquem.

Confira um exemplo de um loop de jogo:

function main () {
  handleInput();
  update();
  render();
};

setInterval(main, 1);

A primeira diferença importante é que a função handleInput não faz nada imediatamente. Se um usuário pressionar uma tecla em um app da Web típico, faz sentido realizar imediatamente a ação desejada. Mas, em um jogo, as coisas precisam acontecer em ordem cronológica para fluir corretamente.

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];
  }
};

Agora sabemos sobre a entrada e podemos considerá-la na função update, sabendo que ela vai aderir ao restante das regras do jogo.

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 fim, depois que tudo for computado, é hora de redesenhar a tela. No DOM, o navegador lida com esse trabalho pesado. No entanto, ao usar <canvas>, é necessário redesenhar manualmente sempre que algo acontece, o que normalmente é em todos os frames.

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

Modelagem baseada em tempo

A modelagem baseada em tempo é o conceito de mover sprites com base no tempo decorrido desde a última atualização do frame. Essa técnica permite que o jogo seja executado o mais rápido possível, garantindo que os sprites se movam a velocidades consistentes.

Para usar a modelagem baseada em tempo, precisamos capturar o tempo decorrido desde que o último frame foi renderizado. Vamos precisar aumentar a função update() do loop do jogo para acompanhar isso.

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

};

Agora que temos o tempo decorrido, podemos calcular a distância que um determinado sprite precisa mover em cada frame. Primeiro, precisamos acompanhar algumas coisas em um objeto de sprite: posição atual, velocidade e direção.

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;
};

Com essas variáveis em mente, veja como mover uma instância da classe de sprite acima usando a modelagem baseada em tempo:

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

Os valores direction.x e direction.y precisam ser normalizados, o que significa que eles precisam estar sempre entre -1 e 1.

Controles

Os controles foram possivelmente o maior obstáculo durante o desenvolvimento do Onslaught! Arena. A primeira demo só tinha suporte ao teclado. Os jogadores moviam o personagem principal pela tela com as teclas de seta e atiravam na direção em que ele estava com a barra de espaço. Embora seja um pouco intuitivo e fácil de entender, isso tornou o jogo quase impossível de jogar em níveis mais difíceis. Com dezenas de inimigos e projéteis voando em direção ao jogador a qualquer momento, é fundamental poder se esquivar dos bandidos enquanto atira em qualquer direção.

Para comparar com jogos semelhantes do mesmo gênero, adicionamos suporte a mouse para controlar uma mira, que o personagem usaria para mirar os ataques. O personagem ainda podia ser movido com o teclado, mas, depois dessa mudança, ele poderia acionar simultaneamente em qualquer direção de 360 graus. Os jogadores mais dedicados gostaram desse recurso, mas ele tinha o efeito colateral indesejado de frustrar os usuários do trackpad.

Ataque! Janela modal de controles da Arena (descontinuada)
Um modal de controles antigos ou "como jogar" no Onslaught. Arena.

Para acomodar os usuários do trackpad, trouxemos de volta os controles das teclas de seta, desta vez para permitir o disparo nas direções pressionadas. Embora sentíssemos que estávamos atendendo a todos os tipos de jogadores, também estávamos introduzindo muita complexidade ao jogo sem perceber. Para nossa surpresa, descobrimos mais tarde que alguns jogadores não sabiam dos controles opcionais de mouse (ou teclado!) para atacar, apesar dos modais de tutorial, que foram amplamente ignorados.

Ataque! Tutorial sobre os controles da Arena
Os jogadores geralmente ignoram a sobreposição do tutorial. Eles preferem jogar e se divertir.

Também temos a sorte de ter alguns fãs europeus, mas eles se queixam de não terem teclados QWERTY típicos e de não poderem usar as teclas WASD para movimentos direcionais. Jogadores canhotos fizeram reclamações semelhantes.

Com esse esquema de controle complexo que implementamos, também há o problema de jogar em dispositivos móveis. De fato, uma das nossas solicitações mais comuns é fazer Onslaught! Arena disponível em Android, iPad e outros dispositivos com tela touch (sem teclado). Uma das principais vantagens do HTML5 é a portabilidade. Portanto, é possível instalar o jogo nesses dispositivos, mas precisamos resolver muitos problemas, principalmente com os controles e o desempenho.

Para resolver esses problemas, começamos a jogar com um método de entrada única de jogo que envolve apenas a interação do mouse (ou toque). Os jogadores clicam ou tocam na tela, e o personagem principal caminha em direção ao local pressionado, atacando automaticamente o vilão mais próximo. O código fica mais ou menos assim:

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

Remover o fator extra de mirar nos inimigos pode facilitar o jogo em algumas situações, mas acreditamos que simplificar as coisas para o jogador tem muitas vantagens. Outras estratégias surgem, como posicionar o personagem perto de inimigos perigosos para mirar neles, e a capacidade de oferecer suporte a dispositivos com tela touch é muito valiosa.

Áudio

Entre os controles e o desempenho, um dos nossos maiores problemas durante o desenvolvimento de Onslaught! Arena era a tag <audio> do HTML5. Provavelmente, o pior aspecto é a latência: em quase todos os navegadores, há um atraso entre a chamada de .play() e a reprodução do som. Isso pode acabar com a experiência do jogador, especialmente quando ele está jogando um jogo rápido como o nosso.

Outros problemas incluem o evento "progress" não ser acionado, o que pode fazer com que o fluxo de carregamento do jogo seja interrompido indefinidamente. Por esses motivos, adotamos o que chamamos de método de "reposição", em que, se o Flash não carregar, vamos mudar para o áudio HTML5. O código fica mais ou menos assim:

/*
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;
  }
};

Também pode ser importante que um jogo ofereça suporte a navegadores que não reproduzem arquivos MP3 (como o Mozilla Firefox). Nesse caso, o suporte pode ser detectado e alterado para algo como Ogg Vorbis, com um código como este:

/*
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
    }
  }
}

Como salvar dados

Não dá para ter um jogo de tiro sem pontuação alta. Sabíamos que precisávamos que alguns dados do jogo fossem persistentes. Embora pudéssemos usar algo antigo, como cookies, queríamos conhecer as novas tecnologias divertidas do HTML5. Não há escassez de opções, incluindo armazenamento local, armazenamento de sessão e bancos de dados Web SQL.

ALT_TEXT_HERE
As pontuações mais altas são salvas, assim como sua posição no jogo depois de derrotar cada chefe.

Decidimos usar localStorage, porque ele é novo, incrível e fácil de usar. Ele oferece suporte ao salvamento de pares de chave-valor básicos, que é tudo o que nosso jogo simples precisa. Confira um exemplo simples de como usá-lo:

if (typeof localStorage == "object") {
  localStorage.setItem("foo", "bar");
  localStorage.getItem("foo"); // Value is "bar"
  localStorage.removeItem("foo");
  localStorage.getItem("foo"); // Value is now null
}

Há algumas armadilhas que você precisa conhecer. Não importa o que você transmita, os valores são armazenados como strings, o que pode levar a alguns 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"}

Resumo

O HTML5 é incrível. A maioria das implementações lida com tudo o que um desenvolvedor precisa, desde gráficos até salvar o estado do jogo. Embora haja alguns problemas de crescimento (como problemas com a tag <audio>), os desenvolvedores de navegadores estão avançando rapidamente e, com as coisas já sendo tão boas, o futuro parece promissor para jogos criados em HTML5.

Ataque! Arena com um logotipo HTML5 oculto
Você pode receber um escudo HTML5 digitando "html5" ao jogar Onslaught. Arena.