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

Классы для игры

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

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

Корабль у нас уже летает: его положение лежит в переменных ship_x и ship_y, а двигает его функция step_ship (внутри неё работают уже знакомые move и clamp). С одним кораблём этого хватает. Но скоро сверху посыплются метеоры, и их будет десятки, а заводить для каждого свои переменные нереально. Нам не хватает способа собрать данные одного предмета и его поведение в одну аккуратную «коробку» — этим и займёмся.

Почему переменных стало тесно

Посмотрите, что нужно знать про один летящий метеор: где он (x и y) и как быстро движется (vx и vy). Это уже четыре переменных. Для двадцати метеоров получится восемьдесят, причём называть их придётся как-то вроде meteor_7_x. А если хранить четыре списка — отдельно все x, отдельно все y и так далее, — легко перепутать, чей это vy, и удалить метеор из одного списка, забыв про остальные.

Представьте кондитерскую. Рецепт печенья — один, а печенья на противне — много, и у каждого своя форма после выпечки, своё место на противне. Нам нужна такая же пара: один чертёж (какие данные есть у метеора и что он умеет) и сколько угодно экземпляров, сделанных по этому чертежу.

Именно так в Python и устроены классы. Класс (class) — описание того, как устроен предмет: какие у него данные и какие действия он умеет делать. Объект (его ещё называют экземпляром класса) — конкретный предмет, созданный по этому описанию. Класс один, объектов может быть сколько угодно.

Класс, объект, __init__ и self

Начнём с самого простого предмета игры — движущейся точки. Данные у неё те же, что мы только что перечисляли: позиция и скорость. А действие одно: сдвинуться за время кадра. Это ровно то, что делает функция move из модуля про движение, поэтому ей и воспользуемся.

Данные объекта называются атрибутами, а функции, которые живут внутри класса, — методами. Особый метод __init__ (читается «инит») запускается сам в тот момент, когда объект создают, и «собирает» его: записывает стартовые значения. Ещё одно слово, которое придётся принять на веру: self — это «тот самый объект, с которым работаем сейчас». Python подставляет его сам, как первый параметр каждого метода.

Python · kometa/logic.py
class Mover:
    def __init__(self, x, y, vx, vy):
        self.x = x
        self.y = y
        self.vx = vx
        self.vy = vy

    def update(self, dt):
        self.x, self.y = move(self.x, self.y, self.vx, self.vy, dt)

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

  • class Mover: — объявляет класс по имени Mover («двигатель»). Имена классов принято писать с заглавной буквы.
  • def __init__(self, x, y, vx, vy): — метод-сборщик. self — создаваемый объект, а x, y, vx, vy — то, что передадут при создании.
  • self.x = x — запись «в объект self положить атрибут x». Без self. число осталось бы обычной локальной переменной и пропало после выхода из метода. Остальные три строки делают то же самое для y, vx и vy.
  • def update(self, dt): — метод «обновиться за время кадра». Параметр dt нам знаком: это время кадра в секундах.
  • self.x, self.y = move(...) — берём позицию и скорость из самого объекта, передаём в уже знакомую функцию move и записываем новую позицию обратно в тот же объект.

Теперь создадим объект и подвигаем его:

Python · фрагмент
m = Mover(100, 50, 40, 0)
m.update(0.5)
print(m.x, m.y)
Вывод
120.0 50.0

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

  • m = Mover(100, 50, 40, 0) — скобки обязательны: так мы вызываем класс и получаем объект. Четыре числа попадают в x, y, vx, vy метода __init__, а self Python подставляет сам.
  • m.update(0.5) — вызываем метод у объекта. Пишем один аргумент, dt = 0.5, а self снова подставляется автоматически: это именно m.
  • print(m.x, m.y) — читаем атрибуты через точку. Скорость 40 пикселей в секунду за полсекунды даёт 20 пикселей, поэтому x стал 120.0; y не изменился, а число с точкой появилось потому, что move умножает на дробное dt.

Много объектов и один цикл

Главная выгода классов вот в чём: сколько бы объектов вы ни создали, у каждого свои атрибуты, а действия одинаковые. Их удобно держать в списке и обновлять циклом — ровно так, как мы работали со звёздами.

Python · фрагмент
movers = [Mover(0, 0, 10, 0), Mover(100, 100, 0, -20)]
for m in movers:
    m.update(0.5)
    print(m.position())

Метод position пока не написан — мы добавим его в практике, он просто возвращает пару (x, y). А вывод будет таким:

Вывод
(5.0, 0.0)
(100.0, 90.0)

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

  • movers = [Mover(...), Mover(...)] — список из двух независимых объектов: первый едет вправо, второй — вверх (у него отрицательная vy, а ось y смотрит вниз).
  • for m in movers: — на каждом витке m — сам объект из списка, а не его копия. Поэтому изменения, сделанные через m, остаются в объекте.
  • m.update(0.5) — один и тот же код двигает и первый объект, и второй, но каждый берёт свои x, y, vx, vy.
  • print(m.position()) — печатает пару координат объекта: сначала у первого, потом у второго.

Объекты не мешают друг другу

Движение первого объекта не меняет атрибуты второго: у каждого объекта свой набор данных. Завтра в игре у каждого метеора будет своя скорость, и это не потребует ни одной лишней переменной в основной программе.

Vector2: пара чисел с арифметикой

Записывать x и y порознь неудобно: каждое действие приходится дублировать. В pygame для этого есть готовый тип Vector2 (вектор из двух чисел). Его можно представить как стрелку на карте: «пройти 3 клетки вправо и 2 вниз». Пара обычных чисел в кортеже складывать умеет по-своему: (1, 2) + (3, 4) склеит их в (1, 2, 3, 4). А Vector2 складывается и умножается на число по компонентам, как в школьной математике.

Python · фрагмент
import pygame

pos = pygame.Vector2(100, 50)
vel = pygame.Vector2(40, 20)
pos += vel * 0.5
print(pos.x, pos.y)
Вывод
120.0 60.0

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

  • pygame.Vector2(100, 50) — создаёт вектор: x = 100, y = 50. Доступ к частям — через pos.x и pos.y.
  • vel * 0.5 — умножение на число меняет обе компоненты сразу: получится вектор (20, 10).
  • pos += ... — прибавляет вектор к вектору покомпонентно. Так формула «позиция плюс скорость на время кадра» превращается в одну строку: pos += vel * dt.

Так будут выглядеть настоящие метеоры в следующем уроке: позиция и скорость как два вектора.

Python · фрагмент
import pygame


class VectorMover:
    def __init__(self, x, y, vx, vy):
        self.pos = pygame.Vector2(x, y)
        self.vel = pygame.Vector2(vx, vy)

    def update(self, dt):
        self.pos += self.vel * dt

Почему Mover в logic.py не использует Vector2

Файл logic.py у нас остаётся чистым Python без pygame: так его можно проверять в песочнице без окон. Поэтому там Mover живёт на обычных числах, а вектор появится в файле, который рисует, — в sprites.py. И ещё одна оговорка: Rect хранит целые числа, а позиция в Vector2 дробная. Перед тем как поставить прямоугольник на место, позицию округляют: round(pos.x).

Где живут классы нашей игры

Класс сам по себе — просто способ организации кода, и у нас получится два семейства классов. Первое — логика: данные и правила без картинки, они лежат в logic.py, не знают про pygame и проверяются в задачах. Таким будет, например, таймер появления метеоров SpawnTimer. Второе — объекты на экране: у них есть картинка, прямоугольник и позиция, они живут в sprites.py. Для таких объектов в pygame уже есть готовый класс-заготовка — спрайт, с ним познакомимся на следующем уроке.

И честно про сегодняшний Mover: он учебный. В финальной игре метеоры будут спрайтами, а Mover там не понадобится. Зато вы научитесь на нём тому, что пригодится в каждом классе курса.

Практика: свой класс Mover

Добавим класс в проект и посмотрим на него в деле. Класс останется в logic.py как учебный пример (игра его вызывать не будет), а демонстрацию в main.py после проверки нужно стереть.

Шаг 1. Класс в logic.py

Откройте logic.py и допишите в самый конец файла, после функции wrap, класс. Он такой же, как выше, плюс метод position:

Python · kometa/logic.py
class Mover:
    def __init__(self, x, y, vx, vy):
        self.x = x
        self.y = y
        self.vx = vx
        self.vy = vy

    def update(self, dt):
        self.x, self.y = move(self.x, self.y, self.vx, self.vy, dt)

    def position(self):
        return (self.x, self.y)

Шаг 2. Проверка без окна

Так как logic.py не требует pygame, его можно проверить прямо в терминале из папки kometa (виртуальное окружение должно быть активно):

Терминал
python -c "from logic import Mover; m = Mover(0, 0, 10, 20); m.update(0.5); print(m.position())"
Вывод
(5.0, 10.0)

Шаг 3. Демонстрация в main.py

Теперь выведем три таких объекта на экран. В main.py допишите Mover в строку импорта: она станет from logic import Mover, direction_from_keys, ship_points, step_ship, wrap. Затем до игрового цикла, сразу после строки show_boxes = False (и перед running = True), создайте список:

Python · фрагмент (вставить до цикла)
movers = [
    Mover(100, 80, 60, 90),
    Mover(480, 40, -30, 120),
    Mover(860, 120, -80, 70),
]

А внутри цикла, сразу после цикла for star in stars:, который рисует звёзды, и перед вызовом draw_ship(...), вставьте обновление и рисование. Для короткой демонстрации мы смешали в одном цикле и движение, и рисование; в игре эти два дела разделены, как вы увидите в следующих уроках:

Python · фрагмент (вставить в цикл)
while running:
    # ... прежний код: события, движение корабля, звёзды, заливка фона и рисование звёзд ...
    for m in movers:
        m.update(dt)
        pygame.draw.circle(screen, FLAME_COLOR, (round(m.x), round(m.y)), 10)
    # ... прежний код: draw_ship и flip ...

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

  • Mover(100, 80, 60, 90) — первый объект: стартует слева сверху и едет вправо-вниз со скоростью 60 и 90 пикселей в секунду.
  • m.update(dt) — каждый кадр сдвигает объект на нужное расстояние. Тот же dt, что у корабля и звёзд.
  • (round(m.x), round(m.y)) — координаты круга: рисовать можно только в целых пикселях, а у объекта числа дробные, поэтому округляем.

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

Запустите python main.py. Три оранжевых круга плавно плывут вниз с разной скоростью и в разные стороны, а корабль по-прежнему слушается клавиш. Через несколько секунд круги уплывут за нижний край — это нормально, края мы пока не проверяем. Когда налюбуетесь, удалите из main.py список movers, цикл с ним и имя Mover из строки импорта (вернётся прежняя строка с direction_from_keys, ship_points, step_ship, wrap); сам класс Mover в logic.py оставьте.

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

  • Что произойдёт, если в main.py добавить четвёртый Mover в список? Нужно ли менять цикл?
  • Измените скорость второго объекта на (0, 0). Что вы увидите и почему?
  • Что выведет print(Mover(5, 7, 1, 1).position()) до вызова update?
  • Какая строка внутри update и какой атрибут отвечают за то, куда сдвинется объект?

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

Здесь четыре случая, которые встречаются у каждого, кто начинает с классов.

Забыли self

Вы пишете def update(dt): без self и получаете TypeError: метод «принимает 1 аргумент, а передано 2». Python всегда передаёт объект первым аргументом, поэтому в списке параметров каждого метода первым стоит self. Аналогично в __init__: строка x = x без self. ничего не сохраняет, и потом m.x падает с AttributeError.

Mover вместо Mover(...)

Если написать m = Mover без скобок, в m окажется сам класс, а не объект. Ошибка вылезет позже: m.x скажет, что у типа нет такого атрибута. Объект создают только вызовом со скобками и со всеми аргументами __init__.

Один список на всех

Допустим, вы записали в тело класса строку trail = [] (вне методов). Такая переменная принадлежит классу, и она одна на все объекты: добавили точку в след одному — она появилась у всех. Данные, которые должны быть у каждого объекта свои, создавайте в __init__ как self.trail = [].

Забыли вернуть позицию

Метод position без return молча возвращает None, и дальше код ломается на распаковке в x, y = m.position(). Если метод должен отдать значение, проверьте, что в нём есть return.

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

  • Класс — чертёж: какие у предмета данные (атрибуты) и что он умеет (методы). Объект — конкретный предмет по этому чертежу; объектов может быть сколько угодно.
  • __init__ запускается при создании объекта и записывает стартовые значения; self — сам объект, он первый параметр каждого метода.
  • Создают объект вызовом со скобками: Mover(100, 50, 40, 0); методы вызывают через точку: m.update(dt).
  • Много объектов хранят в списке и обновляют циклом for m in movers: m.update(dt).
  • pygame.Vector2 — пара чисел, которую можно складывать и умножать на число: pos += vel * dt. Прямоугольнику позицию отдают, округлив.
  • Логические классы без pygame живут в logic.py, а объекты с картинкой — в sprites.py.

Дальше: у pygame есть готовый класс для объекта игры с картинкой и прямоугольником — спрайт, и именно на него мы повесим наши метеоры.

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