Управление с клавиатуры

Учим браузер слушать клавиатуру, запоминать зажатые клавиши в объекте keys и двигать цыплёнка плавно, без рывков и залипаний.

Событие keydown срабатывает в момент нажатия клавиши, а keyup — в момент, когда её отпускают. Между ними клавиша считается зажатой, и именно это состояние мы храним, чтобы каждый кадр двигать героя.

В прошлом уроке про состояние игрока ты собрал объект chicken с координатами x и y и научился рисовать цыплёнка в нужной точке холста. Но пока он стоит как вкопанный. Сегодня мы дадим ему управление: нажал стрелку — цыплёнок побежал, отпустил — остановился. Это первая способность, ради которой вообще стоит писать игру: твой палец на клавише превращается в движение на экране.

Зачем это нужно

Открой любую игру, в которую ты залипал последний месяц — от платформера до гонок. Первое, что ты делаешь, едва началась игра, — тянешься к клавишам и проверяешь: «а как тут ходить?». Если герой слушается мгновенно и плавно, ты уже наполовину влюблён в игру. Если он дёргается, застревает или едет сам по себе после того, как ты отпустил клавишу, — ты закрываешь вкладку через минуту. Управление — это рукопожатие игры с игроком, и оно должно быть крепким.

К концу урока твой цыплёнок будет бегать по холсту во все четыре стороны: нажал стрелку вправо — поехал вправо, зажал вправо и вверх одновременно — поехал по диагонали, отпустил всё — замер на месте. И всё это плавно, кадр за кадром, без рывков, как в настоящей игре. А заодно ты добавишь альтернативное управление на WASD — те самые клавиши, на которых играет полмира.

Звучит просто, но именно здесь новички спотыкаются чаще всего: пытаются двигать героя прямо внутри обработчика нажатия — и получают рваное, дёрганое движение, как у заевшей пластинки. Мы сразу сделаем правильно: разделим «браузер сказал мне, какие клавиши нажаты» и «игровой цикл двигает героя». Это и есть секрет плавного управления.

Как браузер сообщает о клавишах

Представь, что клавиатура — это дверной звонок, а твоя программа — человек дома. Браузер не заставляет тебя постоянно бегать к двери и проверять, не пришёл ли кто. Вместо этого он сам звонит тебе, когда что-то происходит: «эй, нажали клавишу!» или «эй, клавишу отпустили!». Эти «звонки» и называются событиями.

Нас интересуют два события:

  • keydown — браузер звонит в момент, когда клавишу нажали (палец опустился на клавишу).
  • keyup — браузер звонит, когда клавишу отпустили (палец поднялся).

Чтобы «снять трубку» и отреагировать на звонок, мы вешаем на окно обработчик через window.addEventListener. Это как сказать браузеру: «когда нажмут клавишу — вызови вот эту мою функцию и передай ей, какая именно клавиша сработала».

window.addEventListener('keydown', function (event) {
  console.log('нажали:', event.code);
});

window.addEventListener('keyup', function (event) {
  console.log('отпустили:', event.code);
});

Результат: код пока ничего не рисует на холсте — он просто печатает в консоль браузера строки вроде «нажали: ArrowRight» в момент нажатия и «отпустили: ArrowRight» в момент, когда стрелку отпускают. Так ты своими глазами видишь, что браузер действительно «звонит» тебе на каждое движение пальца.

Что лежит в event.code

Внутри функции-обработчика браузер передаёт нам объект event — «карточку звонка» с подробностями. Самое нужное свойство — event.code: это строковое имя физической клавиши. Стрелки приходят как 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', а буквы WASD — как 'KeyW', 'KeyA', 'KeyS', 'KeyD'.

Почему именно event.code, а не похожее свойство event.key? Потому что code привязан к месту клавиши на клавиатуре, а не к напечатанному символу. Если игрок переключит раскладку на русскую, event.key для той же физической клавиши вернёт 'ц' вместо 'w', и управление сломается. А event.code всегда останется 'KeyW' — где бы ни лежала клавиша и какая бы раскладка ни стояла. Для игр это золотое правило: читаем физическую клавишу через event.code.

Главная идея: храним зажатые клавиши в объекте keys

Вот ключевой момент урока. Возникает соблазн двигать цыплёнка прямо внутри обработчика keydown: нажали стрелку — тут же сдвинули chicken.x. Так делать нельзя, и сейчас ты поймёшь почему.

Когда ты зажимаешь клавишу и держишь, браузер ведёт себя как при наборе текста: одно нажатие, пауза, а потом — пулемётная очередь повторов (вспомни, как зажимаешь букву и она печатается «ддддддд»). Эти повторы идут рвано, с задержкой перед первым повтором. Если двигать героя на каждый такой повтор, движение будет дёрганым: рывок, пауза, потом частые мелкие скачки. Совсем не то плавное движение, что в любимой игре.

Решение: обработчики событий не двигают героя. Они лишь записывают, какие клавиши сейчас зажаты, в специальный объект keys. А двигает цыплёнка игровой цикл — каждый кадр, ровным шагом.

Объект keys работает как доска состояний: для каждой клавиши там лежит true (зажата) или клавиши там просто нет (не зажата). По keydown мы ставим true, по keyup — убираем. А игровой цикл каждый кадр смотрит на эту доску и решает, куда двигать героя. Получается чёткое разделение: события отвечают на вопрос «что нажато прямо сейчас?», а цикл — «куда из-за этого двигаться?».

Пример 1. Заводим доску клавиш

Создадим объект keys и научим обработчики его заполнять. Пока без движения — просто фиксируем состояние.

// доска состояний: какие клавиши зажаты прямо сейчас
const keys = {};

window.addEventListener('keydown', function (event) {
  keys[event.code] = true;   // клавишу нажали — отмечаем true
});

window.addEventListener('keyup', function (event) {
  keys[event.code] = false;  // клавишу отпустили — снимаем отметку
});

Результат: на экране ничего не меняется, но за кадром объект keys теперь живёт своей жизнью. Пока ты держишь стрелку вправо, в нём лежит keys['ArrowRight'] === true; как только отпустил — становится false. Это та самая доска, по которой дальше будет ориентироваться игровой цикл.

Разберём построчно:

  1. const keys = {} — пустой объект-доска. Сначала в нём ничего нет, и любая непрочитанная клавиша вернёт undefined, что в условии работает как «не нажата».
  2. В keydown мы пишем keys[event.code] = true — берём имя клавиши (например, 'ArrowRight') и ставим напротив него true.
  3. В keyup ставим false — клавиша больше не зажата.

Заметь: обработчики предельно тупые. Они не думают про цыплёнка, координаты и скорость — только отмечают флажки. Вся «логика» отложена на потом, в игровой цикл. Это и делает движение плавным.

Пример 2. Двигаем цыплёнка в игровом цикле

Теперь подключим тот самый цыплёнок из прошлого урока и научим игровой цикл читать доску keys. Каждый кадр цикл проверяет, какие стрелки зажаты, и сдвигает chicken.x / chicken.y на величину скорости.

const canvas = document.getElementById('game');
const context = canvas.getContext('2d');

// состояние героя из прошлого урока
const chicken = {
  x: 200,
  y: 200,
  size: 40,
  speed: 4   // на сколько пикселей сдвигаемся за кадр
};

const keys = {};
window.addEventListener('keydown', function (e) { keys[e.code] = true; });
window.addEventListener('keyup', function (e) { keys[e.code] = false; });

function update() {
  // читаем доску клавиш и двигаем героя
  if (keys['ArrowLeft'])  chicken.x -= chicken.speed;
  if (keys['ArrowRight']) chicken.x += chicken.speed;
  if (keys['ArrowUp'])    chicken.y -= chicken.speed;
  if (keys['ArrowDown'])  chicken.y += chicken.speed;
}

function draw() {
  context.clearRect(0, 0, canvas.width, canvas.height);
  context.fillStyle = '#ffdc3c';
  context.fillRect(chicken.x, chicken.y, chicken.size, chicken.size);
}

function loop() {
  update();                       // обновили состояние по клавишам
  draw();                         // нарисовали кадр
  requestAnimationFrame(loop);    // зовём себя на следующий кадр
}

loop();

Результат: на холсте — жёлтый квадратик-цыплёнок. Зажми стрелку вправо — он плавно поедет вправо, ровно по 4 пикселя за кадр (примерно 240 пикселей в секунду при 60 FPS). Отпустил — мгновенно замер. Зажми вверх и вправо одновременно — поедет по диагонали в верхний правый угол. Движение гладкое, без рывков и пауз.

Главное, что здесь стоит понять про каждый блок:

  • Четыре if в update() читают доску keys и сдвигают координаты. Если keys['ArrowRight'] равно true — прибавляем к x, и так каждый кадр, пока стрелка зажата.
  • Это не else if, а четыре независимых if. Поэтому вверх и вправо могут сработать в одном кадре — отсюда плавная диагональ.
  • Помни, что в координатах canvas ось y направлена вниз: чтобы поехать вверх, мы вычитаем из y, а чтобы вниз — прибавляем. Это сбивает с толку в первый раз, но быстро укладывается в голове.
  • Движение происходит в update(), который зовётся из игрового цикла loop() через requestAnimationFrame — ровно 60 раз в секунду. Поэтому шаг всегда одинаковый, без пулемётных повторов от удержания клавиши.

Пример 3. Добавляем WASD и убираем дубли через функцию

Полмира играет не на стрелках, а на WASD — левой рукой, чтобы правая лежала на мыши. Дадим игроку выбор: пусть работают и стрелки, и буквы. Заодно вынесем «нажата ли клавиша движения вверх» в маленькую функцию, чтобы не плодить длинные условия.

const chicken = { x: 200, y: 200, size: 40, speed: 4 };

const keys = {};
window.addEventListener('keydown', function (e) { keys[e.code] = true; });
window.addEventListener('keyup', function (e) { keys[e.code] = false; });

// удобные "спросы": зажата ли любая из клавиш этого направления
function up()    { return keys['ArrowUp']    || keys['KeyW']; }
function down()  { return keys['ArrowDown']  || keys['KeyS']; }
function left()  { return keys['ArrowLeft']  || keys['KeyA']; }
function right() { return keys['ArrowRight'] || keys['KeyD']; }

function update() {
  if (left())  chicken.x -= chicken.speed;
  if (right()) chicken.x += chicken.speed;
  if (up())    chicken.y -= chicken.speed;
  if (down())  chicken.y += chicken.speed;
}

Результат: цыплёнок теперь слушается двух наборов клавиш одновременно. Стрелка вправо и буква D делают одно и то же — везут героя вправо. Игрок может держать руку на стрелках или на WASD, как ему удобнее, и переключаться хоть посреди игры.

Что здесь важно:

  • Функции up(), down(), left(), right() возвращают true, если зажата хотя бы одна из двух клавиш направления — за это отвечает оператор || («или»).
  • Теперь update() стал чище и читается почти как обычный текст: «если влево — едем влево». А если завтра захочешь добавить третью клавишу на направление (скажем, для геймпада), правишь одну функцию, а не четыре условия.
  • Вся остальная машинерия — keys, обработчики, игровой цикл — не изменилась. Мы лишь поменяли способ спрашивать, нажата ли клавиша.

Шпаргалка: что за что отвечает

ЭлементРоль
keydownСобытие: клавишу нажали
keyupСобытие: клавишу отпустили
event.codeИмя физической клавиши ('KeyW', 'ArrowUp')
keys[...] = true/falseДоска состояний: какие клавиши зажаты
update()Каждый кадр читает keys и двигает героя

Запомни цепочку как конвейер: палец → событие → доска keys → игровой цикл → движение на экране. Каждое звено делает одно простое дело, и именно поэтому всё работает плавно.

Частые ошибки и подводные камни

На этих граблях прыгают почти все, кто впервые делает управление. Узнаешь заранее — сэкономишь себе вечер мучений «почему он дёргается».

1. Двигать героя прямо в keydown

Самая частая ошибка новичка. Кажется логичным: нажали стрелку — сразу сдвинули цыплёнка прямо в обработчике keydown. Но из-за автоповтора удержания движение выходит рваным: один шаг, заметная пауза, потом дёрганая очередь шагов. Правильно — в обработчике только ставить keys[event.code] = true, а двигать в игровом цикле, ровным шагом каждый кадр.

2. Использовать event.key вместо event.code

Если читать event.key, управление развалится, как только игрок переключит раскладку на русскую: для физической клавиши W придёт не 'w', а 'ц', и твоё условие keys['KeyW'] никогда не сработает. К тому же event.key чувствителен к регистру ('w' и 'W' при зажатом Shift — разные строки). Бери event.code — он привязан к физическому месту клавиши и не зависит ни от раскладки, ни от регистра.

3. Забыть про keyup — клавиша «залипает»

Если повесить только keydown, а keyup забыть, то цыплёнок, разогнавшись вправо, уже не остановится: в доске keys навсегда останется true, и герой уедет за край холста сам по себе. Обработчик keyup обязателен — именно он гасит флажок и останавливает движение.

4. Цепочка else if вместо четырёх отдельных if

Если написать if (up()) ... else if (down()) ... else if (left()) ..., то за кадр сработает только одно направление, и диагонального движения не будет — герой откажется ехать вверх-вправо одновременно. Для движения по диагонали нужны независимые if, каждый сам по себе проверяет свою ось.

5. Путать направление оси y

В canvas начало координат — в левом верхнем углу, и ось y растёт вниз. Новички по школьной привычке пишут chicken.y += speed для движения вверх — и герой уезжает вниз, в обратную сторону. Запомни: вверх — это y -= speed (уменьшаем y), вниз — y += speed.

Мини-проект: цыплёнок в загоне

Теперь твоя очередь. Возьми код из примера 3 (со стрелками и WASD) и доработай его, чтобы получилась настоящая заготовка игры:

  1. Не дай цыплёнку убежать с холста. После строк движения в update() добавь проверки-ограничители: если chicken.x стал меньше 0 — верни его в 0; если больше, чем canvas.width - chicken.size — прижми к этому краю. То же самое по y. Теперь герой упирается в стенки загона, а не исчезает.
  2. Добавь ускорение на Shift. Заведи переменную скорости прямо в кадре: если зажат keys['ShiftLeft'], бери скорость побольше (например, 8), иначе обычную 4. Цыплёнок научится «бегать» при удержании Shift.
  3. Покажи скорость на экране. Через context.fillText(...) выведи в углу холста текущую скорость — так ты увидишь, что Shift действительно её меняет.

Подсказки, чтобы получилось:

  • Ограничители удобно записать через Math.max и Math.min: chicken.x = Math.max(0, Math.min(chicken.x, canvas.width - chicken.size)).
  • Скорость считай в начале update(): const speed = keys['ShiftLeft'] ? 8 : 4; — и дальше двигай на speed вместо chicken.speed.
  • Когда заработает — поиграй с числами: сделай загон меньше, скорость бега выше, добавь управление и на правый Shift ('ShiftRight'). Меняй и смотри, что вышло — так и нащупывается ощущение «правильного» управления.

Если цыплёнок бегает в четыре стороны и по диагонали, тормозит о стенки и ускоряется на Shift — поздравляю, ты собрал управление уровня настоящей игры. Эти же обработчики и доску keys ты будешь переиспользовать во всех следующих уроках без переписывания.

Итоги

Сегодня ты дал цыплёнку первую настоящую способность — слушаться игрока. Вот что у тебя теперь в руках:

  • События keydown и keyup — браузер сам «звонит» тебе на нажатие и отпускание клавиши.
  • Объект keys — доска состояний, где для каждой клавиши лежит true или false; обработчики только заполняют её.
  • Движение в игровом цикле — каждый кадр update() читает доску и сдвигает героя ровным шагом, поэтому движение плавное, без рывков.
  • event.code вместо event.key — читаем физическую клавишу, чтобы раскладка и регистр не ломали управление.
  • Стрелки и WASD вместе — даём игроку привычный выбор.

Главный принцип, который ты унесёшь из урока: событие фиксирует состояние, а цикл его использует. Это разделение спасёт тебя от дёрганого управления не только сейчас, но и в любой будущей игре — от змейки до платформера.

В следующем уроке мы добавим к движению дельта-время: научим цыплёнка двигаться с одинаковой скоростью и на мощном игровом ноутбуке с 144 FPS, и на стареньком планшете с 30 FPS — как успеть на один и тот же автобус при разной скорости ходьбы. Управление у тебя уже есть — пора сделать его честным на любом железе.

Проверьте себя
1. Почему движение героя пишут в игровом цикле, а не прямо в обработчике keydown?
AВ обработчике keydown вообще нельзя менять переменные
BУдержание клавиши даёт рваный автоповтор — движение получится дёрганым
Ckeydown срабатывает только один раз за всю игру
DТак требует синтаксис JavaScript
2. Что хранит объект keys в этом уроке?
AКоординаты x и y цыплёнка
BКакие клавиши зажаты прямо сейчас (true/false по имени клавиши)
CСписок всех клавиш, которые когда-либо нажимали
DСкорость движения героя
3. Почему для игр читают event.code, а не event.key?
Aevent.key вообще не существует
Bevent.code привязан к физической клавише и не зависит от раскладки и регистра
Cevent.code работает быстрее
Devent.key возвращает число, а нам нужна строка
4. Что случится, если повесить keydown, но забыть обработчик keyup?
AНичего, keyup необязателен
BКлавиша «залипнет»: флажок останется true, и герой не остановится
CБраузер выдаст ошибку
DГерой будет двигаться только по диагонали
5. Почему в update() стоят четыре отдельных if, а не цепочка else if?
Aelse if работает медленнее
BОтдельные if позволяют сработать сразу нескольким направлениям — отсюда движение по диагонали
Celse if запрещён внутри функций
DЭто одно и то же, разницы нет
6. Цыплёнок должен поехать вверх. Какую строку написать в canvas?
Achicken.y += chicken.speed
Bchicken.y -= chicken.speed
Cchicken.x -= chicken.speed
Dchicken.up = true