FPS 게임을 위한 Pointer Lock
Pointer Lock API는 마우스 커서를 캡처해서 부드러운 FPS 스타일의 카메라 컨트롤을 가능하게 해줍니다. 1인칭과 3인칭 슈팅 게임에 꼭 필요한 기능이죠.
1) Pointer Lock 요청하기
Pointer Lock은 사용자 제스처가 있어야 동작합니다.
const canvas = document.getElementById('game')
canvas.addEventListener('click', () => {
canvas.requestPointerLock()
})원시 마우스 입력 (가속 비활성화)
기본적으로 브라우저는 운영체제의 마우스 가속을 movementX/movementY에 적용합니다. 그래서 같은 거리를 빠르게 휙 움직일 때와 천천히 끌 때 카메라 회전량이 달라집니다. FPS 조준에서는 보통 그 반대를 원하죠. 같은 물리적 거리는 항상 카메라를 같은 양만큼 회전시켜야 합니다. requestPointerLock()에 unadjustedMovement: true를 넘겨서 가속이 없는 원시 움직임을 요청하세요.
canvas.addEventListener('click', async () => {
try {
await canvas.requestPointerLock({ unadjustedMovement: true })
} catch (err) {
// 여기서는 unadjustedMovement를 지원하지 않으니, OS 보정 델타로 대체
await canvas.requestPointerLock()
}
})최신 requestPointerLock()은 성공하면 resolve되고 실패하면 reject되는 Promise를 반환합니다. 그래서 위 호출을 try/catch로 감싼 것이죠. Chrome과 Edge는 버전 88부터 unadjustedMovement를 지원했고, Safari는 18.4(2025년 3월)부터 지원합니다. 이 버전은 requestPointerLock이 Promise를 반환하도록 고친 릴리스이기도 합니다. Promise를 반환하지 않는 구형 브라우저에서도 여기서는 잘 동작합니다. await은 Promise가 아닌 값도 받아들이고 알 수 없는 옵션은 그냥 무시되기 때문입니다. 그러니 일반 requestPointerLock() 대체 코드는 항상 남겨 두세요.
2) 잠금 상태 감지하기
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) 마우스 움직임 읽기
잠긴 상태에서는 movementX와 movementY를 사용합니다.
document.addEventListener('mousemove', (e) => {
if (document.pointerLockElement !== canvas) return
const sensitivity = 0.002
camera.yaw -= e.movementX * sensitivity
camera.pitch -= e.movementY * sensitivity
// pitch를 제한해서 뒤집힘 방지
camera.pitch = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, camera.pitch))
})4) FPS 카메라 클래스
class FPSCamera {
constructor() {
this.position = { x: 0, y: 1.7, z: 0 } // 눈 높이
this.yaw = 0 // 좌우 회전
this.pitch = 0 // 상하 회전
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()
// 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) 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) 완성된 FPS 컨트롤러
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)
// 수평 이동
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
// 점프
if (this.input.jump && this.onGround) {
this.velocity.y = this.jumpSpeed
this.onGround = false
}
// 중력
if (!this.onGround) {
this.velocity.y += this.gravity * dt
}
// 속도 적용
this.camera.position.x += this.velocity.x * dt
this.camera.position.y += this.velocity.y * dt
this.camera.position.z += this.velocity.z * dt
// 간단한 지면 충돌
if (this.camera.position.y < 1.7) {
this.camera.position.y = 1.7
this.velocity.y = 0
this.onGround = true
}
}
}7) 조준선
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
// 위
ctx.beginPath()
ctx.moveTo(cx, cy - gap)
ctx.lineTo(cx, cy - gap - size)
ctx.stroke()
// 아래
ctx.beginPath()
ctx.moveTo(cx, cy + gap)
ctx.lineTo(cx, cy + gap + size)
ctx.stroke()
// 왼쪽
ctx.beginPath()
ctx.moveTo(cx - gap, cy)
ctx.lineTo(cx - gap - size, cy)
ctx.stroke()
// 오른쪽
ctx.beginPath()
ctx.moveTo(cx + gap, cy)
ctx.lineTo(cx + gap + size, cy)
ctx.stroke()
}8) 마우스 버튼으로 발사하기
document.addEventListener('mousedown', (e) => {
if (!document.pointerLockElement) return
if (e.button === 0) {
// 왼쪽 클릭 - 주 발사
weapon.fire()
} else if (e.button === 2) {
// 오른쪽 클릭 - 조준 사격
weapon.aimDownSights(true)
}
})
document.addEventListener('mouseup', (e) => {
if (e.button === 2) {
weapon.aimDownSights(false)
}
})
// 컨텍스트 메뉴 막기
canvas.addEventListener('contextmenu', (e) => e.preventDefault())9) 감도 설정
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())
}
}
// 마우스 핸들러에서 적용
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) 종료 안내
Pointer Lock을 빠져나가는 방법을 안내하세요.
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)종료 안내에 대해 알아둘 점이 하나 있습니다. 플레이어가 ESC를 눌러 Pointer Lock에서 빠져나가면 브라우저는 다시 잠그기 전에 새로운 사용자 제스처(클릭)를 요구합니다. 'Click to play' 오버레이가 존재하는 이유죠. 하지만 여러분의 코드가 직접 document.exitPointerLock()을 호출했다면(예를 들어 게임 내 메뉴를 열기 위해) 이후 다시 잠그는 데 새 제스처가 필요 없습니다. 그래서 메뉴를 닫을 때 프로그램적으로 다시 잠글 수 있습니다. ESC를 반복해서 누르면 브라우저가 더 명확한 사용자 동작이 있을 때까지 다시 잠그기를 거부하라는 신호로 받아들일 수도 있으니, pointerlockchange에서 자동으로 다시 잠그려 하지 마세요.
iframe 고려사항
iframe 안에서 Pointer Lock을 쓰려면 allow="pointer-lock" 속성이 필요합니다.
<iframe
src="game.html"
allow="pointer-lock; fullscreen"
></iframe>Pointer Lock을 쓸 수 있는지 확인하세요.
if (!document.pointerLockElement && !('requestPointerLock' in canvas)) {
showMessage('Mouse capture not available')
}관련 글
- 게임 입력 처리
- Gamepad API
- WebGL 기초
- WebXR 기초 — 1인칭 경험을 위한 VR 컨트롤
- WebGPU 시작하기 — FPS 게임을 위한 최신 GPU 렌더링
외부 자료
- MDN: Pointer Lock API — 전체 API 레퍼런스
- MDN: MouseEvent.movementX — 마우스 델타 읽기