Skip to content

Bloqueo del puntero para juegos FPS

La API Pointer Lock captura el cursor del ratón y permite controlar la cámara con fluidez al estilo FPS. Es esencial para shooters en primera y tercera persona.

1) Solicitar el bloqueo del puntero

El bloqueo del puntero requiere una acción del usuario:

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

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

Entrada directa del ratón (desactivar la aceleración)

De forma predeterminada, el navegador aplica la aceleración del ratón del sistema operativo a movementX/movementY, por lo que un movimiento rápido y uno lento que recorran la misma distancia producen una rotación distinta de la cámara. Para apuntar en un FPS, normalmente se busca lo contrario: la misma distancia física debe girar siempre la cámara en la misma medida. Solicita movimiento directo sin aceleración pasando unadjustedMovement: true a requestPointerLock():

js
canvas.addEventListener('click', async () => {
  try {
    await canvas.requestPointerLock({ unadjustedMovement: true })
  } catch (err) {
    // unadjustedMovement no es compatible aquí; usar deltas ajustados por el sistema operativo
    await canvas.requestPointerLock()
  }
})

La versión moderna de requestPointerLock() devuelve una promesa que se resuelve si la operación tiene éxito y se rechaza si falla, por eso la llamada anterior está envuelta en try/catch. Chrome y Edge admiten unadjustedMovement desde la versión 88, y Safari desde la 18.4 (marzo de 2025), que también fue la versión que corrigió requestPointerLock para que devolviera una promesa. Los navegadores antiguos que no devuelven una promesa siguen funcionando aquí porque await admite un valor que no sea una promesa y la opción desconocida simplemente se ignora; por tanto, conserva siempre la llamada alternativa a requestPointerLock() sin opciones.

2) Detectar el estado del bloqueo

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

document.addEventListener('pointerlockerror', () => {
  console.error('No se pudo bloquear el puntero')
})

3) Leer el movimiento del ratón

Mientras esté bloqueado, usa movementX y 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
  
  // Limitar la inclinación para evitar que la cámara dé la vuelta
  camera.pitch = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, camera.pitch))
})

4) Clase de cámara FPS

js
class FPSCamera {
  constructor() {
    this.position = { x: 0, y: 1.7, z: 0 } // Altura de los ojos
    this.yaw = 0      // Rotación izquierda/derecha
    this.pitch = 0    // Rotación arriba/abajo
    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()
    
    // Mover solo en el 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) Movimiento con 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)
    
    // Movimiento 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
    }
    
    // Gravedad
    if (!this.onGround) {
      this.velocity.y += this.gravity * dt
    }
    
    // Aplicar la velocidad
    this.camera.position.x += this.velocity.x * dt
    this.camera.position.y += this.velocity.y * dt
    this.camera.position.z += this.velocity.z * dt
    
    // Colisión sencilla con el suelo
    if (this.camera.position.y < 1.7) {
      this.camera.position.y = 1.7
      this.velocity.y = 0
      this.onGround = true
    }
  }
}

7) Retícula

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
  
  // Arriba
  ctx.beginPath()
  ctx.moveTo(cx, cy - gap)
  ctx.lineTo(cx, cy - gap - size)
  ctx.stroke()
  
  // Abajo
  ctx.beginPath()
  ctx.moveTo(cx, cy + gap)
  ctx.lineTo(cx, cy + gap + size)
  ctx.stroke()
  
  // Izquierda
  ctx.beginPath()
  ctx.moveTo(cx - gap, cy)
  ctx.lineTo(cx - gap - size, cy)
  ctx.stroke()
  
  // Derecha
  ctx.beginPath()
  ctx.moveTo(cx + gap, cy)
  ctx.lineTo(cx + gap + size, cy)
  ctx.stroke()
}

8) Disparar con los botones del ratón

js
document.addEventListener('mousedown', (e) => {
  if (!document.pointerLockElement) return
  
  if (e.button === 0) {
    // Clic izquierdo: disparo principal
    weapon.fire()
  } else if (e.button === 2) {
    // Clic derecho: apuntar con la mira
    weapon.aimDownSights(true)
  }
})

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

// Evitar el menú contextual
canvas.addEventListener('contextmenu', (e) => e.preventDefault())

9) Ajustes de sensibilidad

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

// Aplicar en el controlador del ratón
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 para salir

Muestra instrucciones para salir del bloqueo del puntero:

js
function showLockUI() {
  const ui = document.getElementById('lock-ui')
  
  if (document.pointerLockElement) {
    ui.innerHTML = '<p>Pulsa ESC para liberar el ratón</p>'
    ui.style.opacity = '0.5'
    setTimeout(() => ui.style.opacity = '0', 2000)
  } else {
    ui.innerHTML = '<p>Haz clic para jugar</p>'
    ui.style.opacity = '1'
  }
}

document.addEventListener('pointerlockchange', showLockUI)

Hay algo que debes saber sobre el aviso para salir: cuando el jugador pulsa ESC para abandonar el bloqueo del puntero, el navegador exige una nueva acción del usuario (un clic) antes de poder bloquearlo de nuevo. Por eso existe la capa «Haz clic para jugar». Sin embargo, si tu propio código llama a document.exitPointerLock() —por ejemplo, para abrir un menú del juego—, no se necesita una nueva acción para volver a bloquearlo después, por lo que puedes reactivarlo mediante código cuando se cierre el menú. Pulsar ESC repetidamente también puede hacer que el navegador rechace un nuevo bloqueo hasta que el usuario realice una acción más deliberada, así que no intentes volver a bloquearlo automáticamente en pointerlockchange.

Consideraciones sobre iframes

El bloqueo del puntero en iframes requiere el atributo allow="pointer-lock":

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

Comprueba si el bloqueo del puntero está disponible:

js
if (!document.pointerLockElement && !('requestPointerLock' in canvas)) {
  showMessage('La captura del ratón no está disponible')
}

Contenido relacionado

Recursos externos