Não há guia de lágrimas para jogos HTML5

Daniel X. Moore
Daniel X. Moore

Introdução

Você quer criar um jogo usando Canvas e HTML5? Siga este tutorial e você vai conseguir fazer isso em pouco tempo.

O tutorial pressupõe pelo menos um nível intermediário de conhecimento de JavaScript.

Primeiro, jogue o jogo ou acesse diretamente o artigo e confira o código-fonte do jogo.

Como criar a tela

Para desenhar, precisamos criar uma tela. Como este é um guia sem lágrimas, vamos usar o jQuery.

var CANVAS_WIDTH = 480;
var CANVAS_HEIGHT = 320;

var canvasElement = $("<canvas width='" + CANVAS_WIDTH + 
                      "' height='" + CANVAS_HEIGHT + "'></canvas>");
var canvas = canvasElement.get(0).getContext("2d");
canvasElement.appendTo('body');

Loop de jogo

Para simular a aparência de uma jogabilidade suave e contínua, queremos atualizar o jogo e redesenhar a tela mais rápido do que a mente humana e o olho podem perceber.

var FPS = 30;
setInterval(function() {
  update();
  draw();
}, 1000/FPS);

Por enquanto, podemos deixar os métodos de atualização e renderização em branco. O importante é saber que o setInterval() cuida de fazer a chamada periodicamente.

function update() { ... }
function draw() { ... }

Hello World

Agora que temos um loop de jogo, vamos atualizar nosso método de exibição para exibir algum texto na tela.

function draw() {
  canvas.fillStyle = "#000"; // Set color to black
  canvas.fillText("Sup Bro!", 50, 50);
}

Isso é muito legal para texto estático, mas, como já temos um loop de jogo configurado, podemos fazer com que ele se mova com bastante facilidade.

var textX = 50;
var textY = 50;

function update() {
  textX += 1;
  textY += 1;
}

function draw() {
  canvas.fillStyle = "#000";
  canvas.fillText("Sup Bro!", textX, textY);
}

Agora, faça isso. Se você estiver seguindo, ele vai se mover, mas também vai deixar as vezes anteriores em que foi desenhado na tela. Tente entender por que isso acontece. Isso ocorre porque não estamos limpando a tela. Vamos adicionar um código de limpeza de tela ao método de exibição.

function draw() {
  canvas.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
  canvas.fillStyle = "#000";
  canvas.fillText("Sup Bro!", textX, textY);
}

Agora que você tem um texto se movendo na tela, você está na metade do caminho para ter um jogo real. Basta apertar os controles, melhorar a jogabilidade e melhorar os gráficos. Ok, talvez 1/7 do caminho para ter um jogo real, mas a boa notícia é que há muito mais no tutorial.

Como criar o player

Crie um objeto para armazenar os dados do jogador e ser responsável por coisas como desenho. Aqui, criamos um objeto de jogador usando um literal de objeto simples para armazenar todas as informações.

var player = {
  color: "#00A",
  x: 220,
  y: 270,
  width: 32,
  height: 32,
  draw: function() {
    canvas.fillStyle = this.color;
    canvas.fillRect(this.x, this.y, this.width, this.height);
  }
};

Por enquanto, estamos usando um retângulo colorido simples para representar o jogador. Quando desenhamos o jogo, limpamos a tela e desenhamos o jogador.

function draw() {
  canvas.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
  player.draw();
}

Controles do teclado

Como usar atalhos do jQuery

O plug-in jQuery Hotkeys facilita muito o processamento de teclas em vários navegadores. Em vez de se preocupar com problemas keyCode e charCode não decifrados entre navegadores, podemos vincular eventos assim:

$(document).bind("keydown", "left", function() { ... });

Não precisar se preocupar com os detalhes de quais chaves têm quais códigos é uma grande vantagem. Só queremos poder dizer coisas como "quando o jogador pressionar o botão para cima, faça algo". O jQuery Hotkeys permite isso.

Movimento do jogador

A maneira como o JavaScript processa eventos de teclado é totalmente orientada a eventos. Isso significa que não há uma consulta integrada para verificar se uma chave está inativa. Portanto, teremos que usar a nossa.

Você pode estar se perguntando: "Por que não usar uma maneira orientada a eventos de processamento de chaves?" Isso acontece porque a taxa de repetição do teclado varia de acordo com o sistema e não está vinculada ao tempo do loop do jogo. Por isso, a jogabilidade pode variar muito de um sistema para outro. Para criar uma experiência consistente, é importante integrar a detecção de eventos do teclado ao loop de jogo.

A boa notícia é que incluí um wrapper JS de 16 linhas que vai disponibilizar a consulta de eventos. Ele se chama key_status.js, e você pode consultar o status de uma chave a qualquer momento verificando keydown.left etc.

Agora que podemos consultar se as teclas estão pressionadas, podemos usar esse método de atualização simples para mover o jogador.

function update() {
  if (keydown.left) {
    player.x -= 2;
  }

  if (keydown.right) {
    player.x += 2;
  }
}

Faça o teste.

O player pode ser movido para fora da tela. Vamos limitar a posição do jogador para mantê-lo dentro dos limites. Além disso, o player parece lento, então vamos aumentar a velocidade também.

function update() {
  if (keydown.left) {
    player.x -= 5;
  }

  if (keydown.right) {
    player.x += 5;
  }

  player.x = player.x.clamp(0, CANVAS_WIDTH - player.width);
}

Adicionar mais entradas será tão fácil quanto isso. Vamos adicionar alguns tipos de projéteis.

function update() {
  if (keydown.space) {
    player.shoot();
  }

  if (keydown.left) {
    player.x -= 5;
  }

  if (keydown.right) {
    player.x += 5;
  }

  player.x = player.x.clamp(0, CANVAS_WIDTH - player.width);
}

player.shoot = function() {
  console.log("Pew pew");
  // :) Well at least adding the key binding was easy...
};

Adicionar mais objetos de jogo

Projéteis

Agora vamos adicionar os projéteis de verdade. Primeiro, precisamos de uma coleção para armazenar todos eles:

var playerBullets = [];

Em seguida, precisamos de um construtor para criar instâncias de marcadores.

function Bullet(I) {
  I.active = true;

  I.xVelocity = 0;
  I.yVelocity = -I.speed;
  I.width = 3;
  I.height = 3;
  I.color = "#000";

  I.inBounds = function() {
    return I.x >= 0 && I.x <= CANVAS_WIDTH &&
      I.y >= 0 && I.y <= CANVAS_HEIGHT;
  };

  I.draw = function() {
    canvas.fillStyle = this.color;
    canvas.fillRect(this.x, this.y, this.width, this.height);
  };

  I.update = function() {
    I.x += I.xVelocity;
    I.y += I.yVelocity;

    I.active = I.active && I.inBounds();
  };

  return I;
}

Quando o jogador atira, precisamos criar uma instância de bala e adicioná-la à coleção de balas.

player.shoot = function() {
  var bulletPosition = this.midpoint();

  playerBullets.push(Bullet({
    speed: 5,
    x: bulletPosition.x,
    y: bulletPosition.y
  }));
};

player.midpoint = function() {
  return {
    x: this.x + this.width/2,
    y: this.y + this.height/2
  };
};

Agora precisamos adicionar a atualização dos marcadores à função de etapa de atualização. Para evitar que a coleção de marcadores seja preenchida indefinidamente, filtramos a lista de marcadores para incluir apenas os marcadores ativos. Isso também nos permite remover balas que colidiram com um inimigo.

function update() {
  ...
  playerBullets.forEach(function(bullet) {
    bullet.update();
  });

  playerBullets = playerBullets.filter(function(bullet) {
    return bullet.active;
  });
}

A etapa final é desenhar os marcadores:

function draw() {
  ...
  playerBullets.forEach(function(bullet) {
    bullet.draw();
  });
}

Inimigos

Agora é hora de adicionar inimigos da mesma forma que adicionamos as balas.

  enemies = [];

function Enemy(I) {
  I = I || {};

  I.active = true;
  I.age = Math.floor(Math.random() * 128);

  I.color = "#A2B";

  I.x = CANVAS_WIDTH / 4 + Math.random() * CANVAS_WIDTH / 2;
  I.y = 0;
  I.xVelocity = 0
  I.yVelocity = 2;

  I.width = 32;
  I.height = 32;

  I.inBounds = function() {
    return I.x >= 0 && I.x <= CANVAS_WIDTH &&
      I.y >= 0 && I.y <= CANVAS_HEIGHT;
  };

  I.draw = function() {
    canvas.fillStyle = this.color;
    canvas.fillRect(this.x, this.y, this.width, this.height);
  };

  I.update = function() {
    I.x += I.xVelocity;
    I.y += I.yVelocity;

    I.xVelocity = 3 * Math.sin(I.age * Math.PI / 64);

    I.age++;

    I.active = I.active && I.inBounds();
  };

  return I;
};

function update() {
  ...

  enemies.forEach(function(enemy) {
    enemy.update();
  });

  enemies = enemies.filter(function(enemy) {
    return enemy.active;
  });

  if(Math.random() < 0.1) {
    enemies.push(Enemy());
  }
};

function draw() {
  ...

  enemies.forEach(function(enemy) {
    enemy.draw();
  });
}

Como carregar e exibir imagens

É legal ver todas essas caixas voando por aí, mas ter imagens delas seria ainda mais legal. Carregar e desenhar imagens em tela geralmente é uma experiência de lágrimas. Para evitar esse sofrimento, podemos usar uma classe utilitária simples.

player.sprite = Sprite("player");

player.draw = function() {
  this.sprite.draw(canvas, this.x, this.y);
};

function Enemy(I) {
  ...

  I.sprite = Sprite("enemy");

  I.draw = function() {
    this.sprite.draw(canvas, this.x, this.y);
  };

  ...
}

Detecção de colisão

Temos todos esses itens voando pela tela, mas eles não estão interagindo entre si. Para que tudo saiba quando explodir, precisamos adicionar algum tipo de detecção de colisão.

Vamos usar um algoritmo simples de detecção de colisão retangular:

function collides(a, b) {
  return a.x < b.x + b.width &&
         a.x + a.width > b.x &&
         a.y < b.y + b.height &&
         a.y + a.height > b.y;
}

Há algumas colisões que queremos verificar:

  1. Marcadores do jogador => Navios inimigos
  2. Player => Enemy Ships

Vamos criar um método para processar as colisões que podemos chamar do método de atualização.

function handleCollisions() {
  playerBullets.forEach(function(bullet) {
    enemies.forEach(function(enemy) {
      if (collides(bullet, enemy)) {
        enemy.explode();
        bullet.active = false;
      }
    });
  });

  enemies.forEach(function(enemy) {
    if (collides(enemy, player)) {
      enemy.explode();
      player.explode();
    }
  });
}

function update() {
  ...
  handleCollisions();
}

Agora precisamos adicionar os métodos de explosão ao jogador e aos inimigos. Isso vai sinalizar a remoção e adicionar uma explosão.

function Enemy(I) {
  ...

  I.explode = function() {
    this.active = false;
    // Extra Credit: Add an explosion graphic
  };

  return I;
};

player.explode = function() {
  this.active = false;
  // Extra Credit: Add an explosion graphic and then end the game
};

Som

Para completar a experiência, vamos adicionar alguns efeitos sonoros. Sons, como imagens, podem ser um pouco difíceis de usar no HTML5, mas, graças à nossa fórmula mágica sound.js, é possível simplificar muito o som.

player.shoot = function() {
  Sound.play("shoot");
  ...
}

function Enemy(I) {
  ...

  I.explode = function() {
    Sound.play("explode");
    ...
  }
}

Embora a API agora esteja sem falhas, adicionar sons é atualmente a maneira mais rápida de fazer o aplicativo falhar. Não é incomum que os sons cortem ou fechem a guia do navegador inteira. Então, prepare os lenços de papel.

Farewell

Confira a demonstração completa do jogo. Você também pode fazer o download do código-fonte como um arquivo zip.

Espero que você tenha gostado de aprender os conceitos básicos para criar um jogo simples em JavaScript e HTML5. Ao programar no nível certo de abstração, podemos nos isolar das partes mais difíceis das APIs e ser resiliente em face de mudanças futuras.

Referências