Verrouillage du pointeur pour les jeux FPS
L’API Pointer Lock capture le curseur de la souris afin d’offrir des commandes de caméra fluides de type FPS. Elle est indispensable pour les jeux de tir à la première et à la troisième personne.
1) Demander le verrouillage du pointeur
Le verrouillage du pointeur nécessite une action de l’utilisateur :
const canvas = document.getElementById('game')
canvas.addEventListener('click', () => {
canvas.requestPointerLock()
})Entrée brute de la souris (désactiver l’accélération)
Par défaut, le navigateur applique l’accélération de la souris du système d’exploitation à movementX/movementY. Ainsi, un mouvement rapide et un déplacement lent sur la même distance produisent des rotations de caméra différentes. Pour viser dans un FPS, on souhaite généralement l’inverse : une même distance physique doit toujours faire pivoter la caméra du même angle. Demandez des mouvements bruts, sans accélération, en passant unadjustedMovement: true à requestPointerLock() :
canvas.addEventListener('click', async () => {
try {
await canvas.requestPointerLock({ unadjustedMovement: true })
} catch (err) {
// unadjustedMovement unsupported here; fall back to OS-adjusted deltas
await canvas.requestPointerLock()
}
})La version moderne de requestPointerLock() renvoie une Promise qui est résolue en cas de réussite et rejetée en cas d’échec, d’où l’utilisation de try/catch autour de l’appel ci-dessus. Chrome et Edge prennent en charge unadjustedMovement depuis la version 88, et Safari depuis la version 18.4 (mars 2025), qui a également corrigé requestPointerLock afin qu’elle renvoie une Promise. Les anciens navigateurs qui ne renvoient pas de Promise fonctionnent tout de même ici, car await accepte une valeur qui n’est pas une Promise et l’option inconnue est simplement ignorée. Conservez donc toujours l’appel de secours à requestPointerLock() sans option.
2) Détecter l’état du verrouillage
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) Lire les mouvements de la souris
Lorsque le pointeur est verrouillé, utilisez movementX et 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) Classe de caméra FPS
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) Déplacement avec les touches 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) Contrôleur FPS complet
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) Réticule
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) Tirer avec les boutons de la souris
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) Réglages de sensibilité
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) Indication pour quitter
Affichez les instructions permettant de quitter le verrouillage du pointeur :
function showLockUI() {
const ui = document.getElementById('lock-ui')
if (document.pointerLockElement) {
ui.innerHTML = '<p>Appuyez sur Échap pour libérer la souris</p>'
ui.style.opacity = '0.5'
setTimeout(() => ui.style.opacity = '0', 2000)
} else {
ui.innerHTML = '<p>Cliquez pour jouer</p>'
ui.style.opacity = '1'
}
}
document.addEventListener('pointerlockchange', showLockUI)À propos de cette indication : lorsque le joueur appuie sur Échap pour quitter le verrouillage du pointeur, le navigateur exige une nouvelle action de l’utilisateur (un clic) avant d’autoriser un nouveau verrouillage. C’est pourquoi la surcouche « Cliquez pour jouer » est nécessaire. En revanche, si votre propre code appelle document.exitPointerLock() (par exemple pour ouvrir un menu en jeu), aucune nouvelle action n’est nécessaire pour réactiver ensuite le verrouillage. Vous pouvez donc le rétablir par programmation lorsque le menu se ferme. Des pressions répétées sur Échap peuvent également inciter le navigateur à refuser tout nouveau verrouillage jusqu’à ce que l’utilisateur effectue une action plus explicite. N’essayez donc pas de réactiver automatiquement le verrouillage lors de pointerlockchange.
Considérations relatives aux iframes
Le verrouillage du pointeur dans les iframes nécessite l’attribut allow="pointer-lock" :
<iframe
src="game.html"
allow="pointer-lock; fullscreen"
></iframe>Vérifiez si le verrouillage du pointeur est disponible :
if (!document.pointerLockElement && !('requestPointerLock' in canvas)) {
showMessage('La capture de la souris n’est pas disponible')
}À lire également
- Gestion des entrées de jeu
- API Gamepad
- Principes fondamentaux de WebGL
- Notions de base de WebXR — commandes VR pour les expériences à la première personne
- Bien démarrer avec WebGPU — rendu GPU moderne pour les jeux FPS
Ressources externes
- MDN : API Pointer Lock — référence complète de l’API
- MDN : MouseEvent.movementX — lecture des déplacements relatifs de la souris