requestAnimationFrame: сердце игры

Сегодня твоя картинка перестанет быть картинкой и наконец-то задвигается: ты запустишь настоящий игровой цикл и погонишь цыплёнка по экрану.
requestAnimationFrame — браузерная функция, которая вызывает наш код перед каждой перерисовкой экрана и задаёт ритм игрового цикла.

Зачем это вообще нужно

В прошлых уроках мы научились рисовать спрайты и фигуры на canvas, а потом разобрали, что такое игровой цикл и FPS. Но если честно — пока наш цыплёнок просто стоит. Мы нарисовали его один раз и всё. Это как открыть фотку любимой игры: красиво, но играть нельзя.

А теперь представь: ты открываешь свою страницу, и цыплёнок едет по экрану слева направо, как будто кто-то нажал «play». Сам. Без твоих кликов. Вот к этому мы и придём за этот урок. И секрет тут не в магии, а в одной-единственной функции с длинным названием — requestAnimationFrame.

Смотри, к чему мы идём:

// этот код заставит цыплёнка ехать по экрану сам по себе
let chicken = { x: 0, y: 150 };

function loop() {
  ctx.clearRect(0, 0, canvas.width, canvas.height); // стираем старый кадр
  chicken.x += 2;                                   // двигаем чуть вправо
  ctx.drawImage(chickenSprite, chicken.x, chicken.y); // рисуем заново
  requestAnimationFrame(loop);                      // просим повторить
}

requestAnimationFrame(loop); // запускаем сердце игры

Результат: цыплёнок плавно едет по холсту слева направо примерно по два пикселя за кадр, картинка обновляется около 60 раз в секунду и выглядит как настоящая анимация. Никаких рывков, никаких твоих действий — игра живёт сама.

Если этот код пока кажется набором заклинаний — спокойно. К концу урока ты будешь понимать в нём каждую строчку и сможешь сам менять скорость и направление цыплёнка.

И вот что важно понять с самого начала: движение на экране — это иллюзия. Цыплёнок никуда на самом деле не «едет». Просто шестьдесят раз в секунду мы стираем картинку и рисуем её чуть-чуть правее. Точно так же работает кино, мультики и анимация в твоих любимых играх — всё это серия неподвижных кадров, которые сменяются так быстро, что мозг достраивает плавное движение. Когда ты это прочувствуешь, ты начнёшь видеть «изнанку» любой анимации в любой игре — от пинг-понга до открытого мира с тысячами объектов на экране.

Как это работает: флипбук и сердцебиение

Помнишь флипбук — блокнотик, где на каждой странице рисунок чуть-чуть отличается, и когда ты быстро пролистываешь страницы большим пальцем, человечек как будто бежит? Анимация в браузере устроена ровно так же. Каждая «страница» — это один кадр. На каждом кадре мы стираем старую картинку и рисуем новую, чуть-чуть сдвинутую. Глаз не успевает заметить отдельные кадры и видит плавное движение.

Осталось понять, кто листает наш флипбук. Сами мы листать не можем — нам нужно делать это ровно тогда, когда экран готов показать следующую картинку. И тут на сцену выходит requestAnimationFrame.

Что делает requestAnimationFrame

Название длинное, но идея простая. По-английски это «request animation frame» — «попроси кадр анимации». Ты как будто говоришь браузеру: «Эй, когда будешь рисовать следующий кадр на экране — позови вот эту мою функцию, я хочу успеть нарисовать своё».

Игровой цикл — бесконечно повторяющийся набор шагов «обработать ввод — обновить состояние — нарисовать кадр», на котором держится любая игра.

Браузер обновляет экран примерно 60 раз в секунду — это и есть наши 60 FPS (кадров в секунду). Перед каждым обновлением он вызывает нашу функцию. Если внутри этой функции мы в конце снова просим requestAnimationFrame позвать нас — получается бесконечная петля. Это и есть игровой цикл, сердцебиение игры: тук — кадр, тук — кадр, тук — кадр, шестьдесят ударов в секунду.

Один «удар сердца» всегда состоит из трёх шагов:

  1. Очистить холст — стереть прошлый кадр.
  2. Обновить состояние — например, сдвинуть цыплёнка.
  3. Нарисовать новый кадр.

А потом снова попросить браузер позвать нас — и цикл повторяется.

Заметь, насколько это естественно для игр. Любая игра, в которую ты играешь, прямо сейчас крутит такой же цикл под капотом: считывает, нажал ли ты кнопку, двигает персонажей и врагов, проверяет столкновения и перерисовывает картинку — и так десятки раз в секунду. Когда у тебя лагает игра и «проседает FPS», это значит, что один оборот цикла не успевает уложиться в свой крошечный кусочек времени, и кадры начинают пропускаться. Так что ты сейчас собираешь не игрушечную поделку, а ровно тот же механизм, на котором стоят настоящие игры.

Собираем цикл по шагам

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

Шаг 1. Просто крутим цикл

Сначала запустим петлю вообще без движения — пусть она просто крутится и рисует цыплёнка на одном месте. Это покажет, что цикл живой.

const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');

const chickenSprite = new Image();
chickenSprite.src = '/sprites/chicken.png';

let chicken = { x: 50, y: 150 };

function loop() {
  ctx.drawImage(chickenSprite, chicken.x, chicken.y);
  requestAnimationFrame(loop); // в конце просим позвать нас снова
}

requestAnimationFrame(loop); // первый толчок, запускающий сердце

Результат: цыплёнок неподвижно стоит на координатах x = 50, y = 150. Функция loop при этом вызывается около 60 раз в секунду — но визуально ничего не меняется, ведь мы рисуем одно и то же в одном месте. Цикл уже бьётся, мы его просто пока не видим.

Разберём по строчкам. Сначала достаём context через canvas.getContext('2d') — это наш «карандаш» для рисования (мы заводили его в прошлых уроках). Дальше создаём объект chicken, в котором лежат его координаты — это состояние нашего героя. Функция loop — один кадр: рисуем цыплёнка и в самом конце вызываем requestAnimationFrame(loop), передавая туда саму функцию loop. Важно: мы пишем loop без скобок! Мы передаём функцию браузеру, чтобы он позвал её сам, а не вызываем её прямо сейчас. А самая последняя строка вне функции — это первый толчок, который запускает весь механизм.

Шаг 2. Двигаем цыплёнка

Теперь добавим движение. Чтобы цыплёнок ехал вправо, надо каждый кадр чуть увеличивать его x.

let chicken = { x: 50, y: 150 };

function loop() {
  chicken.x += 2; // каждый кадр сдвигаем вправо на 2 пикселя
  ctx.drawImage(chickenSprite, chicken.x, chicken.y);
  requestAnimationFrame(loop);
}

requestAnimationFrame(loop);

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

Вот тут и видно, зачем нужен первый шаг цикла — очистка. Без неё флипбук превращается в кашу: представь, что ты рисуешь бегущего человечка, но не на новых страницах, а поверх старых на одном листе. Получится клякса.

Шаг 3. Очищаем кадр

Добавляем ctx.clearRect в самое начало — стираем весь холст перед тем, как рисовать новый кадр.

let chicken = { x: 50, y: 150 };

function loop() {
  ctx.clearRect(0, 0, canvas.width, canvas.height); // стираем весь холст
  chicken.x += 2;
  ctx.drawImage(chickenSprite, chicken.x, chicken.y);
  requestAnimationFrame(loop);
}

requestAnimationFrame(loop);

Результат: цыплёнок чисто и плавно едет слева направо без всякого шлейфа. Каждый кадр холст начинается с чистого листа, и мы видим только одного цыплёнка в текущей позиции. Вот теперь это настоящая анимация.

Разберём clearRect. Эта команда стирает прямоугольную область холста до прозрачности. Четыре числа — это «откуда и сколько»: 0, 0 — левый верхний угол, canvas.width, canvas.height — ширина и высота. То есть мы говорим «сотри вообще всё». Запомни порядок шагов внутри loop: сначала стираем, потом обновляем, потом рисуем. Если перепутать и стереть после рисования — увидишь пустой экран, ведь ты сотрёшь то, что только что нарисовал.

Шаг 4. Заворачиваем цыплёнка обратно

Сейчас цыплёнок доезжает до правого края и улетает в небытие — навсегда. Давай сделаем, чтобы он появлялся снова слева, как лента в бесконечном ранере.

function loop() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  chicken.x += 2;

  // если уехал за правый край — возвращаем за левый
  if (chicken.x > canvas.width) {
    chicken.x = -64; // 64 — примерная ширина спрайта
  }

  ctx.drawImage(chickenSprite, chicken.x, chicken.y);
  requestAnimationFrame(loop);
}

requestAnimationFrame(loop);

Результат: цыплёнок едет вправо, скрывается за правым краем холста и тут же выезжает заново из-за левого края — и так бесконечно. Получается зацикленная пробежка, на которой потом легко построить ранер.

Здесь мы добавили проверку: как только chicken.x становится больше ширины холста, мы ставим ему x = -64, то есть отправляем чуть за левый край, чтобы он плавно выехал обратно. Это уже маленький кусочек игровой логики, которую мы кладём в шаг «обновить состояние».

Почему не setInterval?

Резонный вопрос: «А я слышал про setInterval — он же тоже умеет повторять код по таймеру. Почему не он?» Раньше игры на JavaScript и правда часто писали через setInterval(loop, 16) — «вызывай каждые 16 миллисекунд». Но у этого подхода есть серьёзные минусы, и requestAnimationFrame почти во всём лучше.

СравниваемsetIntervalrequestAnimationFrame
Синхронизация с экраномНет — может вызвать код в середине отрисовки, кадры дёргаютсяДа — вызывается ровно перед перерисовкой, картинка плавная
Вкладка свёрнута/неактивнаПродолжает крутиться и жрать батарею и процессор впустуюБраузер ставит на паузу — экономит ресурсы
ЧастотаЗадаёшь сам, легко промахнуться мимо реального FPSПодстраивается под экран (обычно 60 Гц)

Самый понятный аргумент — про свёрнутую вкладку. Представь, что ты свернул игру и пошёл смотреть мемы. С setInterval твоя игра продолжает рисовать кадры в пустоту, греет ноутбук и сажает батарею. С requestAnimationFrame браузер видит, что вкладку никто не смотрит, и ставит цикл на паузу до момента, когда ты вернёшься. Умно и вежливо.

Ещё одна тонкость — про плавность. setInterval понятия не имеет, когда экран реально перерисовывается, и может выстрелить нашим кодом прямо посреди отрисовки кадра. Из-за этого движение начинает едва заметно подёргиваться, и картинка кажется «дешёвой». А requestAnimationFrame идеально попадает в ритм монитора — браузер сам ставит наш код ровно в нужный момент. Разница как между видео, которое подтормаживает, и видео, которое идёт идеально гладко: вроде мелочь, но глаз её ловит сразу.

Простое правило: для всего, что рисуется на экране и должно двигаться плавно, используй requestAnimationFrame, а не setInterval.

Частые ошибки новичков

На этих граблях прыгают почти все, кто впервые запускает свой цикл. Глянь заранее — сэкономишь себе нервы.

1. Забыл вызвать requestAnimationFrame внутри loop

Если в конце loop нет строки requestAnimationFrame(loop), функция отработает ровно один раз и всё замрёт. Анимации не будет — будет один-единственный кадр. Цикл живёт только потому, что каждый кадр сам заказывает следующий.

2. Написал requestAnimationFrame(loop()) со скобками

Очень частая опечатка. Скобки после loop означают «вызови функцию прямо сейчас и подставь сюда её результат». А нам надо передать саму функцию, чтобы браузер позвал её позже. Правильно — без скобок: requestAnimationFrame(loop). Со скобками браузер получит не функцию, а undefined, и цикл сломается. Запомни простое правило: в requestAnimationFrame мы кладём имя функции, как будто отдаём браузеру её визитку, а не звоним по ней прямо сейчас.

3. Забыл очистить холст

Это та самая «грязная» анимация со шлейфом из шага 2. Нет clearRect в начале кадра — и каждый новый кадр рисуется поверх старого, превращая экран в кляксу. Запомни: очистка идёт первой.

4. Очистил холст после рисования

Обратная беда. Если поставить clearRect в самый конец loop, ты будешь стирать кадр сразу после того, как нарисовал его. Глаз увидит пустой чёрный (точнее, прозрачный) экран. Порядок строго такой: стереть → обновить → нарисовать.

5. Картинка не успела загрузиться

Спрайт грузится с диска не мгновенно. Если цикл стартует раньше, чем chickenSprite загрузился, первые кадры цыплёнка просто не будет видно — браузеру ещё нечего рисовать. На скорость это не влияет: как только картинка догрузится, цыплёнок появится. Но если хочешь честно, запускай цикл после загрузки спрайта:

const chickenSprite = new Image();
chickenSprite.src = '/sprites/chicken.png';

// запускаем цикл только когда картинка готова
chickenSprite.onload = function () {
  requestAnimationFrame(loop);
};

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

Мини-проект: цыплёнок-челнок

Теперь твоя очередь. Возьми код из шага 3 (с очисткой) и доработай его так, чтобы цыплёнок не улетал за край, а отскакивал от стенок и ездил туда-сюда, как мячик в Понге. Это прямая подготовка к игре, которую мы скоро будем делать.

Подсказка-каркас, который надо дописать:

let chicken = { x: 50, y: 150, vx: 2 }; // vx — скорость по горизонтали

function loop() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  chicken.x += chicken.vx; // двигаем на величину скорости

  // ЗАДАНИЕ: если цыплёнок коснулся левого или правого края —
  // разверни его, поменяв знак скорости: chicken.vx = -chicken.vx;

  ctx.drawImage(chickenSprite, chicken.x, chicken.y);
  requestAnimationFrame(loop);
}

requestAnimationFrame(loop);

Результат (когда допишешь): цыплёнок едет вправо, упирается в правый край и разворачивается влево, потом отскакивает от левого края обратно — и бесконечно катается туда-сюда по холсту.

Подумай вот над чем: проверять край надо двумя условиями — и левый (chicken.x < 0), и правый (chicken.x > canvas.width - 64, где 64 — ширина спрайта). А разворот — это всего одна строчка: меняем знак у vx. Эта пара чисел vx (а скоро добавим и vy) называется вектором скорости — мы разберём её подробно в следующих уроках. Хочешь добавить азарта — после первого разворота чуть увеличь скорость, и цыплёнок начнёт носиться всё быстрее.

Итоги

Сегодня ты оживил картинку. Коротко, что теперь у тебя в руках:

  • requestAnimationFrame(loop) запускает игровой цикл — сердцебиение игры, которое бьётся около 60 раз в секунду.
  • Каждый кадр идёт по трём шагам: очистить → обновить → нарисовать.
  • ctx.clearRect(0, 0, canvas.width, canvas.height) стирает прошлый кадр, чтобы не было грязного шлейфа.
  • Меняя координаты в chicken.x и chicken.y каждый кадр, ты заставляешь спрайт двигаться.
  • requestAnimationFrame лучше setInterval: синхронен с экраном и засыпает на свёрнутой вкладке.

Есть одна тонкость, которую мы сегодня нарочно обошли стороной. Наш цыплёнок едет на «2 пикселя за кадр». Но что, если у друга монитор на 144 FPS, а не на 60? Тогда у него цикл крутится чаще, и цыплёнок улетит в два с лишним раза быстрее! Это как пытаться успеть на один и тот же автобус, когда один идёт пешком, а другой бежит. Чтобы движение было одинаковым на любом железе, придумали дельта-время — и именно с ним мы разберёмся в следующем уроке. А пока — погоняй своего цыплёнка по экрану и порадуйся: ты только что запустил настоящий движок.

Проверьте себя
1. Что делает requestAnimationFrame?
AПросит браузер вызвать нашу функцию перед следующей перерисовкой экрана
BСразу рисует один кадр и больше никогда не вызывается
CЗамедляет игру ровно до 30 кадров в секунду
DЗагружает картинку спрайта с диска
2. Зачем в начале каждого кадра вызывать ctx.clearRect(0, 0, canvas.width, canvas.height)?
AЧтобы стереть прошлый кадр и не получить грязный шлейф из старых картинок
BЧтобы увеличить FPS до 120
CЧтобы загрузить спрайт цыплёнка
DЧтобы остановить игровой цикл
3. Какой из вариантов вызова правильный внутри loop?
ArequestAnimationFrame(loop)
BrequestAnimationFrame(loop())
CrequestAnimationFrame(loop);();
Dloop(requestAnimationFrame)
4. Почему requestAnimationFrame обычно лучше setInterval для игр?
AОн синхронизирован с перерисовкой экрана и засыпает, когда вкладка свёрнута
BОн работает только в старых браузерах
CОн умеет рисовать спрайты без canvas
DОн всегда даёт ровно 1000 кадров в секунду
5. В каком порядке должны идти шаги внутри одного кадра loop?
AОчистить холст → обновить состояние → нарисовать кадр
BНарисовать кадр → очистить холст → обновить состояние
CОбновить состояние → нарисовать кадр → очистить холст
DПорядок не важен, лишь бы все три шага были
6. Что произойдёт, если убрать строку requestAnimationFrame(loop) из конца функции loop?
AФункция выполнится один раз и анимация замрёт на первом кадре
BИгра станет в два раза быстрее
CЦыплёнок начнёт отскакивать от стенок
DБраузер сам бесконечно вызовет loop без нашей помощи