Игра, которую можно отдать
Игра работает, у неё есть меню, звук и рекорд — но живёт она только у вас на компьютере. Чтобы друг её запустил, ему пришлось бы поставить 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(объект, "имя", значение_по_умолчанию): она возвращает атрибут, а если его нет — то, что вы указали третьим аргументом.
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, и проверить её можно, не собирая игру.
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. Поэтому нужна проверка:
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 функцию, которую мы разобрали выше. Больше в этом файле менять ничего не нужно.
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("Все проверки пройдены") вставьте новый блок:
# Сборка
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" на такой блок:
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". Замените её на этот блок:
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 требует асинхронного цикла.
Игра теперь живёт не только у вас. В последнем уроке окинем взглядом весь проект целиком, повторим пройденный путь и решим, куда идти дальше.