Лабиринт уже можно пройти. Теперь приведём начало партии, паузу и перезапуск к понятным правилам: у игры будет одно текущее состояние, а повторный клик не сможет запустить ещё один цикл.
До «Начать» кот стоит. Пауза останавливает движение и сохраняет уровень. После ухода с поля или скрытия вкладки нужно явно продолжить. Победа заканчивает партию, а «Сначала» возвращает первую карту, ключ и предметы.
Продолжаем готовый лабиринт
Открой start/index.html: это финал урока о ключе и двух уровнях. Понадобятся его loadLevel(), checkObjects(), цикл, функции ввода и рисования. Менять карты и столкновения сейчас не нужно. Три файла можно открыть напрямую в браузере или через свой локальный сервер.
start/ — предыдущий лабиринт; step-01/ — состояния и переходы; step-02/ — общий resetGame; step-03/ и final/ — подписи и доступность кнопок. solutions/ добавляет условие выхода по кристаллам. В просмотрщике и ZIP есть инструкция по запуску и ответы на упражнения.
Шаг 1. Одно состояние вместо двух флагов
В предыдущей версии running отвечал за цикл, а finished — за победу. Возможные сочетания этих флагов приходилось помнить отдельно. Заменим их одной строкой:
let state = "ready";
| Состояние | Что можно делать | Что переводит дальше |
|---|---|---|
| ready | Осмотреть старт; движение не обновляется | Кнопка «Начать» → playing |
| playing | Играть, собирать предметы, переходить между картами | Пауза/уход → paused; последний выход → won |
| paused | Смотреть прежнюю позицию; данные уровня сохраняются | Кнопка «Продолжить» → playing |
| won | Прочитать результат; движение завершено | «Сначала» → ready |
Кнопка «Сначала» разрешена из любого состояния и возвращает ready. Переход на вторую карту — всё ещё playing: это часть той же партии. Здесь не нужен общий движок состояний; достаточно нескольких явных проверок.
Удали объявления running и finished. В начале update(dt) и handleKeyDown(event) добавь ранний выход, если игра не идёт. В checkObjects() вместо finished = true записывай state = "won".
if (state !== "playing") return;
Проверка в update защищает само правило движения; проверка в обработчике не запоминает новые нажатия во время паузы. Отпускание клавиши по-прежнему очищает её в handleKeyUp. Уже нажатые направления сбрасываются при остановке.
Как запускать и останавливать цикл
На паузе не будем запрашивать новые кадры. Последний нарисованный кадр остаётся на холсте. При продолжении начинаем отсчёт времени заново, чтобы пауза не превратилась в огромный шаг.
Переменные rafId и lastTime остаются, их начальное значение — null. Замени три функции цикла:
function frame(timestamp) {
rafId = null;
if (state !== "playing") return;
if (lastTime === null) lastTime = timestamp;
const dt = Math.min((timestamp - lastTime) / 1000, 0.05);
lastTime = timestamp;
update(dt);
render();
if (state === "playing") rafId = requestAnimationFrame(frame);
}
function startLoop() {
if (rafId !== null || state !== "playing" || document.hidden) return;
lastTime = null;
rafId = requestAnimationFrame(frame);
}
function stopLoop() {
clearKeys();
if (rafId !== null) cancelAnimationFrame(rafId);
rafId = null;
lastTime = null;
}
rafId хранит номер ожидающего запроса, а не число выполненных кадров. В начале вызванного frame запрос уже исполняется, поэтому переменная очищается. После обновления новый запрос создаётся только при playing. Если выход внутри update поставил won, продолжения не будет.
startLoop() отказывается создавать запрос, когда один уже есть. stopLoop() отменяет ожидающий запрос по его последнему номеру, очищает ввод и время. cancelAnimationFrame отменяет именно запрос; он не обнуляет наши игровые данные автоматически.
Мы не вызываем startLoop() из render() или loadLevel(). Рисование может понадобиться и при остановленной игре, а загрузка карты не должна создавать второй источник кадров.
Начать, продолжить и поставить на паузу
Обработчик запуска принимает только два исходных состояния. Уже играющую или завершённую партию он не запускает снова. canvas.focus() направляет стрелки в игровое поле. Функцию updateControls() сначала сделаем короткой — только для подписи кнопки.
function updateControls() {
playButton.textContent = state === "paused" ? "Продолжить" : "Начать";
}
function handlePlay() {
if ((state !== "ready" && state !== "paused") || document.hidden) return;
clearKeys();
state = "playing";
canvas.focus();
startLoop();
updateControls();
statusText.textContent = hasKey ? "Дверь открыта. Ищи выход." : "Найди ключ и открой дверь.";
}
function handlePause() {
if (state !== "playing") return;
state = "paused";
stopLoop();
updateControls();
statusText.textContent = "Пауза. Нажми «Продолжить», когда будешь готов.";
}
Пауза сохраняет player, levelIndex, ключ и кристаллы. Меняются только состояние, ввод и работа цикла. Повторный вызов паузы безопасен: после первого состояние уже не playing, поэтому следующий сразу завершается.
В ветке победы checkObjects() после state = "won" вызови stopLoop() и updateControls(), затем выведи итог. return true по-прежнему останавливает остаток малого движения.
Пока отдельной функции общего сброса нет, обнови тело существующего handleReset(): оно должно присваивать ready, а не удалённый флаг finished. В самом конце файла вызывай один handleReset(); вместо отдельных loadLevel(0); render();.
function handleReset() {
stopLoop();
loadLevel(0);
state = "ready";
updateControls();
render();
statusText.textContent = "Новая игра. Нажми «Начать».";
}Проверь step-01/: до старта стрелки не сдвигают кота. После «Начать» движение работает. Пауза оставляет текущую позицию, продолжение не делает рывок. Нажми запуск несколько раз — скорость остаётся прежней. Победа не превращается в новую партию от повторного запуска.
Шаг 2. Один полный перезапуск
loadLevel(index) восстанавливает только данные карты. Начало новой партии дополнительно останавливает цикл и переводит приложение в ready. Дадим этой операции отдельное имя resetGame(), а обработчик кнопки будет только вызывать её.
function resetGame() {
stopLoop();
loadLevel(0);
state = "ready";
updateControls();
render();
statusText.textContent = "Новая игра. Нажми «Начать».";
}
function handleReset() {
resetGame();
}
Порядок здесь полезно прочитать вслух: остановить → загрузить первую карту → задать ready → обновить кнопки → нарисовать → показать инструкцию. loadLevel(0) уже сбрасывает ключ, счёт, предметы и положение кота. Не размножай эти присваивания во втором месте.
В самом конце файла, после однократных addEventListener, вызови resetGame(); вместо временного handleReset(); из первого этапа. При первом открытии и после кнопки выполняется одна операция. Обработчики остаются за пределами функций сброса; иначе каждое нажатие добавляло бы новые реакции.
Проверь step-02/: начни игру, возьми ключ, поставь паузу и нажми «Сначала». Теперь первый уровень, ключ не найден, кристаллы на месте, счёт нулевой, игра ждёт старта. Повтори сброс во время движения и после победы.
Уход с поля и скрытая вкладка
Удерживаемая стрелка может остаться в данных, если отпускание произошло уже в другом окне. Поэтому при потере фокуса очищаем направления и ставим паузу. В handleInputBlur() остаются clearKeys(); handlePause();. Он подключён к blur Canvas и окна.
visibilitychange относится к видимости документа. После переключения вкладки или сворачивания окна document.hidden сообщает, что страница скрыта. Замени обработчик на:
function handleVisibilityChange() {
if (document.hidden) {
clearKeys();
handlePause();
}
}
Возвращение на вкладку ничего не запускает. Игрок сам нажимает «Продолжить». Это отдельное правило нашей игры, а не требование браузера. Состояние ready или won от ухода не становится паузой: handlePause() принимает только playing. Различие между потерей фокуса и скрытием описано в MDN о visibilitychange и событии blur.
Проверь: удерживая стрелку, нажми Tab или перейди в другую вкладку. После возвращения кот стоит, а направление не «залипло». При продолжении нужно нажать стрелку снова. Не отключай прокрутку всей статьи ради этой проверки: обработчики стрелок находятся на сфокусированном поле.
Шаг 3. Кнопки показывают доступные действия
Правила уже защищены проверками внутри функций. Теперь интерфейс тоже покажет, какое действие доступно. Замени updateControls():
function updateControls() {
playButton.disabled = state === "playing" || state === "won";
pauseButton.disabled = state !== "playing";
playButton.textContent = state === "paused" ? "Продолжить" : "Начать";
}
Во время игры запуск недоступен, после победы нельзя «продолжить» законченную партию. Пауза доступна только во время игры. Кнопка «Сначала» остаётся доступной всегда. Свойство disabled помогает интерфейсу, но не заменяет проверок внутри обработчиков: функции должны оставаться корректными и при повторном вызове.
Проверь final/: подписи меняются между «Начать» и «Продолжить»; кнопка паузы отключена до старта. После перехода на второй уровень игра продолжает ту же партию. После победы доступен перезапуск. Финал совпадает с step-03/.
Самостоятельно: выход после всех кристаллов
Измени условие прохождения: на каждом уровне теперь нужны ключ и все кристаллы. Если игрок пришёл к открытой двери раньше, покажи понятную подсказку и оставь его на текущей карте. Движение должно продолжать работать, чтобы он мог вернуться за предметами.
Не добавляй новое состояние «не хватает кристаллов» — партия всё ещё идёт. После полного сбора дверь должна переходить на следующую карту или завершать последнюю. Обнови инструкцию над полем, чтобы игрок знал правило до попытки.
Подсказка: где находится проверка выхода
Нужное место в checkObjects() после проверки ключа и пересечения двери, но до смены уровня. Количество кристаллов уже известно как items.length, собранное число — score.
Более конкретная подсказка
Если score < items.length, покажи подсказку и верни false. Это отказ от перехода, а не победа и не пауза. Не вызывай stopLoop().
Разбор после своей попытки
В solutions/ добавлена одна проверка перед веткой следующего уровня. Сначала попробуй прийти с ключом, пропустив кристалл, затем вернись за ним. После перехода счёт снова нулевой и правило действует заново. Перезапуск возвращает исходные условия на первой карте.
Таблица проверки партии
| Действие | Ожидаемый результат |
|---|---|
| Открыть страницу и нажать стрелку | ready, герой стоит, ожидающего RAF нет. |
| Нажать запуск повторно | Один цикл, прежняя скорость. |
| Пауза и несколько секунд ожидания | Координаты, ключ и счёт не меняются. |
| Продолжение | В первом кадре dt равен нулю: кот не делает рывок. |
| Удержание стрелки и уход с поля | paused, ввод очищен; возвращение не запускает игру. |
| Перейти через первую дверь | playing, следующая карта, новый ключ. |
| Пройти последнюю дверь | won, движение и цикл остановлены. |
| Несколько раз нажать «Сначала» | ready, первая карта, один набор предметов; новых обработчиков нет. |
Если состояния противоречат экрану
Выведи в консоль state, rafId и lastTime. На паузе должны быть paused, null, null. Если скорость растёт после старта, ищи лишний requestAnimationFrame или подключение обработчиков внутри сброса. Если после возобновления кот делает скачок, проверь обнуление времени. Если «Сначала» оставляет старый ключ, проверяй loadLevel, а не рисунок двери.
Урок завершён, когда все переходы из таблицы воспроизводятся, самостоятельное правило не останавливает игру раньше времени, а ты можешь восстановить порядок действий resetGame() без полного образца. Код состояний должен помогать объяснить поведение игроку, а не только перечислять четыре слова.