- PVSM.RU - https://www.pvsm.ru -

Смотря, как всё более новые и новые технологии внедряются в веб, смотря, как в него переносят игры, я задумался: «А было бы круто, если бы геймпад тоже можно было подключить...». И в поиске первым же результатом было GamepadAPI [1]. Немного ниже ссылка на W3C GamepadAPI [2]. Посмотрев, попробовав, я обнаружил ряд проблем, подводных камней, которые поставили бы крест на внедрении джойстиков в браузер. И я решил это исправить, создав интерфейс. Что есть «из коробки», и что именно было доработано, изменено и на мой взгляд улучшено, описано под катом.
Поддерживается 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 массив значений, а не объектов. Часть проблем прошла сквозь время и проявляется даже после полной поддержки стандарта (т.е. существуют во всех версиях хромиумов, где джойстики вообще есть):
Gamepad не обновляются, пока не вызван navigator.webkitGetGamepads() или navigator.getGamepads() (если он есть, при чём, если он есть, а вызвать старую версию, то будет брошено «внимание» и вообще ничего не обновится). Т.е. получив объект из функции не обязательно получать его заново, но обязательно просто вызывать эту функцию.
Писать я решил на coffeescript.
Он мне ближе, в нём есть классы, (так же я допилил немного процессор и выложил его [3], теперь в нём есть почти полноценный Си-шный препроцессор!) Поэтому и примеры дальше на кофескрипте.
Первые две проблемы, которые вылезли сразу:
Для удобства я разделил кнопки джойстика на логические блоки.
Сделано это ещё для того, чтобы отслеживать изменения у группы элементов.
Нагло взяв исходные коды ассоциаций кнопок из проекта хромиум я создал карты ассоциаций для джойстиков. Оказывается, они зависят от платформ, а значит для окошек и для пингвина они отличаются от макинтошей. Но что делать, если это новый и/или малоизвестный джойстик? На этот случай класс GamepadMap вынес отдельно. Объект, созданный из этого класса, можно передать в конструктор интерфейса.
Но не всегда всё так плохо! Бывает, что ассоциации в норме. Чтобы отличать готовый маппинг от сырого, ориентируюсь по количеству «осей». В случае, если их не 4 (вертикальная и горизонтальная для каждого из двух стиков), то пытаюсь найти карту ассоциаций получив из свойства "id" VID и PID. Это не безопасно с одной стороны, но с другой параметра лучше найти не смог. Даже значение параметра «mapping» не даёт ничего: в хромиуме, роботающим только с префиксом webkit, этот параметр пуст, но ассоциации уже готовы, как писал выше.
Единственные события, которые есть в GamepadAPI это gamepadconnected и gamepaddisconnected. Нажатия на кнопки и изменения в стиках надо получать самостоятельно. Теоретически это полезно, но на практике не всегда удобно. Особенно, если создавать альтернативу «клавамыши».
И тут я познал дзен в 5 шагов:
Т.к. W3C не даёт вообще никаких рекомендаций на счёт изменение состояния объекта Gamepad в зависимости от реального изменения состояния, то хромиум не стал утруждаться, что в первый (на первых парах), что и во второй раз (поддерживая стандарт полностью): свойства объекта Gamepad актуализируются только при опросе через navigator.getGamepads() или navigator.webkitGetGamepads(). В огнелисе же всё проще, состояние обновляется автоматически. Поэтому если webkit, то дёргаем этот метод каждый раз перед опросом.
Захотелось воссоздать EventTarget интерфейс для элементов, но нельзя просто взять и создать extends EventTarget. Пришлось «наколеночить» свою реализацию, но соблюдая стандарт. Почему не взять готовый Emet? В нём нет и близко соблюдения стандарта, а мне хотелось выполнить всё стандартно там, где это возможно.
Немного полезных методов, таких, как on, off, emet, цепочки и вуаля, класс 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 [4], но просто создать Event и задать ему свойства нам не позволяют. На выручку приходит CustomEvent [5], в котором свойство detail настраиваемо. А чтобы событие вызывалось и в родительских элементах не забываем устанавливать canBubble в true в конструкторе.
Во всех примерах связанных с GamepadAPI для опроса состояния используют requestAnimationFrame. В этом есть плюс и минус:
плюс в том, что когда окно не активно, то и опрашивать состояние незачем.
Но с другой стороны, если это игра, то этот вызов необходим для отрисовки, иначе может пострадать плавность анимации.
Поэтому я решил пойти алтернативным «старинным» путём: focus/blur для окна, setInterval для планировщика и единичный requestAnimationFrame для первого запуска (ведь окно может загрузиться в фоне). Таким образом, браузер сам займётся списком заданий, выполнит необходимые между отрисовками.
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 для ручных и тонких настроек.
Стандарт из рекомендаций и «белых пятен» это плохо. Уж очень много не очевидных моментов.
К джойстику никак нельзя обращаться из воркера. Может быть вредно, если основная логика находится в нём.
Хром старается всё преподнести в лучшем виде, но отвергать неизвестные джойстики, и это, по-моему, перебор (хотя и логичный). Мозилла даёт нам всё «как есть» и «беситесь, как хотите».
Ссылки:
Тестер [6]
Исходный код [7]
Coffeescript width C-preprocessor. [3]
Автор: Louter
Источник [8]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/javascript/74152
Ссылки в тексте:
[1] GamepadAPI: https://developer.mozilla.org/en-US/docs/Web/Guide/API/Gamepad
[2] W3C GamepadAPI: https://dvcs.w3.org/hg/gamepad/raw-file/default/gamepad.html
[3] выложил его: https://github.com/NightMigera/coffeescript
[4] Event: https://developer.mozilla.org/en-US/docs/Web/API/Event
[5] CustomEvent: https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent
[6] Тестер: http://nightmigera.github.io/Gamepad/view/index.html
[7] Исходный код: https://github.com/NightMigera/Gamepad
[8] Источник: http://habrahabr.ru/post/242835/
Нажмите здесь для печати.