Estudo de caso - Onslaught! Arena

Introdução

Em junho de 2010, percebemos que a publicação local Boing Boing, da publicação local "zine", estava realizando uma competição de desenvolvimento de jogos. Vimos isso como uma boa 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. Aqui está o estudo de caso do resultado: um jogo chamado Onslaught! Arena.

O visual retrô e pixelado

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

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

Devido ao tamanho desses sprites, decidimos duplicar os pixels, o que significa que um sprite 16x16 passaria a ter 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 era simplesmente mais fácil de implementar, mas também tinha algumas vantagens de aparência definidas.

Aqui está 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 no lado da 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 duas vezes mais rápido do que renderizar imagens maiores (duplicadas), mas, infelizmente, o redimensionamento de CSS inclui anti-aliasing, algo que não conseguimos encontrar uma maneira de evitar.

Opções de redimensionamento de tela
Esquerda: os recursos perfeitos em pixels dobram no Photoshop. À direita: o redimensionamento de CSS adicionou um efeito desfocado.

Isso foi um ponto fraco do nosso jogo, já que 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 da tela

Todos sabemos que <canvas> é o novo recurso, mas às vezes os desenvolvedores ainda recomendam o uso do DOM. Se você estiver em dúvida sobre o que usar, confira um exemplo de como o <canvas> economizou muito tempo e energia.

Quando um inimigo é atingido em Onslaught! Arena, ele vai piscar em vermelho e vai mostrar brevemente uma animação de "dor". Para limitar o número de gráficos que tivemos que criar, mostramos apenas inimigos com "dor" na direção para baixo. Isso parece aceitável no jogo e economizou muito tempo na criação de sprites. Para os monstros-chefe, no entanto, foi chocante ver um sprite grande (com 64 x 64 pixels ou mais) saindo da frente para a esquerda ou para cima e de repente virado para baixo para o quadro problemático.

Uma solução óbvia seria desenhar uma estrutura problemática para cada chefe em cada uma das oito direções, mas isso levaria muito tempo. Graças a <canvas>, conseguimos resolver esse problema no código:

Lutador tomando danos em Onslaught! Arena
Efeitos interessantes podem ser feitos usando context.globalCompositeOperation.

Primeiro, desenhamos o monstro em um "buffer" <canvas> oculto, sobreposto a ele com vermelho e, em seguida, renderizamos o resultado de volta na tela. O código vai ficar 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);

Loop de jogo

O desenvolvimento de jogos tem algumas diferenças significativas em relação ao desenvolvimento para a Web. Na pilha da Web, é comum reagir a eventos que aconteceram por meio de listeners de eventos. Portanto, o código de inicialização não pode fazer nada além de detectar eventos de entrada. A lógica de um jogo é diferente, já que ela precisa se atualizar constantemente. Se, por exemplo, um jogador não tiver se movido, isso não vai impedir que os duendes o peguem.

Aqui está um exemplo de 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 pressiona 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 seguirá o 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 tiver sido calculado, é hora de redesenhar a tela. Na terra do DOM, o navegador lida com essa elevação. No entanto, ao usar <canvas>, é necessário redesenhar manualmente sempre que algo acontecer, o que geralmente acontece em cada frame.

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 em velocidades consistentes.

Para usar a modelagem baseada em tempo, precisamos capturar o tempo decorrido desde que o último frame foi desenhado. Precisamos aumentar a função update() do loop de jogo para rastrear 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 até onde um determinado sprite precisa mover cada frame. Primeiro, precisamos acompanhar alguns aspectos em um objeto 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 uma 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 sempre ficar entre -1 e 1.

Controles

Os controles foram possivelmente o maior obstáculo ao desenvolver o Onslaught! Arena. Na primeira demonstração, apenas o teclado era compatível. Os jogadores moviam o personagem principal pela tela com as teclas de seta e disparavam na direção que ele estava olhando, usando a barra de espaço. Embora isso fosse intuitivo e fácil de entender, isso tornava o jogo quase impossível em níveis mais difíceis. Com dezenas de inimigos e projéteis voando contra os jogadores a qualquer momento, é essencial entrar entre os criminosos enquanto atira em qualquer direção.

Para comparar com jogos semelhantes do mesmo gênero, adicionamos suporte para mouse para controlar um retículo de alvo, que o personagem usaria para direcionar os ataques. O personagem ainda poderia ser movido com o teclado, mas depois dessa mudança, ele poderia disparar simultaneamente em qualquer direção de 360 graus. Os jogadores mais assíduos adoraram esse recurso, mas ele teve o efeito colateral de frustrar os usuários do trackpad.

Ataque! Modal de controles de arena (descontinuado)
Um controle antigo ou modal "como jogar" no Onslaught! Arena.

Para acomodar usuários do trackpad, trouxemos os controles da tecla de seta de volta, desta vez para permitir o disparo na direção pressionada. Apesar de sentirmos que estávamos atendendo a todos os tipos de jogadores, também estávamos introduzindo involuntariamente muita complexidade no jogo. Para nossa surpresa, mais tarde soubemos que alguns jogadores não estavam cientes dos controles opcionais de mouse (ou teclado) para atacar, apesar dos modais de tutorial, que foram amplamente ignorados.

Ataque! Tutorial de controles de arena
A maioria dos jogadores ignora a sobreposição do tutorial. Eles preferem jogar e se divertir.

Também temos a sorte de ter alguns fãs europeus, mas eles nos disseram que podem não ter teclados QWERTY comuns e não poder usar as teclas WASD para movimentos direcional. Jogadores canhotos manifestaram reclamações semelhantes.

Com esse esquema de controle complexo que implementamos, há também o problema de jogar em dispositivos móveis. De fato, uma das nossas solicitações mais comuns é fazer Onslaught! Arena, disponível para Android, iPad e outros dispositivos de toque (em que não há teclado). Uma das principais vantagens do HTML5 é a portabilidade. Por isso, colocar o jogo nesses dispositivos é definitivamente viável. Só precisamos resolver os muitos problemas, principalmente os controles e o desempenho.

Para resolver esses muitos problemas, começamos a usar um método de entrada única que envolve apenas 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 vai ficar 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);
}

A remoção do fator extra de mirar nos inimigos pode tornar o jogo mais fácil em algumas situações, mas acreditamos que simplificar as coisas para o jogador tem muitas vantagens. Outras estratégias surgem, como ter que posicionar o personagem perto de inimigos perigosos para atacá-los, e a capacidade de oferecer suporte a dispositivos touchscreen é inestimável.

Áudio

Entre os controles e o desempenho, um dos nossos maiores problemas ao desenvolver o Onslaught! Arena era a tag <audio> do HTML5. Provavelmente, o pior aspecto é a latência: em quase todos os navegadores, há um atraso entre chamar .play() e o som sendo realmente reproduzido. Isso pode arruinar a experiência do jogador, especialmente quando estiver jogando com um jogo de ritmo acelerado 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 trave indefinidamente. Por esses motivos, adotamos o que chamamos de método "fall-forward", em que, se o Flash não for carregado, vamos alternar para o áudio HTML5. O código vai ficar 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 seja compatível com navegadores que não reproduzem arquivos MP3 (como o Mozilla Firefox). Se esse for o caso, o suporte poderá 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
    }
  }
}

Economia de dados

Dá para ter um bullet'em up ' em estilo arcade sem pontuações altas! Sabíamos que precisávamos manter alguns dados de jogos para persistir e, embora pudéssemos ter usado algo antigo, como cookies, queríamos nos aprofundar nas novas e divertidas tecnologias HTML5. Não faltam 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 seu lugar no jogo depois de derrotar cada chefe.

Decidimos usar o localStorage por ser novo, incrível e fácil de usar. Ele permite salvar pares de chave-valor básicos, que é tudo do nosso jogo simples. Aqui está 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. Independentemente do que você transmite, 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

É incrível trabalhar com HTML5. A maioria das implementações processa tudo o que um desenvolvedor de jogos precisa, desde gráficos até salvar o estado do jogo. Embora existam algumas problemas de crescimento, como problemas com as tags <audio>, os desenvolvedores de navegadores estão avançando rapidamente e com coisas que já são ótimas, mas o futuro parece brilhante para jogos criados em HTML5.

Ataque! Arena com logotipo HTML5 oculto
Você pode fazer o download de um escudo HTML5 digitando "html5" ao jogar Onslaught! Arena.