Основы спрайтовой анимации
Спрайт — растровое изображение, представляющее визуальный объект в 2D-графике.
Когда у объекта есть анимация — например, персонаж идёт — художник рисует несколько кадров этого движения, как в мультфильме. Чтобы не хранить десятки отдельных файлов и не плодить лишние HTTP-запросы, все кадры объединяют в один файл — спрайт-лист (spritesheet).

Данный спрайт был отрисован в программе Aseprite.
Чтобы работать со спрайтом в коде, нужно знать:
- Ширину и высоту одного кадра (frameWidth, frameHeight).
- Количество кадров по горизонтали и вертикали.
- Отступы (padding/spacing) между кадрами, если есть — иначе на экране будет видна часть соседнего кадра.
Если кадры одинакового размера:
const frameWidth = spriteSheet.width / totalFrames;
Если кадры разного размера, обычно прилагается JSON с координатами каждого кадра. Ниже расписан пример для вышеуказанного спрайта. Общий размер - 98x24, размеры каждого кадра - 22x20, между кадрами и по границам присутсвует отступ в 2 пикселя, поэтому итоговый JSON файл будет выглядеть следующим образом:
{
"frames": {
"walk_0": { "x": 2, "y": 2, "w": 22, "h": 20 },
"walk_1": { "x": 26, "y": 2, "w": 22, "h": 20 },
"walk_2": { "x": 50, "y": 2, "w": 22, "h": 20 },
"walk_2": { "x": 74, "y": 2, "w": 22, "h": 20 },
}
}где x и y - координаты начала кадра относительно спрайта, w и h - ширина и высота кадра.
Чтобы получить координаты нужного кадра из такого файла, достаточно обратиться к нему по имени:
const frame = atlas.frames['walk_1']; ctx.drawImage( spriteSheet, frame.x, frame.y, frame.w, frame.h, dx, dy, frame.w, frame.h );
Такой подход удобнее формулы frameIndex * frameWidth тем, что не требует одинакового размера кадров и явно хранит отступы — координаты уже "зашиты" в файле.
Основной инструмент JS для работы со спрайтами — Canvas API, а конкретно метод drawImage() в его расширенной, 9-параметрической форме:
ctx.drawImage( image, sx, sy, sWidth, sHeight, // откуда вырезать кадр на спрайт-листе (source) dx, dy, dWidth, dHeight // куда и в каком размере нарисовать (destination) );
- sx, sy — координаты левого верхнего угла нужного кадра на спрайт-листе.
- sWidth, sHeight — размер вырезаемого кадра.
- dx, dy — позиция на холсте, куда рисуем.
- dWidth, dHeight — размер, в котором рисуем результат (можно масштабировать, не совпадая с sWidth/sHeight).
Если спрайт-лист — это ряд кадров одинакового размера, координата sx для кадра с номером frameIndex вычисляется тривиально:
const sx = frameIndex * frameWidth; const sy = 0;
Именно эта формула и есть сердце всей спрайтовой анимации в JS: меняя frameIndex во времени, мы двигаем "окно вырезки" по спрайт-листу, и в результате на экране сменяются кадры.
Данная формула работает только если между кадрами нет отступов. Если у спрайта есть padding (как в примере выше, 2px), его нужно учитывать явно, иначе в кадр попадёт часть соседнего изображения:
const padding = 2; const sx = padding + frameIndex * (frameWidth + padding);
Показать анимацию — значит с определённой частотой переключаться между кадрами, отображая на экране только один из них за раз, а остальную часть картинки скрывая.

Анимация вышеуказанного спрайта
Поскольку спрайт нарисован в пиксель-арт стиле, важно учитывать, что при увеличении размера канваса относительно оригинального размера кадра браузер по умолчанию сглаживает изображение, размывая чёткие пиксели. Отключается это так:
ctx.imageSmoothingEnabled = false;
Плюсы данного подхода:
- Один HTTP-запрос вместо десятков — быстрее загрузка страницы.
- Все кадры физически лежат рядом — удобно управлять ими по индексу через JS.
- Индустриальный стандарт: так работают Phaser, PixiJS и большинство 2D-движков, написанных на JavaScript..
В реальной игре процесы анимации и движения объекта идут одновременно, но независимо. Скорость смены кадров и скорость передвижения — разные величины, и в коде они считаются раздельно, через разные таймеры:
frameTimer += deltaTime; // отвечает только за смену картинки if (frameTimer > frameInterval) { currentFrame = (currentFrame + 1) % totalFrames; frameTimer = 0; } x += speed; // отвечает только за перемещение — независимо от кадра
deltaTime здесь — это разница во времени между текущим и предыдущим вызовом requestAnimationFrame (браузер передаёт в callback timestamp в миллисекундах). Без него скорость анимации и движения зависела бы от FPS конкретного устройства: на мощном компьютере всё двигалось бы быстрее, чем на слабом. deltaTime привязывает скорость к реальному времени, а не к количеству отрисованных кадров.
До сих пор мы говорили о спрайтах как о готовых картинках, нарисованных художником и сохранённых в PNG-файл. Но спрайт необязательно должен существовать как отдельный файл — его можно сгенерировать прямо в коде, используя функции рисования Canvas API (fillRect, arc, moveTo/lineTo, градиенты и т.д.). Такой подход часто называют процедурной генерацией графики.
Вместо new Image() с внешним .png мы создаём невидимый (offscreen) canvas, рисуем на нём кадр средствами JS, и в дальнейшем используем этот canvas точно так же, как обычную картинку — Canvas API позволяет передавать в drawImage() не только Image, но и другой canvas. Допустим нам надо отрисовать спрайт размером 64x64 пикселя:
const frameCanvas = document.createElement('canvas'); frameCanvas.width = 64; frameCanvas.height = 64; const fctx = frameCanvas.getContext('2d'); ... //отрисовка спрайта
Теперь frameCanvas — это полноценный "кадр", который можно передать туда же, куда передавали бы загруженное изображение:
ctx.drawImage(frameCanvas, x, y);
Тот же принцип масштабируется на весь спрайт-лист: создаём один большой offscreen-canvas нужного размера и рисуем в него каждый кадр по очереди, сдвигаясь по X на frameWidth для каждого следующего кадра:
const sheetCanvas = document.createElement('canvas'); sheetCanvas.width = frameWidth * totalFrames; sheetCanvas.height = frameHeight; const sctx = sheetCanvas.getContext('2d'); for (let i = 0; i < totalFrames; i++) { const offsetX = i * frameWidth; // здесь рисуем i-й кадр анимации внутри клетки [offsetX, 0, frameWidth, frameHeight], // меняя параметры фигур от кадра к кадру }
После такого цикла sheetCanvas можно использовать точно как обычный спрайт загруженный в виде картинки — вырезать из него кадры через drawImage(). Разница лишь в том, что этот файл никогда не существовал на диске — он "материализовался" только во время выполнения программы.
Плюсы данного подхода:
- Не нужны внешние файлы и хостинг. Не требуется заливать картинку на GitHub, imgur или искать CDN — весь спрайт целиком лежит в JS-коде.
- Параметризация. Можно легко менять размер, цвет, пропорции персонажа через переменные — например, генерировать разные варианты одного и того же спрайта без создания отдельных файлов под каждый вариант.
- Отсутствие сетевых задержек. Не нужно ждать onload — рисование происходит синхронно в момент выполнения кода.
Минусы:
- Производительность при сложных фигурах. Если кадр состоит из множества операций рисования , генерация одного кадра может быть заметно дороже, чем простое копирование готового пикселя из PNG через drawImage(). Именно поэтому такие спрайты обычно генерируют один раз заранее (как в примере выше — в цикл, до старта игрового цикла), а не пересоздают на каждый requestAnimationFrame.
- Код становится сложнее для чтения и поддержки. Художественные детали, которые в PNG-файле видно визуально одним взглядом, в коде превращаются в набор чисел-координат — сложнее вносить правки, сложнее работать в команде с художником.
