$ sudo teach IT
Модуль 7 · Финал · Урок 7.3

Сборка игры для друзей

Вы соберёте игру в один файл для друзей и научите её находить картинки, звуки и рекорд внутри сборки.

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

Игра, которую можно отдать

Игра работает, у неё есть меню, звук и рекорд — но живёт она только у вас на компьютере. Чтобы друг её запустил, ему пришлось бы поставить Python, создать окружение и выполнить pip install pygame-ce. Никто не станет этого делать ради пяти минут игры. Сейчас упакуем всё в один файл, который запускается двойным щелчком, и заодно научим игру находить свои картинки и звуки внутри такой упаковки.

Зачем нужна сборка

Представьте, что вы хотите угостить друга своим фирменным блюдом. Можно прислать ему рецепт и список продуктов — тогда он должен сам купить, нарезать и приготовить. А можно привезти готовое блюдо в контейнере: открыл и ешь. Исходники игры — это рецепт с продуктами, а исполняемый файл — готовое блюдо. Внутри него лежат ваш код, нужные модули, сам интерпретатор Python и все файлы, которые вы добавите.

Процесс упаковки называют сборкой (по-английски bundle), а результат — собранным приложением. Для этого есть программа PyInstaller: она смотрит, какие модули импортирует ваш main.py, и складывает всё нужное в одно место. В курсе мы используем PyInstaller 6.22 (в официальной документации это последняя версия, 6.22.3).

Собирать нужно на той системе, для которой делаете

Результат работы PyInstaller привязан к операционной системе и к версии Python, на которых вы его запустили. Собрали на Windows — получили .exe для Windows. Для macOS сборку нужно запускать на Mac, для Linux — на Linux. Документация прямо говорит: устанавливайте PyInstaller и собирайте приложение отдельно на каждой платформе. Подготовить сборку для другой системы на своём ноутбуке нельзя.

Первая сборка: PyInstaller

Простыми словами: вы даёте PyInstaller главный файл игры и просите положить в контейнер ещё и папку с картинками и звуками. Он делает контейнер. Официально это команда терминала, которую запускают в папке проекта при включённом виртуальном окружении:

Терминал
pip install pyinstaller
pyinstaller --onefile --windowed --name Kometa --add-data "assets:assets" main.py

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

  • pip install pyinstaller — ставит сам инструмент сборки в ваше виртуальное окружение (рядом с pygame-ce).
  • --onefile — собрать один файл. Без этого флага PyInstaller делает папку, в которой лежит исполняемый файл и всё его окружение (это режим по умолчанию).
  • --windowed — не показывать чёрное окно консоли за игрой (на Windows и macOS). На macOS этот флаг ещё и создаёт приложение .app.
  • --name Kometa — имя результата: получится Kometa.exe вместо main.exe. Без флага имя берётся от главного скрипта.
  • --add-data "assets:assets" — добавить в сборку данные: слева откуда (папка assets проекта), справа куда (папка assets внутри сборки), между ними двоеточие.
  • main.py — главный скрипт, с которого начинается игра.

Дальше PyInstaller работает сам и создаёт рядом с проектом три вещи. Папка build/ — черновики, её можно удалять. Папка dist/ — готовый результат, именно его вы отдаёте друзьям. Файл Kometa.spec — запись всех ваших настроек: в следующий раз можно выполнить pyinstaller Kometa.spec и не набирать флаги заново.

Про разделитель в --add-data

В документации PyInstaller формат записан как source:dest_dir — через двоеточие, и начиная с версии 6.0 эта запись принимается на всех системах, включая Windows. Старая запись с точкой с запятой assets;assets встречается в старых статьях, но в курсе мы везде пишем двоеточие. Пути пишите относительные, без буквы диска: так двоеточие внутри пути не запутает команду.

Честно о минусах режима onefile

Режим --onefile удобен, но при каждом запуске распаковывает себя во временную папку, поэтому стартует чуть медленнее, чем сборка-папка. Кроме того, неподписанные программы, особенно в одном файле, нередко вызывают подозрения у антивирусов, у Windows SmartScreen и у macOS: друг может увидеть предупреждение о неизвестном разработчике. Это не значит, что в игре вирус — вы просто не покупали сертификат разработчика. Предупредите друга заранее. Для macOS документация называет сочетание onefile и windowed не рекомендуемым: если отдаёте игру владельцу Mac, уберите --onefile и отправьте результат целиком.

Игра теряет свои файлы

Что пошло не так

Запустите собранный файл на компьютере, где нет вашей папки проекта — и игра, скорее всего, упадёт сразу. Причина в том, как мы искали картинки: ASSETS = Path(__file__).parent / "assets" — «папка assets рядом с этим файлом кода». Пока код лежит в проекте, это правда. А в режиме onefile приложение при запуске распаковывает себя во временную папку с названием вроде _MEI123456, работает оттуда, а при закрытии удаляет её. Это как переезд: вещи те же, а адрес новый, и каждый раз другой.

Две подсказки от PyInstaller

Чтобы программа поняла, что она «переехала», загрузчик PyInstaller (bootloader — маленькая программа, которая запускает вашу игру) оставляет в модуле sys две записи. Модуль sys встроен в Python и хранит сведения о самой запущенной программе; подключается строкой import sys.

  • sys.frozen — «заморожено». Есть только в собранной программе и равно True. При обычном запуске python main.py такого атрибута нет вовсе.
  • sys._MEIPASS — путь к папке, где лежит распакованное содержимое сборки. В режиме onefile это та самая временная папка; в режиме папки — служебная папка внутри сборки. При обычном запуске атрибута тоже нет.

Раз атрибута может не быть, обращаться к нему напрямую опасно: получите ошибку AttributeError. Для этого есть встроенная функция getattr(объект, "имя", значение_по_умолчанию): она возвращает атрибут, а если его нет — то, что вы указали третьим аргументом.

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

frozen = getattr(sys, "frozen", False)
bundle_dir = getattr(sys, "_MEIPASS", "")
print(frozen, repr(bundle_dir))

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

  • import sys — подключаем модуль со сведениями о запущенной программе.
  • getattr(sys, "frozen", False) — спрашиваем: есть ли у sys атрибут frozen? Нет — вернётся False.
  • getattr(sys, "_MEIPASS", "") — то же для пути: нет атрибута — вернётся пустая строка.
  • print(frozen, repr(bundle_dir)) — repr показывает строку в кавычках, чтобы пустая строка была заметна.

При обычном запуске через python фрагмент напечатает:

Вывод
False ''

Теперь у нас есть два сигнала. Осталось один раз решить, какую папку считать «домом игры», и вынести это решение в отдельную функцию. Помните главный приём курса — логика отдельно от отрисовки? Решение «где искать файлы» тоже чистая логика: никакого pygame, только значения на входе и ответ на выходе. Значит, её место в logic.py, и проверить её можно, не собирая игру.

Python · kometa/logic.py
def resource_dir(frozen, bundle_dir, script_dir):
    if frozen and bundle_dir:
        return str(bundle_dir)
    return str(script_dir)

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

  • def resource_dir(frozen, bundle_dir, script_dir) — три входа: «игра собрана?», «папка распаковки», «папка, где лежит скрипт».
  • if frozen and bundle_dir — сборка и при этом путь не пустой: пустая строка в условии считается False, это защита от неполных данных.
  • return str(bundle_dir) — работаем из папки распаковки.
  • return str(script_dir) — во всех остальных случаях — папка рядом со скриптом, как раньше. str() превращает путь Path в обычную строку, чтобы функция всегда возвращала один тип.

Совет: а почему не просто __file__

Документация PyInstaller тоже допускает путь от __file__: загрузчик подставляет туда корректный адрес внутри сборки. Мы выбираем явную функцию ради прозрачности: из resource_dir видно, откуда берётся папка, и её можно проверить обычным тестом.

Куда писать рекорд

Ассеты мы только читаем, поэтому им подходит временная папка. А вот рекорд мы записываем и хотим увидеть завтра. Если сохранить record.json во временную папку, он исчезнет вместе с ней при закрытии игры — как записка на двери номера, из которого вы уже выехали.

Файл с данными игрока нужно держать там, где он переживёт запуск: рядом с исполняемым файлом. Путь к запущенному файлу хранит sys.executable. При обычном запуске это путь к Python (не к вашей игре!), а в собранной игре — путь к вашему Kometa.exe. Поэтому нужна проверка:

Python · фрагмент
import sys
from pathlib import Path

if getattr(sys, "frozen", False):
    data_dir = Path(sys.executable).parent
else:
    data_dir = Path(__file__).parent
RECORD_PATH = data_dir / "record.json"

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

  • Path(sys.executable).parent — папка, в которой лежит запущенный файл: в сборке это папка с игрой.
  • data_dir = Path(__file__).parent — без сборки рекорд лежит рядом с main.py, как мы делали в уроке про файл рекорда.
  • RECORD_PATH = data_dir / "record.json" — оператор / у Path соединяет папку и имя файла.

Где лежит игра, там и рекорд

Если игру положить в папку, куда обычному пользователю писать нельзя (например, Program Files на Windows), сохранение рекорда не удастся. Просите друзей запускать игру из своей папки, например с рабочего стола. На macOS приложение .app — это папка, и файл окажется внутри неё. Для учебного проекта это допустимо; в больших играх для таких данных используют специальные папки пользователя.

А если хочется в браузере

Игру можно отдать и без скачивания: есть инструмент pygbag, который упаковывает pygame-проект для запуска прямо на веб-странице. Но у него есть условие: игровой цикл нужно переписать в асинхронный (async) вид, потому что браузер не терпит программу, которая бесконечным while занимает вкладку. Есть и другие ограничения, в том числе для файлов и звука. Мы эту дорогу не проходим, а лишь знаем, что она существует: когда захотите, найдёте инструкцию в документации самого pygbag.

Практика: собираем «Комету»

У нас в проекте settings.py, logic.py, sprites.py, main.py, check_logic.py и папка assets/. Если папки assets/ нет, сначала выполните python make_assets.py. Работаем в папке kometa/ при включённом виртуальном окружении.

Шаг 1. resource_dir в logic.py

Допишите в самый конец logic.py функцию, которую мы разобрали выше. Больше в этом файле менять ничего не нужно.

Python · kometa/logic.py (в конец файла)
def resource_dir(frozen, bundle_dir, script_dir):
    if frozen and bundle_dir:
        return str(bundle_dir)
    return str(script_dir)

Шаг 2. Проверки в check_logic.py

В список импорта from logic import (...) добавьте имя resource_dir, а перед последней строкой print("Все проверки пройдены") вставьте новый блок:

Python · kometa/check_logic.py (перед финальным print)
# Сборка
assert resource_dir(True, "/tmp/_MEI1", "/game") == "/tmp/_MEI1"
assert resource_dir(False, "", "/game") == "/game"
assert resource_dir(True, "", "/game") == "/game"
assert resource_dir(False, "/tmp/_MEI1", "/game") == "/game"

Шаг 3. sprites.py ищет ассеты через resource_dir

В начале файла добавьте строку import sys, а в строку from logic import direction_from_keys, frame_index, step_ship — имя resource_dir, чтобы получилось from logic import direction_from_keys, frame_index, resource_dir, step_ship. Затем замените строку ASSETS = Path(__file__).parent / "assets" на такой блок:

Python · kometa/sprites.py (вместо строки ASSETS)
BASE_DIR = resource_dir(
    getattr(sys, "frozen", False),
    getattr(sys, "_MEIPASS", ""),
    Path(__file__).parent,
)
ASSETS = Path(BASE_DIR) / "assets"

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

  • BASE_DIR = resource_dir(...) — три аргумента для функции: признак сборки, папка распаковки и папка со скриптом. Результат — строка с «домом» игры.
  • getattr(sys, "frozen", False) — признак «игра собрана»: при запуске python main.py это False.
  • getattr(sys, "_MEIPASS", "") — папка распаковки или пустая строка, если игра запущена как обычный скрипт.
  • Path(__file__).parent — папка со скриптом: путь для обычного запуска.
  • ASSETS = Path(BASE_DIR) / "assets" — функция вернула строку, поэтому оборачиваем её в Path и добавляем папку assets. Все load_image, load_sound и музыка работают через ASSETS, так что больше ничего менять не нужно.

Блок должен стоять после импортов

Строка import sys и новое имя в from logic import нужны до того, как мы вызовем resource_dir. Не оставляйте блок BASE_DIR над импортами: Python читает файл сверху вниз.

Шаг 4. main.py пишет рекорд рядом с игрой

В main.py добавьте import sys к первым строкам и найдите строку RECORD_PATH = Path(__file__).parent / "record.json". Замените её на этот блок:

Python · kometa/main.py (на месте RECORD_PATH)
if getattr(sys, "frozen", False):
    data_dir = Path(sys.executable).parent
else:
    data_dir = Path(__file__).parent
RECORD_PATH = data_dir / "record.json"

Остальное (load_record, save_record, record) остаётся без изменений: они получают путь параметром и не знают, откуда он взялся.

Шаг 5. Проверяем без сборки

Сначала убедитесь, что порядок не сломан: проверки и обычный запуск должны работать как раньше.

Терминал
python check_logic.py
python main.py
Вывод
Все проверки пройдены

Шаг 6. Собираем

Установите PyInstaller и выполните команду из раздела выше. Команда одна строка, на Windows, macOS и Linux она одинаковая:

Терминал
pip install pyinstaller
pyinstaller --onefile --windowed --name Kometa --add-data "assets:assets" main.py

Сборка занимает от нескольких десятков секунд до пары минут. В конце появятся папки build/ и dist/ и файл Kometa.spec. Готовая игра лежит в dist/: Kometa.exe на Windows, файл Kometa на Linux, на macOS — Kometa и приложение Kometa.app.

Шаг 7. Запускаем как друг

Скопируйте готовый файл из dist/ в другую папку, например на рабочий стол, и запустите его двойным щелчком. Он не должен знать, где лежит ваш проект. Проведите партию, побейте рекорд и закройте игру. Рядом с файлом появится record.json; при следующем запуске рекорд будет на месте.

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

  • python check_logic.py по-прежнему печатает «Все проверки пройдены», а четыре новые проверки resource_dir в нём есть.
  • Игра из dist/ запускается без окна консоли и показывает картинки, играет звуки и музыку.
  • Копия файла, лежащая вне папки проекта, тоже работает: значит, ассеты берутся из сборки.
  • Рядом с запущенным файлом появился record.json и после перезапуска рекорд сохранился.
  • Обычный запуск python main.py по-прежнему работает, а его record.json лежит рядом с main.py.

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

Собрали без --add-data

  • Что видно: собранная игра закрывается сразу после запуска либо падает с FileNotFoundError на первой картинке или звуке.
  • Почему так: PyInstaller видит только импортируемые модули, а папку assets вы читаете как файлы сами, поэтому она не попала в сборку.
  • Как исправить: добавьте --add-data "assets:assets" и пересоберите. Чтобы увидеть текст ошибки, соберите один раз без --windowed: тогда рядом с игрой откроется консоль с сообщением.

Собрали на macOS для Windows

  • Что видно: друг получил файл без расширения или с ошибкой «это не приложение Windows».
  • Почему так: результат PyInstaller привязан к системе, на которой его собрали.
  • Как исправить: собирайте на той системе, для которой отдаёте игру: для Windows — на Windows.

Антивирус или SmartScreen ругается на файл

  • Что видно: Windows пишет «неопознанное приложение», антивирус отправляет файл в карантин.
  • Почему так: неподписанные программы в одном файле выглядят для защиты подозрительно, хотя вредного кода в них нет. Так бывает у многих самодельных сборок.
  • Как исправить: расскажите другу заранее, что это нормально, и отдавайте файл напрямую. Нажимайте «Подробнее», затем «Выполнить в любом случае» только для игры, которой доверяете. Режим сборки-папки (без --onefile) срабатывает на антивирусах реже.

Рекорд не сохраняется

  • Что видно: после закрытия игры рекорд обнуляется или файл не появляется.
  • Почему так: путь записи указывает во временную папку, которая удаляется, либо игра лежит в папке, куда обычному пользователю писать нельзя.
  • Как исправить: стройте путь от sys.executable, как в data_dir, и запускайте игру из обычной папки пользователя.

Поправили код, а у собранной игры всё по-старому

  • Что видно: в python main.py изменение видно, а в Kometa.exe нет.
  • Почему так: файл в dist/ собран из старой версии кода и сам не обновляется.
  • Как исправить: после любых правок собирайте заново той же командой; если сборка ведёт себя странно, удалите папки build/ и dist/ и повторите.

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

  • Сборка превращает проект в готовое приложение, которому не нужны ни Python, ни pygame: pyinstaller --onefile --windowed --name Kometa --add-data "assets:assets" main.py.
  • Собирать нужно на той системе, для которой предназначена игра: результат привязан к ОС и версии Python.
  • Папка dist/ — результат, build/ — черновик, .spec — запись настроек для повторной сборки.
  • Всё, что игра читает файлами (картинки, звуки), нужно явно добавить флагом --add-data; разделитель в версии 6.22 — двоеточие.
  • В собранной игре sys.frozen равно True, а sys._MEIPASS ведёт в папку распаковки; getattr(sys, "имя", значение) безопасно читает эти атрибуты.
  • resource_dir(frozen, bundle_dir, script_dir) выбирает папку для ассетов и проверяется без сборки.
  • Рекорд кладём рядом с исполняемым файлом (Path(sys.executable).parent), а не во временную папку.
  • Режим onefile стартует медленнее и чаще вызывает подозрения у антивирусов; веб-версия через pygbag требует асинхронного цикла.

Игра теперь живёт не только у вас. В последнем уроке окинем взглядом весь проект целиком, повторим пройденный путь и решим, куда идти дальше.

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