GamepadAPI или джойстик в браузере

    Здравствуй, Хабр!





    Смотря, как всё более новые и новые технологии внедряются в веб, смотря, как в него переносят игры, я задумался: «А было бы круто, если бы геймпад тоже можно было подключить...». И в поиске первым же результатом было GamepadAPI.
    Немного ниже ссылка на W3C GamepadAPI. Посмотрев, попробовав, я обнаружил ряд проблем, подводных камней, которые поставили бы крест на внедрении джойстиков в браузер. И я решил это исправить, создав интерфейс. Что есть «из коробки», и что именно было доработано, изменено и на мой взгляд улучшено, описано под катом.



    Что есть в GamepadAPI?



    Поддерживается API в фаерфоксе, в хромиуме, опере.
    В полной версии:

    navigator.getGamepads(); возвращают массив джойстиков, объектов Gamepad.
    События подключения и отключения джойстика в объекте window (именно, получение джойстиков из navigator, а события в window): "gamepadconnected", "gamepaddisconnected".
        window.addEventListener("gamepadconnected", function(e) {...})
    

    в функцию передаётся объект события, где свойство e.gamepad — джойстик, который подключился или отключился.

    В самом объекте Gamepad есть свойства:
    • id содержит vendor id, product id (USB) и описание. Формат записи не регламентирован;
    • index которое по счёту подключение;
    • mapping строка, в которой пишется был ли ремапинг и если да, то какой;
    • connected подключен ли джойстик;
    • timestamp DOMHighResTimeStamp когда последний раз обновлялись данные по джойстику;
    • axes массив осей и значений от -1 до 1;
    • buttons массив кнопок, объектов, содержащих pressed (boolean) и value [0; 1] т.к. у триггеров может быть плавное изменение значения, то это следует учесть иногда.


    Но есть две жуткие оговорки:
    1. axes (оси) имеют значение 0 при инициализации, тогда как на самом деле могут быть в значении -1. Это касается курков (триггеров) в линуксе для XInput, в окошках же курки имеют вообще одну ось! Только один меняет значение в положительную сторону, а второй — в отрицательную, что значит, что нажав оба вы получите снова 0.
    2. id своеволен. Чтобы самому распознать джойстик нужно знать VID и PID, значит разбирать надо именно это свойство, но формат «пляшет»: в хромимуме строка содержит описание, а только потом "Vendor: 092c Product: 12a8", в фаерфоксе строка начинается с них, разделяя минусами, например "092c-12a8-...", но самое поганое, что в окошках оказалось, что предзаполнение нулями попросту отсутствует, поэтому в винде строка трансформируется в "92c-12a8-..."


    Т.к. хромиум пытался ввести поддержку впереди планеты всей, ориентируясь по черновикам, поэтому оговорок для браузеров, в которых только префикс webkit, больше:
    • никакого connected;
    • никаких событий подключения и отключения. Хуже: чтобы массив, возвращаемый navigator.webkitGetGamepads() проявился, во время вызова этой функции джойстик должен быть активен (например нажата кнопка);
    • maping пуст, хотя ремаппинг есть;
    • buttons массив значений, а не объектов.

    Часть проблем прошла сквозь время и проявляется даже после полной поддержки стандарта (т.е. существуют во всех версиях хромиумов, где джойстики вообще есть):
    • Если модель джойстика неизвестна, то её вообще нет. В этом случае фф хотя бы даёт интерфейс «как есть», правда он все модели не знает (в исходниках посмотрев видно, что он обращается к API ОС чтобы работать с джойстиками стандартно и без заморочек);
    • Объекты Gamepad не обновляются, пока не вызван navigator.webkitGetGamepads() или navigator.getGamepads() (если он есть, при чём, если он есть, а вызвать старую версию, то будет брошено «внимание» и вообще ничего не обновится). Т.е. получив объект из функции не обязательно получать его заново, но обязательно просто вызывать эту функцию.



    Что же и как совершенствовал?



    Писать я решил на coffeescript.
    Он мне ближе, в нём есть классы, (так же я допилил немного процессор и выложил его, теперь в нём есть почти полноценный Си-шный препроцессор!) Поэтому и примеры дальше на кофескрипте.

    Чуточку подробнее о препроцессоре...
    Кто не знаком с таковым, но знаком с РНР, препроцессор включает файлы аналогично include и определяет константы аналогично define, то тут они есть. Нормальное описание о препроцессоре си можете найти у Кернигана и Ричи, а так же на просторах всемирной паутины.

    Тем кто знаком скажу, что define в функциональном стиле не заработает, а так же передавать определения через командную строку (-DDEBUG например) пока нельзя. (папки включений же можно). В остальном стандарт реализовывал предельно близко к С++11, включая папки включения, замены в заменах, условные операторы. Но исходных констант нет, а include сохраняет отступы (включает файл, добавляя отступы перед строками, равные отступу, на котором написана директива. Нужно из-за синтаксиса языка).



    Первые две проблемы, которые вылезли сразу:
    1. Ассоциации элементов или маппинг. В фаерфоксе его нет, в хромиуме есть.
    2. Отсутствие событийности. Нельзя взять и навесить слушатель на кнопку или стик.



    Ассоциации элементов или маппинг.


    Для удобства я разделил кнопки джойстика на логические блоки.
    • dpad или, в народе, крестовина
    • lrtb триггеры и бамперы (не знаю, как назвать)
    • menu кнопки меню
    • axes стики и их кнопки
    • face главные экшн-кнопки

    Сделано это ещё для того, чтобы отслеживать изменения у группы элементов.


    Нагло взяв исходные коды ассоциаций кнопок из проекта хромиум я создал карты ассоциаций для джойстиков. Оказывается, они зависят от платформ, а значит для окошек и для пингвина они отличаются от макинтошей. Но что делать, если это новый и/или малоизвестный джойстик? На этот случай класс GamepadMap вынес отдельно. Объект, созданный из этого класса, можно передать в конструктор интерфейса.


    Но не всегда всё так плохо! Бывает, что ассоциации в норме. Чтобы отличать готовый маппинг от сырого, ориентируюсь по количеству «осей». В случае, если их не 4 (вертикальная и горизонтальная для каждого из двух стиков), то пытаюсь найти карту ассоциаций получив из свойства "id" VID и PID. Это не безопасно с одной стороны, но с другой параметра лучше найти не смог. Даже значение параметра «mapping» не даёт ничего: в хромиуме, роботающим только с префиксом webkit, этот параметр пуст, но ассоциации уже готовы, как писал выше.


    Внедряем событийность.


    Единственные события, которые есть в GamepadAPI это gamepadconnected и gamepaddisconnected. Нажатия на кнопки и изменения в стиках надо получать самостоятельно. Теоретически это полезно, но на практике не всегда удобно. Особенно, если создавать альтернативу «клавамыши».


    И тут я познал дзен в 5 шагов:



    Получение состояния.


    Т.к. W3C не даёт вообще никаких рекомендаций на счёт изменение состояния объекта Gamepad в зависимости от реального изменения состояния, то хромиум не стал утруждаться, что в первый (на первых парах), что и во второй раз (поддерживая стандарт полностью): свойства объекта Gamepad актуализируются только при опросе через navigator.getGamepads() или navigator.webkitGetGamepads(). В огнелисе же всё проще, состояние обновляется автоматически. Поэтому если webkit, то дёргаем этот метод каждый раз перед опросом.



    EventTarget интерфейс.


    Захотелось воссоздать EventTarget интерфейс для элементов, но нельзя просто взять и создать extends EventTarget. Пришлось «наколеночить» свою реализацию, но соблюдая стандарт. Почему не взять готовый Emet? В нём нет и близко соблюдения стандарта, а мне хотелось выполнить всё стандартно там, где это возможно.

    Немного полезных методов, таких, как on, off, emet, цепочки и вуаля, класс EventTargetEmiter:
    Код класса EventTargetEmiter
    class EventTargetEmiter # implements EventTarget
    
      ###*
       * Список подпсок на события по названию в виде массива.
       * @protected
       * @type Object
      ###
      _subscribe: null
    
      ###*
       * Ссылка на родительский элемент
       * @public
       * @type EventTargetEmiter
      ###
      parent: null
    
      ###*
       * Проверяет правильность создаваемого обработчика события.
       * @protected
       * @method _checkValues
       * @param String|* type имя события
       * @param Handler|* listener  функция-обработчик
      ###
      _checkValues: (type, listener) ->
        unless isString type
          ERR "type not string"
          return false
        unless isFunction listener
          ERR "listener is not a function"
          return false
        true
    
      ###*
       * Перечисленные в `list` события декларируют события и создат традиционные 
       * handler-обработчики
       * @constructor
       * @param Array list названия событий
      ###
      constructor: (list...) ->
        @_subscribe =
          _length: 0
        for e in list
          @_subscribe[e] = []
          @['on' + e] = null
    
      ###*
       * Add function `listener` by `type` with `useCapture`
       * @public
       * @method addEventListener
       * @param String type
       * @param Handler listener
       * @param Boolean useCapture = false
       * @return void
      ###
      addEventListener: (type, listener, useCapture = false) ->
        unless @_checkValues(type, listener)
          return
        useCapture = not not useCapture
        @_subscribe[type].push [listener, useCapture]
        @_subscribe._length++
        return
    
      ###*
       * Remove function `listener` by `type` with `useCapture`
       * @public
       * @method removeEventListener
       * @param String type 
       * @param Handler listener
       * @param Boolean useCapture = false
      ###
      removeEventListener: (type, listener, useCapture = false) ->
        unless @_checkValues(type, listener)
          return
        useCapture = not not useCapture
        return unless @_subscribe[type]?
        for fn, i in @_subscribe[type]
          if fn[0] is listener and fn[1] is useCapture
            @_subscribe[type].splice i, 1
            @_subscribe._length--
            return
        return
    
      ###*
       * Burn, baby, burn!
       * @public
       * @method dispatchEvent
       * @param Event evt
       * @return Boolean
      ###
      dispatchEvent: (evt) ->
        unless evt instanceof Event
          ERR "evt is not event."
          return false
        t = evt.type
        unless @_subscribe[t]?
          throw new EventException "UNSPECIFIED_EVENT_TYPE_ERR"
          return false
        @emet t, evt
    
      ###*
       * Alias for addEventListener, but return this
       * @public
       * @method on
       * @param String type
       * @param Handler listener
       * @param Boolean useCapture
       * @return this
      ###
      on: (args...) ->
        @addEventListener args...
        @
    
      ###*
       * Alias for removeEventListener
       * @public
       * @method off
       * @param String type
       * @param Handler listener
       * @param Boolean useCapture
       * @return this
      ###
      off: (args...) ->
        @removeEventListener args...
        @
    
      ###*
       * Emiter event by `name` and create event or use `evt` if exist
       * @param String name
       * @param Event|null evt
       * @return Boolean
      ###
      emet: (name, evt = null) ->
        # run handled-style listeners
        r = @['on' + name](evt) if isFunction @['on' + name]
        return false if r is false
        # run other
        for fn in @_subscribe[name]
          try r = fn[0](evt)
          break if fn[1] is true or r is false
        if evt?.bubbles is true
          try @parent.emet name, evt
        if evt? then not evt.defaultPrevented else true
    


    свойство _subscribe доступно извне, но это не беда, кто правит протектные свойства (с подчёркиванием) готов к выстрелу себе в ногу. К объекту можно приписать родительский объект, в который передастся «всплывающее» событие.


    Event и CustomEvent.


    Чтобы понять, кто вызвал событие, следует создавать Event, но просто создать Event и задать ему свойства нам не позволяют. На выручку приходит CustomEvent, в котором свойство detail настраиваемо. А чтобы событие вызывалось и в родительских элементах не забываем устанавливать canBubble в true в конструкторе.



    Опрос состояний или pooling.


    Во всех примерах связанных с GamepadAPI для опроса состояния используют requestAnimationFrame. В этом есть плюс и минус:
    плюс в том, что когда окно не активно, то и опрашивать состояние незачем.
    Но с другой стороны, если это игра, то этот вызов необходим для отрисовки, иначе может пострадать плавность анимации.
    Поэтому я решил пойти алтернативным «старинным» путём: focus/blur для окна, setInterval для планировщика и единичный requestAnimationFrame для первого запуска (ведь окно может загрузиться в фоне). Таким образом, браузер сам займётся списком заданий, выполнит необходимые между отрисовками.
    Source
      tick = (time, fn) -> # для удобной записи
        setInterval fn, time
      
      stopTick = (tickId) ->
        clearInterval tickId
    
      _startShedule: (Hz = 60) ->
        requestAnimationFrame = top.requestAnimationFrame or top.mozRequestAnimationFrame or top.webkitRequestAnimationFrame
        requestAnimationFrame => # первый запуск и инициализация
          t = null
          startTimers = ->
            t is null and t = tick (1000 / Hz |0), -> # создаём планировщик, если его нет
              body()
            return
          stopTimers = ->
            if t isnt null # если планировщик есть, то мы его убьём
              stopTick(t)
              t = null
            return
          window.addEventListener 'focus', ->
            startTimers()
          window.addEventListener 'blur', ->
            stopTimers()
          startTimers()
          return
        return
    



    Один геймпад? Вы забыли, как мы играли вдвоём?


    В системе может быть зарегистрировано несколько джойстиков. Да ещё и navigator.getGamepads() возвращает массив, так что нам нужен массив. Но нам бы событийность. Вот тут начинаются пляски с бубном: чтобы унаследовать Array нужно в конструкторе добавить короткую строчку:
      constructor: (items...) ->
        @splice 0, 0, items...
    


    Но нам этого мало, нам бы ещё EventTargetEmiter унаследовать. Сделать это напрямую в кофескрипте не получилось. Поэтому мне помогла простенькая функция, которая передаёт методы и свойства в this:
    _implements = (mixins...) ->
      for mixin in mixins
        @::[key] = value for key, value of mixin::
      @
    


    Так получился простенький класс массива с событиями, только конструктор не принимает длину массива:
    class EventedArray extends Array # implements EventTarget
    
      _implements.call(@, EventTargetEmiter) 
    
      ###*
       * @constructor
       * @param items array-style constructor without single item as length.
      ###
      constructor: (items...) ->
        @splice 0, 0, items...
    



    Дальше всё было относительно тривиально: блоки, кнопки, стики, создание структуры. Эту рутину, по-моему нет смысла описывать, потому что в ней нет ничего нового или нетривиального.



    Итого:



    Создал Gamepads для работы с джойстиками, а так же Gamepad2 и GamepadMap для ручных и тонких настроек.

    Стандарт из рекомендаций и «белых пятен» это плохо. Уж очень много не очевидных моментов.

    К джойстику никак нельзя обращаться из воркера. Может быть вредно, если основная логика находится в нём.

    Хром старается всё преподнести в лучшем виде, но отвергать неизвестные джойстики, и это, по-моему, перебор (хотя и логичный). Мозилла даёт нам всё «как есть» и «беситесь, как хотите».

    Ссылки:

    Тестер

    Исходный код

    Coffeescript width C-preprocessor.
    Поделиться публикацией
    Комментарии 20
      –3
      В хроме же есть USB api но крайне слабо документирован всего с парой примеров, помоему, программеру там без сверстальщика не разобраться. Из примеров вывод на термопринтер чека прямо с сайта, что есть круто.
      Нативно джойстик в лисе это здорово, но сыро. Через js-ctypes можно подключить dll -ку на С а через нее сразу на шарпе писать доступ к джойстику без игр с «беситесь как хотитесь».
        +1
        chrome.usb вроде для расширений/дополнений или я путаю? Расширения интересно, но не торт)
        js-ctypes тоже для компонентов-расширений вроде…
        Суть да дело именно получить используя базовую поставку. А то мы знаем всякие Unity-вебплеера, которые «А, что? Линукс? Да кто вообще играет на линуксе? Ну вообще мы не планируем, но скажем, что рассматриваем...»)

        Хотя тут с маппингом я мог жутко накосячить. Надеюсь на отзывы)
        +3
        Ого, круто как. Давно хочу какую-нибудь игрушку начать писать, эта штука, похоже, может меня подтолкнуть, спасибо!
          0
          Занятно! Попробовал под Хромом и Лисичкой — разные кнопки определяются по разному. Причём косяков больше именно в Хроме. Честно говоря пока на данный момент технология кажется сырой. Но направление правильное. Бай зе вей, может кто подскажет примеры игр для браузеров с поддержкой геймпадов, а ещё лучше с поддержкой сразу нескольких?
            0
            Пока нашёл только эту (одиночная):
            hellorun.helloenjoy.com/
              0
              По поводу определения вендоров и прочего…
              Давно уже есть решение: поддержка только XBox совместимых геймпадов.
              Это обеспечивает нормальное функционирование без дополнительных проблем.
              Все равно большинство геймеров на ПК пришли к использованию XBOX контроллеров, т.к. они являются стандартом.
                +1
                Если для частного продукта такая логика вполне уместна, то для библиотеки общего назначения — нет. Правда именно ХБокс я и отлаживал, но и про остальных не забывал) В конце концов не все готовы отдать за первый геймпад с добрую тысячу, а то и полторы, а первое ощущение очень важно)
                  0
                  > Давно уже есть решение: поддержка только XBox совместимых геймпадов.
                  Решение не очень, мягко говоря.
                  Особенно нервирует, когда в Steam ставят лейблу «полная поддержка контроллера», а поддерживается только XBOX.

                  > Все равно большинство геймеров на ПК пришли к использованию XBOX контроллеров, т.к. они являются стандартом.
                  Не надо говорить за мифическое большинство + браузеры теперь не только в ПК есть.
                    0
                    xbox360ce вам в помощь.
                    Лично я в своих проектах прямо пишу о поддержке только XBox контроллера и даю ссылку на xbox360ce.

                    Это позволяет дать единый интерфейс всем пользователям. Ну а те, кто(по неведомой мне причине) купили не xInput совместимые контроллеры имеют возможность страдать отдельно от основной массы пользователей.

                    Для того, чтобы понять всю прелесть xbox gamepad only игр — достаточно вспомнить как выглядит настройка контроллеров в играх поддерживающих весь спектр контроллеров.
                      0
                      > xbox360ce вам в помощь.
                      Ок, благодарю.

                      Но это выглядит как костыль, вроде «А, у вас Linux… Используйте wine!» вместо нормальной поддержки.
                        +1
                        Тут выбирать — либо страшное окно настроек контроллера, которое пугает пользователя, либо только xbox.
                        Откровенно говоря я не вижу причин покупать в современном мире не xInput геймпады.
                        У меня самого лежит старенький геймпад-клон геймпада от первой плойки. Но он куплен чисто для игры в старые игры, которые xbox геймпады не видят. Его в современных играх и использовать то не получится — не хватает элементов управления.
                          0
                          PS контроллеров поддержку сделал, так что чего их списывать со счетов? Тоже популярная платформа) Ну в том-то и фишка «единого интерфейса», что нету этого окна настроек, все геймпады распознаются) По идее, во всяком случае. Чтобы никто не страдал, ни разработчик, ни игрок. Порой те, у кого нет крутого джойстика, игростанции и всех дел интересны с точки зрения доната и фри-ту-плей)
                        0
                        правка: x360ce конечно же.
                    0
                    Преамбула:
                    Можете называть меня хейтером, граммарнаци или как угодно еще, но Gamepad API никак не поможет внедрению джойстиков в браузер, а поможет исключительно внедрению геймпадов.
                    Отговорки мол «все вокруг так называют и я буду» не катит, потому что джойстик — совершенно другой тип устройства.

                    Вопрос по сути — оно мне даст использовать в браузере именно джойстики или нет?
                      0
                      А чего называть-то? Того джойстика, что «канонический», я не видел на просторах. Увы.
                      Сами окошки зовут интерфейс, как joystick, в линуксе девайс имеет имя joyN, где N число. Я решил не отклоняться от наименований, к тому же слово «геймпад» слово совсем новое, добавлено в 2009-ом, тогда как «джойстик» был добавлен аж в 2000-ных. Трудности перевода)
                        0
                        Да это всё понятно. Но поиском по использованию именно джойстика натыкаешься только вот на статьи про геймпады, потому что авторы (зачем-то) зовут одно другим, понимая это в большинстве случаев :(
                        0
                        По сути все API сделана для поддержки контроллеров. А джойстик это будет, геймпад, руль или что-то еще — не имеет значения.
                        Но слово контроллер слишком общее и для заголовка стать не годится.
                          0
                          Так в теле-то тоже ни слова про контроллеры или рули. О том и говорю. В статье речь только о геймпадах, однако одинаково фигурируют в тексте и геймпады и джойстики, при том, что в названии методов однозначно везде gamepad. Где правда-то? Может быть дополнить статью на эту тему?
                            0
                            Геймпадом я назвал в первом абзаце, как обозначающем, дальше старался использовать только слово джойстик ибо по-русски, только в ещё одном заголовке для конкретики использовал «геймпад», как обиходное… даже в доках все gamepad переведено, как джойстик. Это правила наименования, не более.
                              0
                              Не знаю откуда берутся правила танком называть самолёт. Нет, ну военная техника и то и другое. Используются для схожих целей, конечно. Но сущности-то разные. А насчет " слово джойстик ибо по-русски" словарь говорит, что «Манипулятор в виде укреплённой на шарнире ручки с кнопкой», что на геймпад совсем не похоже, увы. Мне чего-то в этой жизни видимо непонятно.
                              Извините за потраченное время.

                      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                      Самое читаемое