Корабль у нас уже летает: его положение лежит в переменных 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 подставляет его сам, как первый параметр каждого метода.
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и записываем новую позицию обратно в тот же объект.
Теперь создадим объект и подвигаем его:
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__, аselfPython подставляет сам.m.update(0.5)— вызываем метод у объекта. Пишем один аргумент,dt = 0.5, аselfснова подставляется автоматически: это именноm.print(m.x, m.y)— читаем атрибуты через точку. Скорость 40 пикселей в секунду за полсекунды даёт 20 пикселей, поэтомуxстал 120.0;yне изменился, а число с точкой появилось потому, чтоmoveумножает на дробноеdt.
Много объектов и один цикл
Главная выгода классов вот в чём: сколько бы объектов вы ни создали, у каждого свои атрибуты, а действия одинаковые. Их удобно держать в списке и обновлять циклом — ровно так, как мы работали со звёздами.
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 складывается и умножается на число по компонентам, как в школьной математике.
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.
Так будут выглядеть настоящие метеоры в следующем уроке: позиция и скорость как два вектора.
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:
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), создайте список:
movers = [
Mover(100, 80, 60, 90),
Mover(480, 40, -30, 120),
Mover(860, 120, -80, 70),
]
А внутри цикла, сразу после цикла for star in stars:, который рисует звёзды, и перед вызовом draw_ship(...), вставьте обновление и рисование. Для короткой демонстрации мы смешали в одном цикле и движение, и рисование; в игре эти два дела разделены, как вы увидите в следующих уроках:
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 есть готовый класс для объекта игры с картинкой и прямоугольником — спрайт, и именно на него мы повесим наши метеоры.