Skip to content

Juegos web compatibles con móviles

Los dispositivos móviles son la mayor plataforma de juegos. Con unos pocos ajustes, puedes hacer que tu juego web funcione de maravilla en teléfonos y tabletas.

1) Configuración del viewport

Evita el zoom y garantiza un escalado correcto:

html
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">

2) Canvas a pantalla completa

css
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

html, body {
  width: 100%;
  height: 100%;
  overflow: hidden;
  background: #000;
  touch-action: none; /* Prevent browser gestures */
}

#game {
  display: block;
  width: 100%;
  height: 100%;
}

Si dimensionas la interfaz según la altura del viewport en lugar de usar height: 100%, es preferible utilizar las unidades dinámicas de viewport dvh, svh y lvh en vez de vh. Estas tienen en cuenta la barra de herramientas retráctil y expandible del navegador móvil, algo que 100vh ignora (en iOS, 100vh corresponde a la altura con la barra de herramientas oculta, por lo que la parte inferior del diseño queda recortada). Estas unidades alcanzaron el estado Baseline Widely Available y son compatibles con Chrome/Edge 108+, Firefox 101+ y Safari/iOS Safari 15.4+, aproximadamente el 92 % de los usuarios.

3) Gestión de eventos táctiles

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

// Prevent default touch behaviors
canvas.addEventListener('touchstart', (e) => e.preventDefault(), { passive: false })
canvas.addEventListener('touchmove', (e) => e.preventDefault(), { passive: false })

// Track touches
const touches = new Map()

canvas.addEventListener('touchstart', (e) => {
  for (const touch of e.changedTouches) {
    touches.set(touch.identifier, {
      x: touch.clientX,
      y: touch.clientY,
      startX: touch.clientX,
      startY: touch.clientY,
    })
  }
})

canvas.addEventListener('touchmove', (e) => {
  for (const touch of e.changedTouches) {
    const t = touches.get(touch.identifier)
    if (t) {
      t.x = touch.clientX
      t.y = touch.clientY
    }
  }
})

canvas.addEventListener('touchend', (e) => {
  for (const touch of e.changedTouches) {
    touches.delete(touch.identifier)
  }
})

Si tu juego también debe funcionar con un ratón o un lápiz óptico, considera usar Pointer Events (pointerdown/pointermove/pointerup) en lugar de eventos táctiles. MDN los recomienda ahora como un modelo de entrada único e independiente del dispositivo: una sola ruta de código gestiona el tacto, el ratón y el lápiz, mientras que la entrada multitáctil sigue funcionando mediante pointerId (el equivalente a touch.identifier utilizado arriba). Los eventos táctiles siguen siendo perfectamente adecuados para juegos exclusivamente táctiles.

4) Joystick virtual

js
class VirtualJoystick {
  constructor(x, y, radius) {
    this.baseX = x
    this.baseY = y
    this.radius = radius
    this.knobRadius = radius * 0.4
    this.active = false
    this.touchId = null
    this.dx = 0
    this.dy = 0
  }
  
  handleTouchStart(touch) {
    const dist = Math.hypot(touch.clientX - this.baseX, touch.clientY - this.baseY)
    if (dist < this.radius) {
      this.active = true
      this.touchId = touch.identifier
      this.updatePosition(touch.clientX, touch.clientY)
    }
  }
  
  handleTouchMove(touch) {
    if (touch.identifier === this.touchId) {
      this.updatePosition(touch.clientX, touch.clientY)
    }
  }
  
  handleTouchEnd(touch) {
    if (touch.identifier === this.touchId) {
      this.active = false
      this.touchId = null
      this.dx = 0
      this.dy = 0
    }
  }
  
  updatePosition(x, y) {
    let dx = x - this.baseX
    let dy = y - this.baseY
    const dist = Math.hypot(dx, dy)
    
    if (dist > this.radius) {
      dx = (dx / dist) * this.radius
      dy = (dy / dist) * this.radius
    }
    
    this.dx = dx / this.radius
    this.dy = dy / this.radius
  }
  
  draw(ctx) {
    // Base circle
    ctx.beginPath()
    ctx.arc(this.baseX, this.baseY, this.radius, 0, Math.PI * 2)
    ctx.fillStyle = 'rgba(255, 255, 255, 0.2)'
    ctx.fill()
    
    // Knob
    const knobX = this.baseX + this.dx * this.radius
    const knobY = this.baseY + this.dy * this.radius
    ctx.beginPath()
    ctx.arc(knobX, knobY, this.knobRadius, 0, Math.PI * 2)
    ctx.fillStyle = 'rgba(255, 255, 255, 0.5)'
    ctx.fill()
  }
  
  getInput() {
    return { x: this.dx, y: this.dy }
  }
}

5) Botones táctiles

js
class TouchButton {
  constructor(x, y, radius, label) {
    this.x = x
    this.y = y
    this.radius = radius
    this.label = label
    this.pressed = false
    this.touchId = null
  }
  
  contains(px, py) {
    return Math.hypot(px - this.x, py - this.y) < this.radius
  }
  
  handleTouchStart(touch) {
    if (this.contains(touch.clientX, touch.clientY)) {
      this.pressed = true
      this.touchId = touch.identifier
    }
  }
  
  handleTouchEnd(touch) {
    if (touch.identifier === this.touchId) {
      this.pressed = false
      this.touchId = null
    }
  }
  
  draw(ctx) {
    ctx.beginPath()
    ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2)
    ctx.fillStyle = this.pressed ? 'rgba(255, 255, 255, 0.5)' : 'rgba(255, 255, 255, 0.2)'
    ctx.fill()
    
    ctx.font = 'bold 24px sans-serif'
    ctx.fillStyle = '#fff'
    ctx.textAlign = 'center'
    ctx.textBaseline = 'middle'
    ctx.fillText(this.label, this.x, this.y)
  }
}

6) Gestión de la orientación

js
function checkOrientation() {
  const isLandscape = window.innerWidth > window.innerHeight
  
  if (!isLandscape) {
    showRotatePrompt()
  } else {
    hideRotatePrompt()
    resizeCanvas()
  }
}

window.addEventListener('resize', checkOrientation)
window.addEventListener('orientationchange', checkOrientation)

function showRotatePrompt() {
  document.getElementById('rotate-prompt').style.display = 'flex'
}

function hideRotatePrompt() {
  document.getElementById('rotate-prompt').style.display = 'none'
}

7) Optimización del rendimiento

js
// Reduce resolution on low-end devices
function getDeviceScale() {
  const dpr = window.devicePixelRatio || 1
  const isLowEnd = navigator.hardwareConcurrency <= 2
  return isLowEnd ? Math.min(dpr, 1.5) : Math.min(dpr, 2)
}

// Throttle expensive operations
let lastHeavyUpdate = 0
function maybeDoHeavyWork(timestamp) {
  if (timestamp - lastHeavyUpdate > 100) { // 10 fps for heavy stuff
    doExpensiveCalculations()
    lastHeavyUpdate = timestamp
  }
}

// Use lower quality assets
function getAssetQuality() {
  const memory = navigator.deviceMemory || 4
  if (memory <= 2) return 'low'
  if (memory <= 4) return 'medium'
  return 'high'
}

8) Consideraciones sobre la batería

js
// Check battery and reduce effects
async function checkBattery() {
  if ('getBattery' in navigator) {
    const battery = await navigator.getBattery()
    
    if (battery.level < 0.2 && !battery.charging) {
      enableBatterySaver()
    }
    
    battery.addEventListener('levelchange', () => {
      if (battery.level < 0.2 && !battery.charging) {
        enableBatterySaver()
      }
    })
  }
}

function enableBatterySaver() {
  game.particleCount = Math.floor(game.particleCount * 0.5)
  game.targetFps = 30
  console.log('Battery saver enabled')
}

9) API de pantalla completa

js
async function requestFullscreen() {
  const elem = document.documentElement
  
  try {
    if (elem.requestFullscreen) {
      await elem.requestFullscreen()
    } else if (elem.webkitRequestFullscreen) {
      await elem.webkitRequestFullscreen()
    }
    
    // Lock orientation
    if (screen.orientation?.lock) {
      await screen.orientation.lock('landscape')
    }
  } catch (err) {
    console.warn('Fullscreen failed:', err)
  }
}

// Call on user interaction
document.getElementById('play-btn').addEventListener('click', () => {
  requestFullscreen()
  startGame()
})

Una salvedad: la API de pantalla completa no funciona con elementos generales en el iPhone. Safari para iOS solo permite la pantalla completa para elementos de vídeo nativos (mediante el método no estándar webkitEnterFullscreen), por lo que documentElement.requestFullscreen() se rechazará en el iPhone. El bloque try/catch anterior gestiona esto correctamente, así que diseña tu juego para que se pueda jugar sin una pantalla completa real en el iPhone y trata la pantalla completa como una mejora para el iPad, Android y los equipos de escritorio compatibles. La llamada a screen.orientation.lock() tiene la misma limitación en el iPhone, así que no dependas de ella.

10) Lista de comprobación para las pruebas

Antes de publicar:

  • Respuesta táctil — Prueba en dispositivos reales, no solo en emuladores
  • Distintos tamaños de pantalla — Teléfono, tableta, iPad Pro
  • Ambas orientaciones — O bloquea una de ellas
  • Dispositivos de gama baja — Prueba en teléfonos de hace 2 o 3 años
  • Redes lentas — Prueba con una simulación de 3G
  • Safari para iOS — Tiene peculiaridades con el audio y la pantalla completa
  • Chrome para Android — El navegador más común
  • Interrupciones — Llamadas telefónicas y notificaciones
  • Presión de memoria — ¿Se recupera después de permanecer en una pestaña en segundo plano?
js
// Debug touch visualization
function drawTouchDebug(ctx) {
  for (const [id, touch] of touches) {
    ctx.beginPath()
    ctx.arc(touch.x, touch.y, 30, 0, Math.PI * 2)
    ctx.fillStyle = 'rgba(255, 0, 0, 0.5)'
    ctx.fill()
    ctx.fillStyle = '#fff'
    ctx.fillText(id.toString(), touch.x, touch.y)
  }
}

Contenido relacionado

Recursos externos