Gamepad API를 활용한 장애물 넘기기

Marcin Wichary
Marcin Wichary

소개

초보자들은 키보드로 어드벤처 게임을 하고, 소중한 멀티 터치 손가락으로 과일을 자르고, 멋진 최신 움직임 감지 센서로 마이클 잭슨처럼 춤을 추는 척할 수 있습니다. (뉴스 속보: 그럴 수 없습니다.) 하지만 너는 달라. 네가 더 나아. 프로의 경지에요. 게임패드를 손에 쥐는 순간 게임이 시작되고, 게임패드를 내려놓는 순간 게임이 끝납니다.

하지만 잠깐만요. 웹 앱에서 게임패드를 지원하고 싶다면 운이 나쁜 것 아닌가요? 더 이상은 그럴 필요가 없습니다. 새로운 Gamepad API를 사용하면 JavaScript를 사용하여 컴퓨터에 연결된 모든 게임패드 컨트롤러의 상태를 읽을 수 있습니다. 지난주에 Chrome 21에만 적용된 최신 기능이며 Firefox에서도 곧 지원될 예정입니다 (현재 특별 빌드에서 사용 가능).

최근 허들 2012 Google 기념일 로고에 이 기능을 사용할 기회가 있었기 때문에 타이밍이 아주 좋았습니다. 이 도움말에서는 Gamepad API를 기념일 로고에 추가한 방법과 이 과정에서 배운 내용을 간략하게 설명합니다.

2012년 허들 Google 기념일 로고
허들 2012 Google 기념일 로고

게임패드 테스터

상호작용형 기념일 로고는 일시적으로 표시되지만 내부적으로는 꽤 복잡한 경우가 많습니다. 설명을 더 쉽게 하기 위해 Doodle에서 게임패드 코드를 가져와 간단한 게임패드 테스터를 만들었습니다. 이를 사용하여 USB 게임패드가 올바르게 작동하는지 확인하고 내부를 살펴보고 어떻게 작동하는지 확인할 수 있습니다.

현재 어떤 브라우저에서 지원되나요?

Browser Support

  • Chrome: 21.
  • Edge: 12.
  • Firefox: 29.
  • Safari: 10.1.

Source

어떤 게임패드를 사용할 수 있나요?

일반적으로 시스템에서 기본적으로 지원하는 최신 게임패드는 모두 작동합니다. PC에 연결된 비정품 USB 컨트롤러부터 동글을 통해 Mac에 연결된 PlayStation 2 게임패드, Chrome OS 노트북과 페어링된 블루투스 컨트롤러에 이르기까지 다양한 게임패드를 테스트했습니다.

게임패드
게임패드

이 사진은 Doodle을 테스트하는 데 사용한 컨트롤러입니다. "엄마, 제가 회사에서 하는 일이 정말 이거예요." 컨트롤러가 작동하지 않거나 컨트롤이 잘못 매핑된 경우 Chrome 또는 Firefox에 버그를 신고하세요. (이미 수정되지 않았는지 확인하려면 각 브라우저의 최신 버전에서 테스트하세요.)

Gamepad API 기능 감지<

Chrome:

var gamepadSupportAvailable = !!navigator.webkitGetGamepads || !!navigator.webkitGamepads;

Firefox에서는 아직 이를 감지할 수 없는 것 같습니다. 모든 것이 이벤트 기반이며 모든 이벤트 핸들러를 창에 연결해야 하므로 이벤트 핸들러를 감지하는 일반적인 기법이 작동하지 않습니다.

하지만 일시적인 현상일 뿐입니다. 매우 유용한 Modernizr는 이미 Gamepad API에 대해 알려주므로 현재 및 향후의 모든 감지 요구사항에 이 API를 사용하는 것이 좋습니다.

var gamepadSupportAvailable = Modernizr.gamepads;

연결된 게임패드에 대해 알아보기

게임패드를 연결하더라도 사용자가 버튼을 누르지 않으면 어떤 방식으로도 표시되지 않습니다. 이는 디지털 지문 수집을 방지하기 위한 것이지만 사용자 환경에 약간의 문제가 될 수 있습니다. 사용자가 컨트롤러를 연결했는지 알 수 없으므로 사용자에게 버튼을 누르거나 게임패드 관련 안내를 제공하도록 요청할 수 없습니다.

하지만 이 장애물을 넘고 나면 (죄송합니다…) 더 많은 장애물이 기다리고 있습니다.

폴링

Chrome의 API 구현은 navigator.webkitGetGamepads() 함수를 노출합니다. 이 함수를 사용하면 현재 시스템에 연결된 모든 게임패드 목록과 현재 상태 (버튼 + 스틱)를 가져올 수 있습니다. 첫 번째로 연결된 게임패드는 배열의 첫 번째 항목으로 반환됩니다.

(이 함수 호출은 최근에 직접 액세스할 수 있는 배열인 navigator.webkitGamepads[]를 대체했습니다. 2012년 8월 초부터 Chrome 21에서는 이 배열에 액세스해야 하지만 Chrome 22 이상에서는 함수 호출이 작동합니다. 앞으로는 함수 호출이 API를 사용하는 권장 방법이며, 설치된 모든 Chrome 브라우저에 점진적으로 적용될 예정입니다.)

지금까지 구현된 사양 부분에서는 변경사항이 있을 때 이벤트를 실행하는 대신 연결된 게임패드의 상태를 지속적으로 확인하고 필요한 경우 이전 상태와 비교해야 합니다. requestAnimationFrame()을 사용하여 가장 효율적이고 배터리 친화적인 방식으로 폴링을 설정했습니다. Google 기념일 로고의 경우 애니메이션을 지원하는 requestAnimationFrame() 루프가 이미 있지만 완전히 별개의 두 번째 루프를 만들었습니다. 코딩이 더 간단하고 어떤 식으로든 성능에 영향을 미치지 않기 때문입니다.

테스터의 코드는 다음과 같습니다.

/**
 * Starts a polling loop to check for gamepad state.
 */
startPolling: function() {
    // Don't accidentally start a second loop, man.
    if (!gamepadSupport.ticking) {
    gamepadSupport.ticking = true;
    gamepadSupport.tick();
    }
},

/**
 * Stops a polling loop by setting a flag which will prevent the next
 * requestAnimationFrame() from being scheduled.
 */
stopPolling: function() {
    gamepadSupport.ticking = false;
},

/**
 * A function called with each requestAnimationFrame(). Polls the gamepad
 * status and schedules another poll.
 */
tick: function() {
    gamepadSupport.pollStatus();
    gamepadSupport.scheduleNextTick();
},

scheduleNextTick: function() {
    // Only schedule the next frame if we haven't decided to stop via
    // stopPolling() before.
    if (gamepadSupport.ticking) {
    if (window.requestAnimationFrame) {
        window.requestAnimationFrame(gamepadSupport.tick);
    } else if (window.mozRequestAnimationFrame) {
        window.mozRequestAnimationFrame(gamepadSupport.tick);
    } else if (window.webkitRequestAnimationFrame) {
        window.webkitRequestAnimationFrame(gamepadSupport.tick);
    }
    // Note lack of setTimeout since all the browsers that support
    // Gamepad API are already supporting requestAnimationFrame().
    }
},

/**
 * Checks for the gamepad status. Monitors the necessary data and notices
 * the differences from previous state (buttons for Chrome/Firefox,
 * new connects/disconnects for Chrome). If differences are noticed, asks
 * to update the display accordingly. Should run as close to 60 frames per
 * second as possible.
 */
pollStatus: function() {
    // (Code goes here.)
},

하나의 게임패드만 고려하는 경우 다음과 같이 간단하게 데이터를 가져올 수 있습니다.

var gamepad = navigator.webkitGetGamepads && navigator.webkitGetGamepads()[0];

좀 더 영리하게 처리하거나 동시에 두 명 이상의 플레이어를 지원하려면 더 복잡한 시나리오 (두 개 이상의 게임패드가 연결되어 있고 그중 일부가 중간에 연결 해제되는 경우 등)에 대응하기 위해 몇 줄의 코드를 더 추가해야 합니다. 이 문제를 해결하는 한 가지 방법은 테스터 함수 pollGamepads()의 소스 코드를 살펴보는 것입니다.

이벤트

Firefox는 Gamepad API 사양에 설명된 더 나은 대체 방법을 사용합니다. 폴링을 요청하는 대신 게임패드가 연결되거나(더 정확하게는 연결되고 버튼을 눌러 '알림') 연결 해제될 때마다 실행되는 두 가지 이벤트(MozGamepadConnected 및 MozGamepadDisconnected)를 노출합니다. 향후 상태를 계속 반영할 게임패드 객체는 이벤트 객체의 .gamepad 매개변수로 전달됩니다.

테스터 소스 코드에서 다음을 실행합니다.

/**
 * React to the gamepad being connected. Today, this will only be executed
 * on Firefox.
 */
onGamepadConnect: function(event) {
    // Add the new gamepad on the list of gamepads to look after.
    gamepadSupport.gamepads.push(event.gamepad);

    // Start the polling loop to monitor button changes.
    gamepadSupport.startPolling();

    // Ask the tester to update the screen to show more gamepads.
    tester.updateGamepads(gamepadSupport.gamepads);
},

요약

결국 두 접근 방식을 모두 지원하는 테스터의 초기화 함수는 다음과 같습니다.

/**
 * Initialize support for Gamepad API.
 */
init: function() {
    // As of writing, it seems impossible to detect Gamepad API support
    // in Firefox, hence we need to hardcode it in the third clause.
    // (The preceding two clauses are for Chrome.)
    var gamepadSupportAvailable = !!navigator.webkitGetGamepads ||
        !!navigator.webkitGamepads ||
        (navigator.userAgent.indexOf('Firefox/') != -1);

    if (!gamepadSupportAvailable) {
    // It doesn't seem Gamepad API is available – show a message telling
    // the visitor about it.
    tester.showNotSupported();
    } else {
    // Firefox supports the connect/disconnect event, so we attach event
    // handlers to those.
    window.addEventListener('MozGamepadConnected',
                            gamepadSupport.onGamepadConnect, false);
    window.addEventListener('MozGamepadDisconnected',
                            gamepadSupport.onGamepadDisconnect, false);

    // Since Chrome only supports polling, we initiate polling loop straight
    // away. For Firefox, we will only do it if we get a connect event.
    if (!!navigator.webkitGamepads || !!navigator.webkitGetGamepads) {
        gamepadSupport.startPolling();
    }
    }
},

게임패드 정보

시스템에 연결된 모든 게임패드는 다음과 같은 객체로 표현됩니다.

id: "PLAYSTATION(R)3 Controller (STANDARD GAMEPAD Vendor: 054c Product: 0268)"
index: 1
timestamp: 18395424738498
buttons: Array[8]
    0: 0
    1: 0
    2: 1
    3: 0
    4: 0
    5: 0
    6: 0.03291
    7: 0
axes: Array[4]
    0: -0.01176
    1: 0.01961
    2: -0.00392
    3: -0.01176

기본 정보

상단의 몇 가지 필드는 간단한 메타데이터입니다.

  • id: 게임패드에 대한 텍스트 설명
  • index: 한 컴퓨터에 연결된 여러 게임패드를 구분하는 데 유용한 정수입니다.
  • timestamp: 버튼/축 상태의 마지막 업데이트 타임스탬프 (현재 Chrome에서만 지원됨)

버튼 및 스틱

오늘날의 게임패드는 할아버지가 다른 성에서 공주를 구하기 위해 사용했던 것과는 다릅니다. 일반적으로 아날로그 스틱 2개 외에도 최소 16개의 개별 버튼 (일부는 개별, 일부는 아날로그)이 있습니다. Gamepad API는 운영체제에서 보고하는 모든 버튼과 아날로그 스틱에 관한 정보를 제공합니다.

게임패드 객체에서 현재 상태를 가져오면 .buttons[] 배열을 통해 버튼에 액세스하고 .axes[] 배열을 통해 스틱에 액세스할 수 있습니다. 다음은 각 항목에 해당하는 내용을 시각적으로 요약한 것입니다.

게임패드 다이어그램
게임패드 다이어그램

사양은 브라우저에 처음 16개의 버튼과 4개의 축을 다음에 매핑하도록 요청합니다.

gamepad.BUTTONS = {
    FACE_1: 0, // Face (main) buttons
    FACE_2: 1,
    FACE_3: 2,
    FACE_4: 3,
    LEFT_SHOULDER: 4, // Top shoulder buttons
    RIGHT_SHOULDER: 5,
    LEFT_SHOULDER_BOTTOM: 6, // Bottom shoulder buttons
    RIGHT_SHOULDER_BOTTOM: 7,
    SELECT: 8,
    START: 9,
    LEFT_ANALOGUE_STICK: 10, // Analogue sticks (if depressible)
    RIGHT_ANALOGUE_STICK: 11,
    PAD_TOP: 12, // Directional (discrete) pad
    PAD_BOTTOM: 13,
    PAD_LEFT: 14,
    PAD_RIGHT: 15
};

gamepad.AXES = {
    LEFT_ANALOGUE_HOR: 0,
    LEFT_ANALOGUE_VERT: 1,
    RIGHT_ANALOGUE_HOR: 2,
    RIGHT_ANALOGUE_VERT: 3
};

추가 버튼과 축은 위의 버튼과 축에 추가됩니다. 하지만 16개의 버튼이나 4개의 축이 모두 보장되는 것은 아닙니다. 일부는 정의되지 않을 수도 있습니다.

버튼은 0.0 (누르지 않음)에서 1.0 (완전히 누름) 사이의 값을 가질 수 있습니다. 축은 -1.0 (완전히 왼쪽 또는 위쪽)에서 0.0 (중앙)을 거쳐 1.0 (완전히 오른쪽 또는 아래쪽)까지입니다.

아날로그 또는 이산?

표면적으로는 모든 버튼이 아날로그 버튼일 수 있습니다. 예를 들어 숄더 버튼의 경우 이러한 경우가 흔합니다. 따라서 단순히 1.00과 비교하는 대신 기준점을 설정하는 것이 가장 좋습니다 (아날로그 버튼이 약간 더러운 경우). 1.00에 도달하지 않을 수도 있습니다. 이 Doodle에서는 다음과 같이 합니다.

gamepad.ANALOGUE_BUTTON_THRESHOLD = .5;

gamepad.buttonPressed_ = function(pad, buttonId) {
    return pad.buttons[buttonId] &&
            (pad.buttons[buttonId] > gamepad.ANALOGUE_BUTTON_THRESHOLD);
};

아날로그 스틱을 디지털 조이스틱으로 전환하는 경우에도 동일한 작업을 수행할 수 있습니다. 물론 디지털 패드 (D패드)가 항상 있지만 게임패드에 없을 수도 있습니다. 이를 처리하는 코드는 다음과 같습니다.

gamepad.AXIS_THRESHOLD = .75;

gamepad.stickMoved_ = function(pad, axisId, negativeDirection) {
    if (typeof pad.axes[axisId] == 'undefined') {
    return false;
    } else if (negativeDirection) {
    return pad.axes[axisId] < -gamepad.AXIS_THRESHOLD;
    } else {
    return pad.axes[axisId] > gamepad.AXIS_THRESHOLD;
    }
};

버튼 누르기 및 스틱 움직임

이벤트

비행 시뮬레이터 게임과 같은 경우에는 스틱 위치나 버튼 누름을 지속적으로 확인하고 반응하는 것이 더 합리적이지만, 허들 2012 기념일 로고와 같은 경우에는 어떨까요? 프레임마다 버튼을 확인해야 하는 이유가 궁금할 수 있습니다. 키보드나 마우스 위/아래와 같은 이벤트를 가져올 수 없는 이유는 무엇인가요?

다행히도 가능합니다. 나쁜 소식은 미래에 있습니다. 사양에는 있지만 아직 어떤 브라우저에도 구현되지 않았습니다.

폴링

그동안에는 현재 상태와 이전 상태를 비교하고 차이가 있는 경우 함수를 호출하는 방법이 있습니다. 예를 들면 다음과 같습니다.

if (buttonPressed(pad, 0) != buttonPressed(oldPad, 0)) {
    buttonEvent(0, buttonPressed(pad, 0) ? 'down' : 'up');
}
for (var i in gamepadSupport.gamepads) {
    var gamepad = gamepadSupport.gamepads[i];

    // Don't do anything if the current timestamp is the same as previous
    // one, which means that the state of the gamepad hasn't changed.
    // This is only supported by Chrome right now, so the first check
    // makes sure we're not doing anything if the timestamps are empty
    // or undefined.
    if (gamepadSupport.prevTimestamps[i] &&
        (gamepad.timestamp == gamepadSupport.prevTimestamps[i])) {
    continue;
    }
    gamepadSupport.prevTimestamps[i] = gamepad.timestamp;

    gamepadSupport.updateDisplay(i);
}

허들 2012 기념일 로고의 키보드 우선 접근 방식

게임패드가 없는 경우 오늘 기념일 로고의 기본 입력 방법은 키보드이므로 게임패드가 키보드를 거의 그대로 에뮬레이션하도록 결정했습니다. 이는 세 가지 결정을 의미했습니다.

  1. 이 기념일 로고에는 달리기 버튼 2개와 점프 버튼 1개, 총 3개의 버튼만 필요하지만 게임패드에는 더 많은 버튼이 있을 수 있습니다. 따라서 알려진 16개의 버튼과 2개의 스틱을 가장 합리적이라고 생각되는 방식으로 세 가지 논리적 기능에 매핑하여 사용자가 A/B 버튼을 번갈아 누르거나, 숄더 버튼을 번갈아 누르거나, D패드에서 왼쪽/오른쪽을 누르거나, 스틱을 왼쪽과 오른쪽으로 세게 흔드는 방식으로 달릴 수 있도록 했습니다 (물론 일부는 다른 것보다 더 효율적입니다). 예를 들면 다음과 같습니다.

    newState[gamepad.STATES.LEFT] =
        gamepad.buttonPressed_(pad, gamepad.BUTTONS.PAD_LEFT) ||
        gamepad.stickMoved_(pad, gamepad.AXES.LEFT_ANALOGUE_HOR, true) ||
        gamepad.stickMoved_(pad, gamepad.AXES.RIGHT_ANALOGUE_HOR, true),
    
    newState[gamepad.STATES.PRIMARY_BUTTON] =
        gamepad.buttonPressed_(pad, gamepad.BUTTONS.FACE_1) ||
        gamepad.buttonPressed_(pad, gamepad.BUTTONS.LEFT_SHOULDER) ||
        gamepad.buttonPressed_(pad, gamepad.BUTTONS.LEFT_SHOULDER_BOTTOM) ||
        gamepad.buttonPressed_(pad, gamepad.BUTTONS.SELECT) ||
        gamepad.buttonPressed_(pad, gamepad.BUTTONS.START) ||
        gamepad.buttonPressed_(pad, gamepad.BUTTONS.LEFT_ANALOGUE_STICK),
    
  2. 앞서 설명한 임곗값 함수를 사용하여 각 아날로그 입력을 개별 입력으로 처리했습니다.

  3. 우리는 게임패드 입력을 Doodle에 내장하는 대신 Doodle에 볼트로 고정하는 것까지 진행했습니다. 폴링 루프는 실제로 필요한 keydown 및 keyup 이벤트 (적절한 keyCode 포함)를 합성하여 DOM으로 다시 보냅니다.

    // Create and dispatch a corresponding key event.
    var event = document.createEvent('Event');
    var eventName = down ? 'keydown' : 'keyup';
    event.initEvent(eventName, true, true);
    event.keyCode = gamepad.stateToKeyCodeMap_[state];
    gamepad.containerElement_.dispatchEvent(event);

이제 모두 끝났습니다.

도움말 및 유용한 정보

  • 버튼을 누르기 전에는 브라우저에 게임패드가 전혀 표시되지 않습니다.
  • 여러 브라우저에서 동시에 게임패드를 테스트하는 경우 그중 하나만 컨트롤러를 감지할 수 있습니다. 이벤트가 수신되지 않으면 이를 사용 중일 수 있는 다른 페이지를 닫으세요. 또한 경험상 탭을 닫거나 브라우저 자체를 종료해도 브라우저가 게임패드를 '계속 유지'하는 경우가 있습니다. 시스템을 다시 시작하는 것이 문제를 해결하는 유일한 방법인 경우도 있습니다.
  • 항상 그렇듯이 Chrome Canary 및 다른 브라우저의 동급 버전을 사용하여 최상의 지원을 받고 있는지 확인한 다음 이전 버전이 다르게 작동하는 경우 적절하게 조치를 취하세요.

미래의 업무 환경의 기반,

이 글이 새로운 API를 이해하는 데 도움이 되기를 바랍니다. 아직은 약간 불안정하지만 이미 많은 재미를 선사하고 있습니다.

API의 누락된 부분 (예: 이벤트)과 더 광범위한 브라우저 지원 외에도, 진동 제어, 내장 자이로스코프 액세스 등도 지원되기를 바랍니다. 또한 다양한 유형의 게임패드에 대한 지원도 확대되기를 바랍니다. 제대로 작동하지 않거나 전혀 작동하지 않는 게임패드를 발견하면 Chrome에 버그를 신고하거나 Firefox에 버그를 신고해 주세요.

그 전에 허들 2012 낙서를 플레이하고 게임패드에서 얼마나 더 재미있는지 확인해 보세요. 방금 10.7초보다 더 잘할 수 있다고 하셨나요? 한판 붙어볼까요?

추가 자료