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] т.к. у триггеров может быть плавное изменение значения, то это следует учесть иногда. Но есть две жуткие оговорки: axes (оси) имеют значение 0 при инициализации, тогда как на самом деле могут быть в значении -1. Это касается курков (триггеров) в линуксе для XInput, в окошках же курки имеют вообще одну ось! Только один меняет значение в положительную сторону, а второй — в отрицательную, что значит, что нажав оба вы получите снова 0. 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 сохраняет отступы (включает файл, добавляя отступы перед строками, равные отступу, на котором написана директива. Нужно из-за синтаксиса языка).
Первые две проблемы, которые вылезли сразу: Ассоциации элементов или маппинг. В фаерфоксе его нет, в хромиуме есть. Отсутствие событийности. Нельзя взять и навесить слушатель на кнопку или стик. Ассоциации элементов или маппинг. Для удобства я разделил кнопки джойстика на логические блоки. 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.