$ sudo teach IT
Модуль 5 · Графика и звук · Урок 5.1

Картинки из файлов

Вы создадите PNG-картинки скриптом, загрузите их в игру и научитесь менять их размер и поворачивать.

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

Пока вся «Комета» нарисована прямо в программе: корабль, метеор и кристалл собираются из кругов и многоугольников в функциях вроде make_ship_image. Для первых прототипов это удобно, но настоящие игры берут картинки из файлов: художнику проще нарисовать корабль в редакторе, чем подбирать координаты точек в коде. В этом уроке мы научим игру читать картинки с диска, а сами картинки нарисуем отдельным скриптом.

Что такое ассеты и где их берут

Представьте театр. Пьеса — это сценарий, то есть код. А декорации, костюмы и реквизит лежат в кладовой и выходят на сцену, когда понадобятся. В играх кладовая называется просто: ассеты (от английского asset, «ресурс»). Это всё, что игра подгружает: картинки, звуки, музыка, шрифты. Код отвечает за то, что происходит, ассеты — за то, как это выглядит и звучит.

Где их брать? Есть три пути. Первый — нарисовать самому в графическом редакторе. Второй — взять готовые наборы: на специальных сайтах выкладывают картинки и звуки, и у каждого набора есть лицензия. Ищите наборы с пометкой CC0 (это значит «можно использовать свободно, даже в своих играх, без обязательств»), а у остальных читайте условия: иногда нужно указать автора. Третий путь — сделать ассеты кодом. Его мы и выберем: никаких скачиваний и лицензий, а заодно вы увидите, как картинка превращается в файл.

Формат для картинок возьмём PNG: он хранит прозрачность (альфа-канал), без которой корабль превратился бы в корабль на цветном прямоугольнике.

Скрипт, который рисует картинки

Идея простая, как печать фотографии: сначала рисуем на «холсте», потом отправляем холст на «печать» в файл. Холст у нас уже знаком — это поверхность Surface, а рисуем знакомыми pygame.draw. Новое только одно — сохранение. Официальное название функции pygame.image.save(surface, path): она записывает поверхность в файл, а формат выбирает по расширению в имени (.png — значит PNG).

Python · фрагмент
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 — объект «путь». С ним работают как с обычным значением, а вместо склейки строк используют оператор /.

Python · фрагмент
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() всё работает, но медленнее — на сотне спрайтов это заметно.

Python · фрагмент
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:

Python · фрагмент
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 делает ровно так: новая поверхность больше исходной, а пустые углы остаются прозрачными.

Python · фрагмент
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=...) создаёт прямоугольник нужного размера сразу с заданным центром.

Python · фрагмент
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. Он целиком новый, поэтому вот он полностью:

Python · kometa/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: они нужны кристаллу и дальше.

Затем замените импорты и константы в начале файла на эти:

Python · kometa/sprites.py (начало файла)
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 не трогаем:

Python · kometa/sprites.py (внутри класса Player)
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 не нужна:

Python · kometa/sprites.py (класс Laser)
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() — остаётся:

Python · kometa/sprites.py (внутри класса Crystal)
self.image = load_image("crystal.png")

Шаг 4. Метеор вращается

Класс Meteor меняем целиком:

Python · kometa/sprites.py (класс 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 на накопленный угол.

Картинки теперь настоящие, но стоят неподвижно, а взрыв пока вообще не нарисован. В следующем уроке картинки оживут: мы разберём анимацию по кадрам и покажем взрыв метеора.

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