$ sudo teach IT
Модуль 5 · Графика и звук · Урок 5.2

Анимация по кадрам

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

Теория + практика~35 минутСредний

Картинки в «Комете» уже настоящие, но метеор по-прежнему просто исчезает: секунду назад летел, а теперь его нет. Сбитому метеору полагается взрыв, а взрыв — это не одна картинка, а несколько, которые быстро сменяют друг друга. В этом уроке научим игру проигрывать такие серии и пустим вспышку на месте каждого уничтоженного метеора.

Как работает мультфильм

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

Официальные слова такие. Каждая страничка называется кадром анимации, а время, которое один кадр остаётся на экране, — frame_time (по-русски «время кадра»). Само слово «анимация» означает просто «оживление»: показ серии картинок один за другим. У нашего взрыва будет шесть кадров по 0,06 секунды, то есть вся вспышка займёт 0,36 секунды.

Два разных «кадра»

Раньше мы называли кадром один оборот игрового цикла (одну отрисовку экрана). Теперь появился второй смысл. Чтобы не путаться, в этом уроке пишем «кадр игры» для оборота цикла и «кадр анимации» для одной картинки взрыва. Они связаны: кадров игры за время одного кадра анимации проходит несколько.

Как выбрать, какую картинку показать

Самая очевидная идея: завести счётчик, увеличивать его на единицу на каждом обороте цикла, и пусть счётчик говорит, какой кадр рисовать. Работает, но скорость взрыва будет зависеть от FPS. При 60 кадрах игры в секунду шесть кадров анимации пролетят за 6 / 60 = 0,1 секунды, и вспышка мелькнёт незаметно. На слабом компьютере с 30 кадрами игры та же вспышка продлится 0,2 секунды. Мы уже сталкивались с этой бедой, когда двигали корабль, и вылечили её временем кадра dt. Лечим и здесь.

Каждый взрыв запоминает elapsed — сколько секунд прошло с его рождения, и на каждом обороте цикла прибавляет к нему dt. А номер кадра вычисляется из прошедшего времени: делим elapsed на frame_time и отбрасываем дробную часть. Секунда одинакова на любом компьютере, поэтому и взрыв везде длится одинаково. Для frame_time = 0.06 получается такая таблица:

Прошло, секундelapsed / frame_timeКакой кадр показываем
от 0 до 0,06от 0 до 1кадр 0
от 0,06 до 0,12от 1 до 2кадр 1
от 0,12 до 0,18от 2 до 3кадр 2
от 0,18 до 0,24от 3 до 4кадр 3
от 0,24 до 0,30от 4 до 5кадр 4
от 0,30 до 0,36от 5 до 6кадр 5
0,36 и дольше6 и большекадры кончились: взрыв пора убирать

Например, elapsed = 0.13: 0.13 / 0.06 примерно 2,17, целая часть — 2, значит, показываем кадр с номером 2. Номера, как всегда в Python, начинаются с нуля, поэтому шесть кадров имеют номера от 0 до 5. Номера 6 не существует, и это важно запомнить.

Функция frame_index

Эту арифметику удобно вынести в отдельную функцию в logic.py, как мы делали со всей логикой игры: она не знает ни про картинки, ни про Pygame, поэтому её легко проверять отдельно. Представьте помощника, которого вы спрашиваете: «Прошло столько-то секунд, один кадр длится столько-то, всего кадров столько-то. Какой показывать?» Помощник отвечает номером кадра, а если кадры закончились — говорит «ничего».

У анимаций бывает два характера. Взрыв проигрывается один раз и исчезает. А мерцающее пламя двигателя или крутящаяся монетка должны идти по кругу: после последнего кадра снова первый. Это различие мы передаём параметром loop (по-английски «петля»): True — крутить по кругу, False — проиграть один раз.

Для круга пригодится знакомый по функции wrap оператор % — остаток от деления. Если кадров четыре, то индекс 4 превращается в 0, индекс 5 — в 1, и так далее без конца. А «ничего» в Python записывается особым значением None: это не ноль и не пустая строка, а отдельный маркер «значения нет». Проверять его принято словами is None.

Python · фрагмент
def frame_index(elapsed, frame_time, count, loop):
    index = int(elapsed / frame_time)
    if loop:
        return index % count
    if index >= count:
        return None
    return index

Разбор по строкам

  • def frame_index(elapsed, frame_time, count, loop): — четыре параметра: прошедшее время, длительность одного кадра, число кадров и признак «по кругу».
  • index = int(elapsed / frame_time) — делим и отбрасываем дробную часть (int округляет к нулю). Это и есть номер кадра по таблице выше.
  • if loop: — для зацикленной анимации номер никогда не кончается.
  • return index % count — остаток от деления на число кадров «заворачивает» номер в диапазон от 0 до count - 1.
  • if index >= count: — для разовой анимации: номер вышел за последний кадр (для шести кадров это 6 и больше).
  • return None — сообщаем вызывающему: показывать больше нечего.
  • return index — обычный случай: номер кадра внутри диапазона.

Поиграем с функцией: кадров четыре, каждый длится полсекунды, значит, вся анимация занимает две секунды.

Python · фрагмент
print(frame_index(0.0, 0.5, 4, False))
print(frame_index(1.6, 0.5, 4, False))
print(frame_index(2.0, 0.5, 4, False))
print(frame_index(2.0, 0.5, 4, True))
Вывод
0
3
None
0

Разберём последние две строки. Ровно на отметке 2,0 секунды 2.0 / 0.5 даёт 4, а кадров с номерами 0–3 всего четыре: разовая анимация закончилась, ответ None. Та же отметка в режиме loop=True превращается в 4 % 4 = 0 — всё началось сначала. Заметьте, что на самой границе кадр уже сменился: кадр 3 живёт от 1,5 (включая) до 2,0 (не включая).

Ловушка: кадр 0 — тоже «ложь»

В Python число 0 в условии считается ложью, как и None. Если написать if not index:, то на самом первом кадре (номер 0) условие сработает, и взрыв удалится, не успев показаться. Спрашивайте точно: if index is None:.

Рисуем кадры взрыва

Нужны шесть картинок: explosion_0.png … explosion_5.png. Рисовать каждую отдельным кодом было бы долго, поэтому напишем одну функцию, которая получает номер кадра и рисует именно его, а цикл вызовет её шесть раз. Файл, как и в прошлом уроке, создаёт make_assets.py.

Идея такая. Пусть t показывает, как далеко зашёл взрыв: у нулевого кадра t = 0, у последнего t = 1, у остальных — что-то между. Тогда от t легко зависят все остальные параметры: радиус вспышки растёт от 10 до 46 пикселей, а цвет гаснет. Гаснуть помогает прозрачность. До сих пор мы задавали цвет тремя числами (красный, зелёный, синий). Четвёртое число — alpha (альфа-канал) — сообщает, насколько цвет непрозрачен: 255 — сплошной, 0 — невидимый. Работает на поверхностях с pygame.SRCALPHA, как раз такие мы уже создаём для картинок.

Python · фрагмент
count = 6
index = 3
t = index / (count - 1)
radius = 10 + int(t * 36)
alpha = int(255 - t * 204)
print(t, radius, alpha)

Разбор по строкам

  • t = index / (count - 1) — делим номер кадра на номер последнего кадра. Для кадра 3 из шести получится 0,6.
  • radius = 10 + int(t * 36) — радиус от 10 (при t = 0) до 46 (при t = 1). int нужен, потому что размеры рисунка — целые.
  • alpha = int(255 - t * 204) — прозрачность падает от 255 (плотный) до 51 (бледный).

Для шести кадров получаются такие значения:

КадрtРадиусAlpha
00,010255
10,217214
20,424173
30,631132
40,83891
51,04651

Остаётся нарисовать на поверхности три круга друг в друге с одним и тем же радиусом-множителем: большой оранжевый, поменьше жёлтый и маленький светлый в центре. Полный код функции будет в практике ниже.

Спрайт Explosion

Каждый взрыв — отдельный маленький живой объект: родился на месте метеора, проиграл свои шесть кадров и исчез. Такие объекты у нас уже есть: лазер живёт, пока не вылетит за экран. Взрыв устроен так же, только «умирает» не за краем экрана, а когда кончились кадры. Значит, это спрайт, класс Explosion, и у него те же привычные части: image, rect и метод update(dt, *args).

Внутри взрыв хранит список кадров frames и elapsed — свои личные часы. Вот как выглядит его метод update (конструктор будет в практике):

Python · фрагмент
class Explosion(pygame.sprite.Sprite):
    # __init__ (frames, elapsed, image, rect) будет в практике

    def update(self, dt, *args):
        self.elapsed += dt
        index = frame_index(self.elapsed, EXPLOSION_FRAME_TIME, len(self.frames), False)
        if index is None:
            self.kill()
        else:
            self.image = self.frames[index]

Разбор по строкам

  • self.elapsed += dt — часы взрыва тикают: добавляем время, прошедшее с прошлого кадра игры.
  • frame_index(self.elapsed, EXPLOSION_FRAME_TIME, len(self.frames), False) — спрашиваем помощника из logic.py; len(self.frames) — сколько кадров в списке; False — играем один раз.
  • if index is None: — кадры кончились.
  • self.kill() — убираем спрайт изо всех групп, и он исчезает с экрана.
  • self.image = self.frames[index] — иначе подставляем нужную картинку; группа all_sprites.draw(screen) сама нарисует её на прежнем месте.

Загружаем кадры один раз

Откуда возьмётся список frames? Его соберёт маленькая функция load_frames(prefix, count): она в цикле читает файлы explosion_0.png, explosion_1.png и так далее. Важно, где её вызвать. Читать файлы с диска — дело небыстрое. Если каждый новый взрыв будет заново грузить шесть картинок, то при серии взрывов игра начнёт подтормаживать. Поэтому поступаем как в библиотеке: книгу покупают один раз, а читают все по очереди. Список кадров загружается один раз в main.py, после создания окна (помните про convert_alpha), и передаётся каждому новому взрыву. Это безопасно, потому что взрыв кадры не меняет, а только выбирает, какой показать.

Названия файлов собираем так: f"{prefix}_{i}.png". Это знакомая f-строка: то, что стоит в фигурных скобках, подставляется в текст. При prefix = "explosion" и i = 3 получится explosion_3.png.

Где взрываем

Взрыв должен вспыхивать в двух местах программы, и оба уже знакомы. Первое — попадание лазера: groupcollide возвращает словарь, в котором к каждому лазеру приписан список сбитых им метеоров. Второе — удар метеора по кораблю: spritecollide возвращает список врезавшихся метеоров. В обоих случаях метеоры уже удалены из групп, но объекты ещё живы на время нашего цикла, и у них можно спросить meteor.rect.center — центр, где взрыв и должен появиться.

Мы заведём в main.py крошечную функцию explode(center), которая создаёт взрыв и кладёт его в all_sprites. Больше никуда: если добавить взрыв в meteors или lasers, он сам станет участвовать в столкновениях и начнёт засчитывать очки.

Идея для самостоятельной работы

Параметр loop=True мы в игре пока не использовали. Попробуйте после урока нарисовать два кадра пламени двигателя (длинное и короткое), загрузить их тем же load_frames и выбирать картинку корабля через frame_index(elapsed, 0.1, 2, True): пламя начнёт мерцать.

Практика: взрывы в «Комете»

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

Шаг 1. logic.py: функция frame_index

Откройте logic.py и добавьте в самый конец файла:

Python · kometa/logic.py (добавить в конец файла)
def frame_index(elapsed, frame_time, count, loop):
    index = int(elapsed / frame_time)
    if loop:
        return index % count
    if index >= count:
        return None
    return index

Шаг 2. make_assets.py: шесть кадров взрыва

В make_assets.py три правки. Сразу после строки ASSETS = ... добавьте константу (число кадров должно совпадать с тем, что будет в main.py):

Python · kometa/make_assets.py (после строки ASSETS)
EXPLOSION_COUNT = 6

Функцию, рисующую один кадр, вставьте после make_laser() и до save():

Python · kometa/make_assets.py (после функции make_laser)
def make_explosion_frame(index, count):
    t = index / (count - 1)
    radius = 10 + int(t * 36)
    alpha = int(255 - t * 204)
    image = pygame.Surface((96, 96), pygame.SRCALPHA)
    center = (48, 48)
    pygame.draw.circle(image, (255, 120, 40, alpha), center, radius)
    pygame.draw.circle(image, (255, 200, 80, alpha), center, int(radius * 0.7))
    pygame.draw.circle(image, (255, 245, 200, alpha), center, int(radius * 0.35))
    return image

Разбор по строкам

  • pygame.Surface((96, 96), pygame.SRCALPHA) — прозрачный квадрат 96 на 96: радиус 46 с запасом помещается.
  • center = (48, 48) — середина квадрата; все три круга рисуются вокруг одной точки.
  • (255, 120, 40, alpha) — цвет из четырёх чисел: оранжевый, а последнее число — прозрачность, которая от кадра к кадру падает.
  • int(radius * 0.7) и int(radius * 0.35) — внутренние круги меньше наружного в 0,7 и 0,35 раза; получается светлеющая к центру вспышка.

И перед последней строкой с print добавьте цикл, который рисует и сохраняет все кадры. Номер превращаем в текст функцией str, как делали с очками на табло:

Python · kometa/make_assets.py (перед строкой print)
for i in range(EXPLOSION_COUNT):
    save(make_explosion_frame(i, EXPLOSION_COUNT), "explosion_" + str(i) + ".png")

Запустите скрипт ещё раз, чтобы файлы появились:

Терминал
python make_assets.py

В папке assets/ теперь десять файлов: четыре старых и шесть новых explosion_0.png … explosion_5.png. Откройте любой в просмотрщике картинок: на прозрачном фоне увидите оранжево-жёлтый круг (у последнего кадра он самый большой и бледный).

Шаг 3. sprites.py: load_frames и Explosion

В начале sprites.py дополните импорт из logic (остальные строки начала файла не трогайте):

Python · kometa/sprites.py (заменить строку импорта из logic)
from logic import direction_from_keys, frame_index, step_ship

Константу времени кадра добавьте после строки LASER_SPEED = 700:

Python · kometa/sprites.py (после LASER_SPEED)
EXPLOSION_FRAME_TIME = 0.06

Функцию загрузки кадров поставьте сразу после load_image, до классов:

Python · kometa/sprites.py (после функции load_image)
def load_frames(prefix, count):
    frames = []
    for i in range(count):
        frames.append(load_image(f"{prefix}_{i}.png"))
    return frames

А класс Explosion добавьте в самый конец файла:

Python · kometa/sprites.py (добавить в конец файла)
class Explosion(pygame.sprite.Sprite):
    def __init__(self, center, frames):
        super().__init__()
        self.frames = frames
        self.elapsed = 0.0
        self.image = frames[0]
        self.rect = self.image.get_rect(center=center)

    def update(self, dt, *args):
        self.elapsed += dt
        index = frame_index(self.elapsed, EXPLOSION_FRAME_TIME, len(self.frames), False)
        if index is None:
            self.kill()
        else:
            self.image = self.frames[index]

Разбор по строкам

  • frames = [] и for i in range(count): — собираем список: на каждом обороте читаем файл prefix_i.png и добавляем картинку методом append.
  • def __init__(self, center, frames): — взрыву сообщают, где появиться и из каких кадров состоит.
  • self.elapsed = 0.0 — часы взрыва начинают с нуля.
  • self.image = frames[0] — в первый момент показываем нулевой кадр.
  • self.rect = self.image.get_rect(center=center) — рамка спрайта с центром ровно там, где был метеор (так же, как у вращающегося метеора).
  • Метод update — тот, что мы разобрали выше. Все кадры одного размера, поэтому rect при смене картинки менять не нужно.

Шаг 4. main.py: загружаем кадры и взрываем

В main.py пять небольших правок. Первая — импорт:

Python · kometa/main.py (заменить строку импорта из sprites)
from sprites import Crystal, Explosion, Laser, Meteor, Player, load_frames

Вторая — константа после CRYSTAL_SCORE = 25:

Python · kometa/main.py (после CRYSTAL_SCORE)
EXPLOSION_COUNT = 6

Третья — загрузка кадров сразу после строки font = pygame.font.Font(None, 32). Окно к этому месту уже создано, значит, convert_alpha сработает:

Python · kometa/main.py (после создания font)
explosion_frames = load_frames("explosion", EXPLOSION_COUNT)

Четвёртая — функция explode. Поставьте её после строки shot_cooldown = Cooldown(SHOT_COOLDOWN), когда группа all_sprites уже создана:

Python · kometa/main.py (после shot_cooldown)
def explode(center):
    all_sprites.add(Explosion(center, explosion_frames))

Пятая — сами взрывы. Найдите в цикле блок удара по кораблю и блок попадания лазера и замените их на эти. Новые строки — цикл for meteor in hits и внутренний цикл у лазера:

Python · kometa/main.py (заменить два блока в игровом цикле)
while running:
    # ... начало цикла без изменений ...
    hits = pygame.sprite.spritecollide(player, meteors, True, pygame.sprite.collide_circle)
    for meteor in hits:
        explode(meteor.rect.center)
    if hits and invulnerable <= 0:
        lives -= 1
        invulnerable = INVULNERABLE_TIME

    caught = pygame.sprite.spritecollide(player, crystals, True, pygame.sprite.collide_circle)
    score += CRYSTAL_SCORE * len(caught)

    shots = pygame.sprite.groupcollide(lasers, meteors, True, True)
    for hit_meteors in shots.values():
        score += METEOR_SCORE * len(hit_meteors)
        for meteor in hit_meteors:
            explode(meteor.rect.center)
    # ... дальше цикл без изменений ...

Разбор по строкам

  • for meteor in hits: — hits — список метеоров, врезавшихся в корабль; на месте каждого запускаем взрыв. Он вспыхивает и в период неуязвимости: метеор всё равно уничтожен, пусть и без потери жизни.
  • for hit_meteors in shots.values(): — обходим списки сбитых метеоров (по одному на каждый лазер), как и раньше при подсчёте очков.
  • for meteor in hit_meteors: — внутренний цикл по метеорам одного лазера; для каждого создаём взрыв.
  • explode(meteor.rect.center) — центр рамки метеора: именно там он был в момент попадания.

Запуск и что должно получиться

Терминал
python main.py

Стреляйте Пробелом по метеорам: на месте каждого сбитого вспыхивает оранжевый шар, быстро расширяется, бледнеет и пропадает примерно за треть секунды. Врежьтесь в метеор: вспышка появится и там, а жизнь, как раньше, убавится. Если сбить сразу несколько метеоров, взрывы идут одновременно и не мешают друг другу: у каждого свои часы.

Эксперимент

Поменяйте EXPLOSION_FRAME_TIME в sprites.py с 0.06 на 0.2 и запустите игру: взрыв стал вялым и растянулся больше чем на секунду. Теперь верните 0,06 — динамичный вариант лучше. Заметьте: скорость анимации вы поменяли одним числом, а ничего про FPS не трогали.

Проверьте себя

  • В папке assets/ десять PNG-файлов, среди них explosion_0.png … explosion_5.png.
  • На месте каждого сбитого лазером метеора вспыхивает и гаснет круг, а при столкновении с кораблём — тоже.
  • Нажмите F1: зелёная рамка у взрыва видна около трети секунды и затем исчезает — значит, kill() вызывается, спрайты не копятся.
  • Счёт и жизни считаются так же, как до этого урока: взрывы на них не влияют.
  • Вы можете объяснить, почему кадры взрыва загружаются в main.py один раз, а не в конструкторе Explosion.

Частые ошибки

IndexError: list index out of range

Что видно: ошибка в строке self.frames[index] или frames[6]. Почему: номер кадра дошёл до числа кадров. Для шести картинок допустимы номера 0–5, а шестой уже за пределами списка. Обычно забыли проверку index >= count или передали в frame_index неверное count. Как исправить: берите номер только из frame_index и передавайте len(self.frames).

Взрыв застыл на последнем кадре или спрайты копятся

Что видно: картинка вспышки остаётся на экране навсегда, а игра постепенно замедляется. Почему: в update нет проверки index is None и вызова kill(), либо вместо этого номер обрезают через min(index, 5); ещё вариант — в frame_index передали True вместо False. Как исправить: для разовой анимации loop=False, а на None отвечайте self.kill().

Взрыв удаляется, не успев показаться

Что видно: на экране ничего не вспыхивает, хотя ошибок нет. Почему: условие записано как if not index:: кадр с номером 0 тоже считается ложью. Как исправить: пишите if index is None:.

Взрыв то мелькает, то тянется

Что видно: на быстром компьютере вспышка почти незаметна, на медленном затягивается. Почему: кадр анимации менялся на каждом обороте цикла вместо того, чтобы считаться по времени elapsed. Как исправить: копите elapsed += dt и выбирайте кадр через frame_index.

Игра подтормаживает при серии взрывов

Что видно: в момент, когда взрывается несколько метеоров, игра на мгновение замирает. Почему: файлы читаются с диска в __init__ каждого Explosion: шесть чтений на каждый взрыв. Как исправить: загрузите кадры один раз через load_frames в main.py и передавайте список в Explosion(center, frames).

FileNotFoundError: ... explosion_0.png

Что видно: игра падает при старте на строке с load_frames. Почему: после обновления make_assets.py скрипт ещё не запускали, новых файлов в assets/ нет. Как исправить: выполните python make_assets.py и проверьте имена файлов.

Взрыв появляется в левом верхнем углу

Что видно: вспышки рисуются в точке (0, 0). Почему: в Explosion.__init__ написали self.image.get_rect() без center=center. Как исправить: используйте get_rect(center=center).

Что важно запомнить

  • Анимация — серия картинок (кадров анимации), которые быстро сменяют друг друга; время одного кадра — frame_time.
  • Кадр выбираем по прошедшему времени, а не по числу оборотов цикла: int(elapsed / frame_time). Так скорость не зависит от FPS.
  • frame_index(elapsed, frame_time, count, loop) в logic.py: при loop=True крутит по кругу через %, при False возвращает None, когда кадры кончились.
  • Номера кадров идут с нуля: для шести кадров последний — пятый. Проверять результат нужно словами is None, а не not index.
  • Цвет с четвёртым числом alpha задаёт прозрачность; на поверхности с pygame.SRCALPHA круг получает мягкое затухание.
  • Кадры загружаются один раз после set_mode (load_frames) и передаются каждому Explosion.
  • Взрыв — обычный спрайт: elapsed += dt, frame_index, а когда вернулся None — kill(). В all_sprites его добавляем, в meteors и lasers — нет.

Теперь «Комета» красиво выглядит, но по-прежнему немая: лазер стреляет молча, а метеоры взрываются беззвучно. В следующем уроке дадим игре голос — звуки выстрела, взрыва, подбора кристалла и фоновую музыку.

На следующий урок