Skip to content

FPS 游戏中的指针锁定

指针锁定 API 可以捕获鼠标光标,从而实现流畅的 FPS 风格摄像机操控。它是第一人称和第三人称射击游戏的必备功能。

1)请求指针锁定

指针锁定需要由用户手势触发:

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

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

原始鼠标输入(禁用加速度)

默认情况下,浏览器会将操作系统的鼠标加速度应用到 movementX/movementY,因此在相同距离内快速甩动和缓慢拖动鼠标,会产生不同的摄像机旋转幅度。对于 FPS 瞄准,通常需要相反的效果:相同的物理移动距离应始终让摄像机旋转相同的幅度。将 unadjustedMovement: true 传给 requestPointerLock(),即可请求未经加速调整的原始移动数据:

js
canvas.addEventListener('click', async () => {
  try {
    await canvas.requestPointerLock({ unadjustedMovement: true })
  } catch (err) {
    // 此处不支持 unadjustedMovement;回退到经操作系统调整的增量
    await canvas.requestPointerLock()
  }
})

现代的 requestPointerLock() 会返回一个 Promise,成功时兑现,失败时拒绝,因此上面的调用被包装在 try/catch 中。Chrome 和 Edge 从版本 88 开始支持 unadjustedMovement,Safari 则从 18.4(2025 年 3 月)开始支持;该版本也修复了 requestPointerLock,使其能够返回 Promise。不返回 Promise 的旧版浏览器在这里仍然可以正常工作,因为 await 可以接受非 Promise 值,而未知选项也只会被忽略。因此,请始终保留不带参数的 requestPointerLock() 作为回退方案。

2)检测锁定状态

js
document.addEventListener('pointerlockchange', () => {
  if (document.pointerLockElement === canvas) {
    console.log('指针已锁定')
    game.mouseLocked = true
  } else {
    console.log('指针已解锁')
    game.mouseLocked = false
  }
})

document.addEventListener('pointerlockerror', () => {
  console.error('指针锁定失败')
})

3)读取鼠标移动

锁定后,使用 movementXmovementY

js
document.addEventListener('mousemove', (e) => {
  if (document.pointerLockElement !== canvas) return
  
  const sensitivity = 0.002
  camera.yaw -= e.movementX * sensitivity
  camera.pitch -= e.movementY * sensitivity
  
  // 限制俯仰角以防止翻转
  camera.pitch = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, camera.pitch))
})

4)FPS 摄像机类

js
class FPSCamera {
  constructor() {
    this.position = { x: 0, y: 1.7, z: 0 } // 视线高度
    this.yaw = 0      // 左右旋转
    this.pitch = 0    // 上下旋转
    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()
    
    // 仅在 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)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)完整的 FPS 控制器

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)
    
    // 水平移动
    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
    
    // 跳跃
    if (this.input.jump && this.onGround) {
      this.velocity.y = this.jumpSpeed
      this.onGround = false
    }
    
    // 重力
    if (!this.onGround) {
      this.velocity.y += this.gravity * dt
    }
    
    // 应用速度
    this.camera.position.x += this.velocity.x * dt
    this.camera.position.y += this.velocity.y * dt
    this.camera.position.z += this.velocity.z * dt
    
    // 简单的地面碰撞
    if (this.camera.position.y < 1.7) {
      this.camera.position.y = 1.7
      this.velocity.y = 0
      this.onGround = true
    }
  }
}

7)准星

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
  
  // 上
  ctx.beginPath()
  ctx.moveTo(cx, cy - gap)
  ctx.lineTo(cx, cy - gap - size)
  ctx.stroke()
  
  // 下
  ctx.beginPath()
  ctx.moveTo(cx, cy + gap)
  ctx.lineTo(cx, cy + gap + size)
  ctx.stroke()
  
  // 左
  ctx.beginPath()
  ctx.moveTo(cx - gap, cy)
  ctx.lineTo(cx - gap - size, cy)
  ctx.stroke()
  
  // 右
  ctx.beginPath()
  ctx.moveTo(cx + gap, cy)
  ctx.lineTo(cx + gap + size, cy)
  ctx.stroke()
}

8)使用鼠标按键射击

js
document.addEventListener('mousedown', (e) => {
  if (!document.pointerLockElement) return
  
  if (e.button === 0) {
    // 左键单击——主武器开火
    weapon.fire()
  } else if (e.button === 2) {
    // 右键单击——开镜瞄准
    weapon.aimDownSights(true)
  }
})

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

// 阻止上下文菜单
canvas.addEventListener('contextmenu', (e) => e.preventDefault())

9)灵敏度设置

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

// 在鼠标处理程序中应用
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)退出提示

显示退出指针锁定的操作说明:

js
function showLockUI() {
  const ui = document.getElementById('lock-ui')
  
  if (document.pointerLockElement) {
    ui.innerHTML = '<p>按 ESC 键解锁鼠标</p>'
    ui.style.opacity = '0.5'
    setTimeout(() => ui.style.opacity = '0', 2000)
  } else {
    ui.innerHTML = '<p>点击开始游戏</p>'
    ui.style.opacity = '1'
  }
}

document.addEventListener('pointerlockchange', showLockUI)

关于退出提示,有一点需要了解:当玩家按 ESC 键退出指针锁定时,浏览器要求再次收到新的用户手势(一次点击)后才能重新锁定,因此需要显示“点击开始游戏”叠加层。但如果是你自己的代码调用 document.exitPointerLock()(例如为了打开游戏内菜单),之后重新锁定时就不需要新的用户手势,因此可以在菜单关闭后通过代码重新启用锁定。连续按 ESC 键也可能让浏览器拒绝重新锁定,直到用户执行更明确的操作,因此不要尝试在 pointerlockchange 事件触发时自动重新锁定。

Iframe 注意事项

在 iframe 中使用指针锁定需要添加 allow="pointer-lock" 属性:

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

检查指针锁定是否可用:

js
if (!document.pointerLockElement && !('requestPointerLock' in canvas)) {
  showMessage('鼠标捕获不可用')
}

相关内容

外部资源