FPS 游戏中的指针锁定
指针锁定 API 可以捕获鼠标光标,从而实现流畅的 FPS 风格摄像机操控。它是第一人称和第三人称射击游戏的必备功能。
1)请求指针锁定
指针锁定需要由用户手势触发:
const canvas = document.getElementById('game')
canvas.addEventListener('click', () => {
canvas.requestPointerLock()
})原始鼠标输入(禁用加速度)
默认情况下,浏览器会将操作系统的鼠标加速度应用到 movementX/movementY,因此在相同距离内快速甩动和缓慢拖动鼠标,会产生不同的摄像机旋转幅度。对于 FPS 瞄准,通常需要相反的效果:相同的物理移动距离应始终让摄像机旋转相同的幅度。将 unadjustedMovement: true 传给 requestPointerLock(),即可请求未经加速调整的原始移动数据:
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)检测锁定状态
document.addEventListener('pointerlockchange', () => {
if (document.pointerLockElement === canvas) {
console.log('指针已锁定')
game.mouseLocked = true
} else {
console.log('指针已解锁')
game.mouseLocked = false
}
})
document.addEventListener('pointerlockerror', () => {
console.error('指针锁定失败')
})3)读取鼠标移动
锁定后,使用 movementX 和 movementY:
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 摄像机类
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 移动
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 控制器
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)准星
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)使用鼠标按键射击
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)灵敏度设置
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)退出提示
显示退出指针锁定的操作说明:
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" 属性:
<iframe
src="game.html"
allow="pointer-lock; fullscreen"
></iframe>检查指针锁定是否可用:
if (!document.pointerLockElement && !('requestPointerLock' in canvas)) {
showMessage('鼠标捕获不可用')
}相关内容
- 游戏输入处理
- Gamepad API
- WebGL 基础
- WebXR 基础 — 第一人称体验的 VR 操控
- WebGPU 入门 — 用于 FPS 游戏的现代 GPU 渲染
外部资源
- MDN:Pointer Lock API — 完整的 API 参考
- MDN:MouseEvent.movementX — 读取鼠标移动增量