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

Открыть учебную сцену отдельно

Продолжим прежнюю сцену

Нужен финал урока про героя: холст game размером 480 × 320, объект player и drawHero(x, y). Координаты задают верхний левый угол рамки 32 × 40. Наш start совпадает с этим финалом по всем трём файлам. Сохрани отдельную копию собственной работы, затем открой стартовую папку по уже знакомому HTTP-адресу.

В начале рисунок неподвижен. Картинка Canvas сама не следит за объектом: изменение player.x не передвинет уже нарисованные пиксели. Поэтому нужны два действия: update меняет данные, render очищает и заново рисует. Их разделение позволит проверять расстояние без ожидания настоящего кадра.

Двигаем героя одним нажатием

В index.html сохрани холст и подключение файлов. Для первой версии добавь следующий блок после холста. Эти id используются только в этой версии:

<div class="controls">
  <button id="step-button" type="button">Шаг 0,25 с</button>
  <button id="reset-button" type="button">Сначала</button>
</div>
<p id="status" role="status">Готово к запуску.</p>

В game.js оставь получение холста, контекста, объект героя и все функции рисования, от drawScene до drawHero. Внизу пока есть одиночный render(): перенеси его после новых обработчиков, как показано ниже. style.css из step-01 оформляет кнопки и сохраняет пропорции холста; он не управляет временем. Рисунок героя, его вспомогательные функции и светлый фон сохраняются. Функция render остаётся прежней:

function render() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  drawScene();
  drawHero(player.x, player.y);
}

Добавь let speed = 120; рядом с объектом героя. Здесь speed — пиксели в секунду, а параметр dt — длительность одного обновления в секундах. Их произведение даёт расстояние. За четверть секунды при скорости 120 получится 30 пикселей:

function update(dt) {
  player.x = Math.min(canvas.width - player.width, player.x + speed * dt);
}

Math.min выбирает меньшее из двух чисел. Когда кот дойдёт до правого края, его левая координата не превысит 480 - 32 = 448. В этом уроке он остаётся у края; это сцена для изучения времени, без победы или противников.

После функций добавь поиск кнопок и именованные обработчики. В начале файла не должно оставаться второго объявления тех же переменных:

const statusText = document.querySelector("#status");
const resetButton = document.querySelector("#reset-button");
const stepButton = document.querySelector("#step-button");

function handleStep() {
  update(0.25);
  render();
  statusText.textContent = "Положение x: " + player.x;
}

function handleReset() {
  player.x = 80;
  player.y = 180;
  render();
  statusText.textContent = "Герой вернулся в начало.";
}

stepButton.addEventListener("click", handleStep);
resetButton.addEventListener("click", handleReset);
render();

Это законченная версия step-01. Нажми шаг дважды: x изменится с 80 на 110, затем на 140. «Сначала» вернёт 80. Если данные меняются, но рисунок остаётся на месте, проверь вызов render() после update. Если за котом тянется след — очищается ли холст до рисования?

Запускаем анимацию через requestAnimationFrame

requestAnimationFrame(frame) просит браузер вызвать функцию перед очередной перерисовкой. Это один запрос, не бесконечный таймер: чтобы получить следующий кадр, нужно запросить его снова. Мы передаём функцию без скобок. Браузер сам передаст ей временную метку timestamp в миллисекундах. Не вызывай frame() вручную: в таком вызове метки нет.

Частота кадров не гарантирована: экран и загрузка браузера могут отличаться. Поэтому нельзя каждый раз прибавлять одно и то же число пикселей. Разность двух меток показывает промежуток, деление на 1000 переводит его в секунды. Первый кадр нужен как точка отсчёта: при lastTime === null сохраняем текущую метку и получаем нулевой шаг.

В step-02/index.html вместо step-button поставь две кнопки play-button «Продолжить» и pause-button «Пауза». Кнопку сброса и статус оставь. В JavaScript убери поиск stepButton, handleStep и прежние регистрации кнопок. Вместо них добавь состояние цикла и следующие функции; прежний handleReset тоже заменяется. Если после if одна команда, фигурные скобки можно опустить: запись if (!running) return; означает «если цикл остановлен, выйди из функции». Ниже используется эта короткая форма:

const playButton = document.querySelector("#play-button");
const pauseButton = document.querySelector("#pause-button");
let running = false;
let rafId = null;
let lastTime = null;
function frame(timestamp) {
  rafId = null;
  if (!running) return;
  if (lastTime === null) lastTime = timestamp;
  const dt = Math.min((timestamp - lastTime) / 1000, 0.05);
  lastTime = timestamp;
  update(dt);
  render();
  if (running) rafId = requestAnimationFrame(frame);
}

function startLoop() {
  if (running || document.hidden) return;
  running = true;
  lastTime = null;
  rafId = requestAnimationFrame(frame);
  statusText.textContent = "Сцена работает.";
}

function stopLoop() {
  running = false;
  if (rafId !== null) cancelAnimationFrame(rafId);
  rafId = null;
  lastTime = null;
}

function handlePlay() {
  startLoop();
}

function handlePause() {
  stopLoop();
  statusText.textContent = "Пауза. Можно продолжить.";
}

function handleReset() {
  stopLoop();
  player.x = 80;
  player.y = 180;
  render();
  statusText.textContent = "Начальное положение. Нажми «Продолжить».";
}

running отвечает на вопрос «нужно ли продолжать?». rafId хранит идентификатор ожидающего запроса. null означает, что запроса нет. Когда callback уже начался, его старый id больше не нужен. Отмена через cancelAnimationFrame касается именно ожидающего запроса, поэтому id обновляется при каждом новом запросе.

Старт сразу проверяет флаг: пять нажатий не создадут пять цепочек. Остановка отменяет ожидающий кадр и сбрасывает точку отсчёта. Новый старт измеряет время заново. Внизу файла зарегистрируй кнопки один раз и покажи неподвижную сцену:

playButton.addEventListener("click", handlePlay);
pauseButton.addEventListener("click", handlePause);
resetButton.addEventListener("click", handleReset);
render();

Что делать, если кадр задержался

В frame стоит Math.min(..., 0.05): одна порция движения использует не более 0,05 секунды. При 120 пикселях в секунду это не более 6 пикселей за обновление. Если браузер задержался на секунду, мы сознательно не переносим кота сразу на 120 пикселей.

Это выбранное поведение, а не способ вернуть потерянные кадры. При постоянных задержках сцена отстанет от реального времени. Для обычных шагов 1/60 и 1/120 секунды ограничение не срабатывает, и сумма расстояний одинакова. Для секундомера нужна измеряемая точка окончания, как в уроке про таймеры. Для столкновений одного ограничения времени недостаточно: маленький быстрый объект всё ещё способен перескочить препятствие.

Проверь step-02: несколько быстрых стартов не ускоряют движение; пауза сохраняет координату; продолжение не делает большого скачка. Сброс останавливает сцену. Ошибка «скорость удвоилась» — повод поискать лишний запуск requestAnimationFrame или старую регистрацию кнопки.

Ставим паузу при смене вкладки

Браузеры обычно приостанавливают RAF в скрытой вкладке, но учебное правило зададим явно. Событие visibilitychange уже знакомо по таймерам. Когда document.hidden становится истинным, остановим цикл. При возвращении не запускаем его автоматически: пользователь нажмёт «Продолжить». Добавь перед регистрацией кнопок:

function handleVisibilityChange() {
  if (document.hidden) {
    stopLoop();
    statusText.textContent = "Вкладка была скрыта. Нажми «Продолжить».";
  }
}
document.addEventListener("visibilitychange", handleVisibilityChange);

Это final. Запусти движение, уйди на другую вкладку и вернись. Положение сохранится, а статус предложит продолжить. После нажатия первый кадр снова имеет dt = 0. Не добавляй второй RAF в обработчик возвращения: единственное место начала цепочки — startLoop.

Самостоятельно: движение вверх и проверка времени

Из финальной версии сделай подъём: скорость 80 пикселей в секунду, меняется y, движение останавливается у верхней границы 0. Стартовое положение, кнопки и поведение при скрытии сохрани. До запуска вычисли, где будет герой через полсекунды. Затем сравни в Console десять обновлений по 0,05 секунды и двадцать по 0,025: оба опыта начинай со сброса и остановленного цикла.

Подсказка 1: какую часть менять

Верх — это уменьшение y. Менять frame, таймер или рисунок кота не нужно: правило движения находится в update(dt).

Подсказка 2: нижняя граница числа

Для ограничения снизу нужен Math.max, а не Math.min. В обоих опытах суммарное время 0,5 секунды. Убедись, что герой ещё не дошёл до границы: после упора равенство расстояния уже скрывается ограничением.

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

При старте y = 180 и скорости 80 через полсекунды получается y = 140. Оба набора шагов дают это значение с допустимой крошечной погрешностью дробных чисел. В решение добавлено только новое правило update и скорость. Файлы находятся в solutions/vertical просмотрщика и ZIP. Проверить пример решения.

Проверяем запуск, паузу и сброс

  • Ты показываешь отдельно изменение данных и перерисовку.
  • Первый кадр не прыгает; десять повторных стартов не ускоряют движение.
  • Пауза и сброс отменяют ожидающий кадр; после скрытия требуется явное продолжение.
  • Ты рассчитываешь путь по скорости и времени и объясняешь, зачем ограничиваем шаг 0,05 секунды и почему при задержках движение замедлится.
  • Самостоятельное вертикальное движение проверено двумя наборами временных шагов.

Справочно: requestAnimationFrame, cancelAnimationFrame и видимость страницы на MDN.