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:
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:
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
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:
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
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
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
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
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
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
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:
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":
<iframe
src="game.html"
allow="pointer-lock; fullscreen"
></iframe>Prüfe, ob Pointer Lock verfügbar ist:
if (!document.pointerLockElement && !('requestPointerLock' in canvas)) {
showMessage('Mouse capture not available')
}Verwandt
- Eingaben in Spielen
- Gamepad-API
- WebGL-Grundlagen
- WebXR-Grundlagen — VR-Steuerung für Ego-Erlebnisse
- WebGPU-Einstieg — modernes GPU-Rendering für Shooter
Externe Ressourcen
- MDN: Pointer Lock API — vollständige API-Referenz
- MDN: MouseEvent.movementX — Mausdeltas auslesen