Действия ввода вместо клавиш: KeyboardEvent.code, Gamepad API, мёртвая зона

Коротко

Игровая логика должна спрашивать о действиях («прыжок», «влево»), а не о клавишах, — тогда клавиатура, геймпад и касание подключаются через одну карту действий. Для клавиатуры используйте KeyboardEvent.code, для геймпада — стандартную раскладку Gamepad API и опрос раз в кадр, для стика — радиальную мёртвую зону 0,15–0,2 с перемасштабированием. Код карты действий, мёртвой зоны и переназначения ниже проверен в Node.js 24 на поддельных событиях.

Содержание

  1. Почему проверка клавиш в логике ломает игру
  2. Карта действий: один слой между устройством и игрой
  3. KeyboardEvent.code против key: раскладка и подписи
  4. Gamepad API: стандартная раскладка и опрос раз в кадр
  5. Мёртвая зона стика: формула и перемасштабирование
  6. Таблица: привязки по умолчанию
  7. Код на JavaScript: действия, нажатие и отпускание, переназначение
  8. Практический сценарий: экран настроек управления
  9. Когда вам это не подойдёт

Почему проверка клавиш в логике ломает игру

Проверка конкретной клавиши в логике привязывает игру к одному устройству и одной раскладке. Если условие прыжка — 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 шагов:

  1. Список действий. Выведите строки «Влево», «Вправо», «Прыжок», «Пауза» с текущими привязками; подписи клавиш берите из раскладки пользователя или из запасной таблицы.
  2. Режим ожидания. По выбору строки ждите следующего keydown и вызывайте rebind(действие, e.code); Escape отменяет ожидание.
  3. Конфликты. Функция rebind снимает новую клавишу с других действий; покажите игроку, у какого действия она была.
  4. Мёртвая зона. Добавьте ползунок от 0,05 до 0,4 с шагом 0,05 и сразу показывайте точку положения стика после фильтрации.
  5. Сохранение. Сохраняйте карту в localStorage как JSON и проверяйте при загрузке, что каждое действие получило хотя бы один вход.

Когда вам это не подойдёт

Слой действий окупается почти всегда, но есть случаи, где он избыточен или недостаточен:

  • Игра на одну кнопку: если весь ввод — «нажать в любом месте», карта действий из одной строки ничего не упрощает.
  • Текстовый ввод и игры на набор слов: там важен символ, то есть key, а не физическая клавиша.
  • Специальные контроллеры: рули, педали и нестандартные устройства часто приходят с пустым mapping, и для них нужна ручная калибровка осей, которой здесь нет.

Мы разрабатываем J2DRU — браузерный 2D игровой движок; управление с клавиатуры, мыши, геймпада и касанием с переназначением клавиш входит в проверенные внутри разработки основы. Движок публично пока не открыт, заявку можно оставить на странице раннего доступа (подача заявки не гарантирует приглашение).

Источники (проверено: ноябрь 2026): MDN — KeyboardEvent: code, MDN — Keyboard: getLayoutMap(), W3C — Gamepad, W3C — Understanding SC 2.5.5 Target Size (Enhanced).

Читайте также:

Частые вопросы

Чем KeyboardEvent.code отличается от KeyboardEvent.key?

code описывает физическую клавишу и не зависит от раскладки и модификаторов, key — символ, который клавиша напечатает. Клавиша с кодом KeyW в русской раскладке печатает «ц», поэтому проверка key === "w" перестаёт работать, а проверка code === "KeyW" — нет.

Почему navigator.getGamepads() возвращает пустой список?

По спецификации Gamepad, пока пользователь не нажал кнопку или не сдвинул стик, браузер возвращает пустой список — это защита от снятия цифрового отпечатка. Покажите подсказку «нажмите любую кнопку геймпада» и опрашивайте снова.

Какую мёртвую зону стика выбрать?

Стартовое значение — 0,15–0,2 от полного отклонения. Меньше — изношенный стик будет двигать героя сам, больше — пропадёт тонкое управление. Лучше дать игроку ползунок в настройках.

Как показать игроку название клавиши в подсказке?

Хранить привязки по code, а для подписи использовать раскладку пользователя. В браузерах на Chromium есть экспериментальный метод navigator.keyboard.getLayoutMap(), который сопоставляет код KeyW с символом текущей раскладки; в остальных браузерах показывайте запасную подпись.