Nesta aula, vamos colocar em prática os conceitos fundamentais de desenvolvimento de jogos em JavaScript, construindo um jogo simples mas completo. Você aprenderá a estrutura de um loop de jogo, o gerenciamento de estado, a captura de entradas do teclado e a renderização em um canvas HTML5. Este projeto servirá como base para que você possa criar seus próprios jogos e entender como os jogos modernos funcionam internamente.

O jogo que vamos construir é um "pegue o item": um personagem controlado pelo jogador se move em um canvas, e itens (estrelas) aparecem em posições aleatórias. O objetivo é coletar o máximo de itens possível em 60 segundos, com a dificuldade aumentando conforme a pontuação cresce. Ao final, apresentaremos boas práticas e exercícios para fixar o conteúdo.

Loop de jogo

O loop de jogo é o coração de qualquer jogo: ele roda continuamente, atualizando o estado do jogo e renderizando a nova imagem a cada quadro (frame). Em JavaScript, usamos a função requestAnimationFrame para criar um loop suave e sincronizado com a taxa de atualização do monitor (geralmente 60 FPS).

O loop básico consiste em três etapas: atualizar o estado (posições, colisões, pontuação), renderizar (desenhar no canvas) e agendar o próximo quadro. A função requestAnimationFrame recebe uma função de callback que será chamada antes do próximo repaint. Dentro dessa função, chamamos novamente requestAnimationFrame para criar um loop infinito.

function gameLoop(timestamp) {
    // Atualiza o estado do jogo
    update(timestamp);
    // Renderiza a cena
    render();
    // Agenda o próximo quadro
    requestAnimationFrame(gameLoop);
}

// Inicia o loop
requestAnimationFrame(gameLoop);

O parâmetro timestamp é o tempo em milissegundos desde o início da página. Podemos usar a diferença entre quadros (delta time) para garantir que o jogo rode na mesma velocidade em diferentes dispositivos, independentemente da taxa de atualização.

Estado

O estado do jogo é a representação de todos os dados que mudam durante o jogo: posição do jogador, posição dos itens, pontuação, tempo restante, etc. Manter o estado bem organizado é essencial para a manutenção e evolução do jogo. Usaremos um objeto gameState para centralizar essas informações.

Em nosso jogo, o estado inclui: o jogador (com posição x, y e tamanho), a lista de itens (cada um com posição e tamanho), a pontuação, o tempo restante e a dificuldade (que influencia a frequência de aparecimento dos itens). A inicialização do estado é feita em uma função initGame.

const gameState = {
    player: { x: canvas.width / 2, y: canvas.height / 2, size: 30 },
    items: [],
    score: 0,
    timeLeft: 60, // segundos
    difficulty: 1,
    gameOver: false
};

function initGame() {
    gameState.player = { x: canvas.width / 2, y: canvas.height / 2, size: 30 };
    gameState.items = [];
    gameState.score = 0;
    gameState.timeLeft = 60;
    gameState.difficulty = 1;
    gameState.gameOver = false;
    spawnItem();
}

Durante a atualização, modificamos o estado: movemos o jogador com base nas teclas pressionadas, decrementamos o tempo, verificamos colisões e geramos novos itens conforme a dificuldade. É importante que toda a lógica de mudança de estado esteja centralizada na função update.

Interação por teclado

Para capturar as teclas pressionadas, usamos eventos do DOM: keydown e keyup. Mantemos um objeto keys que armazena o estado de cada tecla (pressionada ou não). Quando uma tecla é pressionada, marcamos como true; quando solta, false. Isso permite que o movimento seja contínuo enquanto a tecla estiver pressionada.

const keys = {};

document.addEventListener('keydown', (e) => {
    keys[e.code] = true;
});

document.addEventListener('keyup', (e) => {
    keys[e.code] = false;
});

Na função update, verificamos as teclas de seta (ou WASD) e movemos o jogador de acordo. A velocidade deve ser multiplicada pelo delta time para que o movimento seja independente da taxa de quadros.

const speed = 200; // pixels por segundo

if (keys['ArrowLeft'] || keys['KeyA']) {
    gameState.player.x -= speed * deltaTime;
}
if (keys['ArrowRight'] || keys['KeyD']) {
    gameState.player.x += speed * deltaTime;
}
if (keys['ArrowUp'] || keys['KeyW']) {
    gameState.player.y -= speed * deltaTime;
}
if (keys['ArrowDown'] || keys['KeyS']) {
    gameState.player.y += speed * deltaTime;
}

Além disso, podemos usar o evento keydown para ações discretas, como reiniciar o jogo quando a tecla R for pressionada.

Renderização

A renderização é o processo de desenhar o estado atual do jogo no canvas. Usamos o contexto 2D (ctx) para desenhar formas, textos e imagens. No nosso caso, desenharemos o jogador como um círculo colorido, os itens como estrelas (ou círculos amarelos), a pontuação e o tempo restante no topo.

A função render primeiro limpa o canvas com clearRect, depois desenha todos os elementos. É importante desenhar em ordem: fundo, itens, jogador, HUD (informações).

function render() {
    // Limpa o canvas
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    // Desenha os itens
    gameState.items.forEach(item => {
        ctx.fillStyle = '#FFD700';
        ctx.beginPath();
        ctx.arc(item.x, item.y, item.size, 0, Math.PI * 2);
        ctx.fill();
    });

    // Desenha o jogador
    ctx.fillStyle = '#00F';
    ctx.beginPath();
    ctx.arc(gameState.player.x, gameState.player.y, gameState.player.size, 0, Math.PI * 2);
    ctx.fill();

    // Desenha o HUD
    ctx.fillStyle = '#FFF';
    ctx.font = '20px Arial';
    ctx.fillText('Pontos: ' + gameState.score, 10, 30);
    ctx.fillText('Tempo: ' + Math.ceil(gameState.timeLeft), canvas.width - 150, 30);
}

Para melhorar a aparência, podemos adicionar efeitos como sombras, gradientes ou imagens. Mas o essencial é que a renderização reflita fielmente o estado atual.

Conclusão e boas práticas

Neste projeto, você aprendeu a estruturar um jogo simples em JavaScript usando canvas. As boas práticas incluem: manter o estado centralizado, separar a lógica de atualização da renderização, usar delta time para consistência, e organizar o código em funções pequenas e reutilizáveis.

Além disso, é importante testar o jogo em diferentes navegadores e dispositivos, e considerar a acessibilidade (por exemplo, permitir pausa). Agora, vamos praticar com exercícios que reforçam os conceitos.

Referências

Exercícios

  1. Exercício 1: Implemente um sistema de vidas: o jogador começa com 3 vidas e perde uma ao tocar em um item perigoso (por exemplo, um item vermelho). O jogo termina quando as vidas chegarem a zero. Descreva as mudanças no estado e na lógica de colisão.

    ✓ Resposta: Adicione lives: 3 ao estado inicial. Na detecção de colisão, se o item for perigoso (por exemplo, item.dangerous), decremente lives e remova o item. Se lives chegar a 0, defina gameOver = true. Atualize o HUD para mostrar as vidas restantes.
  2. Exercício 2: Modifique o jogo para que o jogador possa atirar projéteis (por exemplo, bolas de energia) ao pressionar a barra de espaço. Crie uma lista de projéteis no estado e atualize a renderização e a colisão com itens. Descreva a implementação.

    ✓ Resposta: Adicione projectiles: [] ao estado. No evento keydown, se e.code === 'Space', crie um projétil na posição do jogador com uma velocidade vertical (por exemplo, vy: -300). Na função update, mova os projéteis e verifique colisão com itens: se a distância entre o projétil e o item for menor que a soma dos raios, remova ambos e incremente a pontuação. Renderize os projéteis como círculos.
  3. Exercício 3: Crie um sistema de pausa: quando o jogador pressionar a tecla P, o jogo pausa/despausa. Implemente a lógica no loop de jogo para não atualizar o estado quando pausado, mas continuar renderizando (para mostrar a tela congelada).

    ✓ Resposta: Adicione paused: false ao estado. No keydown, se e.code === 'KeyP', alterne paused. No loop de jogo, antes de chamar update, verifique se !gameState.paused. A renderização continua sempre. Exiba um texto "Pausado" na tela quando pausado.
  4. Exercício 4: Implemente um aumento de dificuldade: a cada 10 pontos, reduza o intervalo de geração de itens e aumente a velocidade dos itens (se houver movimento). Descreva como você faria isso no estado e na atualização.

    ✓ Resposta: Adicione spawnInterval: 2000 (ms) e itemSpeed: 0 ao estado. Na função update, quando score aumentar, verifique se score % 10 === 0 e, se sim, reduza spawnInterval por 100 (mínimo 500) e aumente itemSpeed em 20. Use um temporizador para gerar itens com base em spawnInterval. Se os itens se moverem, atualize suas posições com itemSpeed.
  5. Exercício 5: Adicione efeitos sonoros simples usando a Web Audio API: toque um som ao coletar um item e outro ao game over. Descreva os passos para integrar o áudio ao jogo.

    ✓ Resposta: Crie um contexto de áudio (new AudioContext()). Para cada efeito, crie um oscilador (OscillatorNode) e um ganho (GainNode). No momento da coleta, toque um tom curto (ex.: 880Hz por 0.1s). No game over, toque um tom grave (ex.: 220Hz por 0.5s). Lembre-se de conectar os nós e iniciar/parar o oscilador.