Читать книгу: «MINI DOOM. Современный Python через игру Том 2. Окно в ад»
Вместо предисловия
Это второй том книги «MINI DOOM. Современный Python через игру». В первом томе ты прошёл основы Python в консоли: переменные, ветвления, функции, списки и словари, датаклассы, классы и ссылки: собрал консольный рогалик на объектах и поймал три бага, спрятанных в ссылках.
Теперь из консоли выходим в окно: пиксели, игровой цикл, управление и текст на экране. Номера глав, листингов и рисунков в серии сквозные, поэтому ссылка «см. главу 12» ведёт туда же, куда и в однотомнике.
Серия целиком
— Том 1. Учимся держать оружие — главы 1–8: основы Python в консоли, от первой программы до ссылок и ресурсов.
— Том 2. Окно в ад — главы 9–13: окно pygame-ce, пиксели на numpy, игровой цикл, клавиатура и мышь, свой шрифт.
— Том 3. Третье измерение — главы 14–19: рейкастинг, стены, текстуры, пол и свет, профилировщик, двери.
— Том 4. Здесь кто-то есть — главы 20–25: спрайты, монстры, оружие, конечные автоматы, поиск пути, смерть.
— Том 5. Шум, свет и финальные титры — главы 26–29: звук, экраны, уровни в файлах, uv, ruff, mypy и pytest.
Все исходники серии — на GitHub: https://github.com/RushSex/MiniDoom-Python. Папка examples/chNN для каждой главы, готовая игра — папка minidoom. Там же README: uv sync, uv run main.py — и вперёд. Каждая глава начинается с проекта предыдущей, так что можно начать читать этот том, взяв готовый код последней главы прошлого тома.
Часть II. Окно в ад
Консоль хороша для учёбы, но игра в Doom начинается с окна. В этой части мы выберемся из терминала: откроем своё окно, научимся красить в нём каждую точку, запустим настоящий игровой цикл, который крутится шестьдесят раз в секунду, подключим клавиатуру и мышь и выведем на экран текст своим шрифтом. Помогать нам будут две библиотеки: pygame-ce открывает окно и слушает клавиатуру, а numpy быстро считает картинку.
Глава 9. Своё окно
Окно — это не просто прямоугольник на экране. Это объект, который живёт своей жизнью: его двигают, сворачивают, закрывают, перекрывают другими окнами. Операционная система обо всём этом сообщает программе, и программа должна вовремя отвечать, иначе система решит, что она зависла, и напишет в заголовке «Не отвечает». В этой главе мы откроем первое окно и разберёмся, как устроен разговор между окном и программой.
Ставим pygame-ce и numpy
Стандартная библиотека Python умеет многое, но окна с графикой для игр в неё не входят. Для этого есть сторонние библиотеки, и самая известная из них — pygame. Мы возьмём её современную ветку pygame-ce (Community Edition): её активно развивает сообщество, она быстрее и поставляется с подсказками типов для mypy. Под капотом pygame-ce работает библиотека SDL — та самая, на которой сделаны сотни коммерческих игр.
Сторонние библиотеки называют зависимостями проекта и записывают в pyproject.toml. Листинг 9.1 — файл проекта для второй части книги.

Листинг 9.1 — Зависимости проекта: pygame-ce и numpy
Ставить зависимости руками не нужно: в папке проекта выполни uv sync, и uv сам скачает нужные версии в отдельное виртуальное окружение — папку .venv внутри проекта. А можно и одной командой: uv add pygame-ce numpy допишет их в pyproject.toml и тут же поставит. Запускать программу после этого — uv run main.py: uv сам возьмёт Python и библиотеки из окружения проекта. Проверим, что всё на месте, листинг 9.2.

Листинг 9.2 — Проверяем версии pygame-ce, SDL и numpy
Первую строку вывода напечатал сам pygame при импорте — он любит представляться. Остальное — наша программа.

Как окно разговаривает с программой
Представь окошко выдачи в ресторане. Система — кухня, которая непрерывно подсовывает в окошко записки: «по окну щёлкнули мышью», «нажали клавишу», «окно надо перерисовать», «пользователь нажал крестик». Каждая записка называется событием (event). События складываются в очередь твоей программы, и программа должна по одной вынимать их и обрабатывать.
Вынимает записки цикл событий — обычный while, который крутится, пока программа работает. На каждом витке программа забирает все накопившиеся события, отвечает на них, рисует картинку и показывает её. Схема — на рисунке 9.1.

Рисунок 9.1 — Цикл событий: система кладёт записки в очередь, программа забирает их, отвечает и рисует кадр

Первое окно
Вот программа целиком — листинг 9.3. Она открывает окно 1280 × 800 с заголовком MINI DOOM и заливает его тёмно-красным.

Листинг 9.3 — Минимальная программа с окном

Рисунок 9.2 — Первое окно MINI DOOM — пока только тёмно-красный прямоугольник
Строка 7. pygame.init() готовит к работе все части библиотеки: видео, звук, клавиатуру. Вызывается один раз в начале.
Строка 8. set_mode открывает окно заданного размера и возвращает поверхность (Surface) — картинку, на которой мы будем рисовать. Всё, что окажется на этой поверхности, после flip появится в окне.
Строки 13–16 — цикл событий. pygame.event.get() отдаёт все события, накопившиеся с прошлого витка, списком. Нас пока интересует одно — QUIT: пользователь нажал крестик окна или Alt+F4. Тогда снимаем флаг running, и цикл заканчивается.
Строки 17–18 — рисование. fill заливает поверхность цветом. Цвет — кортеж из трёх чисел от 0 до 255: красный, зелёный, синий. flip показывает нарисованное в окне. Почему flip — «перевернуть»? Рисуем мы на скрытом холсте, а показываем готовый: как художник, который пишет картину лицом к себе и разворачивает её к зрителям, только когда закончил. Так зритель никогда не видит недорисованный кадр.
Строка 20. pygame.quit() закрывает окно и освобождает всё, что захватил init.

Шпион событий
Какие ещё записки приходят в очередь? Напишем шпиона, который печатает всё подряд, — листинг 9.4. Робот подождал три кадра, нажал и отпустил w, три кадра двигал мышь, щёлкнул левой кнопкой и закрыл окно.

Листинг 9.4 — Шпион: печатаем каждое событие
У каждого события есть тип — event.type, и у разных типов разные поля. У нажатия клавиши KEYDOWN есть key — код клавиши, который pygame.key.name превращает в понятное имя. У движения мыши MOUSEMOTION есть rel — на сколько пикселей сдвинулась мышь по x и по y. У щелчка — номер кнопки: 1 — левая, 3 — правая.
Обрати внимание на кадры. Нажатие и отпускание w пришли в одном кадре — робот жмёт быстро. Мышь присылала событие на каждом кадре, пока двигалась. Важный вывод: за один виток цикла может прийти сколько угодно событий — ноль, одно или десяток. Поэтому мы разбираем очередь циклом for, а не берём одно событие.
Функция describe разбирает событие через match, и здесь образцы — константы с точкой: pygame.KEYDOWN, pygame.MOUSEMOTION. Помнишь главу 3? Имя с точкой — сравнение, а не ловушка.
Окно, которое не закрыть
Что будет, если забыть обработать QUIT? Листинг 9.5: программа реагирует на клавиши, а крестик игнорирует.

Листинг 9.5 — Цикл без обработки QUIT: крестик не работает
Робот нажал клавишу — программа ответила. Робот нажал крестик — ничего не произошло: событие QUIT пришло в очередь, его вынули и выбросили, потому что никто его не проверял. Цикл while True крутится вечно. Пользователю останется только снимать задачу через диспетчер задач. Ни Ruff, ни mypy такую ошибку не найдут: с их точки зрения вечный цикл — законный приём.

Выход по Esc
В играх окно закрывают не только крестиком, но и клавишей Esc. Добавим вторую проверку, листинг 9.6.

Листинг 9.6 — Выход по Esc
Коды клавиш — константы pygame.K_ESCAPE, pygame.K_w, pygame.K_SPACE и так далее. Условие в строке 12 проверяет сразу две вещи: что событие — нажатие клавиши и что клавиша — Esc. Порядок важен: у события QUIT нет поля key, и если бы мы сначала проверяли event.key, программа упала бы с AttributeError. Ленивый and из главы 3 нас спасает: если первая часть ложна, до event.key дело не дойдёт.
Координаты на экране
Последнее, что надо знать про окно, — как в нём устроены координаты. Начало отсчёта (0, 0) — левый верхний угол. x растёт вправо, а y — вниз, не вверх, как в школьной математике. Правый нижний угол окна 1280 × 800 — точка (1279, 799). Схема — на рисунке 9.3.

Рисунок 9.3 — Экранные координаты: (0, 0) в левом верхнем углу, y растёт вниз
Почему y вниз? По традиции: экраны когда-то рисовались лучом сверху вниз, строка за строкой, как текст на странице. Наша карта уровня в главе 5 была устроена так же: строка 0 — сверху. Так что для нас это уже не новость.
Итоги главы
— pygame-ce открывает окна и слушает ввод, numpy считает картинки; зависимости записываем в pyproject.toml и ставим через uv.
— Система присылает события в очередь, программа забирает их в цикле событий.
— pygame.event.get() отдаёт все накопившиеся события; их может быть сколько угодно за кадр.
— QUIT — крестик окна; без его обработки окно не закрыть.
— Рисуем на поверхности, показываем через display.flip().
— Координаты: (0, 0) — левый верхний угол, y растёт вниз.
Контрольная точка: у тебя есть окно, которое открывается, рисует фон и закрывается по крестику и по Esc. В главе 10 мы научимся красить в нём каждую точку отдельно — и узнаем, почему для этого понадобится numpy.
Упражнения
Упражнение 9.1. Что выведет? Робот запустил программу из листинга 9.7, подождал 10 кадров, нажал и отпустил a, потом 4 кадра держал d и закрыл окно. Сколько кадров и нажатий насчитает программа? Подсказка: удержание клавиши — это одно событие KEYDOWN в начале, а не по событию на каждый кадр.

Листинг 9.7 — Упражнение 9.1
Упражнение 9.2. Найди баг. Программа из листинга 9.5 не закрывается крестиком. Исправь её, не трогая обработку клавиш.
Упражнение 9.3. Напиши сам. Пусть заголовок окна показывает номер текущего кадра и имя последней нажатой клавиши: MINI DOOM | frame 9 | last key: w. Подсказка: pygame.display.set_caption можно вызывать хоть на каждом кадре.
Упражнение 9.4. Подумай. Почему pygame.event.get() отдаёт события списком, а не по одному при каждом вызове? Что было бы с игрой, если бы мы брали одно событие за кадр?
Задачи
Задача 9.1. Светофор. Пусть клавиши 1, 2 и 3 перекрашивают окно в красный, жёлтый и зелёный. Цвет храни в переменной и рисуй его на каждом кадре.
Задача 9.2. Счётчик мыши. Посчитай, сколько пикселей мышь проехала по горизонтали и по вертикали за всё время работы, и выведи итог при закрытии окна.
Задача 9.3. Полноэкранный режим. Найди в документации pygame-ce, какой флаг делает окно полноэкранным, и переключай режим по F11. Что будет с координатами?
Глава 10. Пиксели
Наше окно залито одним цветом. Это удобно, но для игры бесполезно: pygame умеет рисовать прямоугольники и круги, а нам нужны огонь, текстуры и рожа импа. Поэтому мы поступим, как настоящие игры девяностых: заведём в памяти свой кусок экрана, будем красить в нём каждую точку сами, а pygame попросим только показать готовую картинку.
В этой главе разберёмся, как устроен цвет, как картинка лежит в памяти и почему считать её циклами на Python — плохая идея. А заодно познакомимся с главным инструментом второй половины книги — библиотекой numpy.
Пиксель и цвет
Экран — это сетка крошечных лампочек, пикселей. Каждый пиксель светится смесью трёх цветов: красного, зелёного и синего — отсюда сокращение RGB. Яркость каждого канала — число от 0 до 255, ровно один байт. Все три по нулям — чёрный, все по 255 — белый, красный 255 при нулевых остальных — чистый красный. Всего так можно смешать 256³, то есть почти 17 миллионов цветов.
Три байта удобно хранить в одном целом числе: старший байт — красный, дальше зелёный и синий. В шестнадцатеричной записи такое число выглядит как 0xRRGGBB — каждые две цифры ровно байт, поэтому цвет читается прямо глазами. Так цвета пишут в HTML, в редакторах картинок и в старых играх. Как собрать такое число из трёх каналов и разобрать обратно, показывает листинг 10.1.

Листинг 10.1 — Упаковка и распаковка цвета сдвигами и масками
Строка 3. Оператор << сдвигает биты числа влево: r << 16 — это r, умноженное на 65536, то есть красный уехал на два байта выше. Оператор | — побитовое «или» — складывает три канала в одно число, не смешивая их, потому что каждый занимает свои биты.
Строка 7 разбирает обратно. Сдвиг >> возвращает нужный байт в самый низ, а & 0xFF — побитовое «и» с маской из восьми единиц — оставляет только этот младший байт, отрезая всё, что выше. Схема — на рисунке 10.1.

Рисунок 10.1 — Цвет в одном 24-битном числе: по байту на канал
Формат f"{fire:#08x}» печатает число в шестнадцатеричном виде с префиксом 0x, а:024b — в двоичном, ровно 24 цифры. Видно, что двоичная запись — это три байта подряд.

Картинка в памяти: массивы numpy
Картинка 640 × 400 — это 256 000 пикселей по три байта. Хранить её списком списков кортежей можно, но медленно и расточительно: каждый кортеж в Python — отдельный объект со своими накладными расходами. Для таких данных есть numpy — библиотека плотных числовых массивов. Массив numpy хранит числа одного типа сплошным куском памяти, как массив в C, и умеет выполнять операции сразу над всеми элементами. Первое знакомство — листинг 10.2.

Листинг 10.2 — Массив numpy как маленькая картинка 6 × 4
Строка 3. np.zeros создаёт массив, заполненный нулями. Форма (4, 6, 3) — четыре строки, шесть столбцов и три канала в каждой ячейке. Тип uint8 — целое без знака, ровно один байт, от 0 до 255: как раз для канала цвета. Весь массив занимает 72 байта — ни одного лишнего.
Строка 6. frame[1, 2] — пиксель в строке 1 и столбце 2. Порядок тот же, что у карты в главе 5: сначала y, потом x. Индексы пишут через запятую в одних скобках.
Строка 7 — главный фокус numpy. frame[2:4, 3:6] — это срез сразу по двум осям: строки 2–3 и столбцы 3–5, прямоугольник из шести пикселей. Присваивание раскрашивает его целиком, без единого цикла. Это называется векторизацией: операция записывается для всего массива, а цикл крутится внутри numpy, на скорости C.
Строки 9–10. frame[:, :, 0] — все строки, все столбцы, канал 0: красный слой картинки. Двоеточие без чисел значит «всё по этой оси».

Бесплатный фрагмент закончился.
