Сделаем испытание: секунда на подготовку, затем десять секунд на набор очков. Разберём, кто вызывает отложенную функцию, как остановить ожидание и почему частота обновления надписи не равна прошедшему времени.

Испытание на десять секунд

Нажми «Начать», дождись окончания подготовки и набери несколько очков. По истечении времени действие перестанет работать. Повторный старт начинает новую попытку, а «Сброс» отменяет любую подготовку или партию.

Открыть эту версию отдельно

Правило ухода со вкладки здесь простое: незавершённая попытка отменяется. После возвращения нужно явно начать новую. Так игрок не сможет продолжить партию после долгого отсутствия.

Что уже нужно уметь и какие файлы открыть

Нужны знакомые из предыдущих занятий переменные, условия, функции, return, события и обновление DOM. В уроке «Объекты: состояние игры и копирование» ты уже описывал данные игры. Теперь возьмём отдельный небольшой счётчик: на нём будет проще разобраться с таймерами.

В архиве открой start/index.html. Рядом с ним лежат style.css и game.js: HTML подключает соседние файлы относительными путями. Можно открыть страницу прямо в браузере; установки библиотек и сервер пока не требуются. После правки сохраняй файл и обновляй страницу. Если ты уже пользуешься локальным сервером, он тоже подходит.

Исходники урока

start/ — счётчик без времени; step-01/ — подготовка; step-02/ — отсчёт; step-03/ — отмена при скрытии вкладки; final/ — тот же проверенный результат. Инструкция по запуску и версиям есть в просмотрщике и архиве. Ответ на самостоятельное задание лежит отдельно в solutions/.

Скачать ZIP

Сначала проверь старт: до «Начать» очки не начисляются, после — каждый клик прибавляет один, «Сброс» обнуляет счёт и запрещает набор. В 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 и его немедленным вызовом, идентификатором таймера и длительностью, измерением времени и частотой обновления. Самостоятельная версия должна игнорировать лишний старт и всё равно корректно сбрасываться. Сохрани проверенный результат: эти же часы станут основой игры с перемещающейся целью.