Управление с клавиатуры
Учим браузер слушать клавиатуру, запоминать зажатые клавиши в объекте 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. Это та самая доска, по которой дальше будет ориентироваться игровой цикл.
Разберём построчно:
const keys = {}— пустой объект-доска. Сначала в нём ничего нет, и любая непрочитанная клавиша вернётundefined, что в условии работает как «не нажата».- В
keydownмы пишемkeys[event.code] = true— берём имя клавиши (например,'ArrowRight') и ставим напротив негоtrue. - В
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) и доработай его, чтобы получилась настоящая заготовка игры:
- Не дай цыплёнку убежать с холста. После строк движения в
update()добавь проверки-ограничители: еслиchicken.xстал меньше0— верни его в0; если больше, чемcanvas.width - chicken.size— прижми к этому краю. То же самое поy. Теперь герой упирается в стенки загона, а не исчезает. - Добавь ускорение на Shift. Заведи переменную скорости прямо в кадре: если зажат
keys['ShiftLeft'], бери скорость побольше (например,8), иначе обычную4. Цыплёнок научится «бегать» при удержании Shift. - Покажи скорость на экране. Через
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 — как успеть на один и тот же автобус при разной скорости ходьбы. Управление у тебя уже есть — пора сделать его честным на любом железе.