Читать книгу: «MINI DOOM Современный Python через игру Том 4 Здесь кто-то есть»

Шрифт:

Вместо предисловия

Это четвёртый том книги «MINI DOOM. Современный Python через игру». К концу третьего тома у тебя трёхмерный лабиринт: лучи DDA, стены с текстурами без единого файла картинок, пол и потолок, затухание света и двери, которые открываются по E.

Теперь лабиринт перестаёт быть пустым. Номера глав, листингов и рисунков в серии сквозные, поэтому ссылка «см. главу 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 — и вперёд. Каждая глава начинается с проекта предыдущей, так что можно начать читать этот том, взяв готовый код последней главы прошлого тома.

Часть IV. Здесь кто-то есть

Глава 20. Спрайты

Уровень построен, двери скрипят — но он пуст. Пора его населить: аптечки, патроны, а в следующих главах — монстры. Все они в Doom — спрайты: плоские картинки, которые всегда повёрнуты к игроку лицом. Обойди бочку в Doom кругом — и увидишь одну и ту же картинку со всех сторон. Это чит, но гениальный: вместо трёхмерной модели — одна картинка, а мозг дорисовывает объём сам.

Нарисовать картинку — дело нехитрое. Хитрость в том, чтобы поставить её в мир: правильного размера, на правильном месте экрана, за стенами, если они ближе, и перед другими спрайтами, если она ближе них. В этой главе мы решим все три задачи.

Картинка с дырками

Спрайт — не прямоугольник: у аптечки нет углов, вокруг неё видна стена. Значит, кроме цвета, каждому пикселю нужен признак «прозрачен ли он». Добавим к RGB четвёртый канал — альфа (alpha): 0 — дырка, 255 — краска. Спрайт — массив формы (64, 64, 4). Рисовать будем кистями, листинг 20.1.



Листинг 20.1 — Простейшие кисти: эллипс и прямоугольник


Строка 12 — новый холст из нулей: и цвет, и альфа — нули, всё прозрачно. Строки 15–19 — эллипс: np.ogrid даёт столбец номеров строк и строку номеров столбцов, по трансляции получается таблица «внутри эллипса или нет», и в эти пиксели пишем цвет с альфой 255. Запись (*color, 255) — распаковка кортежа цвета плюс ещё одно число: (r, g, b, 255).

Предметы — листинг 20.2. Аптечка — белая коробка с красным крестом в нижней части картинки 64 × 64: предмет лежит на полу, поэтому его картинка прижата к низу. Пачка патронов — коробка и пять патронов.




Листинг 20.2 — Предметы и их картинки



Листинг 20.2 (продолжение) — Предметы и их картинки


Строка 42 — словарь IMAGES строится один раз при импорте модуля: картинки не меняются, рисовать их каждый кадр незачем. В следующей главе для монстров с их позами и кадрами анимации мы заменим этот словарь на функцию с кэшем.

Где на экране

Предмет стоит в точке (sx, sy) карты. Нужно понять, в каком столбце экрана его рисовать и какого он размера. Переведём его координаты в систему игрока, рисунок 20.1: depth — насколько предмет впереди (вдоль взгляда), side — насколько правее. Это две проекции вектора «от игрока к предмету» — на взгляд и на перпендикуляр. Скалярные произведения из главы 14.




Рисунок 20.1 — Проекция спрайта: глубина вдоль взгляда и сдвиг вбок


Дальше — та же перспектива, что у стен. На глубине depth ширина окна камеры — 2 · PLANE · depth клеток, и всё, что внутри, растягивается на 640 столбцов. Значит, сдвиг side в клетках — это side / (PLANE · depth) в долях полуэкрана. Высота — H / depth, как у стены на той же глубине. Посчитаем для нескольких предметов, листинг 20.3.



Листинг 20.3 — Проекция предметов на экран


Предмет прямо по курсу в трёх клетках — в центре экрана, столбец 320, высотой 133 пикселя, как стена на той же глубине. Сдвинем его на клетку вправо — он уехал к столбцу 453. Подойдём ближе — предмет на клетку правее и на клетку впереди окажется в столбце 720: за правым краем экрана, ведь поле зрения у нас около 77°, а этот предмет — под 45° к взгляду, на самой границе. Его левая половина ещё может попасть на экран — поэтому обрезаем спрайт по краям, а не выбрасываем целиком. А предмет за спиной имеет отрицательную глубину, его не рисуем вовсе.

Z-буфер: кто кого закрывает

Теперь главное: предмет за стеной не должен быть виден. В трёхмерной графике это решают буфером глубины (z-буфером): для каждого пикселя помнят, на какой глубине уже что-то нарисовано, и новый пиксель рисуют, только если он ближе. У нас всё проще: стена в каждом столбце одна, и её глубина у нас уже есть — массив dist из draw_view. Пусть draw_view его и возвращает. Тогда для спрайта рисуем только те столбцы, где он ближе стены.

А как быть, если два спрайта перекрывают друг друга? Тут поможет алгоритм художника: рисуем от дальних к ближним, ближние ложатся сверху — как художник пишет сначала горы, потом деревья перед ними. Для этого спрайты сортируются по глубине. Всё вместе — в листинге 20.4.



Листинг 20.4 — Спрайты: проекция, сортировка и z-буфер




Листинг 20.4 (продолжение) — Спрайты: проекция, сортировка и z-буфер


Строки 13–19 — датакласс Sprite: где стоит, какая картинка, масштаб и признак floating — висит ли спрайт в воздухе на уровне глаз (огненный шар) или стоит на полу (аптечка).

Строки 27–31 — сортировка. sorted(sprites, key=depth, reverse=True) — от самого глубокого к самому мелкому. key — функция, по значению которой сравнивать; мы передаём вложенную функцию depth, которая видит x, y и направление взгляда из объемлющей функции. Это замыкание: функция, захватившая переменные снаружи.

Строки 35–39 — проекция ровно по формулам с рисунка 20.1. Для спрайта на полу низ картинки — на линии пола на глубине d: h / 2 + h / 2 / d; это та же строка экрана, на которой стоит низ стены на такой глубине. Летающий спрайт — по центру экрана, на уровне глаз.

Строка 45 — z-буфер в одну строчку: из всех столбцов спрайта оставляем те, где его глубина меньше глубины стены. cols[маска] — булево индексирование одномерного массива.

Строки 48–51 — тексели. step — сколько текселей картинки приходится на пиксель экрана. Номера строк ty и столбцов tx — два одномерных массива, а ty[:, np.newaxis] превращает строки в столбец: по трансляции s.image[ty[:, np.newaxis], tx] — таблица текселей (строки × столбцы × 4). Индексирование массивами из главы 17, только теперь двумерное.

Строки 54–56 — запись с прозрачностью и туманом. c.pixels[y0:y1, cols] — смесь среза и индексирования массивом, а значит, копия (вспомни главу 17). Поэтому мы берём кусок в переменную region, меняем в нём непрозрачные пиксели и записываем его обратно. Если написать c.pixels[y0:y1, cols][solid] = …, изменится временная копия, а холст останется прежним — и спрайт молча не нарисуется.


Предметы на уровне

С этой главы мы играем на настоящем первом уровне игры. Буква h на карте — аптечка, a — патроны; i и z — монстры, но им ещё рано, пока это просто пол. Игра собирает предметы из карты одним генератором списка, а в render рисует сначала стены, потом спрайты, листинг 20.5.



Листинг 20.5 — Игра: предметы и спрайты


Обрати внимание на флаг xray в строках 109–110: если он включён, z-буфер заливается бесконечностью — «стен нет», и спрайты видны сквозь всё. В игре он не нужен, это опыт. Запустим игру и версию «с рентгеном» из одной и той же точки: робот отходит вниз, подходит к синему блоку и смотрит вправо-вверх, туда, где за блоком лежат две аптечки, листинг 20.6.




Листинг 20.6 — Рентген: спрайты без z-буфера




Рисунок 20.2 — Рентген: аптечки и патроны видны сквозь блок




Рисунок 20.3 — Та же точка с z-буфером: из-за угла выглядывает только краешек аптечки

На рисунке 20.2 сквозь техно-блок видны две аптечки и тёмная пачка патронов вдалеке — туман почти съел её цвет. На рисунке 20.3 — честная картинка: от ближней аптечки виден лишь краешек слева от блока, ровно те столбцы, где блок её не закрывает. Z-буфер работает по столбцам: аптечка обрезана точно по вертикальной кромке стены.


Итоги главы

— Спрайт — плоская картинка (h, w, 4) с альфой; всегда повёрнут к игроку.

— Проекция: depth — вдоль взгляда, side — вбок; столбец W/2 · (1 + side / (PLANE · depth)), высота H / depth.

— Z-буфер — глубина стены в каждом столбце; рисуем столбцы спрайта, которые ближе стены.

— Алгоритм художника: сортировка от дальних к ближним, key — функция-замыкание.

— Смесь среза и индексирования массивом — копия; запись через цепочку индексов теряется.

Контрольная точка: на уровне лежат аптечки и патроны, их закрывают стены и друг друга. Подбирать их пока нельзя — это в главе 25. А в следующей главе нарисуем кодом тех, ради кого всё затевалось: монстров.

Упражнения

Упражнение 20.1. Что выведет? Листинг 20.7.



Листинг 20.7 — Упражнение 20.1


Упражнение 20.2. Найди баг. Листинг 20.8 должен нарисовать красный крест на серой стене. Почему в углах чёрное?




Листинг 20.8 — Упражнение 20.2


Упражнение 20.3. Напиши сам. Добавь на уровень бочки — буква b, картинка — коричневый цилиндр (прямоугольник с эллипсами сверху и снизу). Сделай так, чтобы сквозь бочку нельзя было пройти.

Упражнение 20.4. Подумай. Почему спрайты нельзя рисовать до стен?

Задачи

Задача 20.1. Подпрыгивающие патроны. Сделай так, чтобы предметы мягко покачивались вверх-вниз, как в многих шутерах. Подскажу: смещение по вертикали на экране зависит от глубины.

Задача 20.2. Предметы на карте. Нарисуй на виде сверху предметы цветными точками: аптечки красным, патроны жёлтым.

Задача 20.3. Счётчик. Выведи в углу экрана, сколько спрайтов нарисовано в этом кадре и сколько отброшено за спиной или за стенами.

Глава 21. Монстры из кода

Аптечки — это мило, но игре нужны враги. В оригинальном Doom монстров лепили из пластилина, фотографировали с восьми сторон и обрабатывали в графическом редакторе. У нас нет ни пластилина, ни художника — зато есть numpy. В этой главе мы нарисуем кодом импа и зомби в четырёх позах: два шага, атака и боль. По дороге разберём, как одним выражением проверить «внутри ли точка многоугольника», как сделать плоский эллипс объёмным и как заставить функцию рисовать каждую картинку только один раз.

Кисти посерьёзнее

В прошлой главе кистей было две: эллипс и прямоугольник, оба одноцветные. Монстру нужно больше: рога и когти — треугольники, туловище — с объёмом, обводка — чтобы силуэт читался на любой стене, и краска поверх всего — когда монстр ранен. Новый набор кистей — листинг 21.1.



Листинг 21.1 — Кисти: объёмный эллипс, многоугольник, обводка, подкраска




Листинг 21.1 (продолжение) — Кисти: объёмный эллипс, многоугольник, обводка, подкраска

Строки 16–19 — сетка центров пикселей. np.meshgrid даёт две полные таблицы: в xs каждая клетка — её x, в ys — её y. Это то же самое, что np.indices из главы 17, только в дробных координатах центров (+ 0.5) и в порядке (x, y).

Строки 27–33 — эллипс с объёмом. nx, ny — координаты пикселя относительно центра эллипса в долях радиусов: на краю эллипса nx² + ny² = 1. Внутри — то, что меньше единицы. А дальше трюк: яркость падает к краям (− 0.3 · d) и к правому нижнему боку (− 0.3 · (nx + ny)). Получается, будто свет падает слева сверху, а эллипс — не блин, а шар. Три строчки — и плоский рисунок обретает форму.

Строки 36–45 — выпуклый многоугольник. Точка внутри выпуклого многоугольника, если она лежит по одну сторону от всех его рёбер. С какой стороны ребра A → B лежит точка P, говорит знак векторного произведения (B − A) × (P − A) — проверим на примере, листинг 21.2.



Листинг 21.2 — Знак векторного произведения: с какой стороны ребра


Плюс — с одной стороны, минус — с другой, ноль — на прямой. Многоугольник обходим по рёбрам и отмечаем: был ли пиксель хоть раз слева (left) и хоть раз справа (right). Внутри те, кто ни разу не оказался по разные стороны: ~(left & right). Обход по часовой или против — неважно, поэтому порядок вершин можно задавать как угодно, лишь бы многоугольник был выпуклым.

Строки 52–59 — обводка. Сдвигаем маску непрозрачных пикселей на один пиксель вверх, вниз, влево и вправо и объединяем: near — все, у кого есть непрозрачный сосед. Обводка — соседи, которые сами прозрачны: near & ~solid. np.pad на один пиксель нужен, чтобы сдвинутые срезы не выходили за края.

Строки 62–66 — подкраска: смешиваем цвет каждого пикселя с заданным в пропорции amount. Раненый имп на долю секунды краснеет на 35 %.

Имп по частям

Теперь — сам имп. Каждая часть тела — вызов кисти, порядок — снизу вверх по слоям: ноги, туловище, руки, голова. Что нарисовано позже, то сверху. Листинг 21.3.



Листинг 21.3 — Имп: картинка 64 × 64 из эллипсов и треугольников


Строки 33–35 — шаг. В позах WALK1 и WALK2 ноги сдвинуты на 3 пикселя в разные стороны, а руки качаются в противофазе. Две картинки, чередующиеся три раза в секунду, — и монстр «переминается». Метод словаря get с значением по умолчанию 0.0 возвращает ноль для всех остальных поз.

Строки 46–53 — поза атаки: руки с когтями вверх. if pose is Pose.ATTACK — сравнение через is, а не ==: члены Enum — единственные объекты, и is для них правильнее и быстрее.

Строки 56–65 — голова. В позе PAIN голова сдвигается на 2 пикселя вбок — «дёрнулся от боли», — и вся фигура краснеет через tint. В самом конце — обводка, она должна охватить уже всё нарисованное.

Зомби устроен так же: туловище, ремень, руки, винтовка-многоугольник и вспышка выстрела в позе ATTACK. Все позы обоих монстров — на рисунке 21.1. Это не скриншот из игры, а прямой вывод функции monster_image, увеличенный в 5 раз и положенный на бежевый фон, чтобы была видна прозрачность.



Рисунок 21.1 — Все позы импа и зомби — вывод monster_image, увеличенный в 5 раз



Рисовать один раз: @cache

Нарисовать импа — около 3 миллисекунд: два десятка проходов numpy по картинке 64 × 64. Если каждый кадр перерисовывать каждого монстра, на уровне с десятком импов только на это уйдёт весь бюджет кадра. Но ведь картинка импа в позе WALK1 всегда одна и та же! Достаточно нарисовать её один раз и запомнить.

В модуле functools для этого есть декоратор @cache. Он оборачивает функцию: при первом вызове с данными аргументами функция честно работает, а результат кладётся в словарь «аргументы → результат». При следующем вызове с теми же аргументами функция не вызывается вовсе — ответ берётся из словаря. Это называется мемоизация. Листинг 21.4.



Листинг 21.4 — @cache: сто вызовов — одна настоящая работа


Разница — в сотню раз: из кэша достаётся не картинка заново, а ссылка на уже готовый массив. cache_info() показывает статистику: один промах (miss) — первый вызов, когда картинку рисовали, и 99 попаданий (hits). А a is b — True: все получают один и тот же объект.

У @cache два требования. Первое: аргументы должны быть хешируемыми — их кладут ключами в словарь. Члены Enum, числа, строки и кортежи — да, списки и массивы numpy — нет. Поэтому monster_image принимает (вид, поза), а не, скажем, список частей тела. Второе требование коварнее — об этом упражнение 21.2.



Именно поэтому поза PAIN — отдельная картинка в кэше, а не «подкрасить WALK1 на лету»: подкраска изменила бы закэшированный массив, и покраснели бы все импы уровня.

Монстры на уровне

Монстрам нужен класс. Пока у монстра всё просто: вид, координаты и время анимации, листинг 21.5. В главе 23 у него появятся здоровье, состояние и таймеры.




Листинг 21.5 — Предметы и монстры


Игра собирает монстров из карты вместе с предметами — буквы i и z, листинг 21.6. У каждого — своя начальная фаза анимации, вычисленная из координат: иначе все монстры переминались бы синхронно, как солдаты на плацу, и выглядело бы это жутковато даже для ада.

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