Kitabı oxu: «Mini Doom современный C++ через игру том 4 здесь кто то есть»
Вместо предисловия
Это четвёртый том книги «MINI DOOM. Современный C++ через игру». К концу третьего тома у тебя трёхмерный лабиринт: лучи DDA, стены с текстурами без единого файла картинок, пол и потолок, затухание света и двери, которые открываются по E.
Теперь лабиринт перестаёт быть пустым. Номера глав, листингов и рисунков в серии сквозные, поэтому ссылка «см. главу 12» ведёт туда же, куда и в однотомнике.
Серия целиком
— Том 1. Учимся держать оружие — главы 1–8: основы C++ в консоли, от первой программы до памяти и санитайзеров.
— Том 2. Окно в ад — главы 9–13: своё окно Windows, пиксели, игровой цикл, клавиатура и мышь, текст.
— Том 3. Третье измерение — главы 14–19: рейкастинг, стены, текстуры, пол и свет, двери.
— Том 4. Здесь кто-то есть — главы 20–25: спрайты, монстры, оружие, искусственный интеллект, смерть.
— Том 5. Шум, свет и финальные титры — главы 26–29: звук, экраны, уровни, CMake и тесты.
Все исходники серии — на GitHub: https://github.com/RushSex/MiniDoom. Папка examples/chNN для каждой главы, итоговая игра — examples/ch29/doom. Там же README с инструкцией по сборке в Visual Studio 2026. Каждая глава начинается с проекта предыдущей, так что можно начать читать этот том, взяв готовый код последней главы прошлого тома.
Часть IV. Здесь кто-то есть
Лабиринт готов: стены, текстуры, свет, двери. Но бродить по пустому лабиринту — всё равно что ходить по ночному офису: красиво, жутковато и совершенно бессмысленно. Doom — это аптечки за углом, патроны в последний момент и, конечно, те, кто ждёт тебя в темноте.
В этой части мир оживёт. Сначала мы научимся ставить в него плоские картинки — спрайты — так, чтобы стены честно их закрывали. Потом нарисуем кодом монстров с анимацией, выдадим игроку пистолет и дробовик, научим монстров замечать игрока, гоняться за ним по коридорам и стрелять в ответ. В конце части в игре можно будет умереть — а значит, это уже игра.

Глава 20. Спрайты
Спрайт — это плоская картинка, которая стоит в трёхмерном мире и всегда смотрит на игрока. Аптечка, коробка патронов, бочка, монстр — всё это спрайты. Чтобы нарисовать спрайт, нужно ответить на три вопроса: где он на экране, какого он размера и какие его части закрыты стенами. Первые два — это та же перспектива, что у стен. Третий — новая идея, Z-буфер.
К концу главы на уровне будут лежать аптечки и патроны, игрок будет их подбирать, а вверху экрана, как в Doom, появится надпись «PICKED UP A MEDIKIT».
Предметы на карте
Предметы, как и всё на уровне, рисуем прямо в карте: h — аптечка (health), a — патроны (ammo). Map::isWall считает стенами только цифры, поэтому буквы для движения — пустой пол. Сам предмет — маленькая структура, листинг 20.1.

Листинг 20.1 — Предмет: что за вещь, где лежит, подобрана ли
enum class с указанием типа : std::uint8_t — перечисление занимает один байт, а не четыре, как int. Для двух значений это мелочь, но это привычка: в Doom предметов, монстров и состояний сотни.
В конструкторе Game пробегаем по карте и для каждой буквы h или a кладём предмет в центр клетки. Подобранный предмет не удаляется из вектора, а только помечается taken: так проще, а позже, в главе 25, по этому флагу мы вернём все предметы на место при перезапуске уровня.
Картинка с дырками
Стены — прямоугольники, их текстура заполнена целиком. А аптечка — не прямоугольник: вокруг неё должно просвечивать то, что сзади. Значит, у картинки спрайта должны быть прозрачные пиксели. Самый простой способ — занять под это старший байт цвета. Мы до сих пор писали цвет как 0x00RRGGBB, и старший байт всегда был нулём. Теперь договоримся: в спрайтах этот байт — альфа, «непрозрачность». 0xFF — пиксель есть, 0x00 — пикселя нет, сквозь него видно фон. Листинг 20.2 и 20.3.

Листинг 20.2 — Непрозрачный цвет и картинки предметов

Листинг 20.3 — Аптечка и патроны из прямоугольников

Листинг 20.3 (продолжение) — Аптечка и патроны из прямоугольников
Новая картинка Image заполнена нулями — то есть полностью прозрачна. Функция box закрашивает прямоугольник непрозрачным цветом. Аптечка — белая коробка с красным крестом в нижней части картинки 64 × 64: спрайт, как и стена, занимает одну клетку в высоту, а маленький предмет должен лежать на полу, а не висеть посередине.

Где спрайт на экране
Спрайт стоит в точке (sx, sy). Чтобы понять, куда его рисовать, переведём эту точку в систему координат камеры: сколько до неё вперёд по взгляду и сколько вбок, вправо. Вперёд — это проекция вектора «от игрока к спрайту» на направление взгляда, а вбок — на направление «вправо» из главы 14, рисунок 20.1.

Рисунок 20.1 — Спрайт в системе камеры: depth — вперёд по взгляду, side — вбок
Дальше всё уже знакомо. depth — это ровно то расстояние вдоль взгляда, которое мы мерили для стен в главе 16, поэтому и размер спрайта считается так же: h / depth. Положение по горизонтали — из окна камеры: окно на расстоянии 1 имеет полуширину 0,8, на расстоянии depth — 0,8 × depth, и спрайт, сдвинутый вбок на side, попадает в точку side / (0,8 × depth) от −1 до +1. Низ спрайта — на полу, а пол на расстоянии depth виден на (h/2) / depth ниже горизонта, это формула из главы 18.
Вся отрисовка спрайтов — функция drawSprites, листинги 20.4 и 20.5.

Листинг 20.4 — Спрайт и камера

Листинг 20.5 — drawSprites: проекция, сортировка, Z-буфер, прозрачность

Листинг 20.5 (продолжение) — drawSprites: проекция, сортировка, Z-буфер, прозрачность
Строки 19–21 — depth для каждого спрайта. Строки 31–39 — формулы с рисунка 20.1: side, середина на экране, размер, левый и верхний края, шаг по картинке и свет по расстоянию, как у стен.
Строки 41–46 — прямоугольник спрайта на экране, обрезанный по краям. Лямбда px — просто короткое имя для static_cast<int>, чтобы строки помещались на экран.
Строки 47–59 — рисование. Внешний цикл по столбцам, внутренний по строкам. Пиксель картинки рисуем, только если его альфа не ноль. А на строке 48 — главное в этой главе.
Z-буфер: кто кого закрывает
Стены мы рисуем раньше спрайтов. Если просто нарисовать спрайт поверх, он окажется виден сквозь стены — аптечка из соседней комнаты будет висеть на кирпичах. Нужно знать, где стена ближе спрайта, а где дальше. И эта информация у нас уже есть: для каждого столбца экрана мы знаем расстояние до стены. Раньше мы его выбрасывали, а теперь drawView записывает его в массив, листинг 20.6.

Листинг 20.6 — drawView теперь заполняет zbuf
Этот массив — по одному числу на каждый из 640 столбцов — и называется Z-буфером, буфером глубины: «z» по традиции обозначает глубину сцены. Спрайт в столбце col рисуется, только если он ближе стены в этом столбце: depth < zbuf[col]. Рисунок 20.2.

Рисунок 20.2 — Z-буфер: спрайт на глубине 3 рисуется только в столбцах, где стена дальше
Посмотрим, что будет без этой проверки. Уберём строки 48–50 из листинга 20.5 — листинг 20.7 — и снимем кадр со стартовой позиции, рисунок 20.3.

Листинг 20.7 — Ошибка: Z-буфер не проверяется

Рисунок 20.3 — Без Z-буфера: на кирпичной стене висят призрачные патроны и аптечка из соседней комнаты
Присмотрись к кирпичной стене и к металлической колонне справа: на них нарисованы тёмные силуэты — коробка патронов и аптечка из соседней комнаты. Они далеко, поэтому маленькие и тёмные, но они есть и просвечивают сквозь стену. С проверкой их нет.

Спрайты закрывают друг друга
Стены от спрайтов отгородили. Но спрайты могут перекрывать друг друга: аптечка перед коробкой патронов. Z-буфер тут не поможет, в нём только стены. Поможет старый «алгоритм художника» из главы 18: рисуем от дальних к ближним, и ближние ложатся поверх. Для этого спрайты сортируются по depth по убыванию — строка 23 листинга 20.5:
std::ranges::sort(sprites, std::greater{}, &Sprite::depth);
Это сортировка из C++20 с двумя дополнительными аргументами. Второй — сравнение: std::greater{} — «больше», значит, по убыванию. Третий — проекция: указатель на поле &Sprite::depth говорит, что сравнивать надо не спрайты целиком, а только их глубины. Без проекции пришлось бы писать лямбду [](const Sprite& a, const Sprite& b) { return a.depth > b.depth; } — то же самое, только длиннее и с шансом перепутать знак.
Обрати внимание: drawSprites получает вектор по значению, то есть копию. Сортировка переставляет копию, а исходный порядок у вызывающего не меняется. Копия пары десятков спрайтов ничего не стоит, а функция становится честнее: она ничего не портит снаружи.
Подбираем
Остаётся подбирать предметы. Каждый кадр смотрим, не подошёл ли игрок к предмету ближе чем на полклетки, листинг 20.8.

Листинг 20.8 — Подбор предметов и сборка спрайтов для кадра

Листинг 20.8 (продолжение) — Подбор предметов и сборка спрайтов для кадра
Строки 161–165. Расстояние сравниваем в квадрате: dx² + dy² > 0,5². Корень не нужен — если расстояние больше, то и его квадрат больше. Мелкая, но приятная экономия: эту проверку мы будем делать для каждого предмета и монстра каждый кадр.
Строки 166–169 — правило из Doom: если здоровье уже 100%, аптечка остаётся лежать. Её можно вернуться и взять потом, когда понадобится. Патроны берутся всегда.
Строки 181–197 — сборка кадра. Z-буфер — локальный вектор: render константный и не может менять поля Game, да и не нужно — буфер живёт один кадр. Для каждого неподобранного предмета кладём спрайт с указателем на его картинку. Картинки живут в static-массиве itemImage до конца программы, поэтому указатель на них безопасен.
HUD теперь показывает здоровье, патроны и сообщение — по примеру Doom, оно висит вверху слева две секунды. Робот проверяет — листинг 20.9.

Листинг 20.9 — Автотест: идём вдоль комнаты и подбираем

Листинг 20.9 (продолжение) — Автотест: идём вдоль комнаты и подбираем
— start — на уровне 8 предметов, здоровье 80%.
— forward 0.9 s — игрок прошёл через первую аптечку: здоровье 80 + 25 = 105, но не больше 100. Предметов осталось 7, сообщение висит.
— forward 0.7 s — прошли через вторую аптечку, но здоровье 100%, и она осталась лежать: всё ещё 7. Сообщение — от первой, оно висит две секунды.
— waited 2 s — сообщение исчезло.

Рисунок 20.4 — Кадр со старта: две аптечки, ближняя закрывает дальнюю
На рисунке 20.4 обе аптечки — и ближняя честно закрывает дальнюю. В окне робот делает то же: идёт вперёд и подбирает аптечку — рисунок 20.5.

Рисунок 20.5 — Контрольная точка: аптечка подобрана, сообщение вверху, здоровье 100%

Итоги главы
— Спрайт — плоская картинка, всегда повёрнутая к игроку.
— Позиция в системе камеры: depth — проекция на взгляд, side — проекция на «вправо».
— На экране: x = w/2 × (1 + side / (0,8 × depth)), размер h / depth, низ на полу.
— Прозрачность — альфа в старшем байте цвета: 0 — пикселя нет.
— Z-буфер — расстояние до стены в каждом столбце; спрайт рисуется, где он ближе стены.
— Спрайты между собой — от дальних к ближним: std::ranges::sort(v, std::greater{}, &Sprite::depth).
Контрольная точка: аптечки и патроны лежат в мире и подбираются. Но предметы скучные — коробки из прямоугольников. В следующей главе мы научимся рисовать кодом настоящих монстров: с объёмом, контуром и анимацией.
Упражнения
Упражнение 20.1. Что выведет? Листинг 20.10 сортирует предметы по глубине.

Листинг 20.10 — Что выведет программа?
Упражнение 20.2. Найди баг. В листинге 20.11 программист сортирует спрайты и рисует их по порядку. Ближний спрайт A на экране оказывается закрыт дальними. Почему?

Листинг 20.11 — Порядок рисования спрайтов
Упражнение 20.3. Напиши сам. Добавь на карту бочку — букву b. Бочку нельзя подобрать, она просто стоит. Нарисуй её кодом: зелёный цилиндр с тёмными обручами.
Упражнение 20.4. Подумай. Почему у спрайта и у стены на одном расстоянии одинаковая высота на экране? Как сделать маленький спрайт, например гильзу, если размер всегда h / depth?
Задачи
Задача 20.1. Сообщения по очереди. Если подобрать два предмета подряд за полсекунды, второе сообщение затирает первое. Сделай очередь сообщений: каждое показывается хотя бы секунду.
Задача 20.2. Бочки мешают проходить. Сделай, чтобы бочка из упражнения 20.3 была препятствием, как стена. Подумай: бочка — круглая, а стены — клетки.
Задача 20.3. Свечение. В Doom некоторые предметы светятся сами и не темнеют вдали. Добавь в Sprite флаг fullbright и для таких спрайтов используй яркость 256 вместо lightAt.
Глава 21. Рисуем монстров кодом
Аптечку можно собрать из трёх прямоугольников. С монстром так не выйдет: прямоугольный имп выглядит как шкаф с рогами. Живому существу нужны округлые формы, объём, контур — то, что художник делает кистью. Мы будем рисовать кодом, и наша кисть — функции: эллипс с объёмом, многоугольник, обводка.
Попутно разберём две вещи из C++, без которых такие кисти пишутся тяжело. Лямбды — маленькие функции прямо внутри кода, которые могут видеть переменные вокруг себя. И шаблоны функций — функции, которые работают с любым подходящим типом. Мы ими уже пользовались, не называя: std::ranges::sort из главы 20 — шаблон, а [](int x, int y) {…} в главе 14 — лямбда. Теперь разберёмся по-честному.
Лямбды и шаблоны на пальцах
Почти каждая кисть устроена одинаково: пройти по всем пикселям картинки и для каждого решить, красить ли его и каким цветом. Обход один и тот же, отличается только «решение». Значит, обход можно написать один раз, а решение передавать внутрь. Посмотрим на этот приём в консоли — листинг 21.1.

Листинг 21.1 — Эллипс и треугольник буквами: шаблон обхода и лямбды

Листинг 21.1 (продолжение) — Эллипс и треугольник буквами: шаблон обхода и лямбды
Строки 6–14 — шаблон функции. Слово template <typename F> говорит: F — какой-то тип, какой — решим, когда функцию вызовут. Внутри с f обращаются как с функцией: f(x, y). Подойдёт что угодно, что можно так вызвать. При каждом вызове с новым типом компилятор сам напишет отдельную версию forEachCell под этот тип — поэтому шаблон работает так же быстро, как если бы цикл был написан вручную.
Строки 21–24 — лямбда. Квадратные скобки — список захвата: что лямбда берёт из окружения. [&pic] — взять переменную pic по ссылке, чтобы лямбда могла её менять. Круглые скобки — параметры, как у обычной функции. Тело — в фигурных. Лямбду можно сохранить в переменную (её тип знает только компилятор, поэтому пишем auto) и вызывать как функцию: put(x, y, '@').
Строки 27–35 — лямбда с захватом [&]: «всё, что упомянуто, — по ссылке». Она видит cx, cy, rx, ry и put. Внутри — формула эллипса: точка внутри, если (x − cx)² / rx² + (y − cy)² / ry² ≤ 1. И сразу «объём»: чем ближе к левому верхнему краю, тем светлее, к краям — темнее. Буквы @, # и + — три уровня яркости.
Строки 36–56 — треугольник. Как понять, что точка внутри? Возьмём ребро AB и посмотрим, с какой стороны от него лежит точка. Это говорит знак векторного произведения — формула в лямбде cross. Внутри выпуклой фигуры точка лежит с одной и той же стороны от всех рёбер, обойдённых по кругу. Если для одних рёбер знак плюс, а для других минус — точка снаружи. Рисунок 21.1.

Рисунок 21.1 — Точка внутри выпуклого многоугольника — по одну сторону от всех рёбер

Кисти для спрайтов
Те же идеи, только на настоящей картинке Image с альфой. Набор кистей — листинги 21.2 и 21.3.

Листинг 21.2 — Кисти: обход, эллипс, многоугольник, обводка, подкраска

Листинг 21.2 (продолжение) — Кисти: обход, эллипс, многоугольник, обводка, подкраска
forEachPixel — тот же шаблон, что в листинге 21.1, но для Image. Шаблоны пишут целиком в заголовке: компилятор должен видеть их тело в каждом файле, где их вызывают, чтобы сделать нужную версию. Остальные функции — обычные, их тела в paint.cpp.

Листинг 21.3 — Кисти: реализация

Листинг 21.3 (продолжение) — Кисти: реализация

Листинг 21.3 (продолжение) — Кисти: реализация
lit (строки 11–20) умножает каждый канал цвета на яркость k и зажимает в 0…255 — так, в отличие от shade из главы 18, можно и осветлять, k больше единицы. Внутренняя лямбда ch захватывает k по значению: [k] — ей нужно только прочитать число.
fillEllipse (строки 22–36) — эллипс из листинга 21.1, только вместо трёх букв — плавная яркость.
fillPolygon (строки 38–57) — проверка знаков векторного произведения для любого числа вершин. Вершины передаются через std::span<const Point> — «окно» на массив точек, как в главе 14 у Map. Подойдёт std::array, std::vector или обычный массив. Ребро от последней вершины к первой даёт (i + 1) % size: после последнего индекса — снова ноль.
outline (строки 59–74) обводит фигуру: прозрачный пиксель, рядом с которым есть непрозрачный, красится тёмным. Важная деталь — строка 60: смотрим мы на копию картинки, сделанную до обводки, а пишем в оригинал. Почему это важно, покажет упражнение 21.2.
tint (строки 76–89) подмешивает цвет ко всей фигуре — пригодится, когда монстр получит пулю и покраснеет.
Имп и зомби
Теперь можно рисовать. Имп и зомби — листинг 21.4. Каждый — пара десятков вызовов кистей, слой за слоем: сначала то, что сзади, потом то, что спереди. Ноги, туловище, руки, голова, глаза, обводка.

Листинг 21.4 — Монстры из эллипсов и многоугольников

Листинг 21.4 (продолжение) — Монстры из эллипсов и многоугольников

Листинг 21.4 (продолжение) — Монстры из эллипсов и многоугольников
Строки 13–24 — две мелкие кисти поверх fillPolygon: прямоугольник и треугольник. Обрати внимание на std::array<Point, 4> p{{{x0, y0}, …}}: тройные фигурные скобки — внешние для массива, следующие для его внутреннего «сырого» массива, внутренние — для каждой точки. Две пары можно иногда опустить, но с тремя компилятор точно не перепутает.
Строки 26–29 — анимация. В двух позах ноги сдвинуты на 3 пикселя в разные стороны, а руки — на полтора. Чередуем позы — и монстр переминается с ноги на ногу. В Doom у каждого монстра было по четыре кадра ходьбы, и смотрелось это ненамного богаче.
Строки 78–89 — все четыре картинки строятся один раз, при первом вызове. Здесь новый для нас приём: лямбда, которую тут же вызывают, — [] { … }(). Она строит массив кадров и возвращает его, а static инициализируется результатом. Удобно, когда для инициализации нужно несколько строк кода, а не одно выражение.
Монстр в игре — структура Monster: вид, позиция и время анимации. В конструкторе Game буквы i и z на карте превращаются в монстров. Каждому даём свою начальную фазу, 0,37 × x + 0,61 × y, — иначе все монстры на уровне переминались бы строго в ногу, как на параде. В render — листинг 21.5 — выбираем кадр по времени.

Листинг 21.5 — Кадр анимации по времени
static_cast<int>(anim × 3) растёт на единицу три раза в секунду, а остаток от деления на 2 чередует 0 и 1. Этот приём мы уже видели в задаче 13.2 для мигающей надписи.
Pulsuz fraqment bitdi.