Kitabı oxu: «Mini Doom современный C++ через игру том 4 здесь кто то есть»

Şrift:

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

Это четвёртый том книги «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.

Yaş həddi:
18+
Litresdə buraxılış tarixi:
25 oktyabr 2026
Yazılma tarixi:
2026
Həcm:
124 səh. 125 illustrasiyalar
Müəllif hüququ sahibi:
Автор
Yükləmə formatı:

Oxşar kitablar