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 позвать нас — получается бесконечная петля. Это и есть игровой цикл, сердцебиение игры: тук — кадр, тук — кадр, тук — кадр, шестьдесят ударов в секунду.
Один «удар сердца» всегда состоит из трёх шагов:
- Очистить холст — стереть прошлый кадр.
- Обновить состояние — например, сдвинуть цыплёнка.
- Нарисовать новый кадр.
А потом снова попросить браузер позвать нас — и цикл повторяется.
Заметь, насколько это естественно для игр. Любая игра, в которую ты играешь, прямо сейчас крутит такой же цикл под капотом: считывает, нажал ли ты кнопку, двигает персонажей и врагов, проверяет столкновения и перерисовывает картинку — и так десятки раз в секунду. Когда у тебя лагает игра и «проседает 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 почти во всём лучше.
| Сравниваем | setInterval | requestAnimationFrame |
| Синхронизация с экраном | Нет — может вызвать код в середине отрисовки, кадры дёргаются | Да — вызывается ровно перед перерисовкой, картинка плавная |
| Вкладка свёрнута/неактивна | Продолжает крутиться и жрать батарею и процессор впустую | Браузер ставит на паузу — экономит ресурсы |
| Частота | Задаёшь сам, легко промахнуться мимо реального 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? Тогда у него цикл крутится чаще, и цыплёнок улетит в два с лишним раза быстрее! Это как пытаться успеть на один и тот же автобус, когда один идёт пешком, а другой бежит. Чтобы движение было одинаковым на любом железе, придумали дельта-время — и именно с ним мы разберёмся в следующем уроке. А пока — погоняй своего цыплёнка по экрану и порадуйся: ты только что запустил настоящий движок.