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

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

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

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

  • Графика

  • Управление

  • Геймплей

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

#include <iostream>
#include <fstream>

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;
}  

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

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

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

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

#include <iostream>
#include <fstream>

using namespace std;

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

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

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

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

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

#include <iostream>
#include <unistd.h> // для обеспечения доступ к API операционной системы POSIX
#include <termios.h> // для работы с настройками терминала

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; // выводим введенный символ
}

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

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

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

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

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

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

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

vector<vector<AbstractObjects*>> map{x,vector<AbstractObjects*>{y,nullptr}};

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

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

vector<vector<AbstractObjects*>> 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, это как мы двигаем нашего бота. В зависимости от сложности игры он делает несколько шагов к ближайшему продукту. По сути я просто проверяю, где находится бот, ищу продукт с минимальным расстоянием по координатам и перемещаю бота на шаг к продукту. Получилось следующее:

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

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

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

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

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