Предыстория
Какое то время назад пришла ко мне идея исполнить хотя бы немного детскую мечту программировать игры. Надо сказать, что определенный опыт у меня был. Попала в девятом классе ко мне в руки чудесная книга Андрэ Ла мота "Секреты программирования игр", благодаря которой я изучал язык Си, поскольку все там было завязано на нем. И это были незабываемые моменты. Просто это казалось каким то чудом, что я сам, своими руками заставляю двигаться персонажей и вообще, это все даже похоже на игры для Dendy, которые я так мечтал делать в еще более ранний период детства.
В основном сейчас я программирую на Java, и изначально мой выбор пал на библиотеку libGDX. Масштабные фреймворки типа Unity и UnrealEngine я не раcсматривал,поскольку главной целью моей все же было не делать продукт, а просто получить удовольствие от написания игры и ощутить ту магию, когда в школе на языке Си я с нуля писал код для доступа в видеопамять, рисования спрайтов и контролировал каждый бит (почти). Поэтому просто хотелось поэкспериментировать именно с библиотеками, предоставляющими основные функции, типа рисования, обработки клавиатуры. Но как то попалась мне на глаза библиотека Raylib. Испугал меня конечно язык С++.( К слову сказать Raylib поддерживает еще и кучу других языков, но это я узнал потом). Долго ли коротко, принял решение поэкспериментировать с Raylib, заодно изучив С++,на котором никогда не программировал и вообще для меня С++ это какой то высший пилотаж.
Эпизод 1. С++
Для того, чтобы начать, необходимо конечно же разобраться с С++. Последние впечатления от языка Си (именно Си) у меня были только в школе, но начав разбираться, я понял, что старая закалка дает о себе знать и все не так уж и сложно. Да и вообще перейти с Java на Си++ по части синтаксиса не так и сложно. Да и принципы объектно-ориентированного программирования они и в Африке принципы. Однако все оказалось не совсем так как я представлял себе. И главное коварство состояло в том, что думать, что С++ это просто язык с немного отличающимся синтаксисом от Java -очень большое заблуждение. Столкнулся я с несколькими ситуациями, которые ну никак не мог понять.
А все потому, что я думал в контексте Java.
Например, такая история:
#include "spaceship.h"
void main(){
Spaceship ship;
cout << "Ok";
}
При компиляции и запуске программа внезапно завершалась и не доходила до вывода на консоль. Будете смеяться, но я потратил пол дня на поиск проблемы, изломал всю голову, пытаясь ответить на вопрос : "Где? и почему?". Почему, когда я убирал объявление переменной ship,все работало? Для программиста С++ мои страдания вызовут лишь недоуменную ухмылку, но я то думал, что я все еще на планете Java. И это просто объявление указателя на объект типа Spaceship,под который даже память то еще не выделена. Но в C++ это означает, что создается объект, выделяется память и вызывается конструктор без параметров! Такие дела. Эта привычка считать все указателями изрядно потрепала мне нервы в начале экспериментов с С++.
Ну или например ключевое слово this. Ну почему например this.x не работает? Да потому что this это указатель на объект и по синтаксису C++ правильно писать this->x . Поэтому в итоге я понял одно: Программируя на С++,программируй на C++, а не думай, что ты пишешь на Java c измененным синтаксисом.
В общем, решил я применить свой способ изучения нового языка, фреймворка по излюбленной схеме. Поняв только самую базу, идти в бой в реальный проект, пытаясь хоть и неумело, изобретая кучу велосипедов но писать так, как подсказывает логика, интуиция и здравый смысл. Суть в том, что в процессе такой работы я обычно прихожу к действительно правильному пониманию концепций и способов использования языка. Сама жизнь говорит(возвращаясь к примеру):" Не помещай инициализацию объектов прямо в конструктор, лучше вызови потом функцию init(), когда будешь готов :-)"
Я заведомо не читал различные мануалы о том, как правильно организовать игровой цикл, как делать анимацию, как грузить спрайты, потому что мне было просто интересно придумать это, пусть на примитивном уровне, но самому. Может быть это ностальгия по детству, когда единственное что я прочитал в книжке это как вывести картинку в память видеокарты и обработать клавиатуру. Конечно, основные концепции были мне ясны, вроде этапов игрового цикла, но более детальные алгоритмы рождались моей фантазией. И библиотека Raylib как раз очень мне подходит, поскольку там именно основные возможности, типа нарисовать текстуру, проверить клавиатуру, а не реализованный движок с искусственным интеллектом , анимацией. Ну что же...вперед.
Эпизод 2. Установка необходимых инструментов
Сначала конечно необходим компилятор. К слову говоря при скачивании Raylib c официального сайта предлагается несколько сборок с включенными в них компиляторами. Я остановился на сборке с классическим компилятором GCC и его портом на Windows MinGw-w64. После установки главное прописать путь PATH к каталогу BIN компилятора и можно приступать к сборке .
В школе я долгое время пользовался средой Borland C++, где компиляция осуществлялась в самой среде и я плохо осознавал, на самом деле, что же происходит когда я вижу надпись: Build Succesful. Позже, столкнувшись с нехваткой памяти в реальном режиме MSDOS, а ее катастрофически не хватало, нашел на одном из дисков дистрибутив компилятора Watcom C++ 10.0. Данная штука среды не имела, зато позволяла работать со всей имеющейся памятью свыше 640 килобайт при помощи расширителя( так вроде его называли) DOS4GW.
Пришлось мне тогда изучать концепции того, как происходит вообще сборка exe, как прилинковать библиотеку и т.д. И еще тогда мне было страшно смотреть на попадающиеся файлы с названием Makefile, потому что лишь чутка заглянув в их содержимое мне становилось плохо от супер непонятной структуры их текста. Надо сказать, тот опыт очень сильно мне помог сейчас, чтобы как то сдвинуться с мертвой точки и знать от чего вообще плясать. На самом деле, все не так уж и страшно. Итак, вкратце, имеем следующую структуру проекта:
mygame
Include
Obj
Res
Src
Makefile
В инструментах GCC утилита make. Если брать аналог из мира Java,это что - то отдаленно напоминающее maven или gradle. И даже принцип такой же, мы просто запускаем из каталога проекта команду MAKE и она ищет в текущем каталоге Maefile и выполняет прописанные в нем инструкции по сборке исполняемого файла. Для понимания давайте разберем некоторые строчки этого чудесного файла.
RAYLIB_PATH= e:/tools/raylib/raylib
Здесь мы задаем константу, где указываем путь к библиотеке RayLib. Надо учесть, что помимо самой библиотеки в скачанном дистрибутиве содержится и компилятор MinGW и различные инструменты, поэтому у нас и идут два каталога RayLib подряд.
В файле в любом месте задаются константы, которые мы можем использовать в пределах его объема. Например:
OBJ_DIR=OBJ
SRC_DIR=SRC
Здесь мы задаем каталоги, где хранятся исходные файлы c расширением .CPP, а также каталог OBJ, куда будут сохраняться скомпилированные исходные файлы. Любой исходный файл компилируется в машинный код и сохраняется в так называемый объектный файл. Обычно они имеют расширение .o. Правило, превращающее каждый .CPP в в .O имеет следующий вид:
$(OBJ_DIR)/%.o:$(SRC_DIR)/%.cpp Makefile
g++ -c -o $@ $^ $(CFLAGS) -MMD $(INCLUDE_PATHS) -D $(PLATFORM)
Не пугайтесь, это выглядит страшно, а на самом деле все очень просто и даже тривиально. Наиболее важные моменты таковы:
$(OBJ_DIR)/%.o:$(SRC_DIR)/%.cpp
здесь мы просто говорим, что каждый объектный файл формируется из исходного файла с тем же названием. Обратите внимание на использование заданных выше констант. То есть если в каталоге SRC два файла main.cpp и game.cpp,то в каталоге OBJ в результате работы этого правила появиться два файла main.o и game.o.
g++ -c -o $@ $^ $(CFLAGS) -MMD $(INCLUDE_PATHS) -D $(PLATFORM)
это собственно команда компиляции, мы используем программу g++(это компилятор С++) . Интересные моменты в двух последовательностях $@ и $^. Это просто подстановки, где первая заменяется на левую часть предыдущего выражения, а вторая на правую. Очень удобно для задания в командной строке компилятора конечного файла и исходного. Можно представить себе, что выполняется цикл по всем CPP и мы каждую итерацию получаем новый исходник и компилируем его. Стоит также обратить внимание на константы CFLAGS и INCLUDE_PATHS.
CFLAGS - это просто строка, где мы собрали всякие разные параметры компилятора. Вот как она выглядит у нас:
CFLAGS = -Wall -std=c99 -D_DEFAULT_SOURCE -Wno-missing-braces -Wunused-result
INCLUDE_PATHS -набор каталогов, откуда компилятор подключает заголовочные файлы .h. Дело в том, что встречая в коде программы инструкцию #include, компилятор просто включает его содержимое в текущий файл. Это просто подстановка текста. Можно включить в Ваш код любой файл с кодом на С++,и использовать его классы и функции. Однако на практике в заголовочных файлах не содержится сам код, а лишь определения функций и классов. Когда компилятор встречает в исходном тексте создание объекта определенного класса, он смотрит, есть ли в области видимости определение класса, и если находит его, то считает, что все в порядке. Сам код реализации класса содержится например в файле CPP и компилируется ОТДЕЛЬНО. На последней стадии сборки исполняемого файла, программа, называемая линковщик собирает весь скомпилированный код вместе.
Сама библиотека Raylib упрощенно говоря представляет собой два файла:
libraylib.a - это раз таки скомпилированный код с библиотекой
raylib.h - заголовочный файл.
В нашем makefile есть строчка, подключающая отдельные библиотеки из кода libraylib.a:
LDLIBS = -lraylib -lopengl32 -lgdi32 -lwinmm
Все! Абсолютно ничего лишнего и сложного. И это в какой то степени заставляет меня восхищаться С++.
Немного хотелось бы отвлечься от технических деталей и рассказать о своей проблеме в программировании, с которой я столкнулся еще в школе. Все шло хорошо, пока я экспериментировал в программировании под DOS. Все что я писал ложилось в стройную и понятную систему. Я понимал механизм работы программы до самого последнего бита и пиксела. Наверное каждая строчка отражалась в сознании коротким и ясным отрывком, ударом барабана, и сотни этих строчек составляли в неокрепшем детском мозгу цельную картину мира. Наверное в этом я реализовывал свою потребность в контроле. Потом время пришло время, когда я познакомился с C++ Builder и вообще стал пытаться разобраться с программированием под Windows. И тут моя стройная картина мира стала рушиться. Я просто не понимал, как это все работает. То есть вся фишка в том, что разобраться в том, как программировать GUI под Windows не составляло проблемы, просто сам факт, что теперь мне непонятно ЧТО ЭТО за штуковина такая, действия которой я должен отслеживать функциями обратного вызова, теперь мне непонятно КАК этот проект на C++ Builder запускается, где у него точка входа и что происходит, когда я двойным щелчком по кнопке открываю функцию OnCLick(). Уровень абстракции увеличивался, и от этого страх неизвестности также прогрессировал. То есть те инструменты, которые по идее должны упрощать жизнь программиста и увеличивать его эффективность, меня отталкивали. И на какое то время я вообще забросил свои изыскания в программировании. Именно из за этой потери контроля. Конечно, сейчас я смотрю на это с улыбкой, но в свое время меня это очень напрягало. Ну и если честно посмотреть, на самом деле уровень абстракции функции printf отличается от набора нулей и единиц точно так же, как уровень flutter отличается от SkiA.
На самом деле, сегодня мне важно понимать, что сложность современного программирования заставляет доверять все большим и большим аксиомам, выраженных в различных фреймворках, фреймворках над фреймворками и т.д. И нет нужды понимать всю матрешку абстракций, это просто непозволительно в современных условиях. Но все же... вот эти эксперименты с RayLib, - тот самый глоток свежего морозного воздуха.
Эпизод 3 Игровой цикл
Перейдем к собственно коду, друзья.
Хочу порассуждать, что же такое Игра? Игра это система сущностей, действующих по определенным алгоритмам. Тут как нельзя кстати фраза-вся наш жизнь -Игра!. Или весь наш мир-игра. И недаром наверное часто употребляется в игровой индустрии словосочетание игровой мир. Игра представляет собой набор циклов, в которых совершаются повторяющиеся действия. И вообще, любую игру от танчиков до последней какой-нибудь супер навороченной виртуальной системы можно описать следующим образом:
В каждый момент времени игровая система пребывает в определенном состоянии, которое складывается из состояний ее частей. Сюда входят, к примеру:
- расположения танков, зданий, людей
- координаты пуль, летящих из снарядов, или гриба, убегающего от Марио:-)
- состояние убежищ, урон главного героя, текущая высота полета ниндзя в прыжке
- и много много много различных параметров.Получаем информацию от контроллеров ввода
Определенный алгоритм рассчитывает состояние всех частей системы в данный момент
Происходит отрисовка той части мира, которая необходима игроку (по мнению создателей игры). В Марио это кусочек уровня, в танчиках на Dendy вовсе статичный экран, а в шутере от третьего лица вид из глаз главного героя
Все повторяем опять и опять.
Вот вкратце как это реализуется в небольшом эксперименте, который я написал при помощи средств библиотеки Raylib.
while (!WindowShouldClose()) //Начинаем бесконечный цикл до возникновения события ЗАКРЫТЬ_ОКНО if(IsKeyPressed(KEY_LEFT) || IsKeyPressed(KEY_RIGHT)){ // Получаем данные от контроллеров ввода reaper.startWalkForth(); //изменяем состояние } BeginDrawing(); //собственно, производим отрисовку ClearBackground(RAYWHITE); reaper.draw(); EndDrawing();
Да. это весь код. Лукавлю конечно. Дьявол в деталях, но принципиально ничего сложного нет. В реализованном мной примере нет логики именно игрового мира, то есть никто не стреляет, не надо обсчитывать не попала ли пуля в врага и не нанесла ли ему урон, сколько энергии осталось у героя, нашел ли он ключ к двери и т.д. Здесь больше логики, направленной на решение двух простых задач:
В каком месте экрана изобразить главного героя
Какой кадр из его анимационных последовательностей нарисовать
Класс AnimationCycle
Для этого я написал класс AnimationCycle,являющийся моделью одной анимационной последовательности. Например, герой идет вперед. Данному действию соответствует объект walkForth. Соответственно для реализации удара и прыжка создаются объекты kicking,jumpStart и jumpLoop. Для загрузки спрайтов я написал метод, который загружает из определенной папки все лежащие там картинки формата PNG, исходя из того, что каждая картинка это отдельный кадр.
Сами спрайты я скачал на ресурсе где лежат кучи бесплатных и не бесплатных картинок. Таких ресурсов куча по поиску free sprites,например. Спрайты хранятся в объектах типа Texture и загружаются вполне себе очевидным и однозначным способом.
Image image=LoadImage(path.c_str()); //Загружаем картинку из файла
ImageResize(&image,width,height); // можно изменить ее размеры(отмасштабировать)
Texture frame=LoadTextureFromImage(image); //Загрузка текстуры
Хотелось бы обратить внимание на один момент, который попил у меня крови. Если мы начнем загружать текстуры до инициализации системы openGL, то программа вывалится. И это логично. Потому что мы не просто загружаем текстуру, мы загружаем ее в память видеокарты, реализующей интерфейс openGL .Вот эти две строчки инициализируют систему openGL.
InitWindow(screenWidth, screenHeight, "MyGame");
SetTargetFPS(60);
Класс Reaper
Данный объект хранит в себе все возможные состояния анимации героя и соответствующую логику управления ими.
Основные моменты, на мой взгляд, требующие внимания:
Игровой цикл производит отрисовку всего экрана 60 раз в секунду, однако мы можем захотеть не менять кадры так быстро. К примеру, наш герой идет вперед и мы хотим, чтобы он шел со скоростью 24 кадра в секунду. Если мы будем менять кадр каждую итерацию игрового цикла, то ничего у нас не получиться, поэтому мы заводим внутри объекта специальный таймер, который обнуляем после завершения прорисовки очередного кадра и запускаем опять.
Я разбил выполняемую в данный момент анимацию на две составляющих: Анимация движения и Анимация действия:
class Reaper{ AnimationCycle *currentWalkingAnimation; AnimationCycle *currentActionAnimation; }
Для чего это мне? Например, герой идет вперед и одновременно бьет палкой. При этом его currentWalkingAnimation=walkForth, а currentActionAnimation=kicking. На время того, как наше создание бьет палкой, его анимация ходьбы прерывается и начинает исполняться анимация удара. По достижении последнего кадра анимации удара, опять идет отрисовка ходьбы. Для разделения этих двух видов анимаций, я завел свойство infiniteAnimation. Если для определенной анимации infiniteAnimation равен TRUE, то остановить ее можно лишь принудительно, например когда игрок отжал клавишу вправо.
Если же FALSE,то анимация просто прекращается . Данная логика реализована в методе draw() класса Reaper.
В данной статье я не претендую на правильность или неправильность выбранных методик и способов реализации игрового цикла. Просто очень хотелось поделиться тем опытом, который есть у меня со всеми странностями и где то откровенными штуками, наверное вызывающими улыбку у более опытных профи. Весь код есть на GitHub.
.Надеюсь, кому то эта статья окажется полезной.