
Полная версия:
MINI DOOM современный С++ через игру том 3 третье измерение

Алексей Примак
MINI DOOM современный С++ через игру том 3 третье измерение
Вместо предисловия
Это третий том книги «MINI DOOM. Современный C++ через игру». В первых двух томах ты освоил C++ и написал своё окно Windows: кадровый буфер 640 × 400, игровой цикл с dt, управление клавиатурой и мышью через структуру Input и растровый шрифт. Стена между игрой и Windows из главы 12 держится до конца книги.
Теперь окно становится трёхмерным: бросаем лучи и поднимаем стены. Номера глав, листингов и рисунков в серии сквозные, поэтому ссылка «см. главу 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. Каждая глава начинается с проекта предыдущей, так что можно начать читать этот том, взяв готовый код последней главы прошлого тома.
Часть III. Третье измерение
Вот мы и добрались до главного фокуса. В этой части плоский мир встанет на дыбы: из карты, нарисованной символами, вырастут стены, на стенах появятся кирпичи, под ногами — пол, над головой — потолок, а вдали всё утонет в темноте. Без видеокарты, без 3D-библиотек, без единой готовой картинки — только наш холст и немного школьной математики.
Приём называется рейкастинг — «бросание лучей». Из глаз игрока мы пускаем по лучу на каждый столбец экрана и смотрим, где луч упрётся в стену. Чем дальше стена, тем короче её столбец на экране. Так в 1992 году работал Wolfenstein 3D, и в похожей манере думал Doom. Мы пройдём весь путь по шагам: сначала научимся ходить по карте сверху, потом бросим лучи на этой карте, а уже потом поднимем стены.

Глава 14. Вид сверху
До сих пор наш игрок был квадратом, который ездил вверх-вниз-влево-вправо по экрану. В трёхмерной игре так не ходят. В Doom у игрока есть взгляд: он смотрит в какую-то сторону, и клавиша W ведёт туда, куда он смотрит, а не «вверх по экрану». Мышь поворачивает взгляд, A и D делают шаг вбок — стрейф.
В этой главе мы перенесём карту уровня из главы 5 в окно, поставим на неё игрока с направлением взгляда и научим его ходить «как в Doom»: поворачиваться, бегать, шагать вбок и упираться в стены, не застревая в них. Картинку будем рисовать сверху — как карту. Она ещё пригодится: в конце книги из неё выйдет автокарта.
Карта в окне
Карта — та же сетка символов, что в главе 5, только стены теперь обозначены цифрой 1, а не решёткой. Зачем цифры? В главе 17 у каждой цифры будет своя текстура: 1 — камень, 2 — кирпич, и так далее. Уровень — листинг 14.1.

Листинг 14.1 — Первый уровень: 16 × 16 клеток
Строки хранятся в std::array из std::string_view: всё constexpr, вся карта лежит прямо в программе и не занимает ни байта в куче. А работать с картой будем через класс Map — листинги 14.2 и 14.3.

Листинг 14.2 — Класс Map: заголовок

Листинг 14.3 — Класс Map: клетки в одном векторе
Строки 6–12. Конструктор принимает std::span — «окно» на последовательность строк, неважно, лежат они в std::array, в std::vector или в обычном массиве. Все строки склеиваются в один вектор символов: клетка (x, y) лежит под номером y × ширина + x — точно так же, как пиксель в холсте из главы 10.
Строки 14–20. Всё, что за краем карты, считается стеной. Это страховка на будущее: лучи из главы 15 иногда долетают до края, и пусть лучше упрутся в невидимую стену, чем полезут в чужую память.
Строка 22 и дальше — isWall: стена — любая цифра от 1 до 9. А find ищет клетку с нужным символом и возвращает std::optional
Угол и направление
Положение игрока — две дробные координаты x и y, в клетках. x = 1,5 и y = 1,5 — это середина клетки (1, 1). Кроме координат у игрока есть угол — куда он смотрит. Договоримся так: угол 0 — взгляд на восток, вправо по карте. Ось y на карте, как и на экране, смотрит вниз, поэтому угол растёт по часовой стрелке: 90° — юг, 180° — запад, 270° — север.
Но шагать «по углу» нельзя: шаг — это сдвиг по x и по y. Нужно перевести угол в направление — пару чисел, на сколько сдвинуться по x и по y за один шаг длиной 1. Это и делают косинус и синус, рисунок 14.1.

Рисунок 14.1 — Угол a превращается в направление: x-часть — косинус, y-часть — синус
Представь стрелку длиной ровно 1, которая крутится вокруг игрока. Когда она повёрнута на угол a, её конец сдвинут от центра на cos a по x и на sin a по y. При a = 0 стрелка смотрит на восток: cos 0 = 1, sin 0 = 0 — шагаем только по x. При 90° — на юг: cos = 0, sin = 1. При 45° — наискосок, и обе части равны примерно 0,71. Посчитаем это кодом — листинг 14.4.

Листинг 14.4 — Пять направлений: угол → (cos, sin)
Всё как на рисунке. А теперь посмотри на последнюю строку: у севера косинус -0.00. Минус ноль? Дело в том, что 270° в радианах — число, которое нельзя записать в double точно. Косинус от «почти 270°» получается не ноль, а примерно −0,00000000000000018 — крошечное отрицательное число, которое при печати с двумя знаками округлилось до «−0.00». Для движения игрока это ничего не меняет: сдвиг на 10 в степени −16 клетки никто не заметит.

И ещё одна ловушка, в которую наступает каждый. Посмотри, что выводит листинг 14.5.

Листинг 14.5 — Синус 90 — это не единица
Функции std::sin и std::cos понимают угол в радианах, а не в градусах. Полный круг — это 2π радиан, то есть примерно 6,28, а 90° — π/2 ≈ 1,5708. Когда мы пишем std::sin(90.0), функция честно считает синус от 90 радиан — это больше четырнадцати полных оборотов — и получает 0,894. Константа π лежит в заголовке

Вперёд и вбок
Вектор взгляда (dirX, dirY) говорит, куда идти по W. А куда идти по D — шагом вправо? Нужен вектор, повёрнутый на 90° по часовой стрелке. Поворот на 90° делается простой перестановкой: из (dirX, dirY) получается (−dirY, dirX). Проверим: смотрим на восток (1, 0) — «вправо» будет (0, 1), то есть на юг. Если стоять лицом на восток, юг и правда справа. Рисунок 14.2.

Рисунок 14.2 — Взгляд, «вправо» и их сумма: куда ведут W, D и обе клавиши сразу
Теперь класс Game — листинги 14.6 и 14.7.

Листинг 14.6 — Класс Game: карта и игрок

Листинг 14.7 — Класс Game: поворот, ходьба, стены

Листинг 14.7 (продолжение) — Класс Game: поворот, ходьба, стены

Листинг 14.7 (продолжение) — Класс Game: поворот, ходьба, стены
Строки 13–17 — все настройки движения в одном месте. Скорость теперь в клетках в секунду, а не в пикселях: игра больше не знает, сколько пикселей на экране занимает клетка, и не должна знать.
Строки 20–24. Игрок встаёт в центр клетки с буквой P. value_or — запасной вариант, если на карте буквы нет.
Строки 27–30 — поворот. Стрелки крутят взгляд с постоянной скоростью, умноженной на dt, — как ходьба в главе 11. А мышь — без dt: её сдвиг уже «за кадр», сколько проехала, на столько и повернулись. std::remainder держит угол в пределах от −π до π, чтобы после сотни оборотов он не вырос до тысяч радиан и не потерял точность.
Строки 32–45 — ходьба. Сначала из клавиш получаем fwd и side: +1, −1 или 0. Потом складываем «вперёд» и «вбок» по формулам с рисунка 14.2. Если зажаты W и D, длина суммы — √2, поэтому делим на len: игрок идёт наискосок с той же скоростью. Это то же исправление диагонали, что в главе 12, только честное: std::hypot считает длину вектора по Пифагору, √(mx² + my²).
Стены и скольжение
Игрок — не точка. Если проверять стену только под центром игрока, он сможет подойти к стене вплотную и наполовину в неё влезть. Пока смотрим сверху, это просто некрасиво, а в 3D камера окажется внутри стены и покажет мусор. Поэтому у игрока есть радиус — 0,25 клетки, и он считается квадратиком 0,5 × 0,5. Функция blocked на строках 48–62 проверяет все клетки, которых касается этот квадратик, — их бывает от одной до четырёх, рисунок 14.3.

Рисунок 14.3 — Игрок — квадратик со стороной 0,5 клетки; стену проверяем под всеми его углами
std::floor округляет вниз, до целого: floor(1,6) = 1, floor(−0,3) = −1. Так из дробной координаты получается номер клетки. Обычный static_cast
А теперь главный приём главы — строки 64–72, функция tryMove. Можно было бы сделать просто: если новое место свободно — шагаем, если нет — стоим. Но попробуй так пойти наискосок вдоль стены: игрок прилипнет к ней намертво, ведь любой диагональный шаг задевает стену. В Doom так не бывает: там игрок скользит вдоль стены. Секрет в том, чтобы двигаться по осям по очереди: сначала пробуем сдвиг по x, потом отдельно — по y. Стена справа запрещает только x-часть, а y-часть проходит, рисунок 14.4.

Рисунок 14.4 — Скольжение: x-часть шага упёрлась в стену, y-часть прошла

Рисуем карту сверху
Рисование вынесем в отдельный файл — эта картинка понадобится и в следующей главе, и в будущей автокарте. Заголовок topview.hpp — одна функция drawTopView, реализация — листинг 14.8.

Листинг 14.8 — Вид сверху: клетки, игрок, взгляд

Листинг 14.8 (продолжение) — Вид сверху: клетки, игрок, взгляд
Строки 16–26. Каждая клетка — квадрат 22 × 22 пикселя, а рисуем 21 × 21: зазор в пиксель даёт сетку. Строки 28–33 — две лямбды, которые переводят координаты в клетках в пиксели холста. Лямбда с [&] видит переменные вокруг себя: ox, oy и kCell.
Строки 38–44. От игрока рисуем два отрезка: жёлтый — длиной в клетку, куда он смотрит, синий — покороче, куда ведёт клавиша D. Отрезки рисует новый метод холста line, листинг 14.9.

Листинг 14.9 — Отрезок на холсте: шаг за шагом от начала до конца
Идея простая: делаем столько шагов, сколько пикселей по длинной оси отрезка, и на каждом шаге ставим точку в t-й доле пути. std::lround округляет до ближайшего целого — так линия получается ровной. Это не самый быстрый способ, но для пары линий за кадр быстрее и не нужно.
Робот проверяет ходьбу
Как и в главе 12, первым по карте пройдётся робот — листинг 14.10. Начало файла с функциями hold и savePPM — такое же, как раньше, поэтому смотри на main.

Листинг 14.10 — Автотест: прогулка по карте с отчётом

Листинг 14.10 (продолжение) — Автотест: прогулка по карте с отчётом
Проверяем каждую строку.
— forward 1 s. 3 клетки в секунду, одна секунда: x из 1,50 стал 4,50.
— forward 2 s more. Должен был дойти до 10,5, но на x = 7 стена. Игрок остановился на 6,75 — ровно на своём радиусе 0,25 от стены.
— mouse +314. 314 × 0,0025 = 0,785 радиана — это 45°, взгляд на юго-восток.
— forward 1 s. Хотели сдвинуться на (2,12; 2,12), но справа стена. x не изменился, а y проехал 2,12: из 1,50 в 3,62. Вот оно, скольжение.
— strafe left 1 s. «Влево» от юго-востока — северо-восток. Снова x упирается в стену, а y едет назад, к 1,50.
— turn right 0.5 s. 2,5 радиана в секунду × 0,5 секунды = 1,25 радиана ≈ 72°. 45 + 72 = 117°.
— run 1 s. Бег на юго-запад: игрок проскользил вдоль стен и въехал в проход в стене на строке 6 карты — он на рисунке 14.5.

Рисунок 14.5 — Кадр, который сохранил тест: игрок входит в проход, жёлтая линия — взгляд
Новые клавиши в окне
В main_win32.cpp поменялось немного: структура Input теперь другая (листинг 14.11), и функция collectInput раскладывает клавиши по-новому — листинг 14.12. Стрелки вверх и вниз дублируют W и S, а стрелки влево и вправо поворачивают — так в Doom играли без мыши.

Листинг 14.11 — Input для ходьбы «как в Doom»

Листинг 14.12 — collectInput: W, S, A, D, стрелки и мышь
Вертикальный сдвиг мыши мы больше не собираем: в Doom нельзя смотреть вверх и вниз. И игра создаётся по-новому — из карты: doom::Game game{doom::Map{doom::kLevel}};. Фигурные скобки тут не для красоты: с круглыми эта строка подозрительно похожа на объявление функции, и компилятор может понять её именно так. Эту ловушку называют «самым досадным разбором» (most vexing parse), а фигурные скобки её обходят.
Запускаем и даём роботу пройтись: вперёд, поворот мышью, ещё вперёд и поворот стрелкой. Результат — рисунок 14.6.

Рисунок 14.6 — Контрольная точка: карта в окне, игрок слушается мыши и клавиш
Числа на этой картинке не совпадут с автотестом и не должны: робот в окне жмёт клавиши «вживую», и за кадр проходит столько времени, сколько получилось. Повороты мышью тоже другие: Windows сама решает, во сколько «сырых» единиц превратить сдвиг курсора. Именно поэтому точные проверки живут в автотесте, а окно мы смотрим глазами.

Итоги главы
— Позиция игрока — дробные координаты в клетках, плюс угол взгляда в радианах.
— Угол → направление: dirX = cos(angle), dirY = sin(angle). Ось y вниз, угол по часовой.
— «Вправо» от взгляда — (−dirY, dirX). Вперёд и вбок складываются, сумма делится на длину.
— std::sin и std::cos работают в радианах. Градусы — только для людей.
— Игрок — квадратик с радиусом; стену проверяем под всеми его углами, номер клетки — через std::floor.
— Сдвиг по осям по очереди даёт скольжение вдоль стен.
Контрольная точка: по карте ходит игрок со взглядом. Осталось понять, что он видит. В следующей главе мы бросим из его глаз лучи и найдём, где каждый луч упрётся в стену.
Упражнения
Упражнение 14.1. Что выведет? Функция приводит угол в градусах к диапазону от −180 до 180 — листинг 14.13.

Листинг 14.13 — Что выведет программа?
Упражнение 14.2. Найди баг. Программист написал функцию «вправо от взгляда» — листинг 14.14. Игрок смотрит на восток, жмёт D и уходит на север. Где ошибка?

Листинг 14.14 — Почему D ведёт влево?
Упражнение 14.3. Напиши сам. В Doom пятиться назад так же быстро, как идти вперёд, а во многих современных играх — медленнее. Сделай, чтобы по S игрок шёл с 60% скорости.
Упражнение 14.4. Подумай. Мы ограничили dt значением 0,05 секунды в главе 11. Что может случиться со стенами, если убрать это ограничение и игра на секунду зависнет?
Задачи
Задача 14.1. Разворот. Сделай клавишу Q, по которой игрок за 0,25 секунды плавно поворачивается на 180°. Что нужно хранить между кадрами?
Задача 14.2. Инерция. Сейчас игрок останавливается мгновенно. Заведи скорость (vx, vy): клавиши разгоняют её, а без клавиш она каждую секунду теряет, скажем, 90% величины. Как сделать затухание, не зависящее от FPS?
Задача 14.3. Туман войны. Рисуй на карте только клетки, которые находятся не дальше трёх клеток от игрока или уже были рядом с ним раньше. Где хранить, какие клетки игрок видел?
Глава 15. Бросаем лучи
Игрок ходит по карте, но пока ничего не видит. Чтобы нарисовать, что у него перед глазами, нужно ответить на один вопрос, зато много раз за кадр: если смотреть из этой точки в эту сторону, где взгляд упрётся в стену и как далеко она? Ответ даёт луч: мысленно пускаем из глаз прямую и идём вдоль неё, пока не наткнёмся на стену.
В этой главе мы напишем функцию castRay, которая отвечает на этот вопрос быстро и точно, и посмотрим на её работу сверху: из игрока будет бить веер лучей, как свет фонарика. В следующей главе этот же веер превратится в трёхмерную картинку.
Как не надо: шажками
Первая мысль, которая приходит в голову: идти вдоль луча маленькими шажками, скажем по 0,01 клетки, и после каждого шага проверять, не в стене ли мы. Это работает, но плохо сразу в двух смыслах.
— Медленно. До стены в десяти клетках — тысяча шагов. Лучей у нас будет 640 на кадр, кадров — сотни в секунду. Сотни миллионов проверок в секунду только на то, чтобы найти стены.
— Неточно. Луч может пройти через угол стены за один шажок и проскочить его. Чем больше шаг, тем чаще такие дыры. А расстояние до стены получается с ошибкой до размера шага — на экране это рваные, «дрожащие» края стен.
Нужен способ, который проверяет каждую клетку на пути луча ровно один раз и находит точку входа в стену без ошибки. Такой способ есть, и он почти такой же простой.
Идея DDA: от линии к линии
Посмотри на карту как на тетрадь в клетку. Луч переходит из клетки в клетку только в одном случае: когда пересекает линию сетки. Вертикальную линию — значит, шагнул в соседнюю клетку по x. Горизонтальную — по y. Между линиями ничего интересного не происходит: луч внутри одной клетки, а она либо пол, либо стена.
Значит, надо прыгать сразу от одного пересечения к следующему. На каждом шаге есть ровно два кандидата: ближайшая вертикальная линия впереди и ближайшая горизонтальная. Какая ближе — ту и пересекаем, переходим в соседнюю клетку и проверяем её. Этот алгоритм называется DDA — digital differential analyzer, «цифровой дифференциальный анализатор». Название страшное, суть — та, что в этом абзаце. Рисунок 15.1.

Рисунок 15.1 — DDA: луч прыгает от одного пересечения сетки к следующему, проверяя каждую клетку один раз
Самое приятное: расстояние между соседними вертикальными линиями вдоль луча всегда одно и то же. Если луч на каждую единицу пути сдвигается по x на dx, то чтобы сдвинуться по x на целую клетку, надо пройти 1 / |dx|. Для луча (0,8; 0,6) это 1,25. Для горизонтальных линий — 1 / |dy| = 1,67. Посчитали один раз — и дальше только складываем.
Пройдём этот луч по шагам программой — листинг 15.1. Карта маленькая, луч идёт вправо-вниз, поэтому знаки упрощены: клетка всегда растёт.

Листинг 15.1 — DDA по шагам на маленькой карте

Листинг 15.1 (продолжение) — DDA по шагам на маленькой карте
Строки 21–22 — те самые шаги между линиями: 1,25 и 1,67. Строки 23–24 — расстояние до первых линий. Игрок стоит в клетке (1, 1) в точке x = 1,3: до вертикальной линии x = 2 осталось 0,7 клетки по x, то есть 0,7 × 1,25 = 0,875 вдоль луча. До линии y = 2 осталось 0,6 по y, это 0,6 × 1,67 = 1,0.
Дальше цикл: сравниваем nextX и nextY, меньшее — это следующее пересечение. Переходим в соседнюю клетку и прибавляем к этому «следующему» его шаг. Сверь вывод с рисунком 15.1: 0,88 — вправо, 1,00 — вниз, 2,12 — вправо, 2,67 — вниз, 3,38 — вправо, и там стена. Пять шагов вместо трёхсот тридцати восьми по 0,01.

castRay для любого направления
Настоящий луч может смотреть куда угодно: влево, вверх, строго вдоль оси. Общая версия — листинги 15.2 и 15.3.

Листинг 15.2 — Результат луча и объявление castRay

Листинг 15.3 — castRay: DDA в любую сторону

Листинг 15.3 (продолжение) — castRay: DDA в любую сторону
Строки 18–19. Если луч идёт строго вертикально, dx равен нулю, и 1 / dx — деление на ноль. Для double это не падение программы, а бесконечность, но надеяться на это не стоит: подставляем огромное kFar сами. Смысл тот же: вертикальную линию такой луч не пересечёт никогда.
Строки 20–21 — в какую сторону менять номер клетки: влево луч идёт — x уменьшается, вправо — растёт.
Строки 23–26. Расстояние до первой линии зависит от направления. Идём вправо — до линии справа, это cx + 1 − ox. Идём влево — до линии слева, а это ox − cx. Ровно в этом месте чаще всего ошибаются — см. упражнение 15.2.
Строки 28–46 — тот же цикл, что в листинге 15.1. Перед шагом запоминаем расстояние до пересекаемой линии — это и будет расстояние до стены, если за линией стена. И запоминаем, какую линию пересекли: side = 0 — вертикальную, side = 1 — горизонтальную. В главе 16 по side мы будем красить стены по-разному, а в главе 17 — выбирать точку на текстуре.

