Спрайт-листы и анимация персонажа
Учим цыплёнка двигать лапками: берём одну картинку с кадрами, вырезаем по очереди нужный кусочек и переключаем их во времени — получается живая анимация.
Тайлсет / спрайт-лист — одна большая картинка, в которой собрано много кадров или плиток, вырезаемых по координатам.
До этого момента наш цыплёнок был честным трудягой, но выглядел как наклейка. Он бегал, прыгал, собирал монетки и убегал от врагов — всё это ты собрал в уроке про предметы и врагов в платформере — но при этом скользил по экрану одной и той же застывшей позой, будто его приклеили к доске и катают. Сегодня мы это чиним. К концу урока лапки цыплёнка будут перебирать на бегу, крылья махать в прыжке, и он наконец оживёт.
Зачем это нужно: статичный спрайт выглядит мёртвым
Открой любую игру, в которую залипал. Марио на бегу перебирает ногами, в 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.
Мини-проект: оживи цыплёнка полностью
База у тебя есть: вырезание кадра, таймер переключения, выбор анимации по состоянию. Теперь три апгрейда — делай по шагам и после каждого смотри, что изменилось на экране.
Поворот по направлению. Сейчас цыплёнок всегда смотрит вправо, даже когда бежит влево — выглядит странно. Заведи флаг
chicken.facingRightи, когда цыплёнок бежит влево, отражай спрайт зеркально. Подсказка: оберни рисование вcontext.save(), вызовиcontext.scale(-1, 1)и рисуй с инвертированнымx, потомcontext.restore().Анимация приземления. Добавь в лист четвёртый ряд — короткую анимацию «плюх» при касании земли, которая играет один раз и не зацикливается. Подсказка: заведи флаг «анимация доиграла» и на последнем кадре не сбрасывай индекс в ноль, а замри.
Своя скорость на анимацию. Сделай так, чтобы при беге на ускорении (например, с зажатым шифтом)
delayуменьшался — лапки замелькают быстрее, и игрок почувствует спринт ещё и глазами, а не только по скорости.
Если все три пункта заработали — у тебя в руках полноценная система анимации, которую используют в настоящих 2D-играх. Покрути цыплёнком туда-сюда, попрыгай и полюбуйся, как он ожил.
Итоги
Сегодня цыплёнок из наклейки превратился в живого героя. Главное, что стоит унести с собой:
Спрайт-лист — это один файл с кадрами в сеточке. Грузим картинку один раз, а нужный кадр вырезаем при рисовании.
Расширенный drawImage с девятью аргументами вырезает кусок: четыре числа — откуда в листе, четыре — куда на холсте.
Анимация — это флипбук: переключаем
frameIndexпо таймеру с задержкойFRAME_DELAY, иначе кадры мелькают слишком быстро.Состояние героя выбирает анимацию: по скорости и
onGroundрешаем — стойка, бег или прыжок, и берём кадры из нужного ряда листа.
В следующем уроке цыплёнок зазвучит: добавим звуки прыжка, подбора монетки и фоновую музыку — игра окончательно перестанет быть немым кино. А спрайт-анимацию, которую ты только что освоил, мы понесём дальше во все примеры. До встречи, твой цыплёнок уже разминает лапки!