Теперь кот будет двигаться, пока ты удерживаешь стрелку, и останавливаться после отпускания. Мы продолжим прежний цикл: координаты меняются по времени, а обработчики клавиатуры только запоминают намерение игрока. Добавим границы и одинаковую скорость по прямой и диагонали.

Открыть учебную сцену отдельно

Добавляем управление с клавиатуры

start содержит те же файлы, что финал урока про игровой цикл. При старте кот пока сам идёт вправо. Рисунок, 480 × 320, рамка героя 32 × 40, кнопки и RAF сохраняются. Мы заменим только правило движения и добавим ввод. Открой проект по своему HTTP-адресу.

Для этой сцены нужна клавиатура. Кнопки запуска и сброса доступны мышью и через Tab/Enter, но экранного джойстика здесь нет. На телефоне можно проверить размер рисунка и кнопки; это не означает, что движение уже поддерживает сенсорное управление.

Включаем управление только в игровом поле

Стрелки нужны и игре, и чтению страницы. Если перехватывать их на всём document, игра будет мешать статье. Добавь к существующему холсту tabindex="0" и понятную подпись:

<canvas id="game" width="480" height="320" tabindex="0"
  aria-label="Игровое поле: управление стрелками"
  aria-describedby="instructions">Браузер не поддерживает Canvas.</canvas>

Абзацу с управлением над холстом дай id="instructions". Значение tabindex="0" включает холст в обычный порядок Tab. Стиль canvas:focus-visible в файлах подчёркивает активное поле. Рамка — подсказка, куда сейчас пойдёт ввод. Не убирай её ради оформления.

Замени handlePlay, чтобы кнопка сначала передавала фокус полю. canvas.focus() вызывает обычное действие браузера, а не запускает отдельный игровой цикл:

function handlePlay() {
  canvas.focus();
  startLoop();
}

Запоминаем нажатые клавиши

При удержании клавиши браузер может повторять keydown. Если каждый повтор сдвигает кота на десять пикселей, скорость зависит от настроек клавиатуры. Вместо этого запишем true при нажатии и false при отпускании. Сто одинаковых присваиваний не увеличивают скорость.

В game.js добавь объект и функции перед update. На первом этапе обрабатываем только левую и правую стрелки. event — объект события, который браузер передаёт обработчику; event.code обозначает физическую клавишу. setKey возвращает, была ли это нужная нам клавиша:

const keys = { left: false, right: false, up: false, down: false };

function clearKeys() {
  keys.left = false;
  keys.right = false;
  keys.up = false;
  keys.down = false;
}

function setKey(code, pressed) {
  if (code === "ArrowLeft") keys.left = pressed;
  else if (code === "ArrowRight") keys.right = pressed;
  else return false;
  return true;
}

function handleKeyDown(event) {
  if (setKey(event.code, true)) event.preventDefault();
}

function handleKeyUp(event) {
  if (setKey(event.code, false)) event.preventDefault();
}

preventDefault() запрещает стандартное действие этого события — для обработанной стрелки это прокрутка. Другие клавиши, в том числе Tab, не блокируются. Обработчики будут висеть на холсте, поэтому стрелка на кнопке, в тексте или на внешней статье не становится игровым вводом.

Останавливаем кота, когда поле теряет фокус

Игрок может удерживать стрелку и переключить окно до keyup. Старое значение true тогда осталось бы в игре. Добавь clearKeys() в stopLoop сразу после running = false. Это очистит ввод при паузе, сбросе и скрытии. Ещё зарегистрируй следующие события один раз, не внутри цикла или кнопки:

function handleInputBlur() {
  clearKeys();
  handlePause();
}
canvas.addEventListener("keydown", handleKeyDown);
canvas.addEventListener("keyup", handleKeyUp);
canvas.addEventListener("blur", handleInputBlur);
window.addEventListener("blur", handleInputBlur);

blur означает потерю фокуса. Мы ставим паузу и обнуляем ввод, когда фокус уходит с поля или из окна браузера. Чтобы продолжить, нажми кнопку ещё раз. Это намеренное правило: открытая Console тоже может забрать фокус. Пауза в этом случае исправна.

Двигаем кота влево и вправо

Замени прежний автоматический update следующими двумя функциями. dx и dy — направление, а не уже пройденное расстояние. На первом этапе dy всегда ноль. Если обе противоположные стрелки зажаты, −1 и +1 взаимно уничтожаются:

function update(dt) {
  let dx = 0;
  let dy = 0;
  if (keys.left) dx = dx - 1;
  if (keys.right) dx = dx + 1;
  player.x = player.x + dx * speed * dt;
  player.y = player.y + dy * speed * dt;
  keepInside();
}

function keepInside() {
  player.x = Math.max(0, Math.min(canvas.width - player.width, player.x));
  player.y = Math.max(0, Math.min(canvas.height - player.height, player.y));
}

keepInside сначала выбирает не больше допустимого максимума, затем не меньше нуля. Правая граница для левого края героя — 448, нижняя — 280. Использовать радиус здесь нельзя: у героя прямоугольная рамка, а координата — её верхний левый угол. Дробные координаты допустимы; округлять каждый кадр не нужно.

Запусти step-01. Удерживай вправо, отпусти, нажми обе стрелки. Дойди до границы. Нажми Tab: движение должно остановиться, а фокус — уйти с поля. После возвращения кнопкой «Продолжить» прежняя стрелка не остаётся зажатой. Если вся страница перестала прокручиваться стрелками, проверь, к какому элементу прикреплён keydown.

Добавляем движение вверх, вниз и по диагонали

В setKey перед последним else return false добавь распознавание двух оставшихся стрелок:

else if (code === "ArrowUp") keys.up = pressed;
else if (code === "ArrowDown") keys.down = pressed;

В update после проверок left/right добавь:

if (keys.up) dy = dy - 1;
if (keys.down) dy = dy + 1;

Теперь работает step-02. При одновременном движении вправо и вниз обе координаты меняются на полное расстояние. Пара (dx, dy) задаёт направление; такую пару называют вектором. Получается более длинная диагональ: вектор (1, 1) имеет длину примерно 1,414. Это промежуточная версия для наблюдения: по диагонали кот пока движется быстрее.

Делаем скорость по диагонали такой же

Math.hypot(dx, dy) вычисляет длину по двум сторонам: для (1, 0) это 1, для (1, 1) — примерно 1,414. Если разделить обе координаты направления на эту длину, направление сохранится, но общая длина станет 1. Такое приведение называют нормализацией. Проверка length > 0 защищает от деления на ноль, когда клавиш нет.

Между определением направления и изменением позиции вставь блок с Math.hypot. Полный update финальной версии выглядит так; остальные функции из предыдущего этапа сохраняются:

function update(dt) {
  let dx = 0;
  let dy = 0;
  if (keys.left) dx = dx - 1;
  if (keys.right) dx = dx + 1;
  if (keys.up) dy = dy - 1;
  if (keys.down) dy = dy + 1;
  const length = Math.hypot(dx, dy);
  if (length > 0) {
    dx = dx / length;
    dy = dy / length;
  }
  player.x = player.x + dx * speed * dt;
  player.y = player.y + dy * speed * dt;
  keepInside();
}

Проверь final: путь по диагонали за одинаковое время имеет такую же длину, как путь по горизонтали. Сумма изменений x + y для этой проверки не подходит — нужна длина Math.hypot(изменениеX, изменениеY). У стены холста пройденный путь может стать короче: часть намеренного движения ограничивается границей.

Самостоятельно: вторая схема клавиш

Добавь WASD вместе со стрелками, сохранив действие старой схемы. Физические коды — KeyA, KeyD, KeyW, KeyS. Они не зависят от напечатанной буквы при смене раскладки. Не меняй скорость, цикл или рисунок.

Проверь необычный случай: держи A и стрелку влево одновременно, затем отпусти только A. Герой должен продолжить идти влево до отпускания стрелки. Поэтому одного присваивания keys.left = false на любое отпускание недостаточно. Сохрани состояние каждой физической клавиши отдельно, затем вычисляй направление через «или». Объект со свойствами по коду и доступ heldCodes[code] уже известны из урока про объекты.

Подсказка 1: запомни каждую клавишу отдельно

Сначала запомни конкретную физическую клавишу. Затем left равно истине, если зажата хотя бы одна из двух левых клавиш. Не складывай их скорости: две клавиши одного направления не должны разгонять кота.

Подсказка 2: очистка

При паузе замени объект физических клавиш новым пустым объектом и сбрось четыре направления. Проверяй heldCodes.KeyA === true: отсутствующее свойство — это не удержание.

Разбор после самостоятельной попытки

В решении отдельный heldCodes хранит физические клавиши, а keys хранит четыре направления. Смешанное удержание одной стороны, противоположные стороны, другая раскладка и потеря фокуса должны сохранять ожидаемое поведение. Файлы находятся в solutions/wasd просмотрщика и ZIP. Проверить пример решения.

Проверка управления

  • Удержание даёт непрерывное движение, отпускание останавливает; повтор keydown не разгоняет.
  • Стрелки обрабатываются только на активном холсте; Tab позволяет выйти из него.
  • Переключение окна очищает ввод и ставит паузу; после продолжения кот стоит, пока ты снова не нажмёшь стрелку.
  • Кот целиком внутри 480 × 320; диагональ имеет ту же скорость.
  • Вторая схема прошла проверку смешанного удержания и сброса.

Справочно: keydown и фокус, KeyboardEvent.code, потеря фокуса и preventDefault на MDN.