Crie um Jogo da Cobrinha em JavaScript: Tutorial Completo!

Neste artigo, exploraremos a criação de um jogo da Cobrinha, utilizando HTML, CSS e JavaScript.

Não recorreremos a bibliotecas externas; o jogo será executado diretamente no navegador. O desenvolvimento deste jogo é um exercício estimulante que aprimora suas habilidades de resolução de problemas.

Visão Geral do Projeto

O jogo da Cobrinha é um jogo simples no qual você controla os movimentos de uma cobra, guiando-a em direção à comida, enquanto evita obstáculos. Ao alcançar a comida, a cobra a consome e cresce. À medida que o jogo progride, a cobra torna-se cada vez mais longa.

A cobra não deve colidir com as paredes ou com o seu próprio corpo. Conforme o jogo avança, a cobra aumenta de tamanho e torna-se cada vez mais difícil de controlar.

O objetivo deste tutorial é criar um jogo da Cobrinha semelhante ao seguinte:

O código completo do jogo está disponível no meu GitHub. Uma versão em funcionamento está hospedada em GitHub Pages.

Requisitos

Para este projeto, utilizaremos HTML, CSS e JavaScript. O foco principal será no JavaScript. É essencial ter um conhecimento prévio de JavaScript para acompanhar este tutorial. Caso contrário, recomendo que você explore artigos sobre os melhores recursos para aprender JavaScript.

Você também precisará de um editor de código para escrever seu código e um navegador para visualizar o jogo.

Configurando o Projeto

Para começar, vamos preparar os arquivos do projeto. Em uma pasta vazia, crie um arquivo chamado `index.html` e insira o seguinte código:

<!DOCTYPE html>
<html lang="pt-br">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="stylesheet" href="styles.css" />
    <title>Snake</title>
  </head>
  <body>
    <div id="game-over-screen">
      <h2>Fim de Jogo</h2>
    </div>
    <canvas id="canvas" width="420" height="420"> </canvas>
    <script src="snake.js"></script>
  </body>
</html>

Este código cria uma tela básica de “Fim de Jogo”. A visibilidade desta tela será controlada por JavaScript. Ele também define um elemento canvas onde o jogo será desenhado. O código também inclui a folha de estilo e o arquivo JavaScript.

Em seguida, crie um arquivo `styles.css` para os estilos. Insira o seguinte código:

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
    font-family: 'Courier New', Courier, monospace;
}

body {
    height: 100vh;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    background-color: #00FFFF;
}

#game-over-screen {
    background-color: #FF00FF;
    width: 500px;
    height: 200px;
    border: 5px solid black;
    position: absolute;
    align-items: center;
    justify-content: center;
    display: none;
}

No conjunto de regras ‘*’, definimos margens e preenchimentos como zero, para todos os elementos. Também definimos a família de fontes para todos os elementos e configuramos o modelo de caixa como `border-box`, para um dimensionamento mais previsível. Para o `body`, definimos a altura para a altura total da janela de visualização e centralizamos todos os itens. Definimos também a cor de fundo como azul.

Por fim, estilizamos a tela “Fim de Jogo” para ter uma altura de 200 pixels e largura de 500 pixels. Também adicionamos uma cor de fundo magenta e uma borda preta. Definimos sua posição como absoluta para que não seja afetada pelo fluxo normal do documento, e alinhada no centro da tela. Em seguida, centralizamos o seu conteúdo e definimos sua exibição como `none`, para que fique oculta por padrão.

Em seguida, crie um arquivo `snake.js`, que escreveremos nas próximas seções.

Criando Variáveis Globais

O próximo passo neste tutorial é definir algumas variáveis globais que usaremos. No arquivo `snake.js`, adicione as seguintes declarações de variáveis no topo:

// Criando referências para elementos HTML
let gameOverScreen = document.getElementById("game-over-screen");
let canvas = document.getElementById("canvas");

// Criando contexto para desenhar no canvas
let ctx = canvas.getContext("2d");

Essas variáveis armazenam referências ao elemento de tela “Fim de Jogo” e ao elemento canvas. Em seguida, criamos um contexto que será usado para desenhar no canvas.

Agora, adicione as seguintes variáveis abaixo do primeiro conjunto:

// Definições do labirinto
let gridSize = 400;
let unitLength = 10;

A primeira variável define o tamanho da grade em pixels. A segunda define o comprimento de uma unidade do jogo. Esse comprimento unitário será usado em várias partes do jogo, como na espessura das paredes do labirinto, na espessura da cobra, na altura e largura da comida e nos incrementos nos quais a cobra se move.

Em seguida, adicione as seguintes variáveis de jogo. Essas variáveis são usadas para acompanhar o estado do jogo.

// Variáveis de jogo
let snake = [];
let foodPosition = { x: 0, y: 0 };
let direction = "right";
let collided = false;

A variável `snake` rastreia as posições atualmente ocupadas pela cobra. A cobra é composta por unidades, e cada unidade ocupa uma posição no canvas. A posição de cada unidade é armazenada no array `snake`, que contém as coordenadas x e y. O primeiro elemento do array representa a cauda, enquanto o último representa a cabeça.

À medida que a cobra se move, adicionamos elementos ao final do array, movendo a cabeça para frente. Também removemos o primeiro elemento do array, para que o comprimento da cobra permaneça o mesmo.

A variável `foodPosition` armazena a localização atual da comida usando as coordenadas x e y. A variável `direction` armazena a direção em que a cobra está se movendo, enquanto a variável `collided` é uma variável booleana que se torna verdadeira quando uma colisão é detectada.

Declarando Funções

Todo o jogo é dividido em funções, facilitando a escrita e o gerenciamento do código. Nesta seção, declararemos essas funções e seus propósitos. As seções seguintes definirão as funções e discutirão seus algoritmos.

function setUp() {}
function doesSnakeOccupyPosition(x, y) {}
function checkForCollision() {}
function generateFood() {}
function move() {}
function turn(newDirection) {}
function onKeyDown(e) {}
function gameLoop() {}

Em resumo, a função `setUp` configura o jogo. A função `checkForCollision` verifica se a cobra colidiu com uma parede ou consigo mesma. A função `doesSnakeOccupyPosition` recebe uma posição (definida pelas coordenadas x e y) e verifica se alguma parte do corpo da cobra está nessa posição, o que será útil para encontrar uma posição livre para colocar a comida.

A função `move` move a cobra na direção indicada, enquanto a função `turn` altera essa direção. A função `onKeyDown` escutará as teclas pressionadas para mudar a direção da cobra. E por fim, a função `gameLoop` moverá a cobra e verificará se houve colisões.

Definindo as Funções

Nesta seção, definiremos as funções que declaramos anteriormente e discutiremos como cada uma funciona. Haverá uma breve descrição da função antes do código e comentários para explicar cada linha quando necessário.

Função `setUp`

A função `setUp` fará três coisas:

  • Desenhar as bordas do labirinto no canvas.
  • Configurar a cobra, adicionando suas posições à variável `snake` e desenhando-a no canvas.
  • Gerar a posição inicial da comida.
  • Portanto, o código para isso será:

      // Desenhar as bordas no canvas
      // O canvas terá o tamanho da grade mais a espessura das duas bordas laterais
      canvasSideLength = gridSize + unitLength * 2;
    
      // Desenhar um quadrado preto que cobre todo o canvas
      ctx.fillRect(0, 0, canvasSideLength, canvasSideLength);
    
      // Apagar o centro do quadrado preto para criar o espaço de jogo
      // Isso deixa um contorno preto que representa a borda
      ctx.clearRect(unitLength, unitLength, gridSize, gridSize);
    
      // Agora, vamos armazenar as posições iniciais da cabeça e da cauda da cobra
      // O comprimento inicial da cobra será de 60px ou 6 unidades
    
      // A cabeça da cobra estará 30px ou 3 unidades à frente do ponto médio
      const headPosition = Math.floor(gridSize / 2) + 30;
    
      // A cauda da cobra estará 30px ou 3 unidades atrás do ponto médio
      const tailPosition = Math.floor(gridSize / 2) - 30;
    
      // Percorrer da cauda até a cabeça em incrementos de unitLength
      for (let i = tailPosition; i <= headPosition; i += unitLength) {
    
        // Armazenar a posição do corpo da cobra e desenhar no canvas
        snake.push({ x: i, y: Math.floor(gridSize / 2) });
    
        // Desenhar um retângulo nessa posição de unitLength * unitLength
        ctx.fillRect(i, Math.floor(gridSize / 2), unitLength, unitLength);
      }
    
      // Gerar a comida
      generateFood();

    Função `doesSnakeOccupyPosition`

    Esta função recebe as coordenadas x e y como uma posição e verifica se tal posição existe no corpo da cobra. Ela usa o método `find` de array JavaScript para procurar uma posição com as coordenadas correspondentes.

    function doesSnakeOccupyPosition(x, y) {
      return !!snake.find((position) => {
        return position.x == x && position.y == y;
      });
    }

    Função `checkForCollision`

    Esta função verifica se a cobra colidiu com alguma coisa e define a variável `collided` como verdadeira. Começaremos verificando as colisões com as paredes esquerda e direita, as paredes superior e inferior e, em seguida, com a própria cobra.

    Para verificar colisões com as paredes esquerda e direita, verificamos se a coordenada x da cabeça da cobra é maior que `gridSize` ou menor que 0. Para verificar colisões com as paredes superior e inferior, realizaremos a mesma verificação, mas com as coordenadas y.

    Em seguida, verificaremos colisões com a própria cobra. Verificaremos se alguma outra parte do seu corpo ocupa a posição atual da cabeça. Combinando tudo isso, o corpo da função `checkForCollision` fica assim:

     function checkForCollision() {
      const headPosition = snake.slice(-1)[0];
      // Verificar colisões com as paredes esquerda e direita
      if (headPosition.x < 0 || headPosition.x >= gridSize) {
        collided = true;
      }
    
      // Verificar colisões com as paredes superior e inferior
      if (headPosition.y < 0 || headPosition.y >= gridSize) {
        collided = true;
      }
    
      // Verificar colisões com o próprio corpo da cobra
      const body = snake.slice(0, -1);
      if (
        body.find(
          (position) => position.x == headPosition.x && position.y == headPosition.y
        )
      ) {
        collided = true;
      }
    }

    Função `generateFood`

    A função `generateFood` usa um loop `do-while` para encontrar uma posição para colocar a comida que não esteja ocupada pela cobra. Assim que uma posição livre é encontrada, a posição da comida é registrada e desenhada no canvas. O código para a função `generateFood` fica assim:

    function generateFood() {
      let x = 0,
        y = 0;
      do {
        x = Math.floor((Math.random() * gridSize) / 10) * 10;
        y = Math.floor((Math.random() * gridSize) / 10) * 10;
      } while (doesSnakeOccupyPosition(x, y));
    
      foodPosition = { x, y };
      ctx.fillRect(x, y, unitLength, unitLength);
    }

    Função `move`

    A função `move` começa criando uma cópia da posição da cabeça da cobra. Em seguida, com base na direção atual, aumenta ou diminui o valor da coordenada x ou y da cobra. Por exemplo, aumentar a coordenada x equivale a mover para a direita.

    Feito isso, adicionamos a nova `headPosition` ao array `snake` e também desenhamos a nova `headPosition` no canvas.

    Em seguida, verificamos se a cobra comeu comida naquele movimento, comparando se a `headPosition` é igual à `foodPosition`. Se a cobra comeu comida, chamamos a função `generateFood`.

    Se a cobra não comeu comida, excluímos o primeiro elemento do array `snake`. Este elemento representa a cauda, e removê-lo manterá o mesmo comprimento da cobra, dando a ilusão de movimento.

    function move() {
      // Criar uma cópia do objeto que representa a posição da cabeça
      const headPosition = Object.assign({}, snake.slice(-1)[0]);
    
      switch (direction) {
        case "left":
          headPosition.x -= unitLength;
          break;
        case "right":
          headPosition.x += unitLength;
          break;
        case "up":
          headPosition.y -= unitLength;
          break;
        case "down":
          headPosition.y += unitLength;
      }
    
      // Adicionar a nova headPosition ao array
      snake.push(headPosition);
    
      ctx.fillRect(headPosition.x, headPosition.y, unitLength, unitLength);
    
      // Verificar se a cobra está comendo
      const isEating =
        foodPosition.x == headPosition.x && foodPosition.y == headPosition.y;
    
      if (isEating) {
        // Gerar uma nova posição para a comida
        generateFood();
      } else {
        // Remover a cauda se a cobra não estiver comendo
        tailPosition = snake.shift();
    
        // Remover a cauda do canvas
        ctx.clearRect(tailPosition.x, tailPosition.y, unitLength, unitLength);
      }
    }

    Função `turn`

    A última função importante que abordaremos é a função `turn`. Esta função recebe uma nova direção e altera a variável `direction` para essa nova direção. No entanto, a cobra só pode girar em uma direção perpendicular à direção atual.

    Assim, a cobra só pode virar para a esquerda ou para a direita se estiver se movendo para cima ou para baixo. Por outro lado, ela só pode subir ou descer se estiver se movendo para a esquerda ou para a direita. Com essas restrições em mente, a função `turn` fica assim:

    function turn(newDirection) {
      switch (newDirection) {
        case "left":
        case "right":
          // Só permitir virar para a esquerda ou direita se a cobra estava se movendo para cima ou para baixo
          if (direction == "up" || direction == "down") {
            direction = newDirection;
          }
          break;
        case "up":
        case "down":
          // Só permitir virar para cima ou para baixo se a cobra estava se movendo para a esquerda ou para a direita
          if (direction == "left" || direction == "right") {
            direction = newDirection;
          }
          break;
      }
    }

    Função `onKeyDown`

    A função `onKeyDown` é um manipulador de eventos que chamará a função `turn` com a direção correspondente à tecla de seta pressionada. A função, portanto, fica assim:

    function onKeyDown(e) {
      switch (e.key) {
        case "ArrowDown":
          turn("down");
          break;
        case "ArrowUp":
          turn("up");
          break;
        case "ArrowLeft":
          turn("left");
          break;
        case "ArrowRight":
          turn("right");
          break;
      }
    }

    Função `gameLoop`

    A função `gameLoop` será chamada regularmente para manter o jogo em execução. Esta função chamará as funções `move` e `checkForCollision`. Também verifica se a colisão é verdadeira, e caso isso aconteça, interrompe o temporizador que usamos para executar o jogo e exibe a tela “Fim de Jogo”. A função ficará assim:

    function gameLoop() {
      move();
      checkForCollision();
    
      if (collided) {
        clearInterval(timer);
        gameOverScreen.style.display = "flex";
      }
    }

    Iniciando o Jogo

    Para iniciar o jogo, adicione as seguintes linhas de código:

    setUp();
    document.addEventListener("keydown", onKeyDown);
    let timer = setInterval(gameLoop, 200);

    Primeiro, chamamos a função `setUp`. Em seguida, adicionamos o ouvinte de evento `keydown`. Por último, usamos a função `setInterval` para iniciar o temporizador.

    Conclusão

    Neste ponto, seu arquivo JavaScript deve ser semelhante ao que está no meu GitHub. Caso algo não funcione, revise seu código com base no repositório. Em seguida, você pode querer aprender como criar um controle deslizante de imagem usando JavaScript.