Skip to content

게임 입력 처리

좋은 입력 처리는 게임의 성패를 가릅니다. 조작이 끊기거나, 입력이 누락되거나, 버튼 매핑이 헷갈리면 플레이어는 게임 콘텐츠를 보기도 전에 떠나버립니다. 이 튜토리얼에서는 키보드, 마우스, 터치, 게임패드를 통합된 방식으로 처리하는 법을 보여줍니다.

먼저 직접 해보세요:

입력 상태 패턴

가장 단순한 방식은 이벤트에 직접 반응하는 것입니다. 사용자가 키를 누르면 무언가를 하는 식이죠. 이 방식에는 문제가 있습니다. 이벤트는 예측할 수 없는 시점에 발생하는데, 게임 루프는 고정된 속도로 돌아갑니다. keydown 핸들러 안에서 플레이어를 움직이면 이동 속도가 키 반복 속도에 좌우되고, 이 속도는 운영체제마다 다릅니다.

대신 게임 루프가 읽어 들이는 입력 상태 객체를 유지하세요:

js
const input = {
  left: false,
  right: false,
  up: false,
  down: false,
  action: false,
  pointer: { x: 0, y: 0, down: false },
}

이벤트 핸들러는 이 플래그만 설정합니다. 게임 루프는 그것을 읽습니다. 이렇게 분리하면 모든 게 단순해집니다. 업데이트 로직은 입력이 키보드에서 왔는지, 터치에서 왔는지, 게임패드에서 왔는지 신경 쓸 필요가 없습니다.

키보드

e.key 대신 e.code를 쓰세요. 물리적인 키 위치를 가리키기 때문입니다. 그러면 WASD가 비 QWERTY 레이아웃에서도 제대로 동작합니다.

js
const keyMap = {
  ArrowLeft: 'left',
  ArrowRight: 'right',
  ArrowUp: 'up',
  ArrowDown: 'down',
  KeyA: 'left',
  KeyD: 'right',
  KeyW: 'up',
  KeyS: 'down',
  Space: 'action',
}

window.addEventListener('keydown', (e) => {
  const action = keyMap[e.code]
  if (action) {
    input[action] = true
    e.preventDefault()
  }
})

window.addEventListener('keyup', (e) => {
  const action = keyMap[e.code]
  if (action) input[action] = false
})

e.preventDefault()는 방향키가 페이지를 스크롤하지 못하게 막습니다. 직접 처리하는 키에만 호출하세요.

마우스

마우스 좌표는 화면 좌표계로 들어옵니다. 캔버스 좌표계로 변환해야 합니다:

js
const canvas = document.getElementById('game')

canvas.addEventListener('mousemove', (e) => {
  const rect = canvas.getBoundingClientRect()
  input.pointer.x = e.clientX - rect.left
  input.pointer.y = e.clientY - rect.top
})

canvas.addEventListener('mousedown', () => {
  input.pointer.down = true
  input.action = true
})

canvas.addEventListener('mouseup', () => {
  input.pointer.down = false
  input.action = false
})

캔버스가 스케일되어 있다면 (반응형 캔버스 튜토리얼 참고) 스케일 비율로 나눠서 게임 좌표를 얻어야 합니다.

터치

터치는 마우스와 비슷하게 동작하지만, 브라우저가 스크롤하거나 확대하지 못하게 막으려면 e.preventDefault()가 필요합니다:

js
canvas.addEventListener('touchstart', (e) => {
  e.preventDefault()
  const touch = e.touches[0]
  const rect = canvas.getBoundingClientRect()
  input.pointer.x = touch.clientX - rect.left
  input.pointer.y = touch.clientY - rect.top
  input.pointer.down = true
  input.action = true
}, { passive: false })

canvas.addEventListener('touchmove', (e) => {
  e.preventDefault()
  const touch = e.touches[0]
  const rect = canvas.getBoundingClientRect()
  input.pointer.x = touch.clientX - rect.left
  input.pointer.y = touch.clientY - rect.top
}, { passive: false })

canvas.addEventListener('touchend', () => {
  input.pointer.down = false
  input.action = false
})

{ passive: false }는 반드시 필요합니다. 최신 브라우저는 성능을 위해 터치 리스너를 기본적으로 패시브 모드로 두기 때문입니다. 이게 없으면 preventDefault가 동작하지 않습니다.

모바일용 가상 D-패드

모바일에서 방향 조작이 필요한 게임이라면 화면 버튼을 추가하세요:

js
function createVirtualDpad(container) {
  const dpad = document.createElement('div')
  dpad.className = 'virtual-dpad'
  dpad.innerHTML = `
    <button data-dir="up">↑</button>
    <button data-dir="left">←</button>
    <button data-dir="right">→</button>
    <button data-dir="down">↓</button>
  `
  
  dpad.querySelectorAll('button').forEach(btn => {
    const dir = btn.dataset.dir
    btn.addEventListener('touchstart', (e) => {
      e.preventDefault()
      input[dir] = true
    })
    btn.addEventListener('touchend', () => input[dir] = false)
  })
  
  container.appendChild(dpad)
}

이 버튼들은 엄지로 누르기 편할 만큼 크게 만들고 (최소 44x44 픽셀) 화면 아래쪽 양 모서리에 배치하세요.

게임패드

키보드나 마우스와 달리 게임패드 상태는 이벤트 기반이 아닙니다. 매 프레임 폴링해야 합니다:

js
function pollGamepads() {
  const gamepads = navigator.getGamepads()
  const gp = gamepads[0]
  
  if (gp) {
    // 십자키 또는 왼쪽 스틱
    input.left = gp.buttons[14]?.pressed || gp.axes[0] < -0.5
    input.right = gp.buttons[15]?.pressed || gp.axes[0] > 0.5
    input.up = gp.buttons[12]?.pressed || gp.axes[1] < -0.5
    input.down = gp.buttons[13]?.pressed || gp.axes[1] > 0.5
    
    // A 버튼 (표준 매핑)
    input.action = gp.buttons[0]?.pressed
  }
}

pollGamepads()는 update 함수 맨 앞에서 호출하세요. 아날로그 스틱의 0.5 임계값은 데드존을 만들어서 작은 스틱 움직임이 입력으로 잡히지 않게 합니다.

게임패드가 연결되고 끊기는 순간을 감지할 수 있습니다:

js
window.addEventListener('gamepadconnected', (e) => {
  console.log('Gamepad connected:', e.gamepad.id)
})

window.addEventListener('gamepaddisconnected', (e) => {
  console.log('Gamepad disconnected:', e.gamepad.id)
})

게임 루프에서 입력 사용하기

이제 update 함수는 입력 상태를 읽기만 하면 됩니다:

js
function update(dt) {
  pollGamepads()
  
  if (input.left) player.x -= player.speed * dt
  if (input.right) player.x += player.speed * dt
  if (input.up) player.y -= player.speed * dt
  if (input.down) player.y += player.speed * dt
  
  if (input.action && player.canShoot) {
    player.shoot()
  }
}

입력이 키보드에서 왔든, 터치에서 왔든, 게임패드에서 왔든 이 코드는 똑같이 동작합니다. 게임 로직은 입력 출처를 알 필요가 없습니다.

방금 눌림 vs 누르고 있음

어떤 버튼이 이번 프레임에 "방금 눌렸는지"를 감지하고 싶을 때가 있습니다. "현재 눌려 있는지"가 아니라요. 점프가 좋은 예입니다. 버튼을 누르고 있는 동안 계속 점프하는 게 아니라, 한 번 누르면 한 번만 점프하길 원하니까요.

js
const inputPrev = { ...input }

function wasJustPressed(action) {
  return input[action] && !inputPrev[action]
}

function endFrame() {
  Object.assign(inputPrev, input)
}

endFrame()은 update 루프가 끝날 때 호출하세요. 그런 다음 한 번만 실행되는 동작에는 input.action 대신 wasJustPressed('action')을 쓰면 됩니다.

포커스 상실

브라우저 창이 포커스를 잃으면 키보드 이벤트가 더 이상 발생하지 않습니다. 플레이어가 키를 누르고 있었다면 그 상태가 그대로 멈춰 버립니다:

js
window.addEventListener('blur', () => {
  input.left = input.right = input.up = input.down = input.action = false
})

이 처리가 없으면 플레이어가 alt-tab으로 빠져나갈 때 캐릭터가 계속 움직입니다.

모범 사례

플레이어마다 취향이 다르니 WASD와 방향키를 모두 지원하세요. 키 재설정을 허용하고 설정은 localStorage에 저장하세요. 모바일 기기에서는 터치 컨트롤을 제공하세요. 실제 게임패드로 테스트하세요. Xbox, PlayStation, 일반 컨트롤러가 모두 조금씩 다르게 동작하기 때문입니다. 그리고 포커스 상실은 항상 부드럽게 처리하세요.

더 읽을거리

Gamepad API 깊이 보기에서는 진동이나 멀티 플레이어 같은 고급 게임패드 기능을 다룹니다.

모바일 친화적인 웹 게임에서는 터치 입력을 더 자세히 다룹니다.

FPS 게임을 위한 포인터 락에서는 일인칭 조작을 위해 마우스를 캡처하는 방법을 보여줍니다.

Canvas 2D 게임 루프에서는 입력 상태 패턴을 완전한 게임 루프에 통합하는 방법을 보여줍니다.

WebSocket 멀티플레이어 기초에서는 네트워크로 연결된 플레이어 간에 입력을 동기화하는 법을 다룹니다.

외부 자료