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

Полная версия

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

Настройки чтения
Размер шрифта
Высота строк
Поля
На страницу:
1 из 2

Алексей Примак

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: либо клетку, либо «ничего». Помнишь optional из главы 6? Здесь он как раз на месте: на карте может и не оказаться буквы P.

Угол и направление

Положение игрока — две дробные координаты 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. Константа π лежит в заголовке и называется std::numbers::pi.



Вперёд и вбок

Вектор взгляда (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 округляет к нулю и для отрицательных чисел ошибается на клетку — поэтому floor.

А теперь главный приём главы — строки 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 — выбирать точку на текстуре.

На страницу:
1 из 2