웹 게임을 위한 Gamepad API
Gamepad API는 콘솔 수준의 컨트롤러 지원을 웹 게임으로 가져옵니다. Xbox, PlayStation, Switch Pro, 그리고 범용 컨트롤러를 지원합니다.
1) 게임패드 감지
js
window.addEventListener('gamepadconnected', (e) => {
console.log('Gamepad connected:', e.gamepad.id)
console.log('Index:', e.gamepad.index)
console.log('Buttons:', e.gamepad.buttons.length)
console.log('Axes:', e.gamepad.axes.length)
})
window.addEventListener('gamepaddisconnected', (e) => {
console.log('Gamepad disconnected:', e.gamepad.id)
})2) 게임패드 상태 폴링
게임패드 상태는 이벤트 기반이 아니라 폴링으로 읽어야 합니다.
js
function pollGamepads() {
const gamepads = navigator.getGamepads()
for (const gamepad of gamepads) {
if (gamepad) {
processGamepad(gamepad)
}
}
}
// 게임 루프 안에서 호출
function gameLoop() {
pollGamepads()
update()
render()
requestAnimationFrame(gameLoop)
}3) 표준 버튼 매핑
"표준" 매핑 (대부분의 Xbox/PlayStation 컨트롤러)
js
const BUTTONS = {
A: 0, // Cross (PS) / A (Xbox)
B: 1, // Circle (PS) / B (Xbox)
X: 2, // Square (PS) / X (Xbox)
Y: 3, // Triangle (PS) / Y (Xbox)
LB: 4, // L1 (PS) / LB (Xbox)
RB: 5, // R1 (PS) / RB (Xbox)
LT: 6, // L2 (PS) / LT (Xbox)
RT: 7, // R2 (PS) / RT (Xbox)
SELECT: 8, // Share (PS) / Back (Xbox)
START: 9, // Options (PS) / Start (Xbox)
L3: 10, // 왼쪽 스틱 누름
R3: 11, // 오른쪽 스틱 누름
DPAD_UP: 12,
DPAD_DOWN: 13,
DPAD_LEFT: 14,
DPAD_RIGHT: 15,
HOME: 16, // PS 버튼 / Xbox 버튼
}
const AXES = {
LEFT_X: 0,
LEFT_Y: 1,
RIGHT_X: 2,
RIGHT_Y: 3,
}4) 버튼과 스틱 읽기
js
function processGamepad(gamepad) {
// 버튼 (디지털 또는 아날로그)
const jump = gamepad.buttons[BUTTONS.A].pressed
const shoot = gamepad.buttons[BUTTONS.X].pressed
// 트리거 (아날로그 0-1)
const leftTrigger = gamepad.buttons[BUTTONS.LT].value
const rightTrigger = gamepad.buttons[BUTTONS.RT].value
// 스틱 (아날로그 -1 ~ 1)
const leftX = gamepad.axes[AXES.LEFT_X]
const leftY = gamepad.axes[AXES.LEFT_Y]
const rightX = gamepad.axes[AXES.RIGHT_X]
const rightY = gamepad.axes[AXES.RIGHT_Y]
return { jump, shoot, leftTrigger, rightTrigger, leftX, leftY, rightX, rightY }
}5) 데드존 처리
스틱은 정확히 0에 멈추는 경우가 드뭅니다.
js
function applyDeadzone(value, deadzone = 0.15) {
if (Math.abs(value) < deadzone) return 0
// 남은 범위를 0-1로 다시 매핑
const sign = Math.sign(value)
const adjusted = (Math.abs(value) - deadzone) / (1 - deadzone)
return sign * adjusted
}
function getStickInput(gamepad) {
return {
leftX: applyDeadzone(gamepad.axes[AXES.LEFT_X]),
leftY: applyDeadzone(gamepad.axes[AXES.LEFT_Y]),
rightX: applyDeadzone(gamepad.axes[AXES.RIGHT_X]),
rightY: applyDeadzone(gamepad.axes[AXES.RIGHT_Y]),
}
}6) 방사형 데드존 (2D 이동에 더 적합)
js
function applyRadialDeadzone(x, y, deadzone = 0.15) {
const magnitude = Math.sqrt(x * x + y * y)
if (magnitude < deadzone) {
return { x: 0, y: 0 }
}
const normalized = {
x: x / magnitude,
y: y / magnitude,
}
const adjusted = (magnitude - deadzone) / (1 - deadzone)
return {
x: normalized.x * adjusted,
y: normalized.y * adjusted,
}
}
// 사용 예시
const stick = applyRadialDeadzone(
gamepad.axes[AXES.LEFT_X],
gamepad.axes[AXES.LEFT_Y]
)
player.vx = stick.x * player.speed
player.vy = stick.y * player.speed7) 완전한 게임패드 매니저
js
class GamepadManager {
constructor() {
this.gamepads = new Map()
this.prevState = new Map()
window.addEventListener('gamepadconnected', (e) => {
this.gamepads.set(e.gamepad.index, e.gamepad)
console.log('Connected:', e.gamepad.id)
})
window.addEventListener('gamepaddisconnected', (e) => {
this.gamepads.delete(e.gamepad.index)
this.prevState.delete(e.gamepad.index)
})
}
poll() {
const gamepads = navigator.getGamepads()
for (const gp of gamepads) {
if (gp) this.gamepads.set(gp.index, gp)
}
}
getState(index = 0) {
const gp = this.gamepads.get(index)
if (!gp) return null
const stick = applyRadialDeadzone(gp.axes[0], gp.axes[1])
const rightStick = applyRadialDeadzone(gp.axes[2], gp.axes[3])
return {
connected: true,
leftStick: stick,
rightStick: rightStick,
// 페이스 버튼
a: gp.buttons[0].pressed,
b: gp.buttons[1].pressed,
x: gp.buttons[2].pressed,
y: gp.buttons[3].pressed,
// 범퍼
lb: gp.buttons[4].pressed,
rb: gp.buttons[5].pressed,
// 트리거 (아날로그)
lt: gp.buttons[6].value,
rt: gp.buttons[7].value,
// 방향키
dpadUp: gp.buttons[12].pressed,
dpadDown: gp.buttons[13].pressed,
dpadLeft: gp.buttons[14].pressed,
dpadRight: gp.buttons[15].pressed,
// 기타
select: gp.buttons[8].pressed,
start: gp.buttons[9].pressed,
}
}
isPressed(index, button) {
const gp = this.gamepads.get(index)
return gp?.buttons[button]?.pressed || false
}
wasJustPressed(index, button) {
const current = this.isPressed(index, button)
const prev = this.prevState.get(index)?.[button] || false
return current && !prev
}
endFrame() {
for (const [index, gp] of this.gamepads) {
const state = {}
for (let i = 0; i < gp.buttons.length; i++) {
state[i] = gp.buttons[i].pressed
}
this.prevState.set(index, state)
}
}
}
// 사용 예시
const gamepadManager = new GamepadManager()
function update() {
gamepadManager.poll()
const state = gamepadManager.getState(0)
if (state) {
player.vx = state.leftStick.x * player.speed
player.vy = state.leftStick.y * player.speed
if (gamepadManager.wasJustPressed(0, BUTTONS.A)) {
player.jump()
}
}
gamepadManager.endFrame()
}8) 진동 / 햅틱 피드백
js
function vibrate(gamepad, duration = 200, weakMagnitude = 0.5, strongMagnitude = 0.5) {
if (gamepad.vibrationActuator) {
gamepad.vibrationActuator.playEffect('dual-rumble', {
startDelay: 0,
duration,
weakMagnitude, // 고주파 모터
strongMagnitude, // 저주파 모터
})
}
}
// 사용 예시
function onPlayerHit() {
const gp = navigator.getGamepads()[0]
if (gp) vibrate(gp, 150, 0.3, 0.6)
}
function onExplosion() {
const gp = navigator.getGamepads()[0]
if (gp) vibrate(gp, 300, 0.8, 1.0)
}9) 여러 플레이어
js
class LocalMultiplayer {
constructor(maxPlayers = 4) {
this.maxPlayers = maxPlayers
this.players = []
}
getActivePlayers() {
const gamepads = navigator.getGamepads()
const active = []
for (let i = 0; i < this.maxPlayers; i++) {
if (gamepads[i]) {
active.push({
index: i,
gamepad: gamepads[i],
state: this.getState(gamepads[i])
})
}
}
return active
}
getState(gp) {
// ... 앞과 동일
}
}
// 게임 루프 안에서
const multiplayer = new LocalMultiplayer(4)
function update() {
const players = multiplayer.getActivePlayers()
for (const { index, state } of players) {
updatePlayer(index, state)
}
}10) 폴백과 조합 입력
키보드와 게임패드를 함께 지원하기
js
class InputManager {
constructor() {
this.keyboard = new KeyboardInput()
this.gamepad = new GamepadManager()
}
poll() {
this.gamepad.poll()
}
getInput(playerIndex = 0) {
const gpState = this.gamepad.getState(playerIndex)
const kbState = this.keyboard.getState()
// 입력 결합 (아날로그는 게임패드를 우선)
return {
moveX: gpState?.leftStick.x || (kbState.left ? -1 : kbState.right ? 1 : 0),
moveY: gpState?.leftStick.y || (kbState.up ? -1 : kbState.down ? 1 : 0),
jump: gpState?.a || kbState.space,
shoot: gpState?.x || kbState.z,
pause: gpState?.start || kbState.escape,
}
}
endFrame() {
this.gamepad.endFrame()
this.keyboard.endFrame()
}
}관련 글
- 게임 입력 처리
- 모바일 친화적인 웹 게임
- Canvas 2D 게임 루프
- FPS 게임을 위한 Pointer Lock — 게임패드와 마우스 시점 결합하기
- 협동 게임 디자인 — 로컬 협동에서 여러 컨트롤러 입력을 위한 설계
외부 자료
- MDN: Gamepad API — 전체 API 레퍼런스
- MDN: Using the Gamepad API — 단계별 가이드
- Gamepad Tester — 브라우저에서 컨트롤러 매핑 테스트하기