Спрайт-листы и анимация персонажа

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

Тайлсет / спрайт-лист — одна большая картинка, в которой собрано много кадров или плиток, вырезаемых по координатам.

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

Зачем это нужно: статичный спрайт выглядит мёртвым

Открой любую игру, в которую залипал. Марио на бегу перебирает ногами, в Hollow Knight рыцарь машет плащом, даже простейшая Geometry Dash крутит свой кубик. Ни один герой в нормальной игре не ездит по экрану одной замороженной картинкой — потому что мозг мгновенно считывает: это не живое, это слайд из презентации.

А теперь представь: у тебя есть один спрайт цыплёнка — картинка chickenSprite, которую ты рисуешь через drawImage с прошлых уроков. Ты двигаешь её по x, она едет, но поза не меняется. Цыплёнок будто застыл в стоп-кадре и так и катится. Скучно.

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

Тот же фокус крутится в любом видео на телефоне: фильм — это просто куча неподвижных кадров, которые сменяются 24 или 30 раз в секунду. Кино, мультики, твои сторис, гифки с мемами — всё это флипбук на стероидах. Мозг человека так устроен, что десяток-другой картинок в секунду он склеивает в плавное движение. Нам в игре даже проще: четырёх кадров бега уже хватает, чтобы цыплёнок убедительно перебирал лапками. Ты не рисуешь движение — ты подсовываешь глазу несколько поз в правильном ритме, а движение он достроит сам.

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

Главная идея: один файл, много кадров

Первая мысль новичка — «значит, мне нужно десять отдельных PNG-файлов: бег-1, бег-2, бег-3...». Так можно, но так не делают. Грузить десятки файлов медленно, в коде с ними возни, и за всеми надо следить. Поэтому художники складывают все позы героя в одну большую картинку — это и есть спрайт-лист (его же называют тайлсет, когда речь про плитки уровня).

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

Представь лист с наклейками из киоска: все наклейки на одном листе, ровными рядами, одинакового размера. Ты не отрываешь их заранее — ты просто говоришь «мне вот эту, третью слева в верхнем ряду» и берёшь её. Спрайт-лист устроен так же. Допустим, наш цыплёнок нарисован в файле chicken_run.png, где четыре кадра бега выстроены в ряд, и каждый кадр — квадрат 48×48 пикселей. Тогда вся картинка имеет размер 192×48 (четыре квадрата подряд).

Чтобы взять, например, третий кадр, нам нужно знать его координаты внутри картинки. Кадры нумеруются с нуля, как всё в программировании. Левый край третьего кадра (индекс 2) — это 2 × 48 = 96 пикселей от левого края листа. Высота одна на всех — кадр начинается на y = 0. Вот и весь секрет: номер кадра умножаем на ширину кадра — получаем, откуда вырезать.

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

Пример 1. Вырезаем один кадр расширенным drawImage

Раньше мы звали drawImage в коротком виде — «нарисуй всю картинку вот сюда». Но у этого метода есть полная форма с девятью аргументами, и именно она умеет вырезать кусок. Звучит страшно, на деле просто две группы по четыре числа: откуда берём (из картинки) и куда кладём (на холст).

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

// тот же цыплёнок, но теперь его спрайт — это лист кадров
const chickenSprite = new Image();
chickenSprite.src = '/sprites/chicken_run.png'; // 192x48, четыре кадра в ряд

const FRAME_W = 48; // ширина одного кадра
const FRAME_H = 48; // высота одного кадра

// тот самый chicken из прошлых уроков
const chicken = { x: 100, y: 300, w: 48, h: 48 };

chickenSprite.onload = function () {
  const frameIndex = 2; // хотим третий кадр (нумерация с нуля)

  context.drawImage(
    chickenSprite,            // 1. из какой картинки берём
    frameIndex * FRAME_W, 0,  // 2. ОТКУДА: левый-верхний угол кадра внутри листа
    FRAME_W, FRAME_H,         // 3. какой кусок вырезаем (ширина и высота кадра)
    chicken.x, chicken.y,     // 4. КУДА на холсте кладём
    chicken.w, chicken.h      // 5. какого размера рисуем на холсте
  );
};

Результат: на холсте появляется ровно третий кадр цыплёнка из листа — будто мы ножницами вырезали один квадратик 48×48 со сдвигом 96 пикселей слева и наклеили его в точку (100, 300). Остальные три кадра остались в файле, но на экран не попали. Поменяй frameIndex на 0, 1 или 3 — увидишь другую позу.

Разбираем девять аргументов

Чтобы это не было магией, разложим по полочкам. Первый аргумент — сама картинка. Следующие четыре описывают прямоугольник-источник внутри листа: frameIndex * FRAME_W и 0 — это его левый верхний угол, FRAME_W и FRAME_H — его размер. Последние четыре описывают прямоугольник-приёмник на холсте: куда положить и какого размера растянуть. Источник и приёмник у нас одного размера (48×48), но это не обязано совпадать — можно вырезать кадр 48×48 и нарисовать его как 96×96, цыплёнок станет крупнее.

Пример 2. Переключаем кадры по таймеру — анимация бега

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

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

const RUN_FRAMES = 4;   // сколько кадров в анимации бега
const FRAME_DELAY = 6;  // держим каждый кадр 6 тиков игрового цикла

let frameIndex = 0;     // какой кадр показываем сейчас
let frameTimer = 0;     // счётчик тиков

function updateAnimation() {
  frameTimer += 1;
  if (frameTimer >= FRAME_DELAY) {
    frameTimer = 0;
    frameIndex += 1;
    if (frameIndex >= RUN_FRAMES) {
      frameIndex = 0; // дошли до конца — снова на первый кадр
    }
  }
}

function drawChicken() {
  context.drawImage(
    chickenSprite,
    frameIndex * FRAME_W, 0,
    FRAME_W, FRAME_H,
    chicken.x, chicken.y,
    chicken.w, chicken.h
  );
}

function loop() {
  context.clearRect(0, 0, canvas.width, canvas.height);
  updateAnimation();
  drawChicken();
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

Результат: цыплёнок на месте перебирает лапками — кадры 0, 1, 2, 3 сменяют друг друга и зацикливаются. При 60 FPS и задержке в 6 тиков каждый кадр держится примерно одну десятую секунды, и анимация выглядит бодро. Уменьши FRAME_DELAY до 3 — цыплёнок засуетится вдвое быстрее; увеличь до 12 — будет лениво топтаться.

Почему мы держим кадр несколько тиков

Игровой цикл крутится 60 раз в секунду. Если менять frameIndex на каждом тике, то все четыре кадра пролетят за 4/60 секунды — цыплёнок будет дрожать так быстро, что ты увидишь мельтешащую кашу, а не бег. FRAME_DELAY — это как раз «сколько тиков задержать одну страницу флипбука перед перелистыванием». Анимации обычно живут на 8–12 кадрах в секунду, а не на 60 — медленнее, чем сам игровой цикл, и в этом всё дело.

Пример 3. Меняем анимацию в зависимости от действия героя

Настоящий герой не бежит вечно. Он стоит, бежит, прыгает — и для каждого действия своя анимация. Обычно все эти анимации лежат в одном большом листе рядами: верхний ряд — стойка, второй — бег, третий — прыжок. Тогда кроме номера кадра (столбца) нам нужен ещё и ряд.

Опишем анимации словарём: для каждого состояния — с какого ряда брать кадры и сколько их. А состояние цыплёнка («стоит / бежит / прыгает») определим по его скорости, которую мы и так считаем с прошлых уроков.

// строки листа: 0 — стойка, 1 — бег, 2 — прыжок
const ANIMATIONS = {
  idle: { row: 0, frames: 2, delay: 18 },
  run:  { row: 1, frames: 4, delay: 6 },
  jump: { row: 2, frames: 1, delay: 1 },
};

let currentName = 'idle';
let frameIndex = 0;
let frameTimer = 0;

function pickAnimation() {
  // выбираем анимацию по состоянию цыплёнка
  let name;
  if (!chicken.onGround) {
    name = 'jump';
  } else if (chicken.vx !== 0) {
    name = 'run';
  } else {
    name = 'idle';
  }
  // если анимация сменилась — начинаем её с первого кадра
  if (name !== currentName) {
    currentName = name;
    frameIndex = 0;
    frameTimer = 0;
  }
}

function updateAnimation() {
  const anim = ANIMATIONS[currentName];
  frameTimer += 1;
  if (frameTimer >= anim.delay) {
    frameTimer = 0;
    frameIndex = (frameIndex + 1) % anim.frames; // зацикливаем по кругу
  }
}

function drawChicken() {
  const anim = ANIMATIONS[currentName];
  context.drawImage(
    chickenSprite,
    frameIndex * FRAME_W, anim.row * FRAME_H, // столбец и РЯД внутри листа
    FRAME_W, FRAME_H,
    chicken.x, chicken.y,
    chicken.w, chicken.h
  );
}

Результат: цыплёнок стоит и лениво переминается (анимация idle, два кадра, медленная). Стоит нажать стрелку — chicken.vx становится не нулём, и цыплёнок переключается в бодрый бег. Прыгнул — chicken.onGround стал false, и в воздухе застывает поза прыжка с раскрытыми крыльями. Отпустил кнопку, приземлился — снова стоит и переминается. Анимация теперь честно отражает то, что цыплёнок делает.

Обрати внимание на трюк с рядом: anim.row * FRAME_H сдвигает источник по вертикали — мы берём кадр не из верхнего ряда, а из нужного. Столбец задаёт позу внутри анимации, ряд — какая это анимация вообще. Один лист, два числа — и весь арсенал движений цыплёнка под рукой.

Заметь ещё одну приятную вещь: добавить новую анимацию теперь почти ничего не стоит. Нарисовал художник в листе четвёртый ряд — «цыплёнок присел» — ты дописываешь одну строчку в ANIMATIONS (crouch: { row: 3, frames: 2, delay: 10 }) и одну ветку в pickAnimation. Никакого нового кода рисования, никаких новых файлов. Вся механика — выбрать имя, посчитать столбец и ряд, вырезать — остаётся прежней. Так и растут реальные игры: герой обрастает десятками анимаций, а движок под ними один и тот же.

И ещё момент про delay у каждой анимации. Видишь, у idle он 18, а у бега 6? Это не случайность. Когда цыплёнок стоит, ему незачем суетиться — медленное переминание выглядит спокойно. А на бегу лапки должны мелькать, поэтому кадры сменяются втрое чаще. Скорость анимации — это тоже часть характера героя: вялый зомби и юркий цыплёнок отличаются как раз величиной delay.

Частые ошибки и подводные камни

  • Рисуют до загрузки картинки. Если позвать drawImage раньше, чем браузер докачал PNG, на холсте ничего не появится (или вылетит ошибка) — рисовать нечего. Картинка грузится не мгновенно. Поэтому старт игрового цикла вешают на chickenSprite.onload или проверяют, что все спрайты загрузились, прежде чем рисовать.

  • Меняют кадр каждый тик. Без FRAME_DELAY анимация листается 60 раз в секунду и превращается в дрожащую кашу. Кадры должны жить несколько тиков — держи задержку, иначе движения не разглядеть.

  • Путают размер кадра и размер всего листа. В источник drawImage надо передавать размер одного кадра (48×48), а не всей картинки (192×48). Если подставить ширину листа, вырежется сразу несколько кадров вперемешку, и цыплёнок будет выглядеть как слипшаяся гусеница.

  • Забывают зациклить frameIndex. Если просто прибавлять frameIndex += 1 и не возвращаться к нулю, индекс уползёт за последний кадр. Тогда frameIndex * FRAME_W укажет за правый край листа — вырежется пустота, и цыплёнок исчезнет. Спасает остаток от деления: (frameIndex + 1) % anim.frames всегда держит индекс в пределах анимации.

  • Не сбрасывают кадр при смене анимации. Если переключиться с бега (4 кадра) на прыжок (1 кадр), а frameIndex остался равен 3, то для прыжка кадр 3 не существует — снова вырежется пустота. При каждой смене анимации обнуляй frameIndex и frameTimer, как мы сделали в pickAnimation.

Мини-проект: оживи цыплёнка полностью

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

  1. Поворот по направлению. Сейчас цыплёнок всегда смотрит вправо, даже когда бежит влево — выглядит странно. Заведи флаг chicken.facingRight и, когда цыплёнок бежит влево, отражай спрайт зеркально. Подсказка: оберни рисование в context.save(), вызови context.scale(-1, 1) и рисуй с инвертированным x, потом context.restore().

  2. Анимация приземления. Добавь в лист четвёртый ряд — короткую анимацию «плюх» при касании земли, которая играет один раз и не зацикливается. Подсказка: заведи флаг «анимация доиграла» и на последнем кадре не сбрасывай индекс в ноль, а замри.

  3. Своя скорость на анимацию. Сделай так, чтобы при беге на ускорении (например, с зажатым шифтом) delay уменьшался — лапки замелькают быстрее, и игрок почувствует спринт ещё и глазами, а не только по скорости.

Если все три пункта заработали — у тебя в руках полноценная система анимации, которую используют в настоящих 2D-играх. Покрути цыплёнком туда-сюда, попрыгай и полюбуйся, как он ожил.

Итоги

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

  • Спрайт-лист — это один файл с кадрами в сеточке. Грузим картинку один раз, а нужный кадр вырезаем при рисовании.

  • Расширенный drawImage с девятью аргументами вырезает кусок: четыре числа — откуда в листе, четыре — куда на холсте.

  • Анимация — это флипбук: переключаем frameIndex по таймеру с задержкой FRAME_DELAY, иначе кадры мелькают слишком быстро.

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

В следующем уроке цыплёнок зазвучит: добавим звуки прыжка, подбора монетки и фоновую музыку — игра окончательно перестанет быть немым кино. А спрайт-анимацию, которую ты только что освоил, мы понесём дальше во все примеры. До встречи, твой цыплёнок уже разминает лапки!

Проверьте себя
1. Что такое спрайт-лист?
AСписок имён всех спрайтов в коде
BОдна большая картинка, на которой собраны кадры в виде сеточки
CОтдельный PNG-файл на каждый кадр анимации
DТекстовый файл с координатами героя
2. Какая форма drawImage умеет вырезать один кадр из спрайт-листа?
AКороткая: drawImage(img, x, y)
BС пятью аргументами
CРасширенная с девятью аргументами: источник (4 числа) и приёмник (4 числа)
DdrawImage кадры вырезать не умеет, нужны отдельные файлы
3. Как вычислить, с какого пикселя по горизонтали начинается кадр с индексом frameIndex?
AframeIndex + FRAME_W
BframeIndex * FRAME_W
CFRAME_W / frameIndex
Dширина всего листа − frameIndex
4. Зачем нужна задержка FRAME_DELAY и счётчик frameTimer?
AЧтобы кадр держался несколько тиков игрового цикла, иначе анимация мелькает слишком быстро
BЧтобы загрузить картинку
CЧтобы герой двигался по экрану
DЧтобы вырезать кадр большего размера
5. Почему важно сбрасывать frameIndex в ноль при смене анимации?
AИначе картинка не загрузится
BИначе индекс может указывать на несуществующий кадр новой анимации, и вырежется пустота
CЭто ускоряет игру
DСбрасывать необязательно, ничего не сломается
6. Как из общего спрайт-листа выбрать нужную анимацию (стойку, бег или прыжок), если они лежат разными рядами?
AЗагрузить для каждой анимации отдельный файл
BСдвинуть источник по вертикали: anim.row * FRAME_H задаёт ряд, а frameIndex * FRAME_W — столбец
CМенять размер холста под каждую анимацию
DЭто невозможно в одном листе