Skip to content

Gestión de entradas del juego

Una buena gestión de las entradas puede determinar el éxito o el fracaso de un juego. Los controles con retraso, las entradas que no se detectan o las asignaciones de botones confusas frustran a los jugadores antes incluso de que vean el contenido del juego. Este tutorial muestra cómo gestionar el teclado, el ratón, la pantalla táctil y el mando de forma unificada.

Pruébalo primero:

El patrón de estado de entrada

El enfoque ingenuo consiste en reaccionar directamente a los eventos: cuando el usuario pulsa una tecla, se realiza una acción. Esto causa problemas. Los eventos se disparan en momentos impredecibles, mientras que el bucle del juego se ejecuta a una frecuencia fija. Si mueves al jugador desde el controlador de keydown, la velocidad de movimiento dependerá de la frecuencia de repetición de teclas, que varía según el sistema operativo.

En su lugar, mantén un objeto de estado de entrada que pueda leer el bucle del juego:

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

Los controladores de eventos establecen estos indicadores. El bucle del juego los lee. Este desacoplamiento lo simplifica todo: tu lógica de actualización no necesita saber si la entrada procede del teclado, de la pantalla táctil o del mando.

Teclado

Usa e.code en lugar de e.key, ya que hace referencia a la posición física de la tecla. WASD funciona correctamente en distribuciones de teclado que no sean 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
})

e.preventDefault() evita que las teclas de flecha desplacen la página. Llámalo únicamente para las teclas que estés gestionando.

Ratón

Las coordenadas del ratón se expresan en el espacio de la pantalla. Debes convertirlas al espacio del lienzo:

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
})

Si el lienzo está escalado (consulta el tutorial sobre lienzos adaptables), tendrás que dividir entre el factor de escala para obtener las coordenadas del juego.

Pantalla táctil

La entrada táctil funciona de forma similar al ratón, pero necesitas e.preventDefault() para impedir que el navegador desplace o amplíe la página:

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
})

Se requiere { passive: false } porque, por motivos de rendimiento, los navegadores modernos usan de forma predeterminada escuchadores pasivos para los eventos táctiles. Sin esta opción, preventDefault no funcionará.

Cruceta virtual para dispositivos móviles

Para los juegos que necesiten controles direccionales en dispositivos móviles, añade botones en pantalla:

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)
}

Aplica estilos a estos botones para que sean lo bastante grandes como para pulsarlos con los pulgares (al menos 44 × 44 píxeles) y colócalos en las esquinas inferiores.

Mando

A diferencia del teclado y el ratón, el estado del mando no se controla mediante eventos. Debes consultarlo en cada fotograma:

js
function pollGamepads() {
  const gamepads = navigator.getGamepads()
  const gp = gamepads[0]
  
  if (gp) {
    // D-pad or left stick
    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
    
    // A button (standard mapping)
    input.action = gp.buttons[0]?.pressed
  }
}

Llama a pollGamepads() al principio de la función de actualización. El umbral de 0,5 en los sticks analógicos crea una zona muerta para que los pequeños movimientos del stick no se registren como entradas.

Puedes detectar cuándo se conectan y desconectan los mandos:

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

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

Uso de las entradas en el bucle del juego

Ahora la función de actualización solo tiene que leer el 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()
  }
}

Esto funciona con independencia de que la entrada proceda del teclado, de la pantalla táctil o del mando. La lógica del juego no necesita saberlo.

Recién pulsado frente a mantenido

A veces necesitas detectar si un botón acaba de pulsarse en este fotograma, no si se mantiene pulsado en ese momento. Saltar es un buen ejemplo: quieres un salto por cada pulsación, no saltos continuos mientras se mantenga pulsado el botón.

js
const inputPrev = { ...input }

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

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

Llama a endFrame() al final del bucle de actualización. Después, usa wasJustPressed('action') en lugar de input.action para las acciones que solo deben ejecutarse una vez por pulsación.

Pérdida de foco

Cuando la ventana del navegador pierde el foco, dejan de dispararse los eventos del teclado. Si el jugador estaba manteniendo pulsada una tecla, esta se queda atascada:

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

Sin esto, el jugador seguirá moviéndose cuando el usuario cambie de ventana con Alt+Tab.

Prácticas recomendadas

Admite tanto WASD como las teclas de flecha, ya que cada jugador tiene sus preferencias. Permite reasignar los controles y guarda la configuración en localStorage. Proporciona controles táctiles en dispositivos móviles. Realiza pruebas con mandos reales, ya que los mandos de Xbox, PlayStation y los genéricos se comportan de manera ligeramente diferente. Y gestiona siempre la pérdida de foco correctamente.

Más recursos

Análisis detallado de la API de mandos explica funciones avanzadas de los mandos, como la vibración y la compatibilidad con varios jugadores.

Juegos web adaptados a dispositivos móviles explica la entrada táctil con más detalle.

Bloqueo del puntero para juegos FPS muestra cómo capturar el ratón para los controles en primera persona.

Bucle de juego con Canvas 2D muestra el patrón de estado de entrada integrado en un bucle de juego completo.

Conceptos básicos del multijugador con WebSocket explica cómo sincronizar las entradas entre jugadores conectados en red.

Recursos externos