Skip to content

Manipulação de Entrada em Jogos

Uma boa manipulação de entrada pode determinar o sucesso ou o fracasso de um jogo. Controles com atraso, entradas não detectadas ou mapeamentos de botões confusos frustram os jogadores antes mesmo que eles vejam o conteúdo do seu jogo. Este tutorial mostra como lidar com teclado, mouse, toque e controle de forma unificada.

Experimente primeiro:

O Padrão de Estado de Entrada

A abordagem ingênua é reagir diretamente aos eventos: quando o usuário pressiona uma tecla, faça algo. Isso causa problemas. Os eventos são disparados em momentos imprevisíveis, enquanto o loop do jogo é executado em uma frequência fixa. Se você mover o jogador no manipulador de keydown, a velocidade do movimento dependerá da taxa de repetição das teclas, que varia conforme o sistema operacional.

Em vez disso, mantenha um objeto de estado de entrada que seja lido pelo loop do jogo:

js
const input = {
  left: false,
  right: false,
  up: false,
  down: false,
  action: false,
  pointer: { x: 0, y: 0, down: false },
}

Os manipuladores de eventos definem esses indicadores. O loop do jogo os lê. Esse desacoplamento simplifica tudo: sua lógica de atualização não precisa saber se a entrada veio do teclado, do toque ou do controle.

Teclado

Use e.code em vez de e.key, pois ele se refere à posição física da tecla. Assim, o WASD funciona corretamente em layouts diferentes do QWERTY.

js
const keyMap = {
  ArrowLeft: 'left',
  ArrowRight: 'right',
  ArrowUp: 'up',
  ArrowDown: 'down',
  KeyA: 'left',
  KeyD: 'right',
  KeyW: 'up',
  KeyS: 'down',
  Space: 'action',
}

window.addEventListener('keydown', (e) => {
  const action = keyMap[e.code]
  if (action) {
    input[action] = true
    e.preventDefault()
  }
})

window.addEventListener('keyup', (e) => {
  const action = keyMap[e.code]
  if (action) input[action] = false
})

O e.preventDefault() impede que as teclas de seta rolem a página. Chame-o apenas para as teclas que você está tratando.

Mouse

As coordenadas do mouse são fornecidas no espaço da tela. Você precisa convertê-las para o espaço do canvas:

js
const canvas = document.getElementById('game')

canvas.addEventListener('mousemove', (e) => {
  const rect = canvas.getBoundingClientRect()
  input.pointer.x = e.clientX - rect.left
  input.pointer.y = e.clientY - rect.top
})

canvas.addEventListener('mousedown', () => {
  input.pointer.down = true
  input.action = true
})

canvas.addEventListener('mouseup', () => {
  input.pointer.down = false
  input.action = false
})

Se o seu canvas estiver redimensionado (consulte o tutorial sobre canvas responsivo), você precisará dividir pelo fator de escala para obter as coordenadas do jogo.

Toque

O toque funciona de forma semelhante ao mouse, mas você precisa usar e.preventDefault() para impedir que o navegador role a página ou aplique zoom:

js
canvas.addEventListener('touchstart', (e) => {
  e.preventDefault()
  const touch = e.touches[0]
  const rect = canvas.getBoundingClientRect()
  input.pointer.x = touch.clientX - rect.left
  input.pointer.y = touch.clientY - rect.top
  input.pointer.down = true
  input.action = true
}, { passive: false })

canvas.addEventListener('touchmove', (e) => {
  e.preventDefault()
  const touch = e.touches[0]
  const rect = canvas.getBoundingClientRect()
  input.pointer.x = touch.clientX - rect.left
  input.pointer.y = touch.clientY - rect.top
}, { passive: false })

canvas.addEventListener('touchend', () => {
  input.pointer.down = false
  input.action = false
})

O { passive: false } é necessário porque, por padrão, os navegadores modernos usam listeners passivos para eventos de toque por motivos de desempenho. Sem isso, preventDefault não funcionará.

Direcional Virtual para Dispositivos Móveis

Para jogos que precisam de controles direcionais em dispositivos móveis, adicione botões à tela:

js
function createVirtualDpad(container) {
  const dpad = document.createElement('div')
  dpad.className = 'virtual-dpad'
  dpad.innerHTML = `
    <button data-dir="up">↑</button>
    <button data-dir="left">←</button>
    <button data-dir="right">→</button>
    <button data-dir="down">↓</button>
  `
  
  dpad.querySelectorAll('button').forEach(btn => {
    const dir = btn.dataset.dir
    btn.addEventListener('touchstart', (e) => {
      e.preventDefault()
      input[dir] = true
    })
    btn.addEventListener('touchend', () => input[dir] = false)
  })
  
  container.appendChild(dpad)
}

Estilize esses botões para que sejam grandes o suficiente para os polegares (pelo menos 44x44 pixels) e posicione-os nos cantos inferiores.

Controle

Ao contrário do teclado e do mouse, o estado do controle não é orientado por eventos. Você precisa consultá-lo a cada frame:

js
function pollGamepads() {
  const gamepads = navigator.getGamepads()
  const gp = gamepads[0]
  
  if (gp) {
    // Direcional ou analógico esquerdo
    input.left = gp.buttons[14]?.pressed || gp.axes[0] < -0.5
    input.right = gp.buttons[15]?.pressed || gp.axes[0] > 0.5
    input.up = gp.buttons[12]?.pressed || gp.axes[1] < -0.5
    input.down = gp.buttons[13]?.pressed || gp.axes[1] > 0.5
    
    // Botão A (mapeamento padrão)
    input.action = gp.buttons[0]?.pressed
  }
}

Chame pollGamepads() no início da sua função de atualização. O limite de 0,5 nos analógicos cria uma zona morta para que pequenos movimentos não sejam registrados como entrada.

Você pode detectar quando controles são conectados e desconectados:

js
window.addEventListener('gamepadconnected', (e) => {
  console.log('Controle conectado:', e.gamepad.id)
})

window.addEventListener('gamepaddisconnected', (e) => {
  console.log('Controle desconectado:', e.gamepad.id)
})

Usando a Entrada no Loop do Jogo

Agora, sua função de atualização apenas lê o estado de entrada:

js
function update(dt) {
  pollGamepads()
  
  if (input.left) player.x -= player.speed * dt
  if (input.right) player.x += player.speed * dt
  if (input.up) player.y -= player.speed * dt
  if (input.down) player.y += player.speed * dt
  
  if (input.action && player.canShoot) {
    player.shoot()
  }
}

Isso funciona independentemente de a entrada ter vindo do teclado, do toque ou do controle. A lógica do jogo não precisa saber.

Recém-Pressionado vs. Mantido Pressionado

Às vezes, você quer detectar quando um botão acabou de ser pressionado neste frame, não se ele está sendo mantido pressionado. Pular é um bom exemplo: você quer um salto por pressionamento, não saltos contínuos enquanto o botão estiver pressionado.

js
const inputPrev = { ...input }

function wasJustPressed(action) {
  return input[action] && !inputPrev[action]
}

function endFrame() {
  Object.assign(inputPrev, input)
}

Chame endFrame() no final do seu loop de atualização. Depois, use wasJustPressed('action') em vez de input.action para ações que devem acontecer uma única vez.

Perda de Foco

Quando a janela do navegador perde o foco, os eventos do teclado deixam de ser disparados. Se o jogador estava segurando uma tecla, ela fica presa no estado ativo:

js
window.addEventListener('blur', () => {
  input.left = input.right = input.up = input.down = input.action = false
})

Sem isso, o jogador continua se movendo quando o usuário alterna para outra janela com Alt+Tab.

Boas Práticas

Ofereça suporte tanto a WASD quanto às teclas de seta, pois os jogadores têm preferências diferentes. Permita remapear os controles e armazene a configuração no localStorage. Disponibilize controles por toque em dispositivos móveis. Teste com controles reais, pois os controles de Xbox, PlayStation e modelos genéricos se comportam de maneiras ligeiramente diferentes. E sempre trate a perda de foco adequadamente.

Mais Recursos

Análise detalhada da Gamepad API aborda recursos avançados de controles, como vibração e suporte a vários jogadores.

Jogos web adaptados para dispositivos móveis aborda a entrada por toque em mais detalhes.

Pointer Lock para jogos de FPS mostra como capturar o mouse para controles em primeira pessoa.

Loop de jogo com Canvas 2D mostra o padrão de estado de entrada integrado a um loop de jogo completo.

Noções básicas de multijogador com WebSocket aborda a sincronização de entradas entre jogadores conectados pela rede.

Recursos Externos