Pointer Lock untuk game FPS
Pointer Lock API menangkap kursor mouse sehingga memungkinkan kontrol kamera bergaya FPS yang mulus. Fitur ini penting untuk game tembak-menembak orang pertama dan orang ketiga.
1) Meminta pointer lock
Pointer lock memerlukan gestur pengguna:
const canvas = document.getElementById('game')
canvas.addEventListener('click', () => {
canvas.requestPointerLock()
})Input mouse mentah (menonaktifkan akselerasi)
Secara default, browser menerapkan akselerasi mouse dari sistem operasi pada movementX/movementY, sehingga gerakan cepat dan seretan lambat pada jarak yang sama menghasilkan rotasi kamera yang berbeda. Untuk membidik dalam FPS, biasanya Anda menginginkan kebalikannya: jarak fisik yang sama harus selalu memutar kamera dalam jumlah yang sama. Minta pergerakan mentah tanpa akselerasi dengan meneruskan unadjustedMovement: true ke requestPointerLock():
canvas.addEventListener('click', async () => {
try {
await canvas.requestPointerLock({ unadjustedMovement: true })
} catch (err) {
// unadjustedMovement tidak didukung di sini; gunakan delta yang disesuaikan OS
await canvas.requestPointerLock()
}
})requestPointerLock() modern mengembalikan Promise yang terselesaikan saat berhasil dan ditolak saat gagal. Karena itu, pemanggilan di atas dibungkus dalam try/catch. Chrome dan Edge telah mendukung unadjustedMovement sejak versi 88, sedangkan Safari mendukungnya sejak versi 18.4 (Maret 2025), yang juga merupakan rilis yang memperbaiki requestPointerLock agar mengembalikan Promise. Browser lama yang tidak mengembalikan Promise tetap dapat bekerja di sini karena await menerima nilai non-Promise dan opsi yang tidak dikenal akan diabaikan begitu saja. Karena itu, selalu pertahankan requestPointerLock() biasa sebagai fallback.
2) Mendeteksi status penguncian
document.addEventListener('pointerlockchange', () => {
if (document.pointerLockElement === canvas) {
console.log('Pointer terkunci')
game.mouseLocked = true
} else {
console.log('Pointer tidak terkunci')
game.mouseLocked = false
}
})
document.addEventListener('pointerlockerror', () => {
console.error('Pointer lock gagal')
})3) Membaca pergerakan mouse
Saat terkunci, gunakan movementX dan movementY:
document.addEventListener('mousemove', (e) => {
if (document.pointerLockElement !== canvas) return
const sensitivity = 0.002
camera.yaw -= e.movementX * sensitivity
camera.pitch -= e.movementY * sensitivity
// Batasi pitch agar kamera tidak terbalik
camera.pitch = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, camera.pitch))
})4) Class kamera FPS
class FPSCamera {
constructor() {
this.position = { x: 0, y: 1.7, z: 0 } // Tinggi mata
this.yaw = 0 // Rotasi kiri/kanan
this.pitch = 0 // Rotasi atas/bawah
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()
// Bergerak hanya pada bidang 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) Pergerakan 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) Pengontrol FPS lengkap
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)
// Pergerakan horizontal
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
// Melompat
if (this.input.jump && this.onGround) {
this.velocity.y = this.jumpSpeed
this.onGround = false
}
// Gravitasi
if (!this.onGround) {
this.velocity.y += this.gravity * dt
}
// Terapkan kecepatan
this.camera.position.x += this.velocity.x * dt
this.camera.position.y += this.velocity.y * dt
this.camera.position.z += this.velocity.z * dt
// Tabrakan tanah sederhana
if (this.camera.position.y < 1.7) {
this.camera.position.y = 1.7
this.velocity.y = 0
this.onGround = true
}
}
}7) Crosshair
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
// Atas
ctx.beginPath()
ctx.moveTo(cx, cy - gap)
ctx.lineTo(cx, cy - gap - size)
ctx.stroke()
// Bawah
ctx.beginPath()
ctx.moveTo(cx, cy + gap)
ctx.lineTo(cx, cy + gap + size)
ctx.stroke()
// Kiri
ctx.beginPath()
ctx.moveTo(cx - gap, cy)
ctx.lineTo(cx - gap - size, cy)
ctx.stroke()
// Kanan
ctx.beginPath()
ctx.moveTo(cx + gap, cy)
ctx.lineTo(cx + gap + size, cy)
ctx.stroke()
}8) Menembak dengan tombol mouse
document.addEventListener('mousedown', (e) => {
if (!document.pointerLockElement) return
if (e.button === 0) {
// Klik kiri - tembakan utama
weapon.fire()
} else if (e.button === 2) {
// Klik kanan - membidik melalui alat bidik
weapon.aimDownSights(true)
}
})
document.addEventListener('mouseup', (e) => {
if (e.button === 2) {
weapon.aimDownSights(false)
}
})
// Cegah menu konteks
canvas.addEventListener('contextmenu', (e) => e.preventDefault())9) Pengaturan sensitivitas
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())
}
}
// Terapkan di handler mouse
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) Petunjuk keluar
Tampilkan petunjuk untuk keluar dari pointer lock:
function showLockUI() {
const ui = document.getElementById('lock-ui')
if (document.pointerLockElement) {
ui.innerHTML = '<p>Tekan ESC untuk melepaskan kunci mouse</p>'
ui.style.opacity = '0.5'
setTimeout(() => ui.style.opacity = '0', 2000)
} else {
ui.innerHTML = '<p>Klik untuk bermain</p>'
ui.style.opacity = '1'
}
}
document.addEventListener('pointerlockchange', showLockUI)Satu hal yang perlu diketahui tentang petunjuk keluar: ketika pemain menekan ESC untuk keluar dari pointer lock, browser memerlukan gestur pengguna baru (sebuah klik) sebelum Anda dapat menguncinya kembali. Itulah alasan overlay "Klik untuk bermain" diperlukan. Namun, jika kode Anda sendiri memanggil document.exitPointerLock() (misalnya untuk membuka menu dalam game), tidak diperlukan gestur baru untuk mengunci kembali setelahnya. Jadi, Anda dapat mengaktifkannya kembali secara terprogram saat menu ditutup. Menekan ESC berulang kali juga dapat memberi sinyal kepada browser untuk menolak penguncian kembali sampai pengguna melakukan tindakan yang lebih disengaja. Karena itu, jangan mencoba mengunci ulang secara otomatis saat pointerlockchange.
Pertimbangan untuk iframe
Pointer lock di dalam iframe memerlukan atribut allow="pointer-lock":
<iframe
src="game.html"
allow="pointer-lock; fullscreen"
></iframe>Periksa apakah pointer lock tersedia:
if (!document.pointerLockElement && !('requestPointerLock' in canvas)) {
showMessage('Penangkapan mouse tidak tersedia')
}Terkait
- Penanganan input game
- Gamepad API
- Dasar-dasar WebGL
- Dasar-dasar WebXR — kontrol VR untuk pengalaman orang pertama
- Memulai dengan WebGPU — rendering GPU modern untuk game FPS
Sumber Daya Eksternal
- MDN: Pointer Lock API — referensi API lengkap
- MDN: MouseEvent.movementX — membaca delta mouse