FPS oyunları için Pointer Lock
Pointer Lock API, fare imlecini yakalayarak akıcı FPS tarzı kamera kontrollerine olanak tanır. Birinci ve üçüncü şahıs nişancı oyunları için vazgeçilmezdir.
1) Pointer lock isteme
Pointer lock için bir kullanıcı etkileşimi gerekir:
const canvas = document.getElementById('game')
canvas.addEventListener('click', () => {
canvas.requestPointerLock()
})Ham fare girdisi (ivmeyi devre dışı bırakma)
Tarayıcı varsayılan olarak işletim sisteminin fare ivmesini movementX/movementY değerlerine uygular; bu nedenle aynı mesafede yapılan hızlı bir hareket ile yavaş bir sürükleme, kamerayı farklı miktarlarda döndürür. FPS nişan alma için genellikle bunun tersini istersiniz: aynı fiziksel mesafe, kamerayı her zaman aynı miktarda döndürmelidir. requestPointerLock() işlevine unadjustedMovement: true geçirerek ham ve ivmesiz hareket isteyin:
canvas.addEventListener('click', async () => {
try {
await canvas.requestPointerLock({ unadjustedMovement: true })
} catch (err) {
// unadjustedMovement unsupported here; fall back to OS-adjusted deltas
await canvas.requestPointerLock()
}
})Modern requestPointerLock(), başarı durumunda çözümlenen ve başarısızlık durumunda reddedilen bir Promise döndürür; bu nedenle yukarıdaki çağrı try/catch içine alınmıştır. Chrome ve Edge, 88. sürümden beri unadjustedMovement seçeneğini destekler; Safari ise requestPointerLock işlevinin Promise döndürmesini da düzelten 18.4 sürümünden (Mart 2025) beri desteklemektedir. Promise döndürmeyen eski tarayıcılar da burada çalışır; çünkü await, Promise olmayan bir değeri kabul eder ve bilinmeyen seçenek yok sayılır. Bu nedenle sade requestPointerLock() yedeğini her zaman koruyun.
2) Kilit durumunu algılama
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) Fare hareketini okuma
Kilitliyken movementX ve movementY değerlerini kullanın:
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) FPS kamera sınıfı
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 hareketi
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) Tam FPS kontrolcüsü
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) Nişangâh
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) Fare düğmeleriyle ateş etme
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) Hassasiyet ayarları
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) Çıkış istemi
Pointer lock'tan çıkma talimatlarını gösterin:
function showLockUI() {
const ui = document.getElementById('lock-ui')
if (document.pointerLockElement) {
ui.innerHTML = '<p>Farenin kilidini açmak için ESC tuşuna basın</p>'
ui.style.opacity = '0.5'
setTimeout(() => ui.style.opacity = '0', 2000)
} else {
ui.innerHTML = '<p>Oynamak için tıklayın</p>'
ui.style.opacity = '1'
}
}
document.addEventListener('pointerlockchange', showLockUI)Çıkış istemi hakkında bilinmesi gereken bir nokta vardır: Oyuncu pointer lock'tan çıkmak için ESC tuşuna bastığında tarayıcı, yeniden kilitleme yapılabilmesi için yeni bir kullanıcı etkileşimi (tıklama) ister. “Oynamak için tıklayın” katmanının bulunma nedeni budur. Ancak kendi kodunuz document.exitPointerLock() işlevini çağırırsa (örneğin oyun içi bir menü açmak için), sonrasında yeniden kilitleme yapmak için yeni bir etkileşim gerekmez. Böylece menü kapandığında kilidi program aracılığıyla yeniden etkinleştirebilirsiniz. ESC tuşuna art arda basılması da tarayıcının daha bilinçli bir kullanıcı eylemi gerçekleşene kadar yeniden kilitlemeyi reddetmesine yol açabilir. Bu nedenle pointerlockchange sırasında otomatik olarak yeniden kilitlemeye çalışmayın.
Iframe ile ilgili hususlar
Iframe'lerde pointer lock için allow="pointer-lock" özniteliği gerekir:
<iframe
src="game.html"
allow="pointer-lock; fullscreen"
></iframe>Pointer lock özelliğinin kullanılabilir olup olmadığını kontrol edin:
if (!document.pointerLockElement && !('requestPointerLock' in canvas)) {
showMessage('Fare yakalama kullanılamıyor')
}İlgili içerikler
- Oyun girdilerini işleme
- Gamepad API
- WebGL'in temelleri
- WebXR'ın temelleri — birinci şahıs deneyimleri için VR kontrolleri
- WebGPU'ya başlangıç — FPS oyunları için modern GPU görüntü işleme
Harici Kaynaklar
- MDN: Pointer Lock API — eksiksiz API başvurusu
- MDN: MouseEvent.movementX — fare hareket farklarını okuma