Сделаем испытание: секунда на подготовку, затем десять секунд на набор очков. Разберём, кто вызывает отложенную функцию, как остановить ожидание и почему частота обновления надписи не равна прошедшему времени.
Нажми «Начать», дождись окончания подготовки и набери несколько очков. По истечении времени действие перестанет работать. Повторный старт начинает новую попытку, а «Сброс» отменяет любую подготовку или партию.
Правило ухода со вкладки здесь простое: незавершённая попытка отменяется. После возвращения нужно явно начать новую. Так игрок не сможет продолжить партию после долгого отсутствия.
Что уже нужно уметь и какие файлы открыть
Нужны знакомые из предыдущих занятий переменные, условия, функции, return, события и обновление DOM. В уроке «Объекты: состояние игры и копирование» ты уже описывал данные игры. Теперь возьмём отдельный небольшой счётчик: на нём будет проще разобраться с таймерами.
В архиве открой start/index.html. Рядом с ним лежат style.css и game.js: HTML подключает соседние файлы относительными путями. Можно открыть страницу прямо в браузере; установки библиотек и сервер пока не требуются. После правки сохраняй файл и обновляй страницу. Если ты уже пользуешься локальным сервером, он тоже подходит.
start/ — счётчик без времени; step-01/ — подготовка; step-02/ — отсчёт; step-03/ — отмена при скрытии вкладки; final/ — тот же проверенный результат. Инструкция по запуску и версиям есть в просмотрщике и архиве. Ответ на самостоятельное задание лежит отдельно в solutions/.
Сначала проверь старт: до «Начать» очки не начисляются, после — каждый клик прибавляет один, «Сброс» обнуляет счёт и запрещает набор. В render() только надписи и доступность кнопки; данные меняются в обработчиках. Три обработчика подключены один раз внизу файла. Оформление уже готово: в этом занятии меняем game.js.
Этап 1. Делаем паузу перед началом игры
Задача первого этапа: после «Начать» показать «Приготовься…», оставить сброс доступным и только через секунду разрешить набор. Чтобы вызвать функцию один раз с задержкой, используем setTimeout. Ему передают функцию и задержку в миллисекундах: 1000 миллисекунд — одна секунда. Вызов возвращает идентификатор, по которому ожидание можно отменить. Подробнее — справочник MDN: setTimeout.
Функция, переданная для будущего вызова, называется callback, или функцией обратного вызова. Мы уже делали это с событиями: addEventListener('click', handleStart). Теперь причиной вызова будет истёкшая задержка. Передаём beginRound без скобок; beginRound() вызвало бы её сразу.
В начале game.js, после ссылок на элементы, добавь константу и две переменные. Существующие score, playing и message оставь:
const preparationMs = 1000;
let waiting = false;
let preparationId = null;
waiting отвечает на вопрос «идёт подготовка?». null — явное отсутствие значения: сейчас у нас нет идентификатора ожидающего запуска. Это не длительность и не количество секунд. Когда запланируем вызов, запишем в preparationId полученный идентификатор.
Перед handleStart объяви две функции:
function clearTimers() {
if (preparationId !== null) {
clearTimeout(preparationId);
preparationId = null;
}
}
function beginRound() {
preparationId = null;
if (!waiting) return;
waiting = false;
playing = true;
message = 'Можно набирать очки. Ограничение времени появится на следующем этапе.';
render();
}
!== здесь означает «не равно»: есть ли сохранённый идентификатор? clearTimeout(preparationId) отменяет ещё не выполненный вызов. После отмены обнуляем и нашу запись. Когда браузер вызывает beginRound, задержка уже закончилась — идентификатор больше не нужен. Поэтому первой строкой присваиваем null. Условие waiting проверяет, что подготовка ещё идёт. Функция clearTimers пока отменяет только подготовку; на следующем этапе она также будет останавливать отсчёт времени.
Замени существующие функции handleStart и handleReset целиком. Не добавляй вторые функции с теми же именами:
function handleStart() {
clearTimers();
score = 0;
waiting = true;
playing = false;
message = 'Приготовься…';
render();
preparationId = setTimeout(beginRound, preparationMs);
}
function handleReset() {
clearTimers();
score = 0;
waiting = false;
playing = false;
message = 'Попытка сброшена. Нажми «Начать».';
render();
}
setTimeout планирует вызов и сразу возвращает управление. Страница не «засыпает»: кнопка сброса работает во время подготовки. Если нажать старт повторно, сначала отменяется старое ожидание, затем планируется новое. Одно присваивание preparationId = null ничего не отменило бы в браузере: для этого нужен clearTimeout.
Проверка step-01/: начни, сразу нажми сброс и подожди две секунды. Кнопка набора должна остаться недоступной. Затем начни и через полсекунды начни снова: подготовка должна отсчитываться от последнего нажатия. После её окончания счёт растёт; автоматического конца партии пока нет.
Этап 2. Ограничиваем партию десятью секундами
Теперь партия длится десять секунд после подготовки. Ошибка — вычитать единицу из «секунд» при каждом вызове таймера. Браузер может выполнить callback позже: например, был занят другой работой. Тогда число вызовов не скажет, сколько времени прошло. setInterval(updateClock, 100) лишь просит повторять updateClock примерно через 100 миллисекунд; браузер не гарантирует точный интервал между вызовами. Возвращённый идентификатор отменяется через clearInterval. См. MDN: setInterval.
Вместо подсчёта вызовов запомним момент окончания — deadline. performance.now() даёт число миллисекунд относительно начала отсчёта этой страницы. Это монотонные часы для измерения промежутков, а не календарная дата. Берём показания одного и того же источника в начале и при проверке. См. MDN: performance.now.
Например, партия началась при показании 2500, её длительность 10000. Значит, deadline равен 12500. При показании 6100 осталось 6400 миллисекунд. Неважно, сколько раз успел вызваться updateClock: разность по-прежнему 6400.
После preparationMs добавь константу длительности, а рядом с остальными переменными — состояние часов:
const roundDurationMs = 10000;
let remainingMs = roundDurationMs;
let deadline = 0;
let clockId = null;
В render() замени строку timeElement.textContent = 'без отсчёта'; следующей:
timeElement.textContent = Math.ceil(remainingMs / 1000);
Деление на 1000 переводит миллисекунды в секунды. Math.ceil округляет вверх: 1,2 секунды покажутся как 2, а 0 останется 0. Округляем только число на экране. Для конца партии используем точный остаток в миллисекундах, а не округлённую надпись.
В конец clearTimers(), перед закрывающей фигурной скобкой функции, добавь очистку повторяющегося вызова:
if (clockId !== null) {
clearInterval(clockId);
clockId = null;
}
Теперь clearTimers отменяет оба вида ожидания: подготовку и обновление часов. Замени beginRound целиком и объяви после неё две новые функции:
function beginRound() {
preparationId = null;
if (!waiting) return;
waiting = false;
playing = true;
deadline = performance.now() + roundDurationMs;
remainingMs = roundDurationMs;
message = 'Набирай очки до конца времени.';
render();
clockId = setInterval(updateClock, 100);
}
function updateClock() {
if (!playing) return;
remainingMs = Math.max(0, deadline - performance.now());
if (remainingMs === 0) {
finishRound();
return;
}
render();
}
function finishRound() {
clearTimers();
waiting = false;
playing = false;
remainingMs = 0;
message = 'Время вышло. Твой результат: ' + score + '.';
render();
}
Math.max выбирает большее из переданных чисел. Остаток мог стать отрицательным, если функция выполнилась уже после окончания времени; Math.max(0, ...) ограничивает его нулём. finishRound сохраняет заработанные очки, отключает набор и останавливает часы. return после неё не позволяет продолжить обычное обновление этого же вызова.
В обеих функциях handleStart и handleReset после playing = false; добавь восстановление часов:
remainingMs = roundDurationMs;
deadline = 0;
Наконец, замени handleAction:
function handleAction() {
if (!playing) return;
updateClock();
if (!playing) return;
score = score + 1;
render();
}
Почему проверка playing встречается дважды? Первая отсекает нажатия до старта и после результата. Затем мы сверяем часы: срок мог закончиться между обновлениями надписи. updateClock в таком случае завершит партию. Вторая проверка не даст начислить запоздавшее очко.
Проверка step-02/: после подготовки десять секунд доступны действия, затем показания 0 и итоговый счёт. Новая попытка снова имеет полную длительность. Временно замени период 100 в setInterval на 700: надпись будет обновляться реже, но срок не станет семьюдесятью секундами. Конец на экране может отобразиться с задержкой очередного обновления, однако клик после срока не засчитывается. Верни 100.
Этап 3. Отменяем попытку при смене вкладки
Когда вкладка скрыта, браузер может реже вызывать таймеры. В нашей игре при переходе на другую вкладку отменим текущую попытку. Когда игрок вернётся, ему нужно будет нажать «Начать» заново. Это относится и к подготовке. Это правило работает и для примера в статье: достаточно перейти с неё на другую вкладку.
document.hidden — логическое значение: скрыт ли документ сейчас. Событие visibilitychange сообщает, что его видимость изменилась. Само событие приходит и при скрытии, и при возвращении, поэтому проверяем текущее значение. Подробнее — MDN: visibilitychange.
Перед строками подключения обработчиков добавь функцию:
function handleVisibilityChange() {
if (!document.hidden) return;
if (!waiting && !playing) return;
handleReset();
message = 'Вкладка скрыта. Попытка отменена. Начни новую.';
render();
}
Условие !waiting && !playing означает «нет ни подготовки, ни игры». В этом случае ничего не отменяем: например, сохраняем уже полученный результат. При активной попытке используем знакомый сброс, а после него уточняем причину в сообщении. Внизу файла, рядом с тремя обработчиками клика, подключи событие один раз:
document.addEventListener('visibilitychange', handleVisibilityChange);
Проверка step-03/: начни и сразу перейди на другую вкладку. Вернись через пару секунд — попытка отменена, очки 0, набор недоступен. Повтори во время игры. После возвращения подожди: игра не должна начаться сама. final/ совпадает с этой версией. Для проверки настоящего переключения открой пример отдельной вкладкой; ручной вызов функции в Console проверяет только функцию.
Проверь старт, сброс и окончание игры
| Сценарий | Ожидаемый результат |
|---|---|
| Старт, затем сброс во время подготовки | Спустя две секунды набор всё ещё запрещён |
| Старт, ещё один старт через полсекунды | Подготовка идёт от последнего старта |
| Набрать три очка, начать снова | Счёт 0; подготовка; затем полные десять секунд |
| Пять быстрых стартов, затем одно действие | После последней подготовки ровно одно очко |
| Нажать у конца отсчёта и после результата | После срока счёт не увеличивается |
| Скрыть вкладку во время подготовки или партии | Попытка отменена; возвращение её не возобновляет |
| Скрыть вкладку после окончания | Уже показанный результат сохраняется |
Если игра началась сама после сброса, найди каждое место с setTimeout и setInterval и соответствующую отмену. Если одно нажатие даёт несколько очков, проверь, что addEventListener не переехал внутрь старта. Если отсчёт бесконечен, проверь единицы: deadline, performance.now() и длительность должны быть в миллисекундах.
Самостоятельно: запрети повторный старт во время игры
Сохрани финал отдельной копией. Новое требование: если подготовка или партия уже идут, нажатие «Начать» ничего не меняет. Очки и первоначальный срок сохраняются. После результата новая попытка разрешена. Сброс по-прежнему немедленно отменяет любые таймеры и позволяет начать заново. Обработай запрет в логике, а не только через недоступную кнопку.
До правки запиши, какое состояние отличает разрешённый старт от запрещённого. Затем проверь: повтор в середине подготовки, повтор после трёх очков, старт после конца, сброс во время подготовки и старт после него. В первых двух случаях старая попытка продолжается; в остальных создаётся ровно одна новая. Не меняй измерение времени и не добавляй второй обработчик.
Подсказка 1: где принимается решение
За начало отвечает handleStart. Решить, разрешён ли он сейчас, нужно до очистки таймеров и обнуления очков. Воспользуйся ранним return.
Подсказка 2: какое условие подходит
Запрет нужен, если верно хотя бы одно из двух значений: waiting или playing. Знакомый оператор || соединяет их как «или». Очистку для разрешённого старта и сброса оставь.
Сверить после самостоятельной попытки
В ZIP есть solutions/game.js и объяснение в solutions/README.md. Можно запустить проверочный вариант. Сравни начало handleStart, затем закрой ответ и объясни, почему запрещённый старт не должен дойти до clearTimers().
Проверь, что ты освоил
Покажи отмену подготовки, окончание по времени и повторную партию. Объясни своими словами разницу между callback и его немедленным вызовом, идентификатором таймера и длительностью, измерением времени и частотой обновления. Самостоятельная версия должна игнорировать лишний старт и всё равно корректно сбрасываться. Сохрани проверенный результат: эти же часы станут основой игры с перемещающейся целью.