Skip to content

Pointer Lock für Shooter

Die Pointer-Lock-API fängt den Mauszeiger ein und ermöglicht flüssige Kamerasteuerung im Shooter-Stil. Unverzichtbar für Ego- und Third-Person-Shooter.

1) Pointer Lock anfordern

Pointer Lock verlangt eine Nutzergeste:

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

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

Rohe Mauseingabe (Beschleunigung abschalten)

Standardmäßig wendet der Browser die Mausbeschleunigung des Betriebssystems auf movementX/movementY an, ein schnelles Zucken und ein langsames Ziehen über dieselbe Strecke drehen die Kamera also unterschiedlich weit. Beim Zielen im Shooter willst du meist das Gegenteil: Dieselbe physische Strecke soll die Kamera immer gleich weit drehen. Fordere rohe, unbeschleunigte Bewegung an, indem du unadjustedMovement: true an requestPointerLock() übergibst:

js
canvas.addEventListener('click', async () => {
  try {
    await canvas.requestPointerLock({ unadjustedMovement: true })
  } catch (err) {
    // unadjustedMovement unsupported here; fall back to OS-adjusted deltas
    await canvas.requestPointerLock()
  }
})

Das moderne requestPointerLock() gibt ein Promise zurück, das bei Erfolg auflöst und bei Misserfolg rejectet, deshalb steht der Aufruf oben in einem try/catch. Chrome und Edge beachten unadjustedMovement seit Version 88, Safari unterstützt es seit 18.4 (März 2025), also seit derselben Version, die auch requestPointerLock auf ein Promise umgestellt hat. Ältere Browser, die kein Promise zurückgeben, funktionieren hier trotzdem, weil await einen Nicht-Promise-Wert verträgt und die unbekannte Option einfach ignoriert wird. Behalte den einfachen requestPointerLock()-Rückfall also immer.

2) Den Sperrzustand erkennen

js
document.addEventListener('pointerlockchange', () => {
  if (document.pointerLockElement === canvas) {
    console.log('Pointer locked')
    game.mouseLocked = true
  } else {
    console.log('Pointer unlocked')
    game.mouseLocked = false
  }
})

document.addEventListener('pointerlockerror', () => {
  console.error('Pointer lock failed')
})

3) Mausbewegung auslesen

Ist der Zeiger gesperrt, nutz movementX und 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
  
  // Clamp pitch to prevent flipping
  camera.pitch = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, camera.pitch))
})

4) Eine Klasse für die Ego-Kamera

js
class FPSCamera {
  constructor() {
    this.position = { x: 0, y: 1.7, z: 0 } // Eye height
    this.yaw = 0      // Left/right rotation
    this.pitch = 0    // Up/down rotation
    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()
    
    // Move on XZ plane only
    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-Bewegung

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) Ein vollständiger Ego-Controller

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)
    
    // Horizontal movement
    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
    
    // Jumping
    if (this.input.jump && this.onGround) {
      this.velocity.y = this.jumpSpeed
      this.onGround = false
    }
    
    // Gravity
    if (!this.onGround) {
      this.velocity.y += this.gravity * dt
    }
    
    // Apply velocity
    this.camera.position.x += this.velocity.x * dt
    this.camera.position.y += this.velocity.y * dt
    this.camera.position.z += this.velocity.z * dt
    
    // Simple ground collision
    if (this.camera.position.y < 1.7) {
      this.camera.position.y = 1.7
      this.velocity.y = 0
      this.onGround = true
    }
  }
}

7) Fadenkreuz

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
  
  // Top
  ctx.beginPath()
  ctx.moveTo(cx, cy - gap)
  ctx.lineTo(cx, cy - gap - size)
  ctx.stroke()
  
  // Bottom
  ctx.beginPath()
  ctx.moveTo(cx, cy + gap)
  ctx.lineTo(cx, cy + gap + size)
  ctx.stroke()
  
  // Left
  ctx.beginPath()
  ctx.moveTo(cx - gap, cy)
  ctx.lineTo(cx - gap - size, cy)
  ctx.stroke()
  
  // Right
  ctx.beginPath()
  ctx.moveTo(cx + gap, cy)
  ctx.lineTo(cx + gap + size, cy)
  ctx.stroke()
}

8) Schießen mit den Maustasten

js
document.addEventListener('mousedown', (e) => {
  if (!document.pointerLockElement) return
  
  if (e.button === 0) {
    // Left click - primary fire
    weapon.fire()
  } else if (e.button === 2) {
    // Right click - aim down sights
    weapon.aimDownSights(true)
  }
})

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

// Prevent context menu
canvas.addEventListener('contextmenu', (e) => e.preventDefault())

9) Einstellungen zur Empfindlichkeit

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

// Apply in mouse handler
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) Hinweis zum Verlassen

Zeig eine Anleitung, wie man Pointer Lock verlässt:

js
function showLockUI() {
  const ui = document.getElementById('lock-ui')
  
  if (document.pointerLockElement) {
    ui.innerHTML = '<p>Press ESC to unlock mouse</p>'
    ui.style.opacity = '0.5'
    setTimeout(() => ui.style.opacity = '0', 2000)
  } else {
    ui.innerHTML = '<p>Click to play</p>'
    ui.style.opacity = '1'
  }
}

document.addEventListener('pointerlockchange', showLockUI)

Eines solltest du zum Verlassen wissen: Drückt der Spieler ESC, um Pointer Lock zu beenden, verlangt der Browser eine frische Nutzergeste (einen Klick), bevor du erneut sperren kannst, und genau dafür gibt es das Overlay "Click to play". Ruft dein eigener Code dagegen document.exitPointerLock() auf (etwa um ein Ingame-Menü zu öffnen), braucht es danach keine neue Geste, du kannst also beim Schließen des Menüs programmatisch wieder sperren. Wiederholtes Drücken von ESC kann dem Browser außerdem signalisieren, erneutes Sperren zu verweigern, bis eine bewusstere Nutzeraktion kommt, versuch also kein automatisches Wiedersperren bei pointerlockchange.

Hinweise zu Iframes

Pointer Lock in Iframes verlangt das Attribut allow="pointer-lock":

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

Prüfe, ob Pointer Lock verfügbar ist:

js
if (!document.pointerLockElement && !('requestPointerLock' in canvas)) {
  showMessage('Mouse capture not available')
}

Verwandt

Externe Ressourcen