DevOps’ный C++ и «кухонные войны», или Как я начал писать игры во время еды
«Я знаю, что ничего не знаю» Сократ
Для кого: для IT-шников, которые плевали на всех разработчиков и хотят поиграть в свои игры!
О чем: о том, как начать писать игры на C/C++, если вдруг вам это надо!
Зачем вам это читать: разработка приложений — это не моя рабочая специализация, но я стараюсь каждую неделю программировать. Потому что люблю игры!
Привет, меня зовут Андрей Гранкин, я DevOps в компании Luxoft. Разработка приложений — это не моя рабочая специализация, но я стараюсь каждую неделю программировать. Потому что люблю игры!
Индустрия компьютерных игр огромна, по слухам, сегодня даже больше, чем индустрия кино. Игры писали с начала развития компьютеров, используя, по современным меркам, сложные и базовые методы разработки. Со временем стали появляться игровые движки с уже запрограммированной графикой, физикой, звуком. Они позволяют сосредоточиться на разработке самой игры и не заморачиваться по поводу ее основания. Но вместе с ними, с движками, разработчики «слепнут» и деградируют. Само производство игр ставится на конвейер. А количество продукции начинает преобладать над ее качеством.
В то же время, играя в чужие игры, мы постоянно ограничены локациями, сюжетом, персонажами, игровой механикой, которую придумали другие люди. Так что я понял, что…
… настало время создавать свои миры, подвластные только мне. Миры, где я есть и Отец, и Сын, и Святой Дух!
И я искренне верю, что, написав свой собственный игровой движок и игру на нем, получится разуть глаза, протереть форточки и прокачать свою кабину, став более опытным и цельным программистом.
В этой статье попробую рассказать, как начал писать небольшие игры на C/C++, каков процесс разработки и где нахожу время на хобби в условиях большой загруженности. Она субъективна и описывает процесс индивидуального старта. Материал о невежестве и вере, о моей личной картине мира в данный момент. Другими словами, «Администрация не несет ответственности за ваши личные мозги!».
Практика
«Знания без практики бесполезны, практика без знаний опасна» Конфуций
Мой блокнот — моя жизнь!
Итак, на практике могу сказать, что все для меня начинается с блокнота. Туда записываю не только свои повседневные задачи, в нем еще и рисую, программирую, конструирую блок-схемы и решаю задачи, в том числе математические. Всегда используйте блокнот и пишите только карандашом. Это чисто, удобно и надежно, ИМХО.
Мой (уже исписанный) блокнот. Вот так он выглядит. В нем повседневные задачи, идеи, рисунки, схемы, решения, черная бухгалтерия, код и так далее
На данном этапе я успел закончить три проекта (это в моем понимании «законченности», ведь любой продукт можно развивать относительно бесконечно).
Project 0: это 3D-сцена Architect Demo, написанная на C# с использованием игрового движка Unity. Для платформ macOS и Windows.
Game 1: консольная игра Simple Snake (всем известная как «Змейка») под Windows. Написанная на C.
Game 2: консольная игра Crazy Tanks (всем известная как «Танчики»), написанная уже на C++ (с использованием классов) и тоже под Windows.
Project 0. Architect Demo
Платформа: Windows (Windows 7, 10), Mac OS (OS X El Capitan v. 10.11.6)
Первый проект реализован не на C/C++, а на C# с помощью игрового движка Unity. Этот движок был не так требователен к «железу», как Unreal Engine, а также мне показался легче в установке и использовании. Другие движки я не рассматривал.
Целью в Unity для меня не была разработка какой-то игры. Я хотел создать 3D-сцену с каким-то персонажем. Он, а точнее Она (я смоделировал девушку, в которую был влюблен =) должна была двигаться и взаимодействовать с окружающим миром. Важно было только понять, что такое Unity, какой процесс разработки и сколько усилий требуется для создания чего-либо. Так родился проект Architect Demo (название придумано почти от балды). Программирование, моделирование, анимирование, текстурирование заняло у меня, наверное, два месяца ежедневной работы.
Стартовал я с обучающих видео на YouTube по созданию 3D-моделей в Blender. Blender — это отличный бесплатный тул для 3D-моделирования (и не только), не требующий установки. И здесь меня ждало потрясение… Оказывается, моделирование, анимирование, текстурирование — это огромные отдельные темы, на которые можно книжки писать. Особенно это касается персонажей. Чтобы моделировать пальцы, зубы, глаза и другие части тела, вам потребуются знания анатомии. Как устроены мышцы лица? Как люди двигаются? Мне пришлось «вставлять» кости в каждую руку, ногу, палец, фаланги пальцев!
Моделировать ключицы, дополнительные кости-рычаги, для того чтобы анимация выглядела естественно. После таких уроков осознаешь, какой огромный труд проделывают создатели анимационных фильмов, лишь бы сотворить 30 секунд видео. А ведь 3D-фильмы длятся часами! А мы потом выходим из кинотеатров и говорим что-то вроде: «Та, дерьмовый мультик/фильм! Могли сделать и получше…» Глупцы!
И еще, что касается программирования в этом проекте. Как оказалось, самая интересная для меня часть была математическая. Если вы запустите сцену (ссылка на репозиторий в описании проекта), то заметите, что камера вращается вокруг персонажа-девочки по сфере. Чтобы запрограммировать такое вращение камеры, мне пришлось сначала высчитывать координаты точки положения на круге (2D), а потом и на сфере (3D). Самое смешное, что я ненавидел математику в школе и знал ее на три с минусом. Отчасти, наверное, потому что в школе тебе попросту не объясняют, как, черт возьми, эта математика применяется в жизни. Но когда ты одержим своей целью, мечтой, то разум очищается, раскрывается! И ты начинаешь воспринимать сложные задачи как увлекательное приключение. А потом думаешь: «Ну чего же *любимая* математичка не могла нормально рассказать, где эти формулы прислонить можно?».
Просчет формул для вычисления координат точки на круге и на сфере (из моего блокнота)
3D-сцена — это не игра. К тому же моделировать и анимировать 3D-объекты (особенно персонажи) долго и сложно. Поигравшись с Unity, ко мне пришло осознание, что нужно было продолжать, а точнее начинать, с основ. Чего-то простого и быстрого, но в то же время глобального, чтобы понять само устройство игр.
А что у нас простое и быстрое? Правильно, консоль и 2D. Точнее даже консоль и символы. Опять полез искать вдохновение в интернет (вообще, считаю интернет наиболее революционным и опасным изобретением XXI века). Нарыл видео одного программиста, который делал консольный тетрис. И по подобию его игры решил запилить «змейку». С видео я узнал про две фундаментальные вещи — игровой цикл (с тремя базовыми функциями/частями) и вывод в буфер.
Игровой цикл может выглядеть примерно так:
int main()
{
Setup();
// a game loop
while (!quit)
{
Input();
Logic();
Draw();
Sleep(gameSpeed); // game timing
}
return 0;
}
В коде представлена сразу вся функция main(). А игровой цикл начинается после соответствующего комментария. В цикле три базовые функции: Input(), Logic(), Draw(). Сначала ввод данных Input (в основном контроль нажатия клавиш), потом обработка введенных данных Logic, затем вывод на экран — Draw. И так каждый кадр. В такой способ создается анимация. Это как в рисованных мультиках. Обычно обработка введенных данных отбирает больше всего времени и, насколько я знаю, определяет фреймрейт игры. Но тут функция Logic() выполняется очень быстро. Поэтому контролировать скорость смены кадров приходится функцией Sleep() с параметром gameSpeed, который и определяет эту скорость.
Игровой цикл. Программирование «змейки» в блокноте
Если разрабатывать символьную консольную игру, то выводить данные на экран с помощью обычного потокового вывода ‘cout’ не получится — он очень медленный. Поэтому вывод нужно проводить в буфер экрана. Так гораздо быстрее и игра будет работать без глюков. Честно говоря, я не совсем понимаю, что такое буфер экрана и как это работает. Но я приведу тут пример кода, и, возможно, в комментариях кто-то сможет прояснить ситуацию.
Непосредственный вывод на экран некой строки scoreLine (строка отображения очков):
// draw the score
WriteConsoleOutputCharacter(hConsole, scoreLine, GAME_WIDTH, {2,3}, &dwBytesWritten);
По идее, ничего сложного в этой игре нет, мне она представляется хорошим примером начального уровня. Код написан в одном файле и оформлен в нескольких функциях. Нет классов, нет наследования. Вы и сами все можете посмотреть в исходниках игры, перейдя в репозиторий на GitHub.
Выводить символы в консоль — это, наверное, самое простое, что вы можете превратить в игру. Но тогда появляется одна неприятность: символы имеют разную высоту и ширину (высота больше, чем ширина). Таким образом, все будет выглядеть непропорционально, а движение вниз или вверх казаться гораздо быстрее, чем влево или вправо. Этот эффект очень заметен в «Змейке» (Game 1). «Танчики» (Game 2) лишены такого недостатка, так как вывод там организован через закрашивание экранных пикселей разными цветами. Можно сказать, я написал рендерер. Правда, это уже чуть сложнее, хотя и гораздо интереснее.
По данной игре будет достаточно описать мою систему вывода пикселей на экран. Считаю это основной частью игры. А все остальное можно придумать самостоятельно.
Итак, то, что вы видите на экране — это просто набор движущихся разноцветных прямоугольников.
Набор прямоугольников
Каждый прямоугольник представлен матрицей, заполненной числами. Кстати, могу выделить один интересный нюанс — все матрицы в игре запрограммированы как одномерный массив. Не двумерный, а одномерный! С одномерными массивами гораздо проще и быстрее работать.
Пример матрицы игрового танка
Представление матрицы игрового танка одномерным массивом
Более наглядный пример представления матрицы одномерным массивом
Но доступ к элементам массива происходит в двойном цикле, как будто это не одномерный, а двумерный массив. Так сделано потому, что мы все-таки работаем с матрицами.
Обход одномерного массива в двойном цикле. Y — идентификатор строк, X — идентификатор столбцов
Обратите внимание: вместо привычных матричных идентификаторов i, j я использую идентификаторы x и y. Так, мне кажется, приятней для глаз и понятней для мозга. К тому же такая запись позволяет удобно проецировать используемые матрицы на оси координат двухмерного изображения.
Теперь о пикселях, цвете и выводе на экран. Для вывода используется функция StretchDIBits (Header: windows.h; Library: gdi32.lib). В эту функцию, помимо всего прочего, передается следующее: устройство, на которое выводится изображение (в моем случае это консоль Windows), координаты старта отображения картинки, ее ширина/высота и сама картинка в виде битовой карты (bitmap), представленной массивом байтов. Битовая карта в виде массива байт!
Функция StretchDIBits() в работе:
// screen output for game field
StretchDIBits(
deviceContext,
OFFSET_LEFT, OFFSET_TOP,
PMATRIX_WIDTH, PMATRIX_HEIGHT,
0, 0,
PMATRIX_WIDTH, PMATRIX_HEIGHT,
m_p_bitmapMemory, &bitmapInfo,
DIB_RGB_COLORS,
SRCCOPY
);
Под эту битовую карту заблаговременно выделяется память с помощью функции VirtualAlloc(). То есть резервируется необходимое количество байт для хранения информации обо всех пикселях, которые потом будут выведены на экран.
Грубо говоря, битовая карта состоит из набора пикселей. Каждые четыре байта в массиве — это пиксель в формате RGB. Один байт на значение красного цвета, один байт на значение зеленого цвета (G) и один байт на синий цвет (B). Плюс остается один байт на отступ. Эти три цвета — Red/Green/Blue (RGB) — смешиваются друг с другом в разных пропорциях — и получается результирующий цвет пикселя.
Теперь, повторюсь, каждый прямоугольник, или игровой объект, представлен числовой матрицей. Все эти игровые объекты помещаются в коллекцию. И затем расставляются по игровому полю, формируя одну большую числовую матрицу. Каждое число в матрице я сопоставил с определенным цветом. Например, числу 8 соответствует синий цвет, числу 9 — желтый, числу 10 — темно-серый цвет и так далее. Таким образом, можно сказать, у нас получилась матрица игрового поля, где каждое число — это какой-то цвет.
Итак, мы имеем числовую матрицу всего игрового поля с одной стороны и битовую карту для вывода изображения с другой. Пока что битовая карта «пустая» — в ней еще нет информации о пикселях нужного цвета. Это значит, что последним этапом будет заполнение битовой карты информацией о каждом пикселе на основе числовой матрицы игрового поля. Наглядный пример такого преобразования на картинке ниже.
Пример заполнения битовой карты (Pixel matrix) информацией на основе числовой матрицы (Digital matrix) игрового поля (индексы цветов не совпадают с индексами в игре)
Также представлю кусок реального кода из игры. Переменной colorIndex на каждой итерации цикла присваивается значение (индекс цвета) из числовой матрицы игрового поля (mainDigitalMatrix). Затем в переменную color записывается сам цвет на основе индекса. Дальше полученный цвет разделяется на соотношение красного, зеленого и синего оттенка (RGB). И вместе с отступом (pixelPadding) раз за разом эта информация записывается в пиксель, формируя цветное изображение в битовой карте.
В коде используются указатели и побитовые операции, которые могут быть сложны для понимания. Так что советую еще отдельно где-то почитать, как работают такие конструкции.
Заполнение битовой карты информацией на основе числовой матрицы игрового поля:
// set pixel map variables
int colorIndex;
COLORREF color;
int pitch;
uint8_t* p_row;
// arrange pixels for game field
pitch = PMATRIX_WIDTH * BYTES_PER_PIXEL; // row size in bytes
p_row = (uint8_t*)m_p_bitmapMemory; //cast to uint8 for valid pointer arithmetic
(to add by 1 byte (8 bits) at a time)
for (int y = 0; y < PMATRIX_HEIGHT; ++y)
{
uint32_t* p_pixel = (uint32_t*)p_row;
for (int x = 0; x < PMATRIX_WIDTH; ++x)
{
colorIndex = mainDigitalMatrix[y * PMATRIX_WIDTH + x];
color = Utils::GetColor(colorIndex);
uint8_t blue = GetBValue(color);
uint8_t green = GetGValue(color);
uint8_t red = GetRValue(color);
uint8_t pixelPadding = 0;
*p_pixel = ((pixelPadding << 24) | (red << 16) | (green << 8) | blue);
++p_pixel;
}
p_row += pitch;
}
По описанному выше методу в игре Crazy Tanks формируется одна картинка (кадр) и выводится на экран в функции Draw(). После регистрации нажатия клавиш в функции Input() и последующей их обработки в функции Logic() формируется новая картинка (кадр). Правда, игровые объекты уже могут иметь другое положение на игровом поле и, соответственно, отрисовываются в другом месте. Так и происходит анимация (движение).
По идее (если ничего не забыл), понимание игрового цикла из первой игры («Змейка») и системы вывода пикселей на экран из второй игры («Танчики») — это все, что вам нужно, чтобы написать любую свою 2D-игру под Windows. Без звука! 😉 Остальные же части — просто полет фантазии.
Конечно, игра «Танчики» сконструирована гораздо сложнее, чем «Змейка». Я использовал уже язык C++, то есть описывал разные игровые объекты классами. Создал собственную коллекцию — код можно посмотреть в headers/Box.h. Кстати, в коллекции, скорее всего, есть утечка памяти (memory leak). Использовал указатели. Работал с памятью. Должен сказать, что мне очень помогла книга Beginning C++ Through Game Programming. Это отличный старт для начинающих в C++. Она небольшая, интересная и неплохо организована.
На разработку этой игры ушло где-то полгода. Писал, в основном, во время обеда и перекусов на работе. Садился на офисной кухне, топтал харчи и писал код. Или же за ужином дома. Вот и получились у меня такие «кухонные войны». Как всегда, я активно использовал блокнот, и все концептуальные штуки рождались именно в нем.
В завершение практической части пульну несколько сканов моего блокнота. Чтобы показать, что именно я записывал, рисовал, считал, проектировал…
Проектирование изображения танков. И отпределенеи того, сколько пикселей каждый танк долже занимать на экране
Просчет алгоритма и формул по обороту танка вокруг своей оси
Схема моей коллекции (той самой, в которой есть memory leak, скорее всего). Коллегкция создана по типу Linked List
А это тщетные попытки прикрутить к игре искусственный интеллект
Теория
«Даже путь в тысячу миль начинается с первого шага» (Древнекитайская мудрость)
Перейдем от практики к теории! Как же найти время на свое хобби?
Определить, чего вы действительно хотите (увы, это самое сложное).
Расставить приоритеты.
Пожертвовать всем «лишним» в угоду высшим приоритетам.
Двигаться к целям каждый день.
Не ждать, что появится два-три часа свободного времени на хобби.
С одной стороны, вам нужно определить, чего вы хотите и расставить приоритеты. А с другой, возможно, отказаться от каких-то дел/проектов в пользу этих приоритетов. Другими словами, придется пожертвовать всем «лишним». Я где-то слышал, что в жизни должно быть максимум три основных занятия. Тогда вы сможете ими заниматься наиболее качественно. А дополнительные проекты/направления начнут банально перегружать. Но это все, наверное, субъективно и индивидуально.
Существует некое золотое правило: never have a 0% day! О нем я узнал в статье одного indie-разработчика. Если работаете над каким-то проектом, то делайте по нему что-то каждый день. И неважно, сколько вы сделаете. Напишите одно слово или одну строчку кода, посмотрите одно обучающее видео или забейте один гвоздь в доску — просто сделайте хоть что-то. Самое сложное — это начать. Как только начнете, то наверняка сделаете немного больше, чем хотели. Так вы будете постоянно двигаться к своей цели и, поверьте, очень быстро. Ведь основной тормоз всех дел — это прокрастинация.
И важно помнить, что не стоит недооценивать и игнорировать свободные «опилки» времени в 5, 10, 15 минут, ждать каких-то больших «бревен» длительностью в час или два. Стоите в очереди? Продумайте что-то по вашему проекту. Поднимаетесь по эскалатору? Запишите что-то в блокнот. Едите в автобусе? Отлично, прочтите какую-то статью. Используйте каждую возможность. Хватит смотреть котиков и собачек на YouTube! Не засоряйте себе мозг!
И последнее. Если, прочитав эту статью, вам понравилась идея создания игр без использования игровых движков, то запомните имя Casey Muratori. У этого парня есть сайт. В секции «watch -> PREVIOUS EPISODES» вы найдете чудесные бесплатные видеоуроки по созданию профессиональной игры с полнейшего нуля. За пять уроков Intro to C for Windows вы, возможно, узнаете больше, чем за пять лет обучения в университете (об этом кто-то написал в комментариях под видео).
Также Кейси объясняет, что, разработав свой собственный игровой движок, вы будете лучше понимать любые существующие движки. В мире фреймворков, где все пытаются автоматизировать, вы научитесь создавать, а не использовать. Осознаете саму природу компьютеров. А также станете гораздо более толковым и зрелым программистом — профи.
Удачи вам на выбранном пути! И давайте сделаем мир профессиональней.