Человек берёт в руки геймпад, открывает игру в браузере и обнаруживает, что персонаж не двигается. Кнопки на клавиатуре работают, а джойстик молчит. Ещё через минуту выясняется, что в одном браузере он подключился сразу, а в другом требует нажатия кнопки. Такие мелочи решают, будет ли играть человек дальше, и разбираться в них приходится каждому, кто делает игру для веба.
Интерфейс появился в 2017 году и состоит из трёх частей
Поддержка контроллеров в браузерах стала широко доступной с марта 2017 года. Набор возможностей невелик: три типа объектов, два события и одна функция.
Первый объект описывает сам контроллер. У него есть название, номер, число кнопок, число осей, метка времени последнего обновления и признак подключения.
Второй объект описывает отдельную кнопку. Здесь важны два свойства: нажата ли она и насколько сильно. Значение нажатия меняется от нуля до единицы, и это позволяет различать лёгкое касание и полное нажатие.
Третий объект описывает событие, связанное с подключением или отключением.
Функция возвращает список всех контроллеров, известных странице. Массив может содержать пустые элементы: если устройство отключилось, его место сохраняется, чтобы номера остальных не сместились.
Событий всего два. Первое срабатывает при подключении, второе при отключении. Есть важная тонкость: если контроллер был подключён до загрузки страницы, событие подключения не придёт сам по себе. Браузер сообщит о нём только после того, как человек нажмёт кнопку или сдвинет ось. Это защита от снятия отпечатка устройства, и её стоит учитывать в подсказках.
window.addEventListener("gamepadconnected", (event) => {
const pad = event.gamepad;
console.log("Подключён:", pad.id);
console.log("Кнопок:", pad.buttons.length);
console.log("Осей:", pad.axes.length);
});
window.addEventListener("gamepaddisconnected", (event) => {
console.log("Отключён:", event.gamepad.id);
});
Второй момент связан с поведением браузеров. В некоторых из них контроллер становится доступен странице только после взаимодействия и только когда вкладка видима. Игра, работающая в фоне, не получит данные, и это нормально.
Состояние читается опросом, а не событиями нажатия
Главное отличие работы с контроллером от работы с клавиатурой состоит в способе получения данных. Клавиатура присылает события при каждом нажатии. Контроллер событий не присылает: его состояние нужно читать самостоятельно, регулярно, обычно в каждом кадре.
Причина в природе устройства. Аналоговый стик меняет положение плавно, и события для каждого промежуточного значения были бы бессмысленны. Опрос даёт текущее состояние в нужный момент.
function pollGamepads() {
const pads = navigator.getGamepads();
for (const pad of pads) {
if (!pad) {
continue;
}
handleSticks(pad.axes);
handleButtons(pad.buttons);
handleTriggers(pad.buttons);
}
requestAnimationFrame(pollGamepads);
}
Такой цикл запускается один раз и работает постоянно. Важно не создавать второй цикл: два параллельных опроса удваивают работу и приводят к тому, что обработка выполняется дважды за кадр.
Второй приём состоит в сравнении с предыдущим состоянием. Нажатие кнопки удобно определять как переход из ненажатого состояния в нажатое. Если этого не делать, действие сработает столько раз, сколько кадров кнопка остаётся зажатой.
const previous = new Map();
function justPressed(index, pressed) {
const was = previous.get(index) || false;
previous.set(index, pressed);
return pressed && !was;
}
function handleButtons(buttons) {
buttons.forEach((button, index) => {
if (justPressed(index, button.pressed)) {
dispatchAction(index);
}
});
}
Для движения, наоборот, нужно удерживание. Здесь используется текущее состояние, а не переход. Разница между двумя моделями понятна: выстрел срабатывает один раз, а бег продолжается, пока стик отклонён.
Мёртвая зона обязательна, иначе персонаж ползёт сам
Аналоговый стик редко возвращается в идеальный ноль. Износ механизма и особенности калибровки дают небольшое отклонение даже в спокойном состоянии. Если использовать значение как есть, персонаж будет медленно смещаться.
Решение называется мёртвой зоной. Значения ниже порога считаются нулём. Порог подбирается экспериментально и обычно лежит в пределах от пяти до двадцати процентов от максимума.
const DEAD_ZONE = 0.15;
function applyDeadZone(value) {
if (Math.abs(value) < DEAD_ZONE) {
return 0;
}
const sign = Math.sign(value);
const scaled = (Math.abs(value) - DEAD_ZONE) / (1 - DEAD_ZONE);
return sign * scaled;
}
Обратите внимание на вторую часть функции. Простое обнуление значений ниже порога создаёт ступеньку: стик чуть выше порога даёт резкий скачок. Перенормировка растягивает оставшийся диапазон и делает движение плавным.
Второй приём связан с направлением. Стики на разных контроллерах расположены и ориентированы по-разному. Вертикальная ось может быть инвертирована. Проверять направление стоит на конкретных моделях, а не полагаться на стандарт.
function toVector(axes, indexX, indexY) {
const x = applyDeadZone(axes[indexX] || 0);
const y = applyDeadZone(axes[indexY] || 0);
const length = Math.min(1, Math.hypot(x, y));
if (length === 0) {
return { x: 0, y: 0, force: 0 };
}
const angle = Math.atan2(y, x);
return {
x: Math.cos(angle) * length,
y: Math.sin(angle) * length,
force: length,
};
}
Третья тонкость это круговое ограничение. Угол наклона стика даёт диагональ, длина которой больше единицы. Без нормализации персонаж бежит по диагонали быстрее, чем по прямой, а игроки это замечают и используют.
Схемы управления различаются, и их приходится определять
Число кнопок и порядок осей отличаются от устройства к устройству. У одной модели триггеры это кнопки, у другой оси. Крестовина может быть отдельными кнопками или одной осью.
Первое решение это сопоставление по стандарту. Есть общая схема, которой придерживаются производители, и большинство контроллеров ей следуют. Кнопки занимают предсказанные позиции, что упрощает поддержку.
Второе решение это определение по названию. В строке с описанием устройства часто указано семейство. По нему выбирается схема.
const SCHEMES = [
{ match: /xbox|xinput/i, name: "xbox" },
{ match: /dualshock|dualsense|playstation/i, name: "playstation" },
{ match: /switch|joy-con/i, name: "switch" },
];
function detectScheme(pad) {
for (const scheme of SCHEMES) {
if (scheme.match.test(pad.id)) {
return scheme.name;
}
}
return "generic";
}
Третье решение это настройка вручную. Игрок сам назначает кнопки, и приложение сохраняет выбор. Такой подход самый надёжный, потому что учитывает личные предпочтения, но требует интерфейса настройки.
На практике применяют сочетание: определяют схему по названию, дают возможность переназначить и сохраняют результат. Экран настройки занимает день работы, зато снимает поток жалоб.
Виброотклик доступен не везде и требует проверки
Часть контроллеров умеет вибрировать, и это заметно улучшает ощущение от игры. Но поддержка неполная, и полагаться на неё нельзя.
async function rumble(pad, strength, duration) {
const actuator = pad.vibrationActuator;
if (!actuator || typeof actuator.playEffect !== "function") {
return false;
}
try {
await actuator.playEffect("dual-rumble", {
duration,
strongMagnitude: strength,
weakMagnitude: strength * 0.6,
});
return true;
} catch (error) {
return false;
}
}
Функция возвращает признак успеха, и приложение решает, нужна ли замена. Если вибрации нет, подойдёт звук или вспышка на экране.
Второй момент касается длительности. Долгая вибрация раздражает и сажает батарею. Короткий отклик на событие воспринимается лучше, чем постоянная тряска.
Третий момент связан с доступностью. Вибрация не должна быть единственным способом сообщить о важном событии. Часть игроков отключает её на системном уровне.
Раскладка интерфейса должна объяснять управление
Даже идеально работающий контроллер бесполезен, если игрок не понимает, какие кнопки за что отвечают.
Первое правило это показ подсказок на экране. Вместо абстрактного "нажмите кнопку" стоит указывать конкретную: нижняя кнопка, левый триггер.
Второе правило это адаптация текста под устройство. Названия кнопок различаются между семействами, и подсказка должна подстраиваться.
Третье правило это единый стандарт внутри игры. Если подтверждение действия висит на нижней кнопке, оно должно быть там везде, а не меняться от экрана к экрану.
Четвёртое правило это поддержка обоих способов ввода. Игрок может взять контроллер, а через минуту вернуться к клавиатуре. Переключение должно происходить незаметно.
Пятое правило это доступность настроек. Чувствительность стиков, инверсия осей, размер мёртвой зоны. Эти параметры у каждого свои, и отсутствие настройки отпугивает часть игроков.
Отладка и тестирование требуют отдельного экрана
Проверять управление вслепую неудобно. Полезно сделать служебный экран, показывающий состояние всех кнопок и осей.
function debugView(root) {
root.innerHTML = "";
const pads = navigator.getGamepads();
pads.forEach((pad, index) => {
if (!pad) {
return;
}
const block = document.createElement("pre");
block.textContent =
"Устройство " + index + ": " + pad.id + "\n" +
"Кнопки: " + pad.buttons.map((b) => (b.pressed ? "1" : "0")).join("") + "\n" +
"Оси: " + pad.axes.map((a) => a.toFixed(2)).join(", ");
root.appendChild(block);
});
}
Такой экран экономит часы. Вместо догадок о том, какая кнопка соответствует какому номеру, разработчик видит точные значения.
Второй приём это тестирование на нескольких моделях. Поведение контроллеров различается, и проверка на одном устройстве не гарантирует работу на остальных.
Третий приём это проверка переподключения. Контроллер может разрядиться и отключиться посреди игры. Приложение должно корректно пережить это событие и продолжить работу, а после подключения снова принять управление.
Четвёртый приём это тест на разных системах. Названия устройств и порядок кнопок могут отличаться в зависимости от операционной системы, даже для одной модели.
Сравнение двух моделей ввода показывает, что именно приходится добавлять
Полезно поставить рядом обработку клавиатуры и обработку контроллера, чтобы увидеть объём дополнительной работы.
Клавиатура присылает событие на каждое нажатие и отпускание. Приложение подписывается на два события и получает готовые данные: какая клавиша, нажата или отпущена, были ли модификаторы. Повтор при удержании обрабатывается системой.
Контроллер не присылает ничего. Приложение должно само завести цикл, читать состояние, хранить предыдущее значение, вычислять переходы, применять мёртвую зону, нормализовать вектор и определять схему устройства.
Посчитаем объём: обработка клавиатуры занимает десяток строк, обработка контроллера со всеми тонкостями это двести-триста строк. Разница объясняется тем, что клавиатура выдаёт дискретные события, а аналоговое устройство выдаёт непрерывное состояние.
Вывод из сравнения прост. Если игра управляется только дискретными действиями, поддержку контроллера можно свести к сопоставлению кнопок и не усложнять. Как только появляется аналоговое движение или управление камерой стиком, без полного набора обработки обойтись не удастся.
Порядок внедрения экономит время и снижает число жалоб
Работу стоит вести по шагам.
Первый шаг это служебный экран. Он показывает состояние кнопок и осей и позволяет убедиться, что устройство вообще видно странице.
Второй шаг это опрос в цикле анимации. Пока без логики игры, только чтение и вывод значений. Это позволяет проверить частоту обновления.
Третий шаг это мёртвая зона и нормализация. После него движение перестаёт дёргаться и перестаёт зависеть от направления.
Четвёртый шаг это сопоставление кнопок. Сначала для одного семейства устройств, которое есть под рукой.
Пятый шаг это экран настройки. Игрок сам назначает действия, и это снимает большую часть претензий.
Шестой шаг это виброотклик как дополнение, но не как обязательная часть.
Седьмой шаг это проверка на нескольких моделях и на разных системах.
Такой порядок занимает несколько дней, а не недель, и даёт предсказуемый результат. Попытка сразу сделать универсальную поддержку всех устройств приводит к коду, который никто не решается менять, и к ошибкам, которые невозможно воспроизвести.
Поддержка контроллеров в браузере получилась задачей, где техническая часть проста, а вся работа уходит в детали. Опрос в каждом кадре, мёртвая зона, нормализация диагонали, определение схемы, виброотклик с проверкой. Ни один из этих пунктов не выглядит сложным по отдельности, а вместе они определяют, будет ли игрок чувствовать, что держит в руках настоящий геймпад, а не клавиатуру с эмуляцией.