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:
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():
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
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:
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
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
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
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
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
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
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:
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":
<iframe
src="game.html"
allow="pointer-lock; fullscreen"
></iframe>Comprueba si el bloqueo del puntero está disponible:
if (!document.pointerLockElement && !('requestPointerLock' in canvas)) {
showMessage('La captura del ratón no está disponible')
}Contenido relacionado
- Gestión de entradas del juego
- API Gamepad
- Fundamentos de WebGL
- Fundamentos de WebXR — controles de RV para experiencias en primera persona
- Primeros pasos con WebGPU — renderizado moderno con GPU para juegos FPS
Recursos externos
- MDN: API Pointer Lock — referencia completa de la API
- MDN: MouseEvent.movementX — lectura de los deltas del ratón