Skip to content

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:

js
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():

js
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

js
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:

js
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

js
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

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) Pengontrol FPS lengkap

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)
    
    // 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

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
  
  // 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

js
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

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

// 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:

js
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":

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

Periksa apakah pointer lock tersedia:

js
if (!document.pointerLockElement && !('requestPointerLock' in canvas)) {
  showMessage('Penangkapan mouse tidak tersedia')
}

Terkait

Sumber Daya Eksternal