Skip to content

Pointer Lock para jogos FPS

A API Pointer Lock captura o cursor do mouse, permitindo controles suaves de câmera no estilo FPS. Essencial para jogos de tiro em primeira e terceira pessoa.

1) Solicitando o bloqueio do ponteiro

O bloqueio do ponteiro exige uma interação do usuário:

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

canvas.addEventListener('click', () => {
  canvas.requestPointerLock()
})

Entrada bruta do mouse (desativar aceleração)

Por padrão, o navegador aplica a aceleração do mouse do sistema operacional a movementX/movementY. Assim, um movimento rápido e um arrasto lento pela mesma distância produzem rotações diferentes da câmera. Para mirar em jogos FPS, geralmente você quer o contrário: a mesma distância física deve sempre girar a câmera na mesma proporção. Solicite movimentos brutos e sem aceleração passando unadjustedMovement: true para requestPointerLock():

js
canvas.addEventListener('click', async () => {
  try {
    await canvas.requestPointerLock({ unadjustedMovement: true })
  } catch (err) {
    // unadjustedMovement não é compatível aqui; use deltas ajustados pelo sistema operacional
    await canvas.requestPointerLock()
  }
})

A versão moderna de requestPointerLock() retorna uma Promise que é resolvida em caso de sucesso e rejeitada em caso de falha, por isso a chamada acima está envolvida em try/catch. Chrome e Edge oferecem suporte a unadjustedMovement desde a versão 88, e o Safari desde a versão 18.4 (março de 2025), que também corrigiu requestPointerLock para retornar uma Promise. Navegadores mais antigos que não retornam uma Promise ainda funcionam aqui, pois await aceita um valor que não seja uma Promise e a opção desconhecida é simplesmente ignorada. Portanto, sempre mantenha a chamada simples a requestPointerLock() como alternativa.

2) Detectando o estado do bloqueio

js
document.addEventListener('pointerlockchange', () => {
  if (document.pointerLockElement === canvas) {
    console.log('Ponteiro bloqueado')
    game.mouseLocked = true
  } else {
    console.log('Ponteiro desbloqueado')
    game.mouseLocked = false
  }
})

document.addEventListener('pointerlockerror', () => {
  console.error('Falha ao bloquear o ponteiro')
})

3) Lendo o movimento do mouse

Quando o ponteiro estiver bloqueado, use movementX e movementY:

js
document.addEventListener('mousemove', (e) => {
  if (document.pointerLockElement !== canvas) return
  
  const sensitivity = 0.002
  camera.yaw -= e.movementX * sensitivity
  camera.pitch -= e.movementY * sensitivity
  
  // Limite a inclinação para evitar que a câmera vire de cabeça para baixo
  camera.pitch = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, camera.pitch))
})

4) Classe de câmera FPS

js
class FPSCamera {
  constructor() {
    this.position = { x: 0, y: 1.7, z: 0 } // Altura dos olhos
    this.yaw = 0      // Rotação para esquerda/direita
    this.pitch = 0    // Rotação para cima/baixo
    this.sensitivity = 0.002
  }
  
  handleMouseMove(e) {
    this.yaw -= e.movementX * this.sensitivity
    this.pitch -= e.movementY * this.sensitivity
    this.pitch = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, this.pitch))
  }
  
  getForward() {
    return {
      x: Math.sin(this.yaw) * Math.cos(this.pitch),
      y: Math.sin(this.pitch),
      z: Math.cos(this.yaw) * Math.cos(this.pitch),
    }
  }
  
  getRight() {
    return {
      x: Math.cos(this.yaw),
      y: 0,
      z: -Math.sin(this.yaw),
    }
  }
  
  move(forward, right, dt, speed = 5) {
    const fwd = this.getForward()
    const rgt = this.getRight()
    
    // Mova apenas no plano XZ
    this.position.x += (fwd.x * forward + rgt.x * right) * speed * dt
    this.position.z += (fwd.z * forward + rgt.z * right) * speed * dt
  }
  
  getViewMatrix() {
    const forward = this.getForward()
    const target = {
      x: this.position.x + forward.x,
      y: this.position.y + forward.y,
      z: this.position.z + forward.z,
    }
    return lookAt(this.position, target, { x: 0, y: 1, z: 0 })
  }
}

5) Movimento com WASD

js
const input = {
  forward: false,
  backward: false,
  left: false,
  right: false,
  jump: false,
}

window.addEventListener('keydown', (e) => {
  switch (e.code) {
    case 'KeyW': input.forward = true; break
    case 'KeyS': input.backward = true; break
    case 'KeyA': input.left = true; break
    case 'KeyD': input.right = true; break
    case 'Space': input.jump = true; break
  }
})

window.addEventListener('keyup', (e) => {
  switch (e.code) {
    case 'KeyW': input.forward = false; break
    case 'KeyS': input.backward = false; break
    case 'KeyA': input.left = false; break
    case 'KeyD': input.right = false; break
    case 'Space': input.jump = false; break
  }
})

function update(dt) {
  const moveForward = (input.forward ? 1 : 0) - (input.backward ? 1 : 0)
  const moveRight = (input.right ? 1 : 0) - (input.left ? 1 : 0)
  
  camera.move(moveForward, moveRight, dt)
}

6) Controlador FPS completo

js
class FPSController {
  constructor(canvas) {
    this.canvas = canvas
    this.camera = new FPSCamera()
    this.locked = false
    this.speed = 5
    this.sprintMultiplier = 1.5
    
    this.input = {
      forward: false, backward: false,
      left: false, right: false,
      jump: false, sprint: false,
    }
    
    this.velocity = { x: 0, y: 0, z: 0 }
    this.onGround = true
    this.gravity = -20
    this.jumpSpeed = 8
    
    this.bindEvents()
  }
  
  bindEvents() {
    this.canvas.addEventListener('click', () => {
      this.canvas.requestPointerLock()
    })
    
    document.addEventListener('pointerlockchange', () => {
      this.locked = document.pointerLockElement === this.canvas
    })
    
    document.addEventListener('mousemove', (e) => {
      if (this.locked) {
        this.camera.handleMouseMove(e)
      }
    })
    
    window.addEventListener('keydown', (e) => this.handleKey(e.code, true))
    window.addEventListener('keyup', (e) => this.handleKey(e.code, false))
  }
  
  handleKey(code, pressed) {
    switch (code) {
      case 'KeyW': this.input.forward = pressed; break
      case 'KeyS': this.input.backward = pressed; break
      case 'KeyA': this.input.left = pressed; break
      case 'KeyD': this.input.right = pressed; break
      case 'Space': this.input.jump = pressed; break
      case 'ShiftLeft': this.input.sprint = pressed; break
    }
  }
  
  update(dt) {
    if (!this.locked) return
    
    const speed = this.speed * (this.input.sprint ? this.sprintMultiplier : 1)
    
    // Movimento horizontal
    const moveForward = (this.input.forward ? 1 : 0) - (this.input.backward ? 1 : 0)
    const moveRight = (this.input.right ? 1 : 0) - (this.input.left ? 1 : 0)
    
    const forward = this.camera.getForward()
    const right = this.camera.getRight()
    
    this.velocity.x = (forward.x * moveForward + right.x * moveRight) * speed
    this.velocity.z = (forward.z * moveForward + right.z * moveRight) * speed
    
    // Salto
    if (this.input.jump && this.onGround) {
      this.velocity.y = this.jumpSpeed
      this.onGround = false
    }
    
    // Gravidade
    if (!this.onGround) {
      this.velocity.y += this.gravity * dt
    }
    
    // Aplique a velocidade
    this.camera.position.x += this.velocity.x * dt
    this.camera.position.y += this.velocity.y * dt
    this.camera.position.z += this.velocity.z * dt
    
    // Colisão simples com o chão
    if (this.camera.position.y < 1.7) {
      this.camera.position.y = 1.7
      this.velocity.y = 0
      this.onGround = true
    }
  }
}

7) Mira

js
function drawCrosshair(ctx) {
  const cx = ctx.canvas.width / 2
  const cy = ctx.canvas.height / 2
  const size = 10
  const gap = 4
  
  ctx.strokeStyle = '#fff'
  ctx.lineWidth = 2
  
  // Superior
  ctx.beginPath()
  ctx.moveTo(cx, cy - gap)
  ctx.lineTo(cx, cy - gap - size)
  ctx.stroke()
  
  // Inferior
  ctx.beginPath()
  ctx.moveTo(cx, cy + gap)
  ctx.lineTo(cx, cy + gap + size)
  ctx.stroke()
  
  // Esquerda
  ctx.beginPath()
  ctx.moveTo(cx - gap, cy)
  ctx.lineTo(cx - gap - size, cy)
  ctx.stroke()
  
  // Direita
  ctx.beginPath()
  ctx.moveTo(cx + gap, cy)
  ctx.lineTo(cx + gap + size, cy)
  ctx.stroke()
}

8) Atirando com os botões do mouse

js
document.addEventListener('mousedown', (e) => {
  if (!document.pointerLockElement) return
  
  if (e.button === 0) {
    // Botão esquerdo — disparo primário
    weapon.fire()
  } else if (e.button === 2) {
    // Botão direito — mirar pela mira da arma
    weapon.aimDownSights(true)
  }
})

document.addEventListener('mouseup', (e) => {
  if (e.button === 2) {
    weapon.aimDownSights(false)
  }
})

// Impeça a exibição do menu de contexto
canvas.addEventListener('contextmenu', (e) => e.preventDefault())

9) Configurações de sensibilidade

js
class Settings {
  constructor() {
    this.mouseSensitivity = parseFloat(localStorage.getItem('sensitivity') || '1.0')
    this.invertY = localStorage.getItem('invertY') === 'true'
  }
  
  save() {
    localStorage.setItem('sensitivity', this.mouseSensitivity.toString())
    localStorage.setItem('invertY', this.invertY.toString())
  }
}

// Aplique no manipulador do mouse
document.addEventListener('mousemove', (e) => {
  if (!locked) return
  
  const sens = settings.mouseSensitivity * 0.002
  camera.yaw -= e.movementX * sens
  camera.pitch -= e.movementY * sens * (settings.invertY ? -1 : 1)
})

10) Aviso de saída

Mostre instruções para sair do bloqueio do ponteiro:

js
function showLockUI() {
  const ui = document.getElementById('lock-ui')
  
  if (document.pointerLockElement) {
    ui.innerHTML = '<p>Pressione ESC para liberar o mouse</p>'
    ui.style.opacity = '0.5'
    setTimeout(() => ui.style.opacity = '0', 2000)
  } else {
    ui.innerHTML = '<p>Clique para jogar</p>'
    ui.style.opacity = '1'
  }
}

document.addEventListener('pointerlockchange', showLockUI)

Um detalhe importante sobre o aviso de saída: quando o jogador pressiona ESC para sair do bloqueio do ponteiro, o navegador exige uma nova interação do usuário (um clique) antes de permitir outro bloqueio. É por isso que a sobreposição “Clique para jogar” existe. Porém, se o próprio código chamar document.exitPointerLock() (por exemplo, para abrir um menu dentro do jogo), nenhuma nova interação será necessária para bloquear novamente depois. Assim, você poderá reativar o bloqueio por código quando o menu for fechado. Pressionar ESC repetidamente também pode fazer o navegador recusar novas tentativas de bloqueio até que o usuário realize uma ação mais deliberada. Portanto, não tente bloquear o ponteiro automaticamente em pointerlockchange.

Considerações sobre iframes

O bloqueio do ponteiro em iframes exige o atributo allow="pointer-lock":

html
<iframe 
  src="game.html" 
  allow="pointer-lock; fullscreen"
></iframe>

Verifique se o bloqueio do ponteiro está disponível:

js
if (!document.pointerLockElement && !('requestPointerLock' in canvas)) {
  showMessage('A captura do mouse não está disponível')
}

Conteúdo relacionado

Recursos externos