Лабиринтом можно будет управлять четырьмя экранными стрелками. Палец можно увести за кнопку и отпустить: кот остановится. Два пальца дадут диагональное движение, а клавиатура останется рабочей. Размер поля подстроится под экран без изменения карты.
Сохраним координаты игры при изменении размера
Начинаем с 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.