Лабиринтом можно будет управлять четырьмя экранными стрелками. Палец можно увести за кнопку и отпустить: кот остановится. Два пальца дадут диагональное движение, а клавиатура останется рабочей. Размер поля подстроится под экран без изменения карты.

Открыть игру отдельно

Сохраним координаты игры при изменении размера

Начинаем с final прошлого урока: эти файлы лежат в start. Проверь загрузку кота, звук, ключ, паузу и сброс. Сначала открой игру отдельно, чтобы удобно менять ширину окна; затем проверь её внутри статьи.

У Canvas два размера. Атрибуты width="480" height="336" задают внутреннее поле: карта по-прежнему 10 × 7 клеток по 48. CSS определяет размер на странице. Мы уже использовали width: 100%; max-width: 480px; height: auto;: пропорции 480:336 сохраняются, а координаты стен не меняются. Не присваивай canvas.width при каждом resize: это очищает рисунок и сбрасывает состояние контекста.

Экранные кнопки передают направление, поэтому координаты пальца внутри Canvas нам не нужны. Если позже будешь выбирать точку на поле, переводи координаты относительно окна браузера в его внутренние единицы. При отсутствии рамки формула x такая: (event.clientX - rect.left) * canvas.width / rect.width, где rect получен через getBoundingClientRect(). У нашей рамки есть толщина, поэтому для такого режима придётся вычесть рамку и использовать размер области содержимого. Не подставляй clientX напрямую в player.x.

Одно нажатие, один указатель

Pointer Events дают общий набор событий для мыши, пера и касаний. pointerdown означает начало нажатия, pointerup — отпускание, pointercancel — отмену браузером или устройством. У каждого активного контакта есть pointerId. Палец, который удерживает кнопку, и второй палец — два разных указателя.

Начнём с одной стрелки вправо. Перед summary в index.html добавь:

<div class="direction-pad">
  <button type="button" data-direction="right" aria-label="Вправо">→</button>
</div>

data-direction хранит наше имя направления. JavaScript прочитает его через button.dataset.direction. aria-label даёт кнопке текстовое имя, хотя на экране только стрелка. В CSS:

.direction-pad {
  display: flex;
  justify-content: center;
  margin: 16px 0;
}

.direction-pad button {
  min-width: 52px;
  min-height: 52px;
  font-size: 24px;
  touch-action: none;
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
}

.direction-pad .held {
  background: #322052;
}

touch-action: none говорит браузеру, что жест, начавшийся на этой кнопке, обрабатывает игра. Ограничение действует только на экранные стрелки: вне них можно прокручивать статью. user-select убирает выделение подписи при удержании. Не переноси эти правила на body или весь документ.

Нельзя просто менять keys.right = false при любом отпускании: клавиатурная стрелка в этот момент может оставаться зажатой. Разделим источники. keyboardKeys хранит четыре клавиши, activePointers — записи активных касаний. Итоговый keys по-прежнему читает update; функция движения и нормализация диагонали не меняются.

После объявления keys добавь переменные и сбор итогового состояния:

const keyboardKeys = { left: false, right: false, up: false, down: false };
const activePointers = [];
const directionButtons = document.querySelectorAll("[data-direction]");
function refreshKeys() {
  keys.left = keyboardKeys.left;
  keys.right = keyboardKeys.right;
  keys.up = keyboardKeys.up;
  keys.down = keyboardKeys.down;
  for (const pointer of activePointers) keys[pointer.direction] = true;
  for (const button of directionButtons) {
    button.classList.toggle("held", keys[button.dataset.direction]);
  }
}

Доступ keys[pointer.direction] выбирает свойство по строке: для "right" это keys.right. Сначала берём клавиатуру, затем добавляем все указатели. Два источника складываются по правилу «хотя бы один удерживает». classList.toggle("held", условие) добавляет класс при true и снимает при false: так видно, какое направление активно.

Замени старую setKey целиком: теперь она записывает клавиатурное состояние, затем вызывает refreshKeys.

function setKey(code, pressed) {
  if (code === "ArrowLeft") keyboardKeys.left = pressed;
  else if (code === "ArrowRight") keyboardKeys.right = pressed;
  else if (code === "ArrowUp") keyboardKeys.up = pressed;
  else if (code === "ArrowDown") keyboardKeys.down = pressed;
  else return false;
  refreshKeys();
  return true;
}

Удерживаем кнопку даже за её границей

event.currentTarget — кнопка, на которой установлен обработчик. Вызов setPointerCapture(pointerId) направляет последующие события этого указателя ей, даже если палец или мышь ушли за границу. Это не удерживает физический курсор, а меняет получателя событий.

Добавь обработчик. На первом шаге разрешаем только один указатель:

function handlePointerDown(event) {
  if (event.button !== 0 || state !== "playing") return;
  if (activePointers.length > 0) return;
  event.preventDefault();
  const button = event.currentTarget;
  button.setPointerCapture(event.pointerId);
  activePointers.push({ id: event.pointerId, direction: button.dataset.direction, button: button });
  canvas.focus({ preventScroll: true });
  refreshKeys();
}

Проверка event.button оставляет основную кнопку мыши; начало обычного касания также имеет значение 0. preventDefault() не даёт нажатию переместить фокус с игрового поля на кнопку. Иначе прежний blur поставит игру на паузу в момент нажатия. Затем явно фокусируем Canvas. Объект { preventScroll: true } просит браузер оставить прокрутку на месте: иначе фокус может вернуть страницу к холсту и сдвинуть кнопки из-под пальца. Такую же опцию используем в handlePlay. Это настройка focus, а не изменение размеров поля. Если игра пока ready или paused, стрелки её не запускают — сначала нужна кнопка «Начать» или «Продолжить».

Запись содержит id, направление и кнопку. При окончании найдём запись по id. splice(i, 1) удаляет один элемент массива на позиции i. Обратный обход от конца удобен при удалении: соседние записи не будут пропущены. hasPointerCapture проверяет, удерживает ли кнопка указатель; releasePointerCapture освобождает его.

function handlePointerEnd(event) {
  for (let i = activePointers.length - 1; i >= 0; i = i - 1) {
    if (activePointers[i].id === event.pointerId) {
      const pointer = activePointers[i];
      activePointers.splice(i, 1);
      if (pointer.button.hasPointerCapture(pointer.id)) {
        pointer.button.releasePointerCapture(pointer.id);
      }
    }
  }
  refreshKeys();
}

После release браузер может прислать lostpointercapture — потерю захвата. Обработчик должен корректно работать и при повторном вызове: если записи уже нет, удалять нечего. Именно поэтому сначала удаляем запись, а потом освобождаем захват.

Замени clearKeys и зарегистрируй события рядом с новым кодом:

function clearKeys() {
  keyboardKeys.left = false;
  keyboardKeys.right = false;
  keyboardKeys.up = false;
  keyboardKeys.down = false;
  // Убираем запись до release: lostpointercapture может прийти следом.
  while (activePointers.length > 0) {
    const pointer = activePointers.pop();
    if (pointer.button.hasPointerCapture(pointer.id)) {
      pointer.button.releasePointerCapture(pointer.id);
    }
  }
  refreshKeys();
}
for (const button of directionButtons) {
  button.addEventListener("pointerdown", handlePointerDown);
  button.addEventListener("pointerup", handlePointerEnd);
  button.addEventListener("pointercancel", handlePointerEnd);
  button.addEventListener("lostpointercapture", handlePointerEnd);
}

while повторяет тело, пока условие true. Здесь pop убирает последнюю запись и возвращает её, поэтому длина массива уменьшается и цикл заканчивается. Прежние stopLoop, loadLevel и обработчики blur уже вызывают clearKeys: теперь они очищают оба источника. Поставь объявления массивов и кнопок до первого resetGame, иначе первый сброс попытается прочитать ещё не созданные данные.

Это step-01. Начни игру, удерживай единственную стрелку вправо, уведи мышь или палец за кнопку и отпусти. Кот должен остановиться. Отдельно проверь паузу при потере фокуса окна; возвращение не должно восстанавливать старое нажатие.

Добавим остальные стрелки и второй палец

В HTML замени одну кнопку четырьмя:

<div class="direction-pad" role="group" aria-label="Экранные стрелки: удерживай для движения">
<button type="button" data-direction="up" aria-label="Вверх">↑</button>
<button type="button" data-direction="left" aria-label="Влево">←</button>
<button type="button" data-direction="down" aria-label="Вниз">↓</button>
<button type="button" data-direction="right" aria-label="Вправо">→</button>
</div>

Добавь в конец CSS раскладку крестом:

.direction-pad {
  display: grid;
  grid-template-columns: repeat(3, minmax(48px, 64px));
  gap: 8px;
  justify-content: center;
  margin: 16px 0;
}

.direction-pad button {
  min-height: 52px;
  font-size: 24px;
  touch-action: none;
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
}

.direction-pad [data-direction="up"] {
  grid-column: 2;
}

.direction-pad [data-direction="left"] {
  grid-column: 1;
}

.direction-pad .held {
  background: #322052;
  box-shadow: inset 0 0 0 3px #edc15e;
}

Grid создаёт три колонки. repeat(3, minmax(48px, 64px)) означает три одинаковые колонки от 48 до 64 пикселей. Вверх занимает вторую колонку, влево начинает следующую строку; вниз и вправо размещаются рядом. gap оставляет промежуток между кнопками.

В handlePointerDown убери только if (activePointers.length > 0) return;. Теперь разные pointerId одновременно живут в массиве. Не проверяй event.isPrimary: иначе второй палец будет отброшен и диагональ не заработает. Отпускание одного id убирает только его запись. Даже два пальца на одной кнопке независимы.

Получилась step-02. Удерживай вправо и вниз: скорость по диагонали остаётся прежней благодаря уже существующей Math.hypot. Отпусти вправо — останется только вниз. Затем удерживай ArrowRight на клавиатуре и экранную стрелку вправо. Отпускание указателя не должно отпускать клавишу.

Уберём меню с игровых кнопок

В final добавь к экранным кнопкам обработчик contextmenu. Это меню браузера, которое на некоторых устройствах открывается при длинном удержании. Отменяем его только в этом месте:

function preventButtonMenu(event) {
  event.preventDefault();
}
for (const button of directionButtons) {
  button.addEventListener("contextmenu", preventButtonMenu);
}

У переключателя звука замени прежнюю регистрацию mousedown на soundButton.addEventListener("pointerdown", keepGameFocus);. Теперь защита фокуса относится и к касанию. Обработчик click для самого переключения оставь. Пауза, новая игра и потеря фокуса продолжают очищать всё управление.

При изменении ширины окна CSS сожмёт поле, но player, walls и levelIndex останутся прежними. Поверни устройство посреди уровня и проверь, что кот не вернулся к началу. Если встроенный пример тесен, открой отдельную игру: это позволяет отличить проблему оболочки статьи от самой игры.

Откроем локальную игру на телефоне

Сначала подключи компьютер и телефон к своей домашней Wi-Fi сети. На телефоне localhost означает сам телефон, а адрес 127.0.0.1 с урока 19 доступен только на компьютере. Для короткой проверки нужен адрес компьютера в этой сети.

Останови прежний сервер через Ctrl+C. Открой терминал именно в папке выбранной игры, где лежат index.html и assets. В Windows запусти py -3.14 -m http.server 8000 --bind 0.0.0.0; на macOS/Linux с установленным Python — python3 -m http.server 8000 --bind 0.0.0.0. Теперь сервер слушает сетевые интерфейсы компьютера. Он отдаёт содержимое этой папки, поэтому не запускай его в папке с личными документами.

В Windows команда ipconfig покажет IPv4-адрес Wi-Fi адаптера. На macOS/Linux посмотри адрес текущей Wi-Fi сети в системных настройках. Если это, например, 192.168.1.23, на телефоне открой http://192.168.1.23:8000. Подставь свой адрес. Если система спросит доступ для Python, разрешай его только для доверенной частной сети; не отключай защиту целиком. Гостевая сеть может запрещать устройствам видеть друг друга.

Проверяй только в своей доверенной сети, после проверки останови сервер через Ctrl+C. Это временный доступ в домашней сети, а не публикация в интернете. Если связать устройства не удалось, используй эмулятор браузера и отметь это ограничение в записи проверки. У опубликованной игры позже будет обычная ссылка, которую можно открыть с телефона без локального сервера.

Самостоятельно: удобная раскладка для узкого экрана

Проверь final при ширине 320–390 пикселей. Запиши одно конкретное неудобство: кнопка мала, соседняя нажимается случайно, палец закрывает поле, текст вытесняет управление. Измени CSS раскладки и инструкцию так, чтобы исправить именно его. Не меняй события и правила движения без необходимости.

Например, попробуй ряд из четырёх стрелок для двух рук. Сохрани размер каждой области не меньше 44 × 44 CSS-пикселя, видимый фокус и доступные подписи. После изменения проведи тест: нажать две, отпустить одну за пределами, продолжить второй, отпустить обе. Проверь также прокрутку за пределами кнопок.

Подсказка 1: начни с наблюдения

В эмуляторе можно увидеть размеры и отсутствие горизонтальной прокрутки. Удобство реального удержания лучше проверять на телефоне. Если телефона нет, так и запиши: проверена эмуляция, а не реальное устройство.

Подсказка 2: меняется раскладка, не карта

У .direction-pad измени число колонок и назначение grid-column. Не уменьшай tileSize и размеры player ради узкого экрана: поле уже масштабирует CSS.

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

В solutions/wide-pad четыре кнопки стоят в ряд и занимают доступную ширину. Это один вариант, а не универсально лучшая раскладка. Сравни его со своим наблюдением и оставь вариант, который действительно помог.

Проверим весь лабиринт

СценарийЧто должно случиться
Отпустить за кнопкойКот останавливается, подсветка гаснет
Отмена или потеря захватаЗапись этого указателя удаляется
Два пальца, отпускание одногоВторое направление сохраняется
Клавиша и палец вместеИсточники не отменяют друг друга
Пауза, сброс, новая картаСтарые нажатия очищены
Поворот экранаКарта и прогресс сохранены, пропорции поля правильные
Жест вне кнопокСтраница прокручивается

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

Справочно: Pointer Events, захват указателя и touch-action на MDN; параметр bind описан в документации локального сервера Python.