Skip to content

Jogos para web compatíveis com dispositivos móveis

Os dispositivos móveis são a maior plataforma de jogos. Alguns ajustes fazem seu jogo para web funcionar muito bem em celulares e tablets.

1) Configuração da viewport

Evite o zoom e garanta o dimensionamento correto:

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

2) Canvas em tela cheia

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

html, body {
  width: 100%;
  height: 100%;
  overflow: hidden;
  background: #000;
  touch-action: none; /* Impede gestos do navegador */
}

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

Se você dimensionar a interface com base na altura da viewport em vez de height: 100%, prefira as unidades dinâmicas de viewport dvh, svh e lvh em vez de vh. Elas levam em conta a barra de ferramentas retrátil/expansível do navegador móvel, que 100vh ignora (no iOS, 100vh corresponde à altura com a barra de ferramentas oculta, portanto a parte inferior do layout acaba cortada). Essas unidades alcançaram o status Baseline Widely Available e são compatíveis com Chrome/Edge 108+, Firefox 101+ e Safari/iOS Safari 15.4+, abrangendo cerca de 92% dos usuários.

3) Tratamento de eventos de toque

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

// Impede os comportamentos de toque padrão
canvas.addEventListener('touchstart', (e) => e.preventDefault(), { passive: false })
canvas.addEventListener('touchmove', (e) => e.preventDefault(), { passive: false })

// Rastreia os toques
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)
  }
})

Se o jogo também precisar funcionar com mouse ou caneta, considere usar Pointer Events (pointerdown/pointermove/pointerup) em vez de eventos de toque. Agora, a MDN os recomenda como o modelo de entrada único e independente de dispositivo: um único fluxo de código processa toque, mouse e caneta, e o multitoque continua funcionando por meio de pointerId (o equivalente a touch.identifier usado acima). Os eventos de toque continuam sendo perfeitamente adequados para jogos exclusivos para telas sensíveis ao toque.

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) {
    // Círculo da base
    ctx.beginPath()
    ctx.arc(this.baseX, this.baseY, this.radius, 0, Math.PI * 2)
    ctx.fillStyle = 'rgba(255, 255, 255, 0.2)'
    ctx.fill()
    
    // Manopla
    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) Botões de toque

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) Tratamento da orientação

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) Otimização de desempenho

js
// Reduz a resolução em dispositivos de baixo desempenho
function getDeviceScale() {
  const dpr = window.devicePixelRatio || 1
  const isLowEnd = navigator.hardwareConcurrency <= 2
  return isLowEnd ? Math.min(dpr, 1.5) : Math.min(dpr, 2)
}

// Limita a frequência de operações pesadas
let lastHeavyUpdate = 0
function maybeDoHeavyWork(timestamp) {
  if (timestamp - lastHeavyUpdate > 100) { // 10 fps para tarefas pesadas
    doExpensiveCalculations()
    lastHeavyUpdate = timestamp
  }
}

// Usa recursos de qualidade inferior
function getAssetQuality() {
  const memory = navigator.deviceMemory || 4
  if (memory <= 2) return 'low'
  if (memory <= 4) return 'medium'
  return 'high'
}

8) Cuidados com a bateria

js
// Verifica a bateria e reduz os efeitos
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('Modo de economia de bateria ativado')
}

9) API de tela cheia

js
async function requestFullscreen() {
  const elem = document.documentElement
  
  try {
    if (elem.requestFullscreen) {
      await elem.requestFullscreen()
    } else if (elem.webkitRequestFullscreen) {
      await elem.webkitRequestFullscreen()
    }
    
    // Bloqueia a orientação
    if (screen.orientation?.lock) {
      await screen.orientation.lock('landscape')
    }
  } catch (err) {
    console.warn('Falha ao ativar a tela cheia:', err)
  }
}

// Chama durante uma interação do usuário
document.getElementById('play-btn').addEventListener('click', () => {
  requestFullscreen()
  startGame()
})

Uma ressalva: a API de tela cheia não funciona para elementos comuns no iPhone. O Safari do iOS só oferece tela cheia para elementos de vídeo nativos (por meio do método não padronizado webkitEnterFullscreen), portanto documentElement.requestFullscreen() será rejeitado no iPhone. O try/catch acima trata isso sem interromper o jogo. Por isso, projete o jogo para ser jogável sem tela cheia de verdade no iPhone e considere a tela cheia um aprimoramento para iPad, Android e computadores, onde há suporte. A chamada screen.orientation.lock() tem a mesma limitação no iPhone, portanto não dependa dela.

10) Checklist de testes

Antes de publicar:

  • Resposta ao toque — Teste em dispositivos reais, não apenas em emuladores
  • Vários tamanhos de tela — Celular, tablet, iPad Pro
  • Ambas as orientações — Ou bloqueie em uma delas
  • Dispositivos de baixo desempenho — Teste em celulares com 2 a 3 anos de uso
  • Redes lentas — Teste com simulação de 3G
  • Safari no iOS — Tem particularidades com áudio e tela cheia
  • Chrome no Android — O navegador mais comum
  • Interrupções — Ligações telefônicas, notificações
  • Pressão sobre a memória — O jogo se recupera depois que a aba fica em segundo plano?
js
// Visualização de depuração dos toques
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)
  }
}

Conteúdo relacionado

Recursos externos