Kitabı oxu: «Mini Doom современный С++ через игру том 2 окно в ад»
Вместо предисловия
Это второй том книги «MINI DOOM. Современный C++ через игру». В первом томе ты прошёл основы C++ в консоли: переменные, ветвления, функции, контейнеры, свои типы, классы и память — и закончил консольным рогаликом, который проверяет AddressSanitizer.
Теперь из консоли выходим в окно: пиксели, игровой цикл, управление и текст на экране. Номера глав, листингов и рисунков в серии сквозные, поэтому ссылка «см. главу 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. Каждая глава начинается с проекта предыдущей, так что можно начать читать этот том, взяв готовый код последней главы прошлого тома.
Часть II. Окно в ад
Консоль своё отслужила. Чёрный экран с буквами годится для рогалика, но Doom — это пиксели: текстуры, огонь, кровь. В этой части мы откроем настоящее окно Windows, научимся красить в нём каждую точку, заведём игровой цикл, который крутится сотни раз в секунду, подключим клавиатуру и мышь и напишем свой шрифт. К концу части по окну будет бегать квадратный «игрок», которого ты ведёшь мышью и клавишами, а в углу будет тикать счётчик кадров.
Одно важное устройство этой части. Код, который общается с Windows, мы с самого начала будем держать отдельно от кода, который рисует и считает. Так сделан и настоящий MINI DOOM: вся игра не знает о Windows ничего, а маленький файл main_win32.cpp передаёт ей нажатия клавиш и показывает готовые кадры. Поэтому код игры можно проверять автотестами без всякого окна — и мы будем это делать.

Глава 9. Своё окно
Окно в Windows — не просто прямоугольник на экране. Это объект, который живёт своей жизнью: его двигают, сворачивают, закрывают, перекрывают другими окнами, и обо всём этом Windows сообщает твоей программе. Программа должна внимательно слушать и вовремя отвечать, иначе Windows решит, что она зависла, и напишет в заголовке «Не отвечает».
В этой главе мы напишем минимальную программу с окном на чистом Win32 API — без библиотек и мастеров проектов. Строк будет немного, но каждая важна: на этом скелете будет висеть весь MINI DOOM.
Как Windows разговаривает с программой
Представь окно ресторана выдачи. Windows — кухня, которая непрерывно подсовывает в окошко записки: «по окну щёлкнули мышью», «нажали клавишу», «окно надо перерисовать», «окно закрывают». Каждая записка называется сообщением. Записки складываются в очередь сообщений твоей программы, и программа должна по одной вынимать их и обрабатывать.
Вынимает записки цикл сообщений — обычный while в конце wWinMain. А обрабатывает их оконная процедура — функция, которую ты пишешь сам и сообщаешь Windows её адрес. Windows вызывает её для каждого сообщения, а ты решаешь: ответить самому или передать стандартному обработчику. Вся схема — на рисунке 9.1.

Рисунок 9.1 — Цикл сообщений: Windows кладёт записки в очередь, программа вынимает их и отдаёт оконной процедуре

Первое окно
Вот программа целиком — листинг 9.1. Она открывает окно 1280 × 800 с заголовком MINI DOOM и заливает его чёрным. Файл назван main_win32.cpp: в нём живёт всё, что связано с Windows, и в следующих главах этот файл будет только расти.

Листинг 9.1 — Минимальная программа с окном

Листинг 9.1 (продолжение) — Минимальная программа с окном
Собираем, запускаем — и вот оно, рисунок 9.2. Пустое чёрное окно, но это наше окно: его можно двигать, сворачивать, разворачивать на весь экран и закрывать крестиком.

Рисунок 9.2 — Первое окно MINI DOOM — настоящий запуск программы из листинга 9.1
Разберём три главных шага, они отмечены в комментариях цифрами.
Шаг 1, строки 34–42 — класс окна. Прежде чем создать окно, Windows хочет знать его «породу»: какая у окон этого вида оконная процедура, какой курсор, как называется порода. Это описание — структура WNDCLASSEXW — регистрируется функцией RegisterClassExW. Пустые фигурные скобки в объявлении wc{} обнуляют все поля, а мы заполняем только нужные. Поле cbSize — размер самой структуры: так Windows понимает, с какой версией структуры имеет дело.
Шаг 2, строки 44–53 — само окно. Мы хотим, чтобы картинка внутри окна была ровно 1280 × 800, но окно — это ещё рамка и заголовок. AdjustWindowRect берёт желаемый размер картинки и увеличивает его на толщину рамки и заголовка. Потом CreateWindowExW создаёт окно по зарегистрированному классу, а ShowWindow показывает его. Чем окно отличается от его клиентской области, видно на рисунке 9.3.

Рисунок 9.3 — Окно — это заголовок, рамка и клиентская область; AdjustWindowRect добавляет рамку к нужному размеру картинки
Шаг 3, строки 55–59 — цикл сообщений. GetMessageW ждёт, пока в очереди появится записка, и кладёт её в msg. TranslateMessage превращает нажатия клавиш в символы — для игры это не важно, но так принято. DispatchMessageW отдаёт записку нашей оконной процедуре. Цикл крутится, пока GetMessageW возвращает положительное число. Когда приходит сообщение о выходе, она вернёт ноль — и программа завершится.
Строки 9–28 — оконная процедура. Мы обрабатываем всего два сообщения. WM_PAINT приходит, когда окну нужно перерисоваться — например, его вытащили из-за другого окна. Мы заливаем всё чёрным между BeginPaint и EndPaint. WM_DESTROY приходит, когда окно уничтожено, и PostQuitMessage(0) кладёт в очередь то самое сообщение о выходе, которое остановит цикл. Всё остальное — передвижение, изменение размера, клик по крестику — отдаём стандартному обработчику DefWindowProcW на строке 27.
Обрати внимание на букву W на конце почти всех функций и на букву L перед строками: L"MINI DOOM". Это «широкие» версии функций и строк для Unicode. Обычная строка хранит символы в char, по байту на символ, а широкая — в wchar_t. Сколько места это занимает в Windows, покажет листинг 9.2: я собрал его компилятором под Windows и запустил как настоящую Windows-программу.

Листинг 9.2 — Размеры обычных и широких строк в Windows
В Windows wchar_t занимает два байта, и строка L"DOOM" весит вдвое больше обычной. Этими двухбайтовыми символами — кодировкой UTF-16 — Windows и говорит с программами начиная с Windows NT. Поэтому мы пишем W-функции и L-строки: так русские, японские и любые другие буквы в заголовках окон работают без кракозябр.
А последняя строка вывода — привет из главы 2. Помнишь грабли про long, который в Windows занимает 4 байта, а в Linux — 8? Вот они, живьём: Windows-программа честно сообщает 4. Если собрать тот же файл под Linux, получится wchar_t в 4 байта и long в 8. Типы в C++ не обязаны иметь одинаковый размер на разных системах, и об этом полезно помнить.


Таблица 9.1 — Функции Win32, из которых собрано первое окно
Сборка
В Visual Studio создай пустой проект C++ — не «Консольное приложение» и не «Классическое приложение Windows», последнее генерирует кучу лишнего кода. Добавь файл main_win32.cpp, выставь настройки строгости из таблицы 1.2 и ещё две из таблицы 9.2.

Таблица 9.2 — Дополнительные настройки проекта с окном
Я собираю тот же файл компилятором clang под Windows — листинг 9.3. Первая попытка специально без режима Unicode, вторая — правильная.

Листинг 9.3 — Сборка под Windows: без Unicode и с ним
Без Unicode сборка падает на строке 38. Дело в том, что у большинства функций Windows по две версии: LoadCursorA для старых однобайтовых строк и LoadCursorW для Unicode. Константа IDC_ARROW тоже бывает двух видов, и какой именно — решает макрос UNICODE. Мы вызвали W-функцию, а IDC_ARROW оказался A-вида — типы не сошлись. Флаг -municode включает Unicode и заодно говорит, что точка входа — wWinMain. Флаг -mwindows — что это оконная программа, а не консольная. Во второй раз компилятор промолчал, а команда file подтвердила: получился исполняемый файл Windows с графическим интерфейсом, GUI.



Итоги главы
— Windows общается с программой сообщениями, которые копятся в очереди.
— Цикл сообщений вынимает их GetMessageW и отдаёт DispatchMessageW оконной процедуре.
— Оконная процедура обрабатывает нужные сообщения, остальные отдаёт DefWindowProcW.
— Окно создаётся по зарегистрированному классу; AdjustWindowRect считает размер с рамкой.
— WM_DESTROY → PostQuitMessage → GetMessageW возвращает 0 → программа выходит.
— Функции и строки — W-версии и L"..."; подсистема — Windows, точка входа — wWinMain.
Контрольная точка: у тебя есть чёрное окно MINI DOOM, которое честно отвечает Windows и закрывается крестиком. В главе 10 мы перестанем заливать его чёрной кистью и начнём красить каждый пиксель сами.
Упражнения
Упражнение 9.1. Что выведет? Программа из листинга 9.4 собрана под Windows. Что она напечатает? Будь внимателен с последним числом.

Листинг 9.4 — Упражнение 9.1
Упражнение 9.2. Найди баг. В программе из листинга 9.5 окно открывается и закрывается крестиком, но если посмотреть в диспетчер задач, процесс doom.exe остаётся висеть. Что сломано?

Листинг 9.5 — Упражнение 9.2

Листинг 9.5 (продолжение) — Упражнение 9.2
Упражнение 9.3. Напиши сам. Сделай так, чтобы окно закрывалось по клавише Esc. Подсказка: нажатие клавиши приходит сообщением WM_KEYDOWN, а код клавиши лежит в параметре wp; у Esc это VK_ESCAPE.
Упражнение 9.4. Подумай. Что случится, если в обработчике WM_PAINT вместо пары BeginPaint и EndPaint просто ничего не делать и вернуть 0?
Задачи
Задача 9.1. Окно фиксированного размера. Сделай окно, у которого нельзя менять размер мышью и которое нельзя развернуть на весь экран. Подсказка: стиль WS_OVERLAPPEDWINDOW — это комбинация нескольких флагов; убери из неё лишние оператором & и ~.
Задача 9.2. Подтверждение выхода. При нажатии крестика Windows присылает WM_CLOSE. Перехвати его и спроси игрока через MessageBoxW, точно ли он хочет выйти. Закрой окно, только если ответ «Да».
Задача 9.3. Счётчик сообщений. Заведи в оконной процедуре статический счётчик и выводи число полученных сообщений в заголовок окна через SetWindowTextW. Подвигай окно мышью и посмотри, сколько сообщений приходит за секунду.
Глава 10. Пиксели
Чёрное окно из прошлой главы залито кистью Windows. Это удобно, но для игры бесполезно: кисть умеет красить прямоугольники, а нам нужны огонь, текстуры и рожа импа. Поэтому мы поступим, как настоящие игры девяностых: заведём в памяти свой кусок экрана, будем красить в нём каждую точку сами, а Windows попросим только показать готовую картинку.
В этой главе разберёмся, как устроен цвет, как двумерная картинка лежит в одномерной памяти и как одним вызовом растянуть её на всё окно. Попутно наступим на три настоящих грабли — и все три снимем на скриншоты.
Пиксель и цвет
Экран — это сетка крошечных лампочек, пикселей. Каждый пиксель светится смесью трёх цветов: красного, зелёного и синего — отсюда сокращение RGB. Яркость каждого канала — число от 0 до 255, ровно один байт. Все три по нулям — чёрный, все по 255 — белый, красный 255 при нулевых остальных — чистый красный. Всего так можно смешать 256³, то есть почти 17 миллионов цветов.
Три байта удобно хранить в одном 32-битном числе std::uint32_t. Мы будем раскладывать их так: старший байт пустой, дальше красный, зелёный и синий. В шестнадцатеричной записи это выглядит как 0x00RRGGBB — каждые две цифры ровно байт, поэтому цвет читается прямо глазами. Как собрать такое число из трёх каналов и разобрать обратно, показывает листинг 10.1.

Листинг 10.1 — Упаковка и распаковка цвета сдвигами и масками
Строка 8 собирает цвет. Оператор << сдвигает биты числа влево: r << 16 переносит красный на 16 бит выше, в третий байт; g << 8 — во второй. Синий остаётся на месте. Оператор | («или» для битов) складывает три сдвинутых числа в одно, не мешая друг другу, — ведь каждое занимает свой байт. Как биты разъезжаются по местам, показано на рисунке 10.1.

Рисунок 10.1 — Цвет в одном 32-битном числе: по байту на канал
Строки 16–18 разбирают цвет обратно. Сдвиг вправо опускает нужный байт в самый низ, а маска & 0xFF отрезает всё, что выше: оставляет только младшие восемь бит. В выводе видно, что из 0x00FF7828 вернулись те же 255, 120 и 40.
Строка 22 затемняет цвет вдвое — каждый канал пополам. Этим приёмом в главе 18 мы будем погружать дальние стены во тьму. А формат {:08X} в std::format печатает число шестнадцатеричными цифрами, восемь знаков с ведущими нулями: так байты цвета видны по отдельности.

Картинка в памяти
Наша картинка — 640 × 400 пикселей. Как хранить двумерную сетку? Можно вектором векторов, но это четыреста отдельных кусков памяти. Игры поступают проще: один длинный ряд из 640 × 400 = 256 000 чисел, строки картинки лежат друг за другом, как вагоны поезда. Пиксель (x, y) живёт по номеру y × width + x: пропусти y целых строк и отсчитай x в нужной. Проверим на крошечном экране 4 × 3 — листинг 10.2.

Листинг 10.2 — Двумерный экран в одномерном векторе
Первая строка вывода — память как она есть: двенадцать символов подряд. Остальные три — тот же вектор, нарезанный по четыре: так его видим мы. Буква C в точке (1, 2) лежит под номером 2 × 4 + 1 = 9. Схема — на рисунке 10.2.

Рисунок 10.2 — Экран 4 × 3 и тот же экран в памяти: строки идут друг за другом
У такой укладки есть ловушка, и она в листинге 10.3. Программа хочет обвести экран 640 × 400 рамкой: левый край — пиксель x = 0, правый — x = width. Собрано с санитайзером.

Листинг 10.3 — Правый край на пиксель дальше, чем нужно
Санитайзер нашёл запись за концом вектора на строке 13. Но посмотри внимательнее, когда он её нашёл: на последней, четырёхсотой строке. Правый край — x = width, а последний пиксель строки — width − 1. Для всех строк, кроме последней, пиксель «x = width» — это на самом деле первый пиксель следующей строки. Программа молча красила левый край строкой ниже, и ни один сторож этого не заметил. Только на последней строке «следующей строки» уже нет, и запись ушла за конец памяти.

Холст: класс Canvas
Обернём кусок памяти в класс — как учились в главе 7. Холст знает свою ширину и высоту, умеет залить всё одним цветом, поставить точку и закрасить прямоугольник. И ничего не знает про Windows: это обычный C++. Заголовок — листинг 10.4.

Листинг 10.4 — Холст: заголовок

Листинг 10.4 (продолжение) — Холст: заголовок
Функция rgb объявлена constexpr: цвета вроде rgb(30, 0, 0) компилятор посчитает ещё при сборке. Метод data() отдаёт указатель на первый пиксель — он понадобится, чтобы передать картинку Windows. Реализация — листинг 10.5.

Листинг 10.5 — Холст: реализация

Листинг 10.5 (продолжение) — Холст: реализация
Строки 13–17 — та самая формула y × width + x, спрятанная в приватный метод index. Арифметика идёт в std::size_t, чтобы при больших картинках не переполнить int.
Строки 23–28. put проверяет обе координаты и молча пропускает точки за краем. Это правило холста: рисовать можно где угодно, но краска за край не попадёт. Монстр, вышедший наполовину за экран, просто обрежется.
Строки 30–43. fillRect не зовёт put для каждой точки, а один раз обрезает прямоугольник по краям холста. Внутри цикла проверок нет совсем — а пикселей в прямоугольнике могут быть тысячи. Мелочь, но в графике такие мелочи складываются в кадры в секунду.
Рисуем сцену
Нарисуем что-нибудь в духе Doom: адское небо, уходящий в темноту пол, четыре колонны и статус-бар внизу. Плавный переход цвета называется градиентом, и рисовать его мы будем построчно — листинг 10.6.

Листинг 10.6 — Тестовая сцена: градиенты и прямоугольники

Листинг 10.6 (продолжение) — Тестовая сцена: градиенты и прямоугольники
Строки 8–20 — смешивание цветов. mix находит промежуточное число между a и b: на шаге 0 — a, на последнем — почти b. blend делает то же для каждого из трёх каналов отдельно: распаковывает их сдвигом и маской, смешивает и запаковывает обратно. Это та же распаковка, что в листинге 10.1, только в лямбде.
Строки 27–34. Небо и пол — это сотни горизонтальных полосок высотой в пиксель, и у каждой свой цвет из градиента. Небо светлеет к горизонту, пол темнеет к низу экрана.
Сцена тоже не знает про Windows, поэтому её можно нарисовать и проверить автотестом безо всякого окна — листинг 10.7. Он рисует сцену в холст, печатает цвета нескольких точек и сохраняет картинку в файл.
Pulsuz fraqment bitdi.