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:
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">2) Canvas a pantalla completa
* {
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
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
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
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
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
// 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
// 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
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?
// 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
- Gestión de la entrada del juego
- Canvas adaptable para juegos
- PWA para juegos sin conexión
- API Web Audio para juegos — gestión de las restricciones de audio de iOS
- Publica un juego web que cargue rápido — optimización de los tiempos de carga para conexiones móviles
- Incrustación de juegos mediante iframe — consideraciones específicas para incrustaciones en móviles
Recursos externos
- MDN: Eventos táctiles — gestión multitáctil
- MDN: API de orientación de pantalla — bloqueo de la orientación
- MDN: API de pantalla completa — activación de la pantalla completa en móviles
- web.dev: Diseño adaptable — patrones adaptables aplicables a la interfaz de los juegos