Допустим, у нас есть персонаж. Пока мы разбирались с архитектурой, автоматами и камерой, он был просто цветным прямоугольником. Ну, может, статичной картинкой. Стоит на месте - одна картинка. Бежит - та же картинка. Атакует - всё та же картинка. Выглядит как искусство.
Нам нужно, чтобы персонаж выглядел живым: ноги двигаются при ходьбе, руки машут при атаке, тело покачивается в покое. Как этого добиться?
Принцип тот же, что и в мультфильмах: быстро показываем последовательность картинок, и глаз воспринимает это как движение. Каждая картинка - это кадр (frame) анимации. Показываем 8-12 кадров в секунду - и прямоугольник превращается в живого персонажа.
Первая мысль: хранить каждый кадр в отдельном файле. walk_01.png, walk_02.png, ... walk_08.png. Это работает, но неудобно - 10 анимаций по 8 кадров = 80 файлов. Загрузка каждого файла - это обращение к диску, и на слабых машинах это может заметно тормозить.
Поэтому используют спрайт-лист (sprite sheet) - одно большое изображение, в котором все кадры расположены сеткой:
![]()
Каждый кадр имеет одинаковый размер (например, 64x64 пикселей). Чтобы получить нужный кадр, мы вычисляем его положение на листе по индексу. Т.е. зная номер кадра, размер кадра и количество столбцов, мы можем вырезать прямоугольную область из большого изображения.
class SpriteSheet:
def __init__(self, image, frame_width, frame_height):
self.image = image
self.frame_width = frame_width
self.frame_height = frame_height
self.columns = image.get_width() // frame_width
def get_frame(self, index):
col = index % self.columns
row = index // self.columns
x = col * self.frame_width
y = row * self.frame_height
frame = self.image.subsurface((x, y, self.frame_width, self.frame_height))
return frame
# usage
sheet_image = pygame.image.load("hero_walk.png").convert_alpha()
sheet = SpriteSheet(sheet_image, frame_width=64, frame_height=64)
frame_0 = sheet.get_frame(0) # first frame
frame_5 = sheet.get_frame(5) # sixth frame
public class SpriteSheet
{
private Texture2D texture;
private int frameWidth;
private int frameHeight;
private int columns;
public SpriteSheet(Texture2D texture, int frameWidth, int frameHeight)
{
this.texture = texture;
this.frameWidth = frameWidth;
this.frameHeight = frameHeight;
columns = texture.Width / frameWidth;
}
public Rectangle GetFrameRect(int index)
{
int col = index % columns;
int row = index / columns;
return new Rectangle(
col * frameWidth,
row * frameHeight,
frameWidth,
frameHeight
);
}
}
// usage
var sheetTexture = Content.Load<Texture2D>("hero_walk");
var sheet = new SpriteSheet(sheetTexture, frameWidth: 64, frameHeight: 64);
Rectangle frame0 = sheet.GetFrameRect(0);
spriteBatch.Draw(sheetTexture, position, frame0, Color.White);
Спрайт-листы можно собирать вручную в графическом редакторе или использовать инструменты вроде TexturePacker, Aseprite (экспорт), или LibreSprite (бесплатный). Многие бесплатные ассеты на itch.io уже идут в виде готовых спрайт-листов.
Ок. Мы умеем доставать отдельные кадры из листа. Теперь нужно их переключать - показывать один за другим, чтобы получилась анимация.
Наивный подход: менять кадр на каждой итерации игрового цикла. Каждый Update - следующий кадр.
Проблема? Скорость анимации привязана к FPS. При 60 FPS анимация из 8 кадров проиграется за 0.13 секунды - слишком быстро, персонаж мельтешит. При 30 FPS - за 0.27 секунды. На разных компьютерах анимация будет выглядеть по-разному. Знакомая ситуация - мы уже сталкивались с этим в игровом цикле, когда обсуждали deltaTime.
Решение то же: считаем время. Заводим таймер, и переключаем кадр только когда прошло достаточно времени.
class Animation:
def __init__(self, sprite_sheet, frames, frame_duration):
self.sprite_sheet = sprite_sheet
self.frames = frames # list of frame indices, e.g. [0, 1, 2, 3]
self.frame_duration = frame_duration # seconds per frame
self.current_index = 0
self.timer = 0.0
def update(self, delta_time):
self.timer += delta_time
if self.timer >= self.frame_duration:
self.timer -= self.frame_duration
self.current_index = (self.current_index + 1) % len(self.frames)
def get_current_frame(self):
frame_id = self.frames[self.current_index]
return self.sprite_sheet.get_frame(frame_id)
# usage
walk_anim = Animation(sheet, frames=[0, 1, 2, 3, 4, 5], frame_duration=0.1)
# in game loop
walk_anim.update(delta_time)
current_frame = walk_anim.get_current_frame()
screen.blit(current_frame, (player.x, player.y))
public class Animation
{
private SpriteSheet spriteSheet;
private int[] frames;
private float frameDuration;
private int currentIndex;
private float timer;
public Animation(SpriteSheet spriteSheet, int[] frames, float frameDuration)
{
this.spriteSheet = spriteSheet;
this.frames = frames;
this.frameDuration = frameDuration;
}
public void Update(float deltaTime)
{
timer += deltaTime;
if (timer >= frameDuration)
{
timer -= frameDuration;
currentIndex = (currentIndex + 1) % frames.Length;
}
}
public Rectangle GetCurrentFrameRect()
{
return spriteSheet.GetFrameRect(frames[currentIndex]);
}
}
// usage
var walkAnim = new Animation(sheet, new[] { 0, 1, 2, 3, 4, 5 }, frameDuration: 0.1f);
// in Update:
walkAnim.Update(deltaTime);
// in Draw:
var srcRect = walkAnim.GetCurrentFrameRect();
spriteBatch.Draw(sheetTexture, position, srcRect, Color.White);
frame_duration задаётся в секундах. При frame_duration = 0.1 кадр меняется каждые 100 мс, т.е. 10 кадров анимации в секунду. Это не зависит от FPS игры - на 30 FPS и на 120 FPS анимация будет выглядеть одинаково.
Обратите внимание: timer -= frame_duration, а не timer = 0 - так мы не теряем остаток времени при лагах.
У персонажа обычно не одна анимация. Как минимум: покой (idle), ходьба (walk), прыжок (jump), атака (attack). Как переключаться между ними?
Если вы читали раздел про конечные автоматы, то ответ уже знаете. Анимации - это состояния, а переходы между ними зависят от того, что делает персонаж. Стоит - idle. Пошёл - walk. Нажал атаку - attack. Анимация атаки закончилась - обратно в idle (или walk, если всё ещё движется).
Не обязательно строить полноценный автомат с классами состояний. Для анимаций часто хватает простого подхода: храним словарь анимаций и текущее состояние. Ключи - enum, не строки. Опечатка в строке "atack" - тихий баг, а AnimState.ATACK не скомпилируется.
from enum import Enum
class AnimState(Enum):
IDLE = 0
WALK = 1
ATTACK = 2
class AnimationController:
def __init__(self):
self.animations = {}
self.current_state = None
self.current_anim = None
def add(self, state, animation):
self.animations[state] = animation
def play(self, state):
if self.current_state == state:
return # already playing
self.current_state = state
self.current_anim = self.animations[state]
self.current_anim.reset()
def update(self, delta_time):
if self.current_anim:
self.current_anim.update(delta_time)
def get_current_frame(self):
if self.current_anim:
return self.current_anim.get_current_frame()
return None
# setup
controller = AnimationController()
controller.add(AnimState.IDLE, Animation(sheet, frames=[0, 1], frame_duration=0.25))
controller.add(AnimState.WALK, Animation(sheet, frames=[2, 3, 4, 5, 6, 7], frame_duration=0.1))
controller.add(AnimState.ATTACK, Animation(sheet, frames=[8, 9, 10, 11], frame_duration=0.07))
controller.play(AnimState.IDLE)
# in game loop
if is_attacking:
controller.play(AnimState.ATTACK)
elif is_moving:
controller.play(AnimState.WALK)
else:
controller.play(AnimState.IDLE)
controller.update(delta_time)
frame = controller.get_current_frame()
screen.blit(frame, (player.x, player.y))
public enum AnimState { Idle, Walk, Attack }
public class AnimationController
{
private Dictionary<AnimState, Animation> animations = new();
private AnimState? currentState;
private Animation currentAnim;
public void Add(AnimState state, Animation animation)
{
animations[state] = animation;
}
public void Play(AnimState state)
{
if (currentState == state)
return;
currentState = state;
currentAnim = animations[state];
currentAnim.Reset();
}
public void Update(float deltaTime)
{
currentAnim?.Update(deltaTime);
}
public Rectangle GetCurrentFrameRect()
{
return currentAnim.GetCurrentFrameRect();
}
}
// setup
var controller = new AnimationController();
controller.Add(AnimState.Idle, new Animation(sheet, new[] { 0, 1 }, 0.25f));
controller.Add(AnimState.Walk, new Animation(sheet, new[] { 2, 3, 4, 5, 6, 7 }, 0.1f));
controller.Add(AnimState.Attack, new Animation(sheet, new[] { 8, 9, 10, 11 }, 0.07f));
controller.Play(AnimState.Idle);
// in Update:
if (isAttacking)
controller.Play(AnimState.Attack);
else if (isMoving)
controller.Play(AnimState.Walk);
else
controller.Play(AnimState.Idle);
controller.Update(deltaTime);
// in Draw:
var srcRect = controller.GetCurrentFrameRect();
spriteBatch.Draw(sheetTexture, position, srcRect, Color.White);
Метод play проверяет, не играет ли уже эта анимация. Если да - ничего не делает, если нет - сбрасывает в начало. Без этой проверки каждый кадр перезапускает анимацию - будет дёрганье.
Для этого добавим метод reset в наш класс Animation:
def reset(self):
self.current_index = 0
self.timer = 0.0
public void Reset()
{
currentIndex = 0;
timer = 0;
}
Ходьба и покой - циклические анимации. Они крутятся по кругу, пока персонаж делает соответствующее действие. Но атака - это разовое действие. Анимация атаки должна проиграться один раз и остановиться (или переключиться на другую).
Для этого добавим в Animation флаг looping и способ узнать, закончилась ли анимация.
Теперь в игровом цикле проверяем finished - если анимация закончилась, переключаемся на idle. Грубо говоря: "Играй атаку. Когда закончится - вернись в покой." Если вы строили автомат для персонажа, то анимация атаки - это состояние с автоматическим переходом по завершении.
Персонаж идёт вправо - показываем анимацию ходьбы. Персонаж идёт влево - показываем... ту же анимацию, но отражённую по горизонтали. Зачем рисовать отдельные спрайты для каждого направления, если можно просто перевернуть?
# flip horizontally
flipped_frame = pygame.transform.flip(frame, True, False)
# in practice:
frame = controller.get_current_frame()
if facing_left:
frame = pygame.transform.flip(frame, True, False)
screen.blit(frame, (player.x, player.y))
// MonoGame: use SpriteEffects
var effects = facingLeft
? SpriteEffects.FlipHorizontally
: SpriteEffects.None;
spriteBatch.Draw(
sheetTexture,
position,
srcRect,
Color.White,
rotation: 0f,
origin: Vector2.Zero,
scale: 1f,
effects: effects,
layerDepth: 0f
);
В Python мы создаём отражённую копию через pygame.transform.flip. В MonoGame используем SpriteEffects.FlipHorizontally - это флаг для Draw, текстура при этом не копируется.
Для четырёх направлений (top-down игры) отражение не поможет - спрайт "вверх" не получить из спрайта "вниз". В таких играх в спрайт-листе хранят отдельные ряды для каждого направления: ряд 0 - вниз, ряд 1 - влево, ряд 2 - вправо, ряд 3 - вверх.
Иногда нужно, чтобы что-то произошло на конкретном кадре анимации. Например, звук удара должен раздаться не в начале анимации атаки, а в момент, когда меч достигает цели - допустим, на 3-м кадре. Или проверка коллизии атаки должна случиться на определённом кадре, а не на протяжении всей анимации.
Простой способ: после каждого переключения кадра проверяем, не является ли новый кадр "событийным":
Это пересекается с событийной моделью: анимация - издатель, а звуковая система и система коллизий - подписчики. Можно вместо колбэков использовать полноценные события, если у вас уже есть такая система.
Анимация в 2D - это:
| Что | Где почитать подробнее |
|---|---|
| deltaTime и игровой цикл | Основной цикл |
| Конечные автоматы для состояний персонажа | Конечные автоматы |
| Коллизии и хитбоксы для атак | Коллизии |
| Событийная модель для привязки звуков/эффектов | Событийная модель |
| Типичные ошибки (логика в Draw, нет deltaTime) | Типичные ошибки |