Kitabı oxu: «MINI DOOM Современный Python через игру Том 5 Шум свет и финальные титры»
Вместо предисловия
Это пятый том книги «MINI DOOM. Современный Python через игру». К концу четвёртого тома в лабиринте живут монстры: спрайты с z-буфером, имп и зомби, нарисованные кодом, пистолет и дробовик, конечный автомат на match, поиск в ширину, огненные шары и смерть с рестартом.
Остался последний слой: звук, экраны, уровни в файлах и то, как проект собирают и проверяют взрослые. Номера глав, листингов и рисунков в серии сквозные, поэтому ссылка «см. главу 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 — и вперёд. Каждая глава начинается с проекта предыдущей, так что можно начать читать этот том, взяв готовый код последней главы прошлого тома.
Часть V. Шум, свет и финальные титры
Глава 26. Звук из ничего
Выключи звук в Doom — и он перестанет быть страшным. Рык импа за стеной, скрип двери, глухой удар дробовика — половина атмосферы. У нас звуков нет, а файлов со звуками, как и с текстурами, брать неоткуда. Значит, синтезируем: каждый звук игры — формула, посчитанная numpy при запуске. А потом отдадим получившиеся волны pygame.mixer и наступим по дороге на одни очень неприятные грабли. В конце главы — разговор о потоках и знаменитом GIL: почему звук играет параллельно игре и почему потоки в Python ускоряют далеко не всё.
Что такое звук для компьютера
Звук — это колебания давления воздуха. Микрофон превращает их в напряжение, а компьютер 22 050 раз в секунду записывает его значение — сэмпл. Звук длиной в секунду — массив из 22 050 чисел от −1 до 1. Динамик делает обратное: двигает мембрану по этим числам. Значит, синтезировать звук — это просто посчитать массив. А массивы мы считать умеем.
Самые простые звуки — периодические волны. Синус звучит чисто, как камертон. Пила — резко, «по-скрипичному». Меандр — прямоугольная волна — гудит, как старые игровые приставки. Шум — случайные значения — шипит, как радио между станциями; из него получаются выстрелы, взрывы и скрипы. Частота — сколько раз в секунду повторяется волна: 440 Гц — нота ля. Рисунок 26.1 — эти волны, посчитанные нашим синтезатором, и один готовый звук.

Рисунок 26.1 — Четыре формы волны (по 30 мс на 100 Гц) и звук выстрела целиком — вывод synth.py
Звук выстрела внизу рисунка — это шум и меандр, сложенные вместе, у которых частота быстро падает. Резкий удар в начале и затухание к концу — огибающая, громкость, меняющаяся во времени.
Синтезатор на numpy
Каждый звук игры описывается рецептом: несколько голосов (форма волны, частота в начале и в конце, громкость), время нарастания, длина и частота среза фильтра. Все рецепты — одна таблица, листинг 26.1.

Листинг 26.1 — Рецепты звуков

Листинг 26.1 (продолжение) — Рецепты звуков
Это та же идея, что и с оружием в главе 22: звуки — данные. Выстрел пистолета — короткий шум сверху вниз плюс низкий меандр. Дробовик — то же, но ниже, длиннее и с пилой. Взрыв — шум, падающий до 100 Гц, плюс басовый синус. Подбор предмета — единственный «музыкальный» звук: меандр, взлетающий на октаву, от 660 до 1320 Гц. Константы S, W, Q, N в строке 40 — короткие псевдонимы форм, чтобы таблица читалась, а не расползалась.
Теперь сам синтезатор, листинг 26.2.

Листинг 26.2 — Генератор, огибающая, фильтр и сборка звука

Листинг 26.2 (продолжение) — Генератор, огибающая, фильтр и сборка звука
Строки 63–66 — генератор волны. Частота едет от start к end не линейно, а по степени: ухо слышит частоты в логарифмической шкале, и спуск с 800 до 400 Гц звучит так же, как с 400 до 200. Главная хитрость — фаза: накопленная сумма частот, np.cumsum. Фаза — сколько периодов волна прошла к этому сэмплу. Если бы мы считали sin(2π · f · t) при меняющейся f, волна рвалась бы и щёлкала; через накопленную фазу частота меняется плавно.
Строки 67–76 — формы волны из дробной части фазы frac: синус — sin(2π · frac), пила — 2 · frac − 1 (линейно от −1 до 1), меандр — +1 в первой половине периода и −1 во второй. Шум — новое случайное число раз в период: номер периода floor(phase) работает индексом в массив случайных значений. Чем выше «частота» шума, тем чаще он меняется и тем выше шипит.
Строки 79–84 — огибающая: за attack секунд громкость линейно растёт от нуля до единицы, потом падает к нулю по квадрату. np.where выбирает формулу для каждого сэмпла.
Строки 87–97 — фильтр нижних частот: срезает «писк», оставляет «бас». Формула y += a · (x − y) — каждый новый выход тянется к входу, но не догоняет его сразу, поэтому быстрые колебания сглаживаются. И вот тут numpy бессилен: каждый сэмпл зависит от предыдущего, и посчитать их все одной операцией над массивом нельзя. Приходится честно крутить цикл Python — первый цикл по сэмплам в нашей игре. На все звуки уходит около десятой доли секунды, и всего один раз — при запуске.
Строки 100–106 — сборка: сумма голосов, фильтр, огибающая и нормализация: делим на пиковое значение, чтобы все звуки были одинаково громкими, независимо от числа голосов. sum с начальным значением np.zeros(n) — сумма массивов начинается с массива нулей нужной длины, и результат — заведомо массив, даже если голос в рецепте один.

Звуки можно не только играть, но и записать в файлы — модулем wave из стандартной библиотеки. Листинг 26.3 пишет все 12 звуков в WAV: открывай любым плеером и слушай, что получилось.

Листинг 26.3 — Все звуки — в файлы WAV
Сэмплы от −1 до 1 переводятся в 16-битные целые: умножение на 32 767 и astype(np.int16). WAV — простейший формат: заголовок с частотой, числом каналов и размером сэмпла, а потом сырые байты массива, tobytes().
Микшер pygame — и грабли
Играть звуки будет pygame.mixer. Он умеет превращать массив numpy в звук — pygame.sndarray.make_sound — и играть до 16 звуков одновременно на разных каналах. Попробуем самое простое, листинг 26.4.

Листинг 26.4 — Звук из массива — первая попытка
Микшер стереофонический: pygame.init() включил его с настройками по умолчанию — 44 100 Гц, 16 бит, два канала. А наш массив одномерный — моно, и make_sound ждёт двумерный: по столбцу на канал. Мало того: даже если бы форма подошла, частота микшера 44 100, а наш звук посчитан для 22 050 — он проиграется вдвое быстрее и на октаву выше.
Лечение — настроить микшер под наши звуки. Но pygame.mixer.init(…) после pygame.init() ничего не делает: микшер уже включён, и повторная инициализация молча игнорируется. Нужно сначала выключить его — pygame.mixer.quit() — и только потом включить с правильными настройками, листинг 26.5.

Листинг 26.5 — Звук через pygame.mixer

Строки 20–24 — проигрывание. Звук ставится на свободный канал; если все 16 заняты, play вернёт None, и звук пропадёт. Громкость — у канала, а не у звука: один и тот же выстрел может звучать громко рядом и тихо вдали одновременно.
Игра называет — оболочка играет
Как связать игру со звуком? Стена из главы 12 не пускает pygame в игру. Поэтому игра звуков не играет — она их называет: в нужный момент кладёт событие «звук такой-то, громкость такая-то» в список. Раз в кадр оболочка забирает список и проигрывает, листинг 26.6.

Листинг 26.6 — Звуковые события в игре
Громкость падает с расстоянием линейно: рядом — 1, в 16 клетках — тишина, и дальше событие даже не создаётся. take_sounds отдаёт накопленное и заводит новый пустой список: обмен через присваивание кортежа out, self.sounds = self.sounds, [] — одной строкой. Звуки называют все: оружие при выстреле, монстры при пробуждении, боли, смерти, атаке, двери при начале движения, предметы при подборе, игрок при ранении и смерти.
Раз звуки — просто события, их можно проверить без колонок. Листинг 26.7: игрок открывает дверь к импу, стоит, на сотом кадре стреляет, а программа печатает всё, что он услышал бы.

Листинг 26.7 — Журнал звуков
Читается, как радиопостановка: скрип двери, рык импа, свист шара, крик игрока и взрыв в одно мгновение. Выстрел игрока, боль импа — и на шум просыпаются ещё трое, двое зомби и второй имп: их голоса тише, они дальше. Обрати внимание, сколько звуков приходится на один кадр: выстрел, боль и три пробуждения на 1,67 секунды — пять каналов сразу. Вот зачем их 16.
Потоки и GIL
Как микшер играет звук, пока игра крутит свой цикл? В отдельном потоке: SDL заводит фоновый поток, который каждые несколько миллисекунд досыпает в звуковую карту очередную порцию сэмплов. Игра в это время рисует кадр. Потоки — это способ делать несколько дел «одновременно» внутри одной программы.
Почему бы и нам не ускорить что-нибудь потоками — например, считать звуки в четыре потока? Проверим, листинг 26.8: четыре одинаковые задачи подряд и в четырёх потоках через ThreadPoolExecutor.

Листинг 26.8 — Потоки: цикл Python против numpy

Листинг 26.8 (продолжение) — Потоки: цикл Python против numpy
Цикл Python в потоках стал медленнее, а сортировка numpy — быстрее, почти в число ядер. Виноват GIL — глобальная блокировка интерпретатора. В обычном CPython байт-код Python в каждый момент выполняет только один поток; остальные ждут своей очереди. Четыре потока с циклом Python не работают параллельно, а по очереди, да ещё и тратят время на передачу блокировки друг другу. А numpy, уходя в свой код на C надолго, отпускает GIL — и потоки с тяжёлыми операциями numpy действительно работают на разных ядрах. Так же отпускают GIL ввод-вывод, ожидание сети и… звуковой поток SDL.

Вывод для нашей игры: синтез звуков стоит десятую долю секунды один раз при запуске, и усложнять его потоками незачем. А вот то, что звук играет в фоне, — заслуга потока, который за нас завёл SDL.
Итоги главы
— Звук — массив сэмплов от −1 до 1, 22 050 в секунду; синтез — это вычисление массива.
— Формы волн из дробной части накопленной фазы; частота едет по степени.
— Огибающая задаёт громкость во времени, нормализация выравнивает звуки.
— Рекуррентный фильтр не векторизуется: цикл по сэмплам, один раз при запуске.
— pygame.mixer.init после pygame.init молча ничего не меняет: сначала mixer.quit.
— Игра называет звуки событиями, оболочка их играет: стена из главы 12 цела.
— GIL: потоки с чистым Python не ускоряются; numpy и ожидание ввода-вывода отпускают GIL.
Контрольная точка: игра звучит. Осталось сделать её похожей на настоящую: статус-бар с лицом героя, титульный экран, пауза и экран победы.
Упражнения
Упражнение 26.1. Что выведет? Листинг 26.9: частота дискретизации 8 Гц, волна 2 Гц.

Листинг 26.9 — Упражнение 26.1
Упражнение 26.2. Найди баг. Почему в листинге 26.4 звук не создался и что ещё пошло бы не так, если бы массив был двумерным?
Упражнение 26.3. Напиши сам. Добавь звук для смены оружия — короткий щелчок затвора — и вызови его в update_weapon.
Упражнение 26.4. Подумай. Почему громкость задаётся каналу, а не объекту pygame.mixer.Sound?
Задачи
Задача 26.1. Стерео. Сделай звуки объёмными: источник справа звучит громче в правом ухе. Подсказка: channel.set_volume(left, right) и скалярное произведение с вектором «вправо» из главы 14.
Задача 26.2. Эхо. Добавь в synthesize параметр echo: к звуку прибавляется его копия, сдвинутая на 0,15 секунды и вдвое тише. Это можно сделать без цикла?
Задача 26.3. Музыка. Напиши функцию, которая синтезирует простую мелодию из списка нот: нота — (частота, длительность), и проигрывает её на титульном экране.
Глава 27. HUD и экраны
У нас есть игра, но нет программы: запустил — и сразу в бою, умер — Enter, дошёл до выхода — ничего. Настоящая игра — это экраны: титульный с огнём, сама игра со статус-баром, пауза, итоги уровня с процентами, финальные титры. В этой главе мы нарисуем статус-бар как в Doom — с лицом героя, которое косится по сторонам и разбивается в кровь, — и соберём все экраны в ещё один конечный автомат, на этот раз — автомат всей программы.
Код игры по ходу причесали: Game научилась считать время на уровне (time), отмечать, что уровень пройден (level_done — когда игрок наступает на телепорт X), и помнить, какие клетки игрок уже видел (seen) — для автокарты. Временная строка HEALTH/AMMO в углу экрана из прошлых глав ушла: её место занял статус-бар. Полные файлы — в архиве примеров; здесь — то, что появилось нового.
Статус-бар
Панель внизу экрана высотой 44 пикселя: патроны, здоровье, оружие, лицо, убитые и время. Каждая ячейка — крупное красное число и мелкая подпись. Мир над панелью рисуется как раньше, только оружие поднято на её высоту, листинг 27.1.

Листинг 27.1 — Статус-бар и поверх-экранный интерфейс

Листинг 27.1 (продолжение) — Статус-бар и поверх-экранный интерфейс
Строки 13–17 — ячейка: значение по центру ячейки шрифтом ×3 с тенью, подпись — шрифтом ×1. Центрирование через text_width из главы 13.
Строки 34–35 — время. divmod(секунды, 60) — частное и остаток одной операцией: минуты и секунды. Формат {seconds:02} дополняет секунды нулём до двух цифр: 1:05, а не 1:5.
Строки 36–38 — лицо. Настроение — от здоровья: больше 66 % — бодр, больше 33 % — ранен, иначе избит, а мёртвый — с крестиками вместо глаз. Цепочка 3 if dead else 0 if … else 1 if … else 2 — вложенные условные выражения; читается слева направо, как лестница. Взгляд меняется каждые полторы секунды по кругу «прямо — влево — прямо — вправо», а при сильном ударе (пелена ярче 0,3) лицо открывает рот. Все варианты — на рисунке 27.1, это прямой вывод face_image из art.py.

Рисунок 27.1 — Лицо героя 20 × 21 пиксель в разных состояниях — вывод face_image, увеличенный в 10 раз

Огонь с титульника
Титульный экран — огонь, как в PlayStation-версии Doom 1995 года. Алгоритм знаменит своей простотой: нижний ряд пикселей горит в полную силу, а каждый пиксель выше берёт жар у пикселя под собой, чуть остывший и чуть сдвинутый вбок ветром. Пламя само поднимается, мерцает и тает, листинг 27.2.

Листинг 27.2 — Огонь из PSX Doom на numpy

Листинг 27.2 (продолжение) — Огонь из PSX Doom на numpy
Строки 11–18 — палитра: 37 оттенков от чёрного через тёмно-красный и оранжевый к белому. Пять опорных цветов, а между ними — линейная интерполяция. Жар — номер цвета в палитре; рисование — индексирование массивом self.colors[self.heat] из главы 17.
Строки 28–35 — шаг огня. В оригинале это двойной цикл по пикселям; у нас — numpy: для всего поля сразу случайное r от 0 до 3; жар идёт на строку выше со сдвигом 1 − r по горизонтали и остывает на r & 1 — младший бит r, то есть на 0 или 1. np.maximum(…, 0) не даёт жару уйти в минус. Поле 160 × 100, рисуется увеличенным в 4 раза — крупные пиксели и есть стиль.
Огню нужен свой темп: 30 шагов в секунду, независимо от FPS. В App для этого — накопитель времени fire_clock: каждый кадр в него добавляется dt, и пока накопилось больше 1/30 секунды — делаем шаг. Это фиксированный шаг из упражнения 11.3 — пригодился.
Автомат программы
Экраны и переходы между ними — снова конечный автомат, рисунок 27.2. Уже третий в книге: двери, монстры, теперь вся программа. Это не совпадение — так устроено почти любое интерактивное приложение.

Рисунок 27.2 — Автомат экранов: от титульника до титров и обратно
Код — класс App, листинг 27.3. Он теперь главный: оболочка из главы 12 держит не Game, а App, а уже App держит текущую игру и решает, обновлять ли её.

Листинг 27.3 — Автомат экранов

Листинг 27.3 (продолжение) — Автомат экранов
Строки 43–45 — enter: переход в новое состояние со сбросом таймера экрана и щелчком меню. Все переходы — только через него, поэтому таймер никогда не забудем обнулить.
Pulsuz fraqment bitdi.