Как написать 2D игру на C++ и чистом STL для терминала в Linux

Привет, Хабр, я PHP разработчик с опытом работы в продакшне более 8 лет. После долгого и упорного труда мне стало скучно пилить микросервисы и бэкенды в хайлоде, я решил постичь магию разработки игр. Выбрал курс по Unreal Engine 5 и C++, так как там все структурировано, понятно, и в случае необходимости есть кому задать вопрос. На первой лекции по основам С++ преподаватель сразу предложил челлендж — написать 2D игру без использования игрового движка. Идея мне понравилась и я сразу приступил к реализации. Спойлер — вот что вышло:

c0370e08be8497f66320499720a5ed46.png

Если загуглить, как написать игру на С++ вылазит тысяча и один гайд с использованием SDL, SFML или тех же OpenGL+Glew. Я подумал, что чем сложнее решить проблему, тем больше опыта и знаний я получу, поэтому решил не идти по пути меньшего сопротивления и отказаться от использования мультимедийных библиотек.

Рабочий комп у меня на операционной системе Ubuntu 22, я не хотел её менять на винду, решил сделать игру на линукс. Помимо прочего, это плюс к опыту разработки под линукс и кроссплатформу которого у меня нет.

Разбираться с тем, как в линуксе создавать окна и биндить кнопки я не захотел, поэтому решил, что игра будет для терминала, тем более, что все простые программы с курса мы запускали именно там. В связи с этим предстояло решить ряд проблем:

  • Графика

  • Управление

  • Геймплей

Графика. Я знаю, что терминал не поддерживает изображения и канвас. В общем-то, он и не должен, поскольку программа специализируется для выполнения команд. Однако, меня это не остановило, и я решил поэкспериментировать и написать следующий код:

#include 
#include 

using namespace std;

int main() {
	fstream my_file;
    my_file.open("animated-zombie.jpg", ios::in); // открываем файл
    char ch;
    while (1) {
        my_file >> ch;
        if (my_file.eof())
            break;
        cout << ch; // выводим содержимое файла
    }
    my_file.close(); 
	return 0;
}  

В результате мы видим следующее:

2eb1eebe8b69d0cf3dcedb06203cb379.png

Что и следовало ожидать: файл мы можем считать, и даже можем вывести его содержимое, а преобразовать это содержимое в изображение нет (то же самое будет и с другими форматами изображения). Можно было попробовать с xdg-open или fim, но это нужно ставить отдельные либы в линукс и не понятно как с ними работать из С++. Сразу я подумал, что на этом все, и, таким образом, челлендж выполнить не получится, но тут я вспомнил про ANSI ART. Для тех, кто не знает — это рисование примитивами.

d9ce8fc4d9de6a3d956c98dfc7dcfb13.jpeg

Конечно, рисовать анимации и персонажей долго и сложно, но в Unicode есть куча символов и смайлов, а если открыть на Ubuntu раздел в меню «тулзы», то там можно найти characters

ef41780aa3f4305788bcee11a567f53c.png

Здесь присутствует символьный код изображения. Пробуем сделать std: cout символа в коде. Видно, что символ сразу преобразовывается в изображение. Пробуем скомпилировать и запустить

#include 
#include 

using namespace std;

int main() {
	cout << "⬛" << endl;
	return 0;
}

0c42660b218db4bde4a03cbbeb1306d2.png

Видим, что все работает, только размер не устраивает, но с этим разберемся потом.

Управление — нажатие клавиш. Как самостоятельно отследить нажатие клавиш? После часов гугления я понял, что никак. По сути нажатие клавиш, это прерывание, которое передается в процессор, и дальше процессор оповещает об этом ОС. Как получить событие нажатия на клавишу, я не смог разобраться (если кто знает поделитесь в комментариях). В общем я решил использовать стандартный поток ввода.

Проблемы здесь две. Первая — это то, что после каждого запроса ввода нужно вводить данные и заканчивать ввод нажатием клавиши enter, что для игры совершенно не подходит. Вторая проблема в том, что ввод, это, конечно же, I/O операция, которая блокирует вывод и ввод. Таким образом, моя игра будет ждать пока пользователь не введет действие. Разберемся во всем по порядку.

В случае с linux терминалом у нас есть файл termios.h. По сути, это настройки терминала. В них мы можем переопределить определитель, когда считаем команду введенной.

#include 
#include  // для обеспечения доступ к API операционной системы POSIX
#include  // для работы с настройками терминала

using namespace std;

// здесь будем хранить предыдущие настройки
struct termios saved_attributes;

// метод для установки в терминале предыдущих настроек
void reset_input_mode (void)
{
    tcsetattr (STDIN_FILENO, TCSANOW, &saved_attributes);
}

// метод установки новых настроек терминала
void set_input_mode (void)
{
    struct termios tattr; // структура для новых настроек

    if (!isatty (STDIN_FILENO)) // проверка, что переопределяем именно терминал
    {
        fprintf (stderr, "Not a terminal.\n"); // вывод ошибки
        exit (EXIT_FAILURE); // выход из программы
    }

    tcgetattr (STDIN_FILENO, &saved_attributes); // получаем настройки терминала и заполняем saved_attributes
    atexit (reset_input_mode); // наш метод возвращения настроек будет вызываться при успешном завершении программы

    tcgetattr (STDIN_FILENO, &tattr); // получаем текущие настройки терминала и заполняем tattr
    tattr.c_lflag &= ~(ICANON|ECHO); // убираем канонический ввод и вывод символов
    tattr.c_cc[VMIN] = 2; // Минимальное количество символов для неканонического ввода
    tattr.c_cc[VTIME] = 0; // Время ожидания в миллисекундах для неканонического ввода
    tcsetattr (STDIN_FILENO, TCSAFLUSH, &tattr); // установка новых настроек терминала
}


int main() {
    set_input_mode();
    char c;
    read (STDIN_FILENO, &c, 1); // читаем 1 символ и записываем в переменную char c
    cout << "test 1" << endl;
    cout << c << endl;
	return 0;
}

В результате мы нажимаем на клавишу, не ждем пока пользователь введет enter, читаем один символ из потока ввода и сразу выводим ее значение.

Вторая проблема блокировки потока при запросе ввода. Пробовал сделать в терминале неблокирующий ввод, но тогда программа введет себя непредсказуемо. Также пробовал сделать асинхронность, но тоже не помогло. Решил, что так как мы пишем на C++, нет никаких проблем выделить ввод данных в отдельный поток.

char c;
while(c != 'a') { // остановимся, когда введем символ a
    thread th([&]() {
        read (STDIN_FILENO, &c, 1); // читаем 1 символ и записываем в переменную char c
    }); // передаем в поток анонимную функцию чтения из stdin
    th.detach(); // открепляем новый поток от текущего потока что бы вполнять паралельно
    std::this_thread::sleep_for(std::chrono::milliseconds(100)); // делаем паузу в цикле, так как процессор сильно быстрый
    cout << c << endl; // выводим введенный символ
}

Запускаем и видим, что выводятся переводы строки и если нажать клавишу, то отображается ее символ, а затем сразу перенос строки. Это происходит потому что каждую итерацию цикла мы читаем и выводим значение, которое прочитали.

a04ad6ca9897298a4ece9a6240bb044b.png

В нашем случае пустые символы ввода будем просто игнорировать, они нам не мешают, но вот по производительности это не очень хорошо. Однако, так как у нас простая игра не для продакшена, решил оставить как есть.

Геймплей — это дело лично каждого. Кому-то нравиться шутеры, кому-то головоломки. Я хотел сделать что-то простое, но не сильно. Вспомнил культовую игру пакман и решил сделать что-то похожее, но без уровней. Идея простая. У нас есть комната, за границы которой мы не выходим. Есть таймер, по истечению которого игра заканчивается. Играем мы за персонажа и наша цель за отведенное время собрать каких-то предметов больше, чем соперник. Соперником будет второй игрок или ПК. И так, приступим к реализации.

В первую очередь, у нас есть бесконечный цикл, в котором работает вся наша игра, но так как цикл выполняется слишком быстро поставим задержку в секунду, и получим нужный нам FPS. Далее создадим структуру.

06e91eac4b3d6e146cdf65998884ea1c.png

В итоге получаем следующее: AbstractObjects — это наш базовый класс, в котором есть координаты Х и Y, т.е. наша горизонталь и диагональ. Хранить весь игровой уровень будем в матрице N на M, получаем многомерный массив. Также есть view — это представление объекта в изображении. Solid — говорит нам о том, что это твердый объект и с ним можно взаимодействовать. Два виртуальных метода — print и getScorePoints, а также остальные классы Bomb, Eats, Inedible, Player, Walls — наследуют абстрактный класс AbstractObjects, реализуют методы родителя и, если надо, дополняют своими.

Далее идет класс Timer. Он служит для отсчета времени до завершения игры. Следующий класс ScorePoints нужен для подсчета очков игрока и соперника, а также дополняет методами, один из которых добавляет score, а другой отнимает. Класс Menu нужен для выбора сложности и типа игры.

Класс Scene будет хранить вектор векторов на указатели AbstractObjects (vector это контейнер для хранения данных, чем то похож на массив). Выглядит это так:

vector> map{x,vector{y,nullptr}};

Тут видно, что, так как на игровом поле у нас будет много объектов разных классов, а вектор может хранить только один тип, мы создаем вектор векторов и вектор будет хранить указатели на AbstractObjects. Можно было сделать UNION, но зачем, если есть полиморфизм. Создаем объект любого класса Bomb, Eats, Inedible, Player или Walls и добавляем в наш вектор, так как все эти классы наследуются от AbstractObjects, в итоге у них общий базовый тип. Также в сцене есть методы, которые устанавливают на сцену новый объект по координатам, получают объект по координатам, удаляют объект и находят ближайшие объекты для игроков с кротчайшим путем до них (эти методы нужны для бота).

Класс Render получает объект типа Scene и рендерит все, что у нас на сцене. Таким образом, получаем физическое представление из нашего вектора векторов с нашими объектами.

vector> map = this->scene.getMap();
uint16_t x = this->scene.getSizeX();
uint16_t y = this->scene.getSizeY();
for(size_t i = 0; i < x; ++i) {
    for(size_t j = 0; j < y; ++j)
        if (map[i][j])
            map[i][j]->print();
        else
            cout << "  ";
    cout << endl;
}

Метод print вызывается у одного из классов: Bomb, Eats, Inedible, Player или Walls, так как мы их добавили в вектор сцены и получаем к ним доступ в векторе по ключам i и j.

Games — самый важный класс, так как в нем реализована вся логика игры: выводим меню, выбираем тип игры и сложность, записываем в переменные Games класса, начинаем игру, создаем все наши объекты Score, Timer, Scene, в рандомные места ставим наши продукты, которые будут собирать игроки с разными скорами, за подбор бомбы отнимаем сопернику очки, ставим стены, определяем куда движется игрок при нажатии клавиш и двигаем его, при этом проверяем, что это: если стена, то не перемещаем игрока, если продукт, то добавляем очки, если другой игрок, то ничего не делаем. Каждую итерацию цикла новое состояние сцены, поэтому мы его рендерим занаво, и получается, что в системе происходят события, которые меняют состояние нашей сцены, в связи с чем рендерим ее на экран, вернее в терминал, важно что перед рендерингом экран очищается от предыдущей сцены, проверяем закончилось ли время на таймере и завершаем игру, определяем победителя.

Наверное, самое интересное в классе Games, это как мы двигаем нашего бота. В зависимости от сложности игры он делает несколько шагов к ближайшему продукту. По сути я просто проверяю, где находится бот, ищу продукт с минимальным расстоянием по координатам и перемещаю бота на шаг к продукту. Получилось следующее:

16089271517558bc644fe7fa1cfa3e66.gif

Расписывать каждую функцию в статье я не буду, статья получилась и так достаточно объемной, но там нет ничего сложного, можете ознакомиться с проектом у меня на гитхабе:

https://github.com/casilliose/game-engine-2d

На этой гифке я увеличивал размер шрифта, чтобы мои символы юникода были больше и было лучше видно. Пробовал сделать то же самое через termios, там есть свойство c_cflag и его значение можно изменить на CSIZE маска размера символов. Значениями будут:  CS5,  CS6,  CS7 или CS8. Но не вышло (если кто знает как увеличить шрифт в терминале через C++ напишите в комменты, пожалуйста).

Конечно, тут много косяков, как в плане кода, так и в плане логики. После прохождения курса по C++ я знаю про кроскомпиляцию и как написать проект, чтобы собрать игру на windows, также как зарефакторить класс Games по подклассам для ввода игрока, рандомном появлении продуктов и так далее, сделать правильные инклюды файлов с защитой от двойной ставки, вынести определение классов в .h файлы, заменить сырые указатели на умные, и еще много чего на что у меня нет времени. Если вы хотите понять основы любой игры, сделайте свою игру без игровых движков, где многое будет реализовано за вас. Поверьте, это очень интересно.

Добавляйтесь в LinkedIn и пишите вопросы или предложения, с радостью отвечу.

© Habrahabr.ru