Корабль у нас пока живёт в переменных ship_x и ship_y, а рисует его отдельная функция draw_ship. Для одного корабля это терпимо, но метеоров будет десятки, и заводить на каждый свои x, y, скорость и функцию рисования нельзя. Вы уже умеете описывать объект классом, а в Pygame есть готовая заготовка именно для объектов игры — спрайт. Сегодня переведём на неё корабль и метеор.
Спрайт: актёр со своим костюмом
Представьте театр. У каждого актёра есть костюм — как он выглядит, и место на сцене — где он стоит. Режиссёру не нужно помнить, кто во что одет. Он выходит и говорит: «Все, сделали шаг!», затем: «Все, встали на свои места для зрителей!» — и каждый актёр сам знает, что делать.
Спрайт в играх — это такой актёр. Он знает, как выглядит, где находится и что делает за один кадр. Официально в Pygame это класс pygame.sprite.Sprite. Его заготовка требует от вас всего двух вещей: у объекта должны быть атрибут image (поверхность, Surface — «костюм») и атрибут rect (прямоугольник Rect — «место на сцене»). Имена именно такие, ни буквой иначе: группа, о которой скажем ниже, ищет их по этим названиям.
Вот самый маленький спрайт — красный квадрат:
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.
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) — наш «шаг», который будет вызывать группа. Он делает ровно то, что раньше делал главный цикл: узнаёт направление, двигает корабль в границах, переносит прямоугольник.
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 и ещё сколько угодно аргументов.
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() вычеркнет его из обеих.
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 и вставьте в него весь код:
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, две группы и пять пробных метеоров. Файл целиком:
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 и по сигналу создаёт новые метеоры.