$ sudo teach IT
Модуль 3 · Спрайты · Урок 3.3

Спрайты и группы

Вы превратите корабль и метеор в спрайты, сложите их в группы и будете обновлять и рисовать всё одной командой.

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

Корабль у нас пока живёт в переменных ship_x и ship_y, а рисует его отдельная функция draw_ship. Для одного корабля это терпимо, но метеоров будет десятки, и заводить на каждый свои x, y, скорость и функцию рисования нельзя. Вы уже умеете описывать объект классом, а в Pygame есть готовая заготовка именно для объектов игры — спрайт. Сегодня переведём на неё корабль и метеор.

Спрайт: актёр со своим костюмом

Представьте театр. У каждого актёра есть костюм — как он выглядит, и место на сцене — где он стоит. Режиссёру не нужно помнить, кто во что одет. Он выходит и говорит: «Все, сделали шаг!», затем: «Все, встали на свои места для зрителей!» — и каждый актёр сам знает, что делать.

Спрайт в играх — это такой актёр. Он знает, как выглядит, где находится и что делает за один кадр. Официально в Pygame это класс pygame.sprite.Sprite. Его заготовка требует от вас всего двух вещей: у объекта должны быть атрибут image (поверхность, Surface — «костюм») и атрибут rect (прямоугольник Rect — «место на сцене»). Имена именно такие, ни буквой иначе: группа, о которой скажем ниже, ищет их по этим названиям.

Вот самый маленький спрайт — красный квадрат:

Python · фрагмент
class Block(pygame.sprite.Sprite):
    def __init__(self):
        super().__init__()
        self.image = pygame.Surface((40, 40))
        self.image.fill((200, 80, 80))
        self.rect = self.image.get_rect()

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

  • class Block(pygame.sprite.Sprite): — свой класс Block, построенный на готовой заготовке Pygame. Скобки с именем заготовки означают: «возьми всё, что умеет Sprite, и добавь своё». Этот приём называется наследованием.
  • super().__init__() — «сначала пусть заготовка выполнит свою подготовку». Без этой строки спрайт нельзя положить в группу.
  • self.image = pygame.Surface((40, 40)) — чистая поверхность 40 на 40 пикселей: наш «костюм». Размер передаётся парой чисел в скобках.
  • self.image.fill((200, 80, 80)) — заливаем костюм красным, как раньше заливали screen.
  • self.rect = self.image.get_rect() — метод get_rect() возвращает Rect того же размера, что и картинка. Пока он стоит в левом верхнем углу, дальше мы будем его переставлять.

Строка super().__init__() обязательна

Если её забыть, класс создастся без ошибок, а упадёт программа позже — в тот момент, когда вы добавите спрайт в группу, с сообщением AttributeError про отсутствующий атрибут. Поэтому строка стоит в начале каждого нашего спрайта.

Картинка из фигур

Файлов с картинками у нас нет и не будет до модуля про ассеты, поэтому костюмы мы нарисуем сами, теми же командами pygame.draw, что и раньше. Разница в одном: рисуем не на окне, а на отдельной маленькой поверхности.

Аналогия — прозрачная калька. Вы рисуете на кальке корабль, а потом кладёте её на нужное место стола. Фон кальки прозрачный, поэтому вокруг корабля не будет прямоугольной рамки. Официально такая поверхность создаётся с флагом pygame.SRCALPHA: он включает прозрачность для каждого пикселя.

Важно, какие координаты использовать. У такой поверхности свои координаты: точка (0, 0) — её левый верхний угол, а не угол окна. Поэтому корабль мы рисуем вокруг центра самой картинки, а где он окажется в окне, решит rect.

Python · фрагмент
def make_ship_image():
    image = pygame.Surface((48, 40), pygame.SRCALPHA)
    pygame.draw.circle(image, FLAME_COLOR, (24, 35), 4)
    points = ship_points(24, 22, 20)
    pygame.draw.polygon(image, SHIP_COLOR, points)
    pygame.draw.polygon(image, WHITE, points, 2)
    return image


def make_meteor_image(radius):
    image = pygame.Surface((radius * 2, radius * 2), pygame.SRCALPHA)
    pygame.draw.circle(image, METEOR_COLOR, (radius, radius), radius)
    pygame.draw.circle(image, METEOR_EDGE, (radius, radius), radius, 3)
    return image

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

  • pygame.Surface((48, 40), pygame.SRCALPHA) — прозрачная заготовка 48 на 40 пикселей. Это размер нашего корабля как объекта.
  • pygame.draw.circle(image, FLAME_COLOR, (24, 35), 4) — пламя. Первый аргумент теперь image, а не screen: рисуем на кальке.
  • points = ship_points(24, 22, 20) — та самая функция из logic.py: три вершины треугольника вокруг точки (24, 22) с размером 20. Эта точка почти в центре заготовки, поэтому корабль целиком помещается внутрь.
  • polygon(image, SHIP_COLOR, points) и polygon(image, WHITE, points, 2) — корпус и белая обводка толщиной 2, как в draw_ship.
  • make_meteor_image(radius) — поверхность radius * 2 на radius * 2, внутри серый круг с центром (radius, radius) и тёмная обводка потолще. Радиус параметром, потому что позже метеоры будут разных размеров.
  • return image — функция отдаёт готовый костюм, а класс повесит его на спрайт.

Корабль стал чуть меньше

Раньше размер корабля был 24, теперь на картинке 48 на 40 он нарисован с размером 20. Так он целиком помещается в свою «кальку» вместе с пламенем. Цвета остались прежними, но объявляются теперь в sprites.py: оттуда нельзя импортировать константы из main.py (иначе файлы зациклятся друг на друге).

Класс Player: хозяин корабля

Теперь соберём игрока. Мы уже знаем, что Rect хранит целые числа, а скорость корабля 360 пикселей в секунду при dt около 0,016 даёт шаг 5,76 пикселя. Если хранить позицию только в rect, дробная часть будет теряться. Поэтому у спрайта две позиции, как у мастера с чертежом и готовой деталью: точная pos (pygame.Vector2, пара дробных чисел) и грубая rect для рисования. Каждый кадр мы считаем pos и копируем её в rect.center, округляя.

Чтобы класс не зависел от main.py, всё нужное ему отдаём через конструктор: Player(x, y, bounds), где bounds — та же четвёрка (min_x, min_y, max_x, max_y), которую принимает step_ship из logic.py.

Метод update(dt, keys) — наш «шаг», который будет вызывать группа. Он делает ровно то, что раньше делал главный цикл: узнаёт направление, двигает корабль в границах, переносит прямоугольник.

Python · фрагмент
class Player(pygame.sprite.Sprite):
    def __init__(self, x, y, bounds):
        super().__init__()
        self.image = make_ship_image()
        self.rect = self.image.get_rect()
        self.pos = pygame.Vector2(x, y)
        self.bounds = bounds
        self.rect.center = (round(self.pos.x), round(self.pos.y))

    def update(self, dt, keys):
        left = keys[pygame.K_LEFT] or keys[pygame.K_a]
        right = keys[pygame.K_RIGHT] or keys[pygame.K_d]
        up = keys[pygame.K_UP] or keys[pygame.K_w]
        down = keys[pygame.K_DOWN] or keys[pygame.K_s]
        dx, dy = direction_from_keys(left, right, up, down)
        x, y = step_ship(self.pos.x, self.pos.y, dx, dy, PLAYER_SPEED, dt, self.bounds)
        self.pos.x, self.pos.y = x, y
        self.rect.center = (round(self.pos.x), round(self.pos.y))

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

  • self.image = make_ship_image() — костюм берём у функции из прошлого раздела.
  • self.pos = pygame.Vector2(x, y) — точная позиция центра корабля; self.bounds — запоминаем границы полёта.
  • self.rect.center = (round(self.pos.x), round(self.pos.y)) — ставим прямоугольник так, чтобы его центр совпал с позицией. round нужен, потому что rect хранит целые числа.
  • def update(self, dt, keys) — метод, который вызовет группа. dt — время кадра, keys — состояние клавиш, полученное в главном цикле.
  • left = keys[pygame.K_LEFT] or keys[pygame.K_a] и три следующие строки — те же четыре «нажата ли сторона», что были в цикле: стрелка или WASD.
  • direction_from_keys(...) даёт направление длиной не больше 1, step_ship(...) двигает и держит в границах. Обе функции лежат в logic.py и там проверены без окна.
  • self.pos.x, self.pos.y = x, y — записываем новую позицию, и последняя строка переносит её в rect.

Как клавиши попадают в update

Откуда Player узнаёт про клавиши? Вариантов три. Можно вызвать pygame.key.get_pressed() прямо внутри метода — коротко, но класс начнёт зависеть от клавиатуры, и его неудобно проверять. Можно вызывать player.update(dt, keys) отдельно от остальных — работает, но двух разных способов обновления в цикле хотелось бы избежать. Мы выберем третий: главный цикл один раз за кадр получает keys и отдаёт их группе, а та передаёт всем спрайтам. Это самый простой вариант, а у класса появляется понятная «вход-выходная» запись: на входе время и клавиши, на выходе новая позиция.

Класс Meteor: падающий камень

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

Для этого у спрайта есть метод kill(). Аналогия: актёр уходит со сцены насовсем и вычёркивается из всех списков труппы. Официально: kill() удаляет спрайт из всех групп, в которых он состоял. После этого его не обновляют и не рисуют, а когда на него не останется ссылок, Python освободит память.

Есть деталь с сигнатурой. Мы собираемся вызывать all_sprites.update(dt, keys), то есть обновлять игрока и метеоры одним вызовом с одинаковыми аргументами. Значит, update у метеора тоже обязан принять keys, хотя ему они не нужны. Решение — записать update(self, dt, *args): звёздочка перед именем означает «забери все остальные аргументы в кучу args». Метеор получит клавиши, положит в args и никогда не заглянет в эту кучу. Правило на будущее: любой спрайт из общей группы принимает dt и ещё сколько угодно аргументов.

Python · фрагмент
class Meteor(pygame.sprite.Sprite):
    def __init__(self, width, height):
        super().__init__()
        self.image = make_meteor_image(METEOR_RADIUS)
        self.rect = self.image.get_rect()
        self.height = height
        x = random.randint(METEOR_RADIUS, width - METEOR_RADIUS)
        self.pos = pygame.Vector2(x, -METEOR_RADIUS)
        self.vel = pygame.Vector2(0, random.randint(150, 260))
        self.rect.center = (round(self.pos.x), round(self.pos.y))

    def update(self, dt, *args):
        self.pos += self.vel * dt
        self.rect.center = (round(self.pos.x), round(self.pos.y))
        if self.pos.y > self.height + OFFSCREEN_MARGIN:
            self.kill()

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

  • Meteor(width, height) — размер экрана передаём в конструктор, потому что sprites.py не знает про WIDTH и HEIGHT из main.py. Высота нужна позже, чтобы понять, что метеор улетел.
  • x = random.randint(METEOR_RADIUS, width - METEOR_RADIUS) — случайный x так, чтобы метеор целиком помещался по ширине.
  • self.pos = pygame.Vector2(x, -METEOR_RADIUS) — он стартует чуть выше экрана, так что вылетает сверху плавно, а не появляется внезапно.
  • self.vel = pygame.Vector2(0, random.randint(150, 260)) — скорость в пикселях в секунду: по x ноль, по y случайное число от 150 до 260. У каждого метеора своя, поэтому они не падают строем.
  • self.pos += self.vel * dt — позиция плюс скорость на время кадра. С Vector2 одна строка заменяет две.
  • *args — принимаем и игнорируем любые лишние аргументы, например keys.
  • if self.pos.y > self.height + OFFSCREEN_MARGIN: self.kill() — когда центр метеора ниже края экрана на запас 40 пикселей, спрайт удаляется из всех групп.

Группы: весь отряд одной командой

Теперь о «режиссёре». Группа — это контейнер для спрайтов, который умеет делать со всеми ими одно и то же. Официально — pygame.sprite.Group. Создаётся пустой, дальше в неё складывают спрайты.

Что нужноКак записываетсяЧто происходит
Создать группуmeteors = pygame.sprite.Group()Пустой контейнер.
Добавить спрайтmeteors.add(meteor)Спрайт попадает в группу; можно добавить и в несколько групп.
Обновить всехmeteors.update(dt, keys)У каждого спрайта вызывается его update с теми же аргументами.
Нарисовать всехmeteors.draw(screen)Для каждого спрайта берётся image и кладётся на screen по rect.
Посчитатьlen(meteors)Сколько спрайтов сейчас в группе.
Перебратьfor m in meteors:Обычный цикл по спрайтам группы.
Удалитьmeteor.kill()Спрайт пропадает из всех своих групп сразу.

Зачем две группы? all_sprites нужна, чтобы обновлять и рисовать всех сразу: игрока и метеоры. meteors — только метеоры: по ней мы будем считать их количество и проверять столкновения (в следующих уроках). Один метеор лежит в обеих группах, и это нормально: kill() вычеркнет его из обеих.

Python · фрагмент
all_sprites = pygame.sprite.Group()
meteors = pygame.sprite.Group()

meteor = Meteor(960, 640)
all_sprites.add(meteor)
meteors.add(meteor)
print(len(meteors))

meteor.kill()
print(len(meteors), len(all_sprites))

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

  • Две пустые группы: общая и только для метеоров.
  • meteor = Meteor(960, 640) — создаём метеор для экрана 960 на 640. В группах его пока нет.
  • all_sprites.add(meteor) и meteors.add(meteor) — кладём один и тот же объект в обе группы. Копии не создаются.
  • print(len(meteors)) выведет 1.
  • meteor.kill() — вычёркиваем из обеих групп сразу. Следующий print выведет 0 0.

Можно удалять прямо во время update

Когда вы вызываете group.update(...), Pygame перебирает копию списка спрайтов, поэтому метеор, который вызвал self.kill() внутри своего update, не ломает цикл. Именно это мы и используем.

Практика: корабль и метеоры становятся спрайтами

Переделаем проект «Комета». Файл logic.py трогать не нужно: нам понадобятся direction_from_keys, ship_points и step_ship из предыдущих уроков.

Шаг 1. Создайте sprites.py

В папке kometa/ рядом с main.py создайте файл sprites.py и вставьте в него весь код:

Python · kometa/sprites.py
import random

import pygame

from logic import direction_from_keys, ship_points, step_ship

WHITE = (255, 255, 255)
SHIP_COLOR = (90, 200, 255)
FLAME_COLOR = (255, 170, 60)
METEOR_COLOR = (120, 124, 140)
METEOR_EDGE = (70, 74, 90)
METEOR_RADIUS = 20
PLAYER_SPEED = 360
OFFSCREEN_MARGIN = 40


def make_ship_image():
    image = pygame.Surface((48, 40), pygame.SRCALPHA)
    pygame.draw.circle(image, FLAME_COLOR, (24, 35), 4)
    points = ship_points(24, 22, 20)
    pygame.draw.polygon(image, SHIP_COLOR, points)
    pygame.draw.polygon(image, WHITE, points, 2)
    return image


def make_meteor_image(radius):
    image = pygame.Surface((radius * 2, radius * 2), pygame.SRCALPHA)
    pygame.draw.circle(image, METEOR_COLOR, (radius, radius), radius)
    pygame.draw.circle(image, METEOR_EDGE, (radius, radius), radius, 3)
    return image


class Player(pygame.sprite.Sprite):
    def __init__(self, x, y, bounds):
        super().__init__()
        self.image = make_ship_image()
        self.rect = self.image.get_rect()
        self.pos = pygame.Vector2(x, y)
        self.bounds = bounds
        self.rect.center = (round(self.pos.x), round(self.pos.y))

    def update(self, dt, keys):
        left = keys[pygame.K_LEFT] or keys[pygame.K_a]
        right = keys[pygame.K_RIGHT] or keys[pygame.K_d]
        up = keys[pygame.K_UP] or keys[pygame.K_w]
        down = keys[pygame.K_DOWN] or keys[pygame.K_s]
        dx, dy = direction_from_keys(left, right, up, down)
        x, y = step_ship(self.pos.x, self.pos.y, dx, dy, PLAYER_SPEED, dt, self.bounds)
        self.pos.x, self.pos.y = x, y
        self.rect.center = (round(self.pos.x), round(self.pos.y))


class Meteor(pygame.sprite.Sprite):
    def __init__(self, width, height):
        super().__init__()
        self.image = make_meteor_image(METEOR_RADIUS)
        self.rect = self.image.get_rect()
        self.height = height
        x = random.randint(METEOR_RADIUS, width - METEOR_RADIUS)
        self.pos = pygame.Vector2(x, -METEOR_RADIUS)
        self.vel = pygame.Vector2(0, random.randint(150, 260))
        self.rect.center = (round(self.pos.x), round(self.pos.y))

    def update(self, dt, *args):
        self.pos += self.vel * dt
        self.rect.center = (round(self.pos.x), round(self.pos.y))
        if self.pos.y > self.height + OFFSCREEN_MARGIN:
            self.kill()

Все функции и классы вам уже знакомы по разделам выше. Новое только в верхней части: константы цветов и скорости. Они переехали в этот файл вместе с кораблём, поскольку ему ничего нельзя брать из main.py. Заметьте также OFFSCREEN_MARGIN = 40 — запас, после которого метеор удаляется.

Шаг 2. Перепишите main.py

Теперь main.py. Из него уходят ship_x, ship_y, функция draw_ship, а вместе с ними константы SHIP_COLOR, FLAME_COLOR, SHIP_SIZE и SHIP_SPEED, а также коробка ship_rect с её строками из урока 3.1 (рамки теперь рисуются для спрайтов) и демонстрация Mover из урока 3.2, если вы её не стёрли. Движение корабля теперь внутри Player.update. Из logic.py главному файлу нужен только wrap для звёзд, поэтому строка импорта сокращается. Появляются импорт Meteor, Player из sprites.py, константа SHIP_BOUNDS, спрайт player, две группы и пять пробных метеоров. Файл целиком:

Python · kometa/main.py
import random

import pygame

from logic import wrap
from sprites import Meteor, Player

WIDTH = 960
HEIGHT = 640
FPS = 60
TITLE = "Комета"
BG_COLOR = (12, 14, 32)
WHITE = (255, 255, 255)
BOX_COLOR = (80, 255, 120)
SHIP_HALF = 24
SHIP_MIN_Y = HEIGHT // 2
SHIP_MAX_Y = HEIGHT - 40
SHIP_BOUNDS = (SHIP_HALF, SHIP_MIN_Y, WIDTH - SHIP_HALF, SHIP_MAX_Y)
STAR_SPEED = 40

pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption(TITLE)
clock = pygame.time.Clock()

stars = []
for _ in range(120):
    stars.append([random.randint(0, WIDTH), random.randint(0, HEIGHT), random.randint(1, 3)])

player = Player(WIDTH // 2, HEIGHT - 80, SHIP_BOUNDS)
all_sprites = pygame.sprite.Group()
meteors = pygame.sprite.Group()
all_sprites.add(player)
for _ in range(5):
    meteor = Meteor(WIDTH, HEIGHT)
    all_sprites.add(meteor)
    meteors.add(meteor)

show_boxes = False
running = True
while running:
    dt = clock.tick(FPS) / 1000

    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        elif event.type == pygame.KEYDOWN:
            if event.key == pygame.K_ESCAPE:
                running = False
            elif event.key == pygame.K_F1:
                show_boxes = not show_boxes

    keys = pygame.key.get_pressed()
    all_sprites.update(dt, keys)
    for star in stars:
        star[1] = wrap(star[1] + STAR_SPEED * star[2] * dt, 0, HEIGHT)

    screen.fill(BG_COLOR)
    for x, y, size in stars:
        pygame.draw.circle(screen, WHITE, (x, y), size)
    all_sprites.draw(screen)
    if show_boxes:
        for sprite in all_sprites:
            pygame.draw.rect(screen, BOX_COLOR, sprite.rect, 2)
    pygame.display.set_caption(TITLE + " | метеоров: " + str(len(meteors)))
    pygame.display.flip()

pygame.quit()

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

  • from sprites import Meteor, Player — берём классы из нового файла.
  • SHIP_BOUNDS = (SHIP_HALF, SHIP_MIN_Y, WIDTH - SHIP_HALF, SHIP_MAX_Y) — границы полёта: слева и справа по краю корабля, по вертикали — нижняя половина экрана.
  • player = Player(WIDTH // 2, HEIGHT - 80, SHIP_BOUNDS) — корабль внизу по центру, как раньше.
  • all_sprites и meteors — две группы; игрок кладётся только в первую.
  • Цикл for _ in range(5) создаёт пять метеоров для пробы и кладёт каждый в обе группы. Пока они появляются только в начале игры, а таймер появится в следующем уроке.
  • show_boxes и F1 — включают и выключают зелёные рамки спрайтов. Теперь их рисует цикл for sprite in all_sprites, и ship_rect больше не нужен.
  • all_sprites.update(dt, keys) — одна строка двигает игрока и метеоры. all_sprites.draw(screen) — одна строка рисует всех.
  • set_caption(... + str(len(meteors))) — число метеоров в заголовке окна: так видно, как работает kill().

Звёзды теперь рисуются чуть короче

В уроке 2.4 звёзды рисовались через star[0], star[1], star[2]. В листинге выше та же работа записана распаковкой тройки: for x, y, size in stars, как в первом модуле. Результат одинаков, а список stars из троек [x, y, size] и пересчёт star[1] через wrap не изменились.

Шаг 3. Запустите

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

Что должно получиться:

  • Корабль внизу по центру, управляется стрелками и WASD, как раньше, и не вылетает за границы.
  • Сверху падают пять серых метеоров с разной скоростью и из разных мест; в заголовке окна написано «Комета | метеоров: 5», число уменьшается до нуля, когда метеоры улетают.
  • Звёзды плывут вниз; Esc закрывает окно.
  • F1 показывает зелёные рамки вокруг корабля и метеоров, повторное нажатие их убирает.

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

  • Метеоры падают, а в заголовке число уменьшается: значит, kill() работает.
  • Если закомментировать строку meteors.add(meteor), метеоры всё равно падают (они в all_sprites), но число в заголовке будет 0. Почему?
  • Увеличьте range(5) до range(30) и посмотрите, как выглядит поток. Верните обратно.
  • Поменяйте в Meteor диапазон скорости на random.randint(50, 90). Что изменилось? Верните.
  • Нажмите F1: рамка корабля должна совпадать с его картинкой.

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

AttributeError при добавлении в группу

Что видно: программа падает на строке all_sprites.add(...) с ошибкой AttributeError. Почему: в конструкторе забыли super().__init__(), и заготовка Sprite не подготовила свои внутренние данные. Как исправить: первой строкой __init__ напишите super().__init__().

TypeError: update() takes 2 positional arguments but 3 were given

Что видно: при запуске all_sprites.update(dt, keys) программа падает с TypeError. Почему: у метеора сигнатура update(self, dt), а группа передаёт всем спрайтам и dt, и keys. Как исправить: записать update(self, dt, *args).

Метеоры висят на месте или корабль не двигается

Что видно: спрайты нарисованы, но стоят там, где появились. Почему: позиция pos меняется, а rect не обновляется — группа рисует по rect. Как исправить: в конце update всегда пишите self.rect.center = (round(self.pos.x), round(self.pos.y)).

Спрайт не нарисован вообще

Что видно: окно открылось, звёзды есть, корабля или метеоров нет. Почему: спрайт не добавлен в группу, которую вы рисуете, или вы забыли строку all_sprites.draw(screen), или отрисовка стоит до screen.fill(...) и затирается заливкой. Как исправить: проверьте all_sprites.add(...) и порядок: заливка, звёзды, спрайты, flip.

Импорт из main.py внутри sprites.py

Что видно: ImportError при запуске. Почему: main.py импортирует sprites.py, а тот пытается импортировать main.py — файлы ждут друг друга. Как исправить: нужные числа передавайте в конструктор (Meteor(WIDTH, HEIGHT), Player(x, y, bounds)).

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

  • Спрайт — класс на основе pygame.sprite.Sprite с обязательными image (Surface) и rect (Rect).
  • В __init__ спрайта первой строкой стоит super().__init__().
  • Картинку из фигур рисуем на pygame.Surface(размер, pygame.SRCALPHA); координаты на ней свои, от её левого верхнего угла.
  • Точную позицию держим в pos (Vector2), а rect.center каждый кадр выставляем по округлённому pos.
  • Group.update(...) передаёт свои аргументы каждому спрайту, поэтому у всех спрайтов группы совместимые сигнатуры: update(self, dt, *args).
  • Group.draw(screen) рисует все спрайты по их image и rect, len(group) считает, sprite.kill() убирает спрайт из всех групп.
  • sprites.py не импортирует main.py: размеры экрана и границы приходят в конструктор.

Метеоры пока появляются только в начале игры и скоро кончаются. В следующем уроке научим их падать непрерывным дождём: заведём таймер, который копит dt и по сигналу создаёт новые метеоры.

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