Коротко
Игровая логика должна спрашивать о действиях («прыжок», «влево»), а не о клавишах, — тогда клавиатура, геймпад и касание подключаются через одну карту действий. Для клавиатуры используйте KeyboardEvent.code, для геймпада — стандартную раскладку Gamepad API и опрос раз в кадр, для стика — радиальную мёртвую зону 0,15–0,2 с перемасштабированием. Код карты действий, мёртвой зоны и переназначения ниже проверен в Node.js 24 на поддельных событиях.
Содержание
- Почему проверка клавиш в логике ломает игру
- Карта действий: один слой между устройством и игрой
- KeyboardEvent.code против key: раскладка и подписи
- Gamepad API: стандартная раскладка и опрос раз в кадр
- Мёртвая зона стика: формула и перемасштабирование
- Таблица: привязки по умолчанию
- Код на JavaScript: действия, нажатие и отпускание, переназначение
- Практический сценарий: экран настроек управления
- Когда вам это не подойдёт
Почему проверка клавиш в логике ломает игру
Проверка конкретной клавиши в логике привязывает игру к одному устройству и одной раскладке. Если условие прыжка — e.key === " ", то на геймпаде прыжка нет, на телефоне тоже, а левша, которому удобнее прыгать стрелкой «вверх», не может это изменить. Если условие — e.key === "w", движение вперёд перестаёт работать при включённой русской раскладке, потому что та же клавиша печатает «ц».
Каждое такое место в логике приходится править при добавлении устройства. Чем позже вводится слой действий, тем больше этих мест.
Карта действий: один слой между устройством и игрой
Карта действий — это таблица «действие → список входов», и логика игры читает только действия. Для каждого действия в каждом кадре вычисляются три значения: удерживается ли оно (down), нажато ли в этом кадре (pressed) и отпущено ли (released). Нажатие — это «удерживается сейчас и не удерживалось в прошлом кадре».
Для движения добавляют аналоговое значение moveX от −1 до 1: клавиши дают −1, 0 или 1, стик — промежуточные значения. Если клавиши нажаты, они важнее стика, иначе берётся стик.
KeyboardEvent.code против key: раскладка и подписи
Для управления используют KeyboardEvent.code, потому что он описывает физическую клавишу. По MDN, code не меняется от раскладки и модификаторов и полезен как раз для игр, где клавиатура заменяет геймпад; при этом им нельзя определить, какой символ напечатан на клавише: код KeyQ на раскладке AZERTY соответствует клавише A. Отсюда правило:
- логика и привязки — по
code(KeyW,Space,ArrowUp); - подписи в подсказках — по раскладке пользователя: метод
navigator.keyboard.getLayoutMap()вернёт символ для кода, но MDN помечает его как экспериментальный и доступный не во всех основных браузерах, поэтому нужна запасная подпись.
Поля ввода текста (имя игрока, чат) обрабатывают по key, иначе русский текст набрать нельзя.
Gamepad API: стандартная раскладка и опрос раз в кадр
Геймпад в браузере не присылает событий нажатия кнопок — его состояние опрашивают через navigator.getGamepads() раз в кадр. Спецификация Gamepad рекомендует опрашивать как можно ближе к вызову requestAnimationFrame и с той же частотой. Ключевые факты из спецификации:
- кнопки отдают значение от 0 до 1 и признак
pressed, оси — от −1 до 1; - если браузер узнал устройство,
mappingравен"standard", и индексы фиксированы: кнопка 0 — нижняя в правой группе, 9 — правая в центральной группе, 12–15 — крестовина (верх, низ, лево, право), оси 0 и 1 — левый стик; - до первого нажатия кнопки или движения стика
getGamepads()возвращает пустой список — защита от снятия цифрового отпечатка; - событие
gamepadconnectedдля уже подключённого устройства может прийти только после нажатия кнопки.
Если mapping — пустая строка, раскладка неизвестна: предложите игроку назначить кнопки вручную.
Мёртвая зона стика: формула и перемасштабирование
Мёртвая зона отсекает малые отклонения стика, которые дают износ и дрожание, и считать её нужно по длине вектора, а не по каждой оси отдельно. Покомпонентная зона «прилипает» к осям: по диагонали стик начинает работать позже, чем по прямой. Радиальная зона с перемасштабированием:
длина = √(x² + y²)
если длина < зона: выход = (0, 0)
иначе: k = min(1, (длина − зона) / (1 − зона)) / длина; выход = (x·k, y·k)
Перемасштабирование важно: без него на границе зоны значение скачет с 0 сразу до 0,2, и медленно красться не получится. С ним выход плавно идёт от 0 до 1.
Таблица: привязки по умолчанию
Привязки ниже — стартовый набор для платформера; каждое действие получает минимум две клавиши и одну кнопку геймпада.
| Действие | Клавиатура (code) | Геймпад (standard) | Касание |
|---|---|---|---|
| Влево | KeyA, ArrowLeft | кнопка 14, ось 0 влево | левая половина экрана, зона джойстика |
| Вправо | KeyD, ArrowRight | кнопка 15, ось 0 вправо | то же |
| Прыжок | Space, KeyW, ArrowUp | кнопка 0 | круглая зона справа снизу, от 44×44 CSS-пикселей |
| Пауза | Escape, KeyP | кнопка 9 | значок в углу |
Размер зоны касания взят из критерия WCAG 2.5.5 (44×44 CSS-пикселя), подробнее — в статье про HUD и адаптивный интерфейс.
Код на JavaScript: действия, нажатие и отпускание, переназначение
Код реализует карту действий, опрос раз в кадр, мёртвую зону и переназначение без двойных привязок. Он проверен в Node.js 24 на поддельных событиях клавиатуры и поддельном геймпаде; числа в комментариях совпадают с выводом.
// Карта действий: логика игры спрашивает «нажат ли прыжок», а не «нажат ли пробел»
const bindings = {
left: { keys: ["KeyA", "ArrowLeft"], pad: [14] }, // 14 — левая кнопка крестовины
right: { keys: ["KeyD", "ArrowRight"], pad: [15] },
jump: { keys: ["Space", "KeyW", "ArrowUp"], pad: [0] }, // 0 — нижняя кнопка правой группы
pause: { keys: ["Escape", "KeyP"], pad: [9] },
};
const keysDown = new Set();
function onKey(e) {
// e.code — физическое положение клавиши: KeyW одинаков в раскладках QWERTY и ЙЦУКЕН
if (e.type === "keydown") keysDown.add(e.code); else keysDown.delete(e.code);
}
// Радиальная мёртвая зона с перемасштабированием: вне зоны значение снова идёт от 0 до 1
function deadzone(x, y, dz) {
const len = Math.hypot(x, y);
if (len < dz) return { x: 0, y: 0 };
const k = Math.min(1, (len - dz) / (1 - dz)) / len;
return { x: x * k, y: y * k };
}
// Опрос раз в кадр: состояние «сейчас» и «в прошлом кадре» даёт нажатие и отпускание
let prev = {};
function pollActions(pad) {
const now = {};
for (const name in bindings) {
const b = bindings[name];
let on = b.keys.some(function (k) { return keysDown.has(k); });
if (pad && pad.mapping === "standard") {
on = on || b.pad.some(function (i) { return pad.buttons[i] && pad.buttons[i].pressed; });
}
now[name] = { down: on, pressed: on && !prev[name], released: !on && !!prev[name] };
}
const stick = pad ? deadzone(pad.axes[0], pad.axes[1], 0.2) : { x: 0, y: 0 };
const keyX = (now.right.down ? 1 : 0) - (now.left.down ? 1 : 0);
now.moveX = keyX !== 0 ? keyX : stick.x;
prev = {};
for (const name in bindings) prev[name] = now[name].down;
return now;
}
// Переназначение: новая клавиша снимается с других действий, чтобы не было двойных привязок
function rebind(action, code) {
for (const name in bindings) {
bindings[name].keys = bindings[name].keys.filter(function (k) { return k !== code; });
}
bindings[action].keys.unshift(code);
}
// Проверка на поддельных событиях и геймпаде
const fakePad = function (buttons, axes) {
return { mapping: "standard", axes, buttons: buttons.map(function (p) { return { pressed: p, value: p ? 1 : 0 }; }) };
};
const idle = fakePad(new Array(17).fill(false), [0, 0, 0, 0]);
onKey({ type: "keydown", code: "KeyW" }); // в русской раскладке e.key здесь «ц»
let a = pollActions(idle);
console.log(a.jump.pressed, a.jump.down); // true true
a = pollActions(idle);
console.log(a.jump.pressed, a.jump.down); // false true — удержание, не новое нажатие
onKey({ type: "keyup", code: "KeyW" });
console.log(pollActions(idle).jump.released); // true
console.log(deadzone(0.15, 0.05, 0.2)); // { x: 0, y: 0 } — дрожание стика отсечено
const d = deadzone(0.6, 0, 0.2);
console.log(d.x.toFixed(2)); // 0.50 — после зоны шкала снова от 0
console.log(deadzone(1, 0, 0.2).x); // 1
const tilted = fakePad(new Array(17).fill(false), [0.6, 0.1, 0, 0]);
console.log(pollActions(tilted).moveX.toFixed(2)); // 0.50
rebind("jump", "KeyD");
console.log(bindings.jump.keys, bindings.right.keys); // [ 'KeyD', 'Space', 'KeyW', 'ArrowUp' ] [ 'ArrowRight' ]
Стик, отклонённый на 0,6 вправо, при зоне 0,2 даёт скорость 0,5: из диапазона 0,2–1 значение 0,6 находится ровно посередине.
Практический сценарий: экран настроек управления
Экран переназначения строится поверх той же карты действий за 5 шагов:
- Список действий. Выведите строки «Влево», «Вправо», «Прыжок», «Пауза» с текущими привязками; подписи клавиш берите из раскладки пользователя или из запасной таблицы.
- Режим ожидания. По выбору строки ждите следующего
keydownи вызывайтеrebind(действие, e.code);Escapeотменяет ожидание. - Конфликты. Функция
rebindснимает новую клавишу с других действий; покажите игроку, у какого действия она была. - Мёртвая зона. Добавьте ползунок от 0,05 до 0,4 с шагом 0,05 и сразу показывайте точку положения стика после фильтрации.
- Сохранение. Сохраняйте карту в
localStorageкак JSON и проверяйте при загрузке, что каждое действие получило хотя бы один вход.
Когда вам это не подойдёт
Слой действий окупается почти всегда, но есть случаи, где он избыточен или недостаточен:
- Игра на одну кнопку: если весь ввод — «нажать в любом месте», карта действий из одной строки ничего не упрощает.
- Текстовый ввод и игры на набор слов: там важен символ, то есть
key, а не физическая клавиша. - Специальные контроллеры: рули, педали и нестандартные устройства часто приходят с пустым
mapping, и для них нужна ручная калибровка осей, которой здесь нет.
Мы разрабатываем J2DRU — браузерный 2D игровой движок; управление с клавиатуры, мыши, геймпада и касанием с переназначением клавиш входит в проверенные внутри разработки основы. Движок публично пока не открыт, заявку можно оставить на странице раннего доступа (подача заявки не гарантирует приглашение).
Источники (проверено: ноябрь 2026): MDN — KeyboardEvent: code, MDN — Keyboard: getLayoutMap(), W3C — Gamepad, W3C — Understanding SC 2.5.5 Target Size (Enhanced).
Читайте также: