Пока вся «Комета» нарисована прямо в программе: корабль, метеор и кристалл собираются из кругов и многоугольников в функциях вроде make_ship_image. Для первых прототипов это удобно, но настоящие игры берут картинки из файлов: художнику проще нарисовать корабль в редакторе, чем подбирать координаты точек в коде. В этом уроке мы научим игру читать картинки с диска, а сами картинки нарисуем отдельным скриптом.
Что такое ассеты и где их берут
Представьте театр. Пьеса — это сценарий, то есть код. А декорации, костюмы и реквизит лежат в кладовой и выходят на сцену, когда понадобятся. В играх кладовая называется просто: ассеты (от английского asset, «ресурс»). Это всё, что игра подгружает: картинки, звуки, музыка, шрифты. Код отвечает за то, что происходит, ассеты — за то, как это выглядит и звучит.
Где их брать? Есть три пути. Первый — нарисовать самому в графическом редакторе. Второй — взять готовые наборы: на специальных сайтах выкладывают картинки и звуки, и у каждого набора есть лицензия. Ищите наборы с пометкой CC0 (это значит «можно использовать свободно, даже в своих играх, без обязательств»), а у остальных читайте условия: иногда нужно указать автора. Третий путь — сделать ассеты кодом. Его мы и выберем: никаких скачиваний и лицензий, а заодно вы увидите, как картинка превращается в файл.
Формат для картинок возьмём PNG: он хранит прозрачность (альфа-канал), без которой корабль превратился бы в корабль на цветном прямоугольнике.
Скрипт, который рисует картинки
Идея простая, как печать фотографии: сначала рисуем на «холсте», потом отправляем холст на «печать» в файл. Холст у нас уже знаком — это поверхность Surface, а рисуем знакомыми pygame.draw. Новое только одно — сохранение. Официальное название функции pygame.image.save(surface, path): она записывает поверхность в файл, а формат выбирает по расширению в имени (.png — значит PNG).
import pygame
picture = pygame.Surface((32, 32), pygame.SRCALPHA)
pygame.draw.circle(picture, (255, 200, 60), (16, 16), 14)
pygame.image.save(picture, "sun.png")
Разбор по строкам
pygame.Surface((32, 32), pygame.SRCALPHA)— пустой холст 32 на 32 пикселя; флагSRCALPHAделает его прозрачным, поэтому всё, что мы не закрасим, останется «пустым» в файле.pygame.draw.circle(...)— жёлтый круг по центру холста: поверхность, цвет, центр, радиус.pygame.image.save(picture, "sun.png")— записывает холст в файлsun.pngрядом с тем местом, откуда запущена программа.
Окно для этого не нужно: рисовать на отдельной поверхности и сохранять её можно, даже не вызывая set_mode. Поэтому наш скрипт make_assets.py — обычная программа, которая запускается один раз, создаёт папку assets/ с четырьмя файлами и завершается. Игра о нём потом не знает: она просто читает готовые PNG.
Почему ассеты делаем скриптом, а не вручную
Скрипт можно запустить заново после любой правки: поменяли цвет метеора в коде — запустили — получили новый файл. К тому же весь курс остаётся самодостаточным: вы не зависите от чужих файлов и их лицензий.
Путь к файлу: почему не просто «assets»
Допустим, в игре написано pygame.image.load("assets/ship.png"). Такой путь называется относительным: он отсчитывается не от файла программы, а от папки, в которой стоит терминал, когда вы запускаете Python. Запустили из kometa/ — файл найден. Запустили из папки выше командой python kometa/main.py — Python ищет assets/ не там и сдаётся. Это как адрес «второй дом налево»: куда налево, зависит от того, где вы стоите.
Надёжнее дать игре точный адрес: «рядом с моим собственным файлом». Для этого в стандартной библиотеке Python есть модуль pathlib, а в нём класс Path — объект «путь». С ним работают как с обычным значением, а вместо склейки строк используют оператор /.
from pathlib import Path
ASSETS = Path(__file__).parent / "assets"
print(ASSETS / "ship.png")
/Users/anna/kometa/assets/ship.png
Разбор по строкам
from pathlib import Path— берём из модуляpathlibклассPath.__file__— специальная переменная Python: полный путь к файлу, который сейчас выполняется (у нас этоsprites.py).Path(__file__).parent— обернули этот путь вPathи взяли.parent, то есть папку, в которой файл лежит:kometa/./ "assets"— оператор/у путей не делит, а дописывает следующую часть. Получилась папкаkometa/assets. ИмяASSETSзаглавными — наша договорённость для константы.ASSETS / "ship.png"— ещё один шаг вглубь: полный адрес картинки. В выводе у вас будет ваш собственный путь, у нас он только пример.
Бонус: / одинаково работает на Windows, macOS и Linux, а Path сам подставит нужные косые черты. Разницу между \ и / помнить не придётся.
Загрузка картинки: load и convert_alpha
Теперь обратная сторона: из файла в память. Здесь два шага, как при покупке книги: сначала принести её домой, потом поставить на свою полку. Первый шаг — pygame.image.load(path): читает файл с диска и возвращает новую поверхность. Второй — метод поверхности .convert_alpha(): он возвращает копию в таком же формате пикселей, как у окна, но с сохранением прозрачности. Зачем? Pygame рисует быстрее, когда картинка и экран «говорят на одном языке» пикселей. Без convert_alpha() всё работает, но медленнее — на сотне спрайтов это заметно.
screen = pygame.display.set_mode((960, 640))
ship = pygame.image.load(ASSETS / "ship.png").convert_alpha()
print(ship.get_size())
(48, 40)
Разбор по строкам
set_mode(...)— создаёт окно. Эта строка идёт первой не случайно, см. выноску ниже.pygame.image.load(ASSETS / "ship.png")— читает файл по полному пути; результат — обычная поверхность..convert_alpha()— приводит поверхность к формату окна, не теряя прозрачных мест.ship.get_size()— метод поверхности, возвращает пару «ширина, высота» в пикселях.
Ловушка: convert_alpha до set_mode
Формат окна известен только после set_mode. Если вызвать convert_alpha() раньше, Pygame скажет pygame.error: No video mode has been set. Поэтому нельзя загружать картинки на уровне модуля в sprites.py (import выполняется раньше set_mode). Мы будем загружать картинку внутри конструктора спрайта, а спрайты создаются в main.py уже после появления окна.
А если файла нет или имя написано с опечаткой? Pygame честно сообщит: FileNotFoundError (ошибка «файл не найден»), и в тексте ошибки будет путь, по которому он искал. Прочитайте его внимательно: в девяти случаях из десяти виноваты опечатка в имени или забытый запуск make_assets.py.
Чтобы не повторять две длинные строки в каждом спрайте, завернём их в функцию. Именно её будет вызывать весь sprites.py:
def load_image(name):
return pygame.image.load(ASSETS / name).convert_alpha()
Разбор по строкам
def load_image(name)— функция принимает только имя файла, например"meteor.png".return ...— склеивает путь, загружает файл, конвертирует и отдаёт готовую поверхность вызывающему.
Масштаб и поворот
Картинка редко сразу подходит по размеру. Метеоры у нас разные — от 20 до 48 пикселей, а рисовать для каждого свой файл глупо. Проще нарисовать один побольше и сжимать. Для этого есть pygame.transform.scale(surface, (width, height)): она не меняет исходную картинку, а возвращает новую поверхность заданного размера. Это важное правило всего модуля transform: функции возвращают копию, а оригинал остаётся как был, поэтому результат нужно куда-то присвоить.
Второй инструмент — pygame.transform.rotate(surface, angle). Угол в градусах, положительный угол вращает против часовой стрелки. Тут есть подвох, который удобно показать на коробке. Если повернуть квадратную коробку на 45 градусов, ей нужна «габаритная» рамка шире исходной: углы торчат наружу. Pygame делает ровно так: новая поверхность больше исходной, а пустые углы остаются прозрачными.
rock = load_image("meteor.png")
small = pygame.transform.scale(rock, (40, 40))
turned = pygame.transform.rotate(small, 45)
print(small.get_size(), turned.get_width() > 40)
(40, 40) True
Разбор по строкам
load_image("meteor.png")— наша функция; картинка 64 на 64.pygame.transform.scale(rock, (40, 40))— новая поверхность 40 на 40;rockостался прежним.pygame.transform.rotate(small, 45)— копия, повёрнутая на 45 градусов.print(...)— выводит размер сжатой картинки и ответ на вопрос «стала ли повёрнутая шире 40 пикселей?» Да: у квадрата, повёрнутого на 45 градусов, диагональ примерно в 1,41 раза длиннее стороны, и габаритная рамка растёт.
Rect после поворота: считаем от центра
У новой поверхности новый размер, значит, и новый rect. Если взять image.get_rect() как есть, он встанет в левый верхний угол (0, 0), и метеор «прыгнет» в угол экрана. А если сохранить старую левую верхнюю точку, метеор будет качаться при вращении, как лодка на волне. Нам нужно, чтобы центр оставался на месте. В Pygame это решается одним параметром: get_rect(center=...) создаёт прямоугольник нужного размера сразу с заданным центром.
old_center = self.rect.center
self.image = pygame.transform.rotate(self.original, self.angle)
self.rect = self.image.get_rect(center=old_center)
Разбор по строкам
old_center = self.rect.center— запоминаем точку, вокруг которой хотим вращать.pygame.transform.rotate(self.original, self.angle)— поворачиваем исходную картинку на текущий угол.get_rect(center=old_center)— новый прямоугольник под новый размер, но с прежним центром.
Крутим original, а не image
Последняя тонкость. Допустим, на каждом кадре метеор поворачивает уже повёрнутую картинку ещё на один градус. После каждого поворота края слегка размываются, а размер подрастает на пару пикселей из-за пустых углов. Через сотню кадров картинка превратится в мутное пятно. Выход — хранить чистый original и каждый кадр заново поворачивать его на накопленный угол angle. Копия получается свежей, ошибка не копится.
Совет: две картинки в спрайте
Если спрайт вращается, заведите пару original (никогда не меняется) и image (то, что рисуется сейчас). Всё, что связано с поворотом и масштабом, делайте из original.
Практика: картинки вместо фигур
Заменим нарисованные в коде фигуры настоящими файлами. План: создаём четыре PNG, учим sprites.py их загружать, а метеорам добавляем вращение. Работаем в проекте kometa/ при активированном виртуальном окружении.
Шаг 1. Скрипт make_assets.py
Создайте рядом с main.py новый файл make_assets.py. Он целиком новый, поэтому вот он полностью:
from pathlib import Path
import pygame
from logic import ship_points
ASSETS = Path(__file__).parent / "assets"
def make_ship():
image = pygame.Surface((48, 40), pygame.SRCALPHA)
pygame.draw.circle(image, (255, 170, 60), (24, 35), 4)
pygame.draw.circle(image, (255, 230, 140), (24, 35), 2)
points = ship_points(24, 22, 20)
pygame.draw.polygon(image, (90, 200, 255), points)
pygame.draw.polygon(image, (255, 255, 255), points, 2)
pygame.draw.line(image, (190, 235, 255), (24, 8), (24, 26), 2)
pygame.draw.circle(image, (20, 60, 110), (24, 20), 4)
return image
def make_meteor():
image = pygame.Surface((64, 64), pygame.SRCALPHA)
pygame.draw.circle(image, (120, 124, 140), (32, 32), 31)
for cx, cy, r in [(22, 24, 8), (42, 40, 10), (40, 18, 5)]:
pygame.draw.circle(image, (90, 94, 110), (cx, cy), r)
pygame.draw.circle(image, (70, 74, 90), (cx, cy), r, 2)
pygame.draw.circle(image, (70, 74, 90), (32, 32), 31, 3)
pygame.draw.circle(image, (175, 180, 198), (20, 16), 4)
return image
def make_crystal():
image = pygame.Surface((32, 40), pygame.SRCALPHA)
points = [(16, 2), (30, 20), (16, 38), (2, 20)]
pygame.draw.polygon(image, (60, 220, 170), points)
pygame.draw.polygon(image, (150, 255, 225), [(16, 2), (2, 20), (16, 20)])
pygame.draw.polygon(image, (30, 150, 120), [(16, 20), (30, 20), (16, 38)])
pygame.draw.polygon(image, (255, 255, 255), points, 2)
return image
def make_laser():
image = pygame.Surface((8, 28), pygame.SRCALPHA)
pygame.draw.rect(image, (255, 90, 120), (0, 0, 8, 28), border_radius=4)
pygame.draw.rect(image, (255, 230, 235), (3, 3, 2, 22), border_radius=1)
return image
def save(image, name):
pygame.image.save(image, ASSETS / name)
ASSETS.mkdir(exist_ok=True)
save(make_ship(), "ship.png")
save(make_meteor(), "meteor.png")
save(make_crystal(), "crystal.png")
save(make_laser(), "laser.png")
print("Картинки сохранены в", ASSETS)
Разбор по строкам
from logic import ship_points— берём готовую функцию изlogic.py: треугольник корабля рисуем теми же точками, что и раньше.ASSETS = Path(__file__).parent / "assets"— папка для картинок рядом со скриптом, где бы его ни запустили.make_ship()— холст 48 на 40, как у прежнего корабля. Сначала два круга пламени (оранжевый и светлее внутри), затем корпус, белая обводка (последний аргумент 2 — толщина линии), бликpygame.draw.line(поверхность, цвет, начало, конец, толщина)вдоль корпуса и тёмный круг кабины.make_meteor()— серый круг 64 на 64, три кратера и светлый блик. Циклfor cx, cy, r in [...]берёт из списка тройки «центр x, центр y, радиус» и рисует каждый кратер заливкой и тонким контуром; потом общий контур метеора. Кратеры стоят несимметрично, чтобы вращение было видно.make_crystal()— ромб из четырёх точек, на нём две «грани» разного оттенка и белая обводка.make_laser()— узкий прямоугольник. Параметрborder_radiusскругляет углы, внутри светлая «сердцевина» ((3, 3, 2, 22)— x, y, ширина, высота).save(image, name)— одна функция, чтобы четыре раза не писатьpygame.image.save.ASSETS.mkdir(exist_ok=True)— создаёт папкуassets/;exist_ok=Trueзначит «если уже есть, не ругайся».- Последние строки — создаём каждую картинку и сохраняем под своим именем. Таких файлов будет четыре.
Запустите скрипт один раз:
python make_assets.py
Картинки сохранены в /Users/anna/kometa/assets
Откройте папку assets/ в проводнике или в VS Code: там должны лежать ship.png, meteor.png, crystal.png и laser.png. Двойным щелчком их можно открыть и рассмотреть. (Путь в выводе у вас будет свой.)
Шаг 2. sprites.py: загрузка
В начале sprites.py сначала удалите лишнее. Функции make_ship_image, make_meteor_image и make_crystal_image и цвета фигур (WHITE, SHIP_COLOR, FLAME_COLOR, METEOR_COLOR, METEOR_EDGE, CRYSTAL_COLOR, а также LASER_COLOR и LASER_SIZE) больше не нужны. Размеры и скорости остаются, в том числе CRYSTAL_RADIUS и CRYSTAL_SPEED: они нужны кристаллу и дальше.
Затем замените импорты и константы в начале файла на эти:
import random
from pathlib import Path
import pygame
from logic import direction_from_keys, step_ship
ASSETS = Path(__file__).parent / "assets"
METEOR_MIN_SIZE = 20
METEOR_MAX_SIZE = 48
PLAYER_SPEED = 360
PLAYER_RADIUS = 16
CRYSTAL_RADIUS = 14
CRYSTAL_SPEED = 90
OFFSCREEN_MARGIN = 40
LASER_SPEED = 700
def load_image(name):
return pygame.image.load(ASSETS / name).convert_alpha()
Разбор по строкам
from pathlib import Path— новый импорт для пути.from logic import direction_from_keys, step_ship—ship_pointsубрали: корабль теперь не строится из точек, а вsprites.pyэту функцию больше никто не вызывает.ASSETSиload_image— то, что разбирали выше.CRYSTAL_RADIUSиCRYSTAL_SPEED— остались: картинка кристалла теперь из файла, но радиус для кругов столкновения и скорость падения по-прежнему нужны классуCrystal.
Если у вас иначе
Имена констант в вашем файле могут чуть отличаться от тех, что в этом листинге. Главное правило: удалить нужно то, что строило картинку из фигур, а размеры (METEOR_MIN_SIZE, PLAYER_SPEED и так далее) оставить.
Шаг 3. Корабль, лазер, кристалл
У Player меняется одна строка: вместо make_ship_image() загружаем файл. Остальной конструктор и метод update не трогаем:
class Player(pygame.sprite.Sprite):
def __init__(self, x, y, bounds):
super().__init__()
self.image = load_image("ship.png")
self.rect = self.image.get_rect()
# остальные строки конструктора и метод update остаются как были
Класс Laser целиком (он короткий): картинка из файла, размер берём у неё же через get_rect(), поэтому константа LASER_SIZE не нужна:
class Laser(pygame.sprite.Sprite):
def __init__(self, x, y):
super().__init__()
self.image = load_image("laser.png")
self.rect = self.image.get_rect()
self.rect.midbottom = (x, y)
self.pos = pygame.Vector2(self.rect.center)
self.vel = pygame.Vector2(0, -LASER_SPEED)
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 < -OFFSCREEN_MARGIN:
self.kill()
В Crystal найдите строку self.image = make_crystal_image(self.radius) и замените её одной строкой. Всё остальное — скорость падения, rect, kill() — остаётся:
self.image = load_image("crystal.png")
Шаг 4. Метеор вращается
Класс Meteor меняем целиком:
class Meteor(pygame.sprite.Sprite):
def __init__(self, width, height):
super().__init__()
self.size = random.randint(METEOR_MIN_SIZE, METEOR_MAX_SIZE)
self.radius = self.size // 2
self.original = pygame.transform.scale(load_image("meteor.png"), (self.size, self.size))
self.image = self.original
self.rect = self.image.get_rect()
self.height = height
x = random.randint(self.radius, width - self.radius)
self.pos = pygame.Vector2(x, -self.radius)
self.vel = pygame.Vector2(0, random.randint(150, 260))
self.angle = 0
self.spin = random.randint(-90, 90)
self.rect.center = (round(self.pos.x), round(self.pos.y))
def update(self, dt, *args):
self.pos += self.vel * dt
self.angle += self.spin * dt
self.image = pygame.transform.rotate(self.original, self.angle)
self.rect = self.image.get_rect(center=(round(self.pos.x), round(self.pos.y)))
if self.pos.y > self.height + OFFSCREEN_MARGIN:
self.kill()
Разбор по строкам
self.original = pygame.transform.scale(load_image("meteor.png"), (self.size, self.size))— загрузили большую картинку и сразу сжали до случайного размера метеора. Это «чистая» копия, она больше не изменится.self.image = self.original— пока метеор не повернулся, рисуем её же.self.angle = 0— текущий угол в градусах;self.spin = random.randint(-90, 90)— скорость вращения в градусах в секунду (минус — в другую сторону). Так же, как скорость движения, она не зависит от FPS.self.angle += self.spin * dt— наращиваем угол по формуле «скорость на время кадра».pygame.transform.rotate(self.original, self.angle)— каждый кадр поворачиваем original на накопленный угол.self.image.get_rect(center=(...))— новый прямоугольник под увеличившуюся картинку с центром вpos. Хитбокс-круг от центра и радиуса не изменился, так что столкновения работают как раньше.
Шаг 5. main.py и запуск
В main.py ничего переписывать не нужно. Проверьте только одно: строки player = Player(...) стоят после screen = pygame.display.set_mode(...) (так и было) — иначе конструктор не сможет вызвать convert_alpha(). Запускайте:
python main.py
Должно получиться: тот же геймплей, но корабль с кабиной и пламенем, серые метеоры с кратерами, которые кувыркаются при падении, зелёные гранёные кристаллы и розовые лазерные лучи со светлой серединой. Клавиша F1 по-прежнему показывает рамки и круги — рамка метеора заметно «дышит» во время поворота, а круг стоит на месте.
Эксперимент
Запустите игру из папки выше: python kometa/main.py. Благодаря ASSETS = Path(__file__).parent / "assets" всё продолжит работать. А теперь на минуту замените в load_image путь на обычное "assets/" + name и повторите запуск из той же папки: увидите FileNotFoundError. Верните, как было.
Проверьте себя
- Папка
assets/появилась рядом сmain.py, в ней четыре PNG-файла. - Метеоры вращаются, некоторые вправо, другие влево, а их центр летит по прямой вниз и не дёргается.
- Метеоры по-прежнему сбиваются лазером и сталкиваются с кораблём в тех же местах, что и раньше.
- Игра запускается и из папки
kometa/, и из папки выше. - Вы можете объяснить, почему поворачивается
self.original, а неself.image.
Частые ошибки
pygame.error: No video mode has been set
Что видно: ошибка сразу при запуске или при создании объекта. Почему: convert_alpha() вызвали раньше, чем set_mode, обычно из-за загрузки картинки на уровне модуля (SHIP = load_image(...) прямо в sprites.py). Как исправить: загружайте внутри конструктора или функции, которая вызывается после создания окна.
FileNotFoundError: No file ... found
Что видно: ошибка с путём к файлу. Почему: опечатка в имени, не запускали make_assets.py или забыли assets/. Путь в тексте ошибки подскажет, где Pygame искал. Как исправить: сверьте имя, запустите python make_assets.py ещё раз.
Метеор мутнеет, растёт и сдвигается при вращении
Что видно: с каждым кадром картинка всё грязнее и больше. Почему: в update повернули уже повёрнутую self.image. Как исправить: поворачивайте self.original на накопленный self.angle.
Метеор прыгает в левый верхний угол
Что видно: после первого поворота метеор мелькает в углу экрана. Почему: self.rect = self.image.get_rect() без center. Как исправить: get_rect(center=...).
Игра работает из одной папки и ломается из другой
Что видно: FileNotFoundError зависит от места запуска. Почему: в пути написано просто "assets/...", а это относительно папки терминала. Как исправить: строить путь от Path(__file__).parent.
Что важно запомнить
- Ассеты — картинки и звуки игры; код отдельно, ассеты отдельно. Наши ассеты создаёт скрипт
make_assets.py, он запускается один раз. pygame.image.save(surface, path)записывает поверхность в файл, формат выбирается по расширению; PNG хранит прозрачность.- Путь строим от файла:
ASSETS = Path(__file__).parent / "assets", оператор/дописывает части пути. pygame.image.load(path).convert_alpha()— загрузка послеset_mode; нет файла —FileNotFoundError.transform.scaleиtransform.rotateвозвращают новую поверхность, оригинал не меняется; повёрнутая картинка больше.- После поворота берите
get_rect(center=...), чтобы центр спрайта остался на месте. - Вращающемуся спрайту нужна пара
originalиimage: крутим всегдаoriginalна накопленный угол.
Картинки теперь настоящие, но стоят неподвижно, а взрыв пока вообще не нарисован. В следующем уроке картинки оживут: мы разберём анимацию по кадрам и покажем взрыв метеора.