Привет, Хабр, я 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 и пишите вопросы или предложения, с радостью отвечу.

Комментарии (5)


  1. savostin
    29.12.2022 20:16

    Как получить событие нажатия на клавишу, я не смог разобраться (если кто знает поделитесь в комментариях).

    Я бы начал эксперименты с std::cin.readsome()

    Не совсем событие, но и не блокирует. Вполне можно использовать в while(true) {}


    1. nUser123
      30.12.2022 16:09

      или создать поток с помощью std::async и ждать нажатия функцией kbhit()


  1. nipper
    29.12.2022 22:30

    Ошибся


  1. Cheater
    30.12.2022 00:18
    +5

    Я знаю, что терминал не поддерживает изображения и канвас.

    Это не совсем так. Как вам пингвинов в консоли рисуют при старте Linux. https://en.wikipedia.org/wiki/Linux_framebuffer

    Можно было попробовать с xdg-open или fim

    Нерелевантно. Xdg-open - это просто приложение-прослойка, запускающее просмотрщик файла (выбираемый юзером). Как правило этот просмотрщик не консольный, а графический, например feh или Eye of Gnome. Самостоятельной способности открывать изображение в терминале итд у xdg-open нет. Fim - уже ближе, это приложение для вывода графики в POSIX терминал через пресловутый framebuffer, но вам в любом случае нужно не приложение, а библиотека (API), тк вы пишете свою программу. Но ок, вы ниже говорите что будете выводить только текст и псевдографику.

    Как получить событие нажатия на клавишу, я не смог разобраться (если кто знает поделитесь в комментариях)

    Если вы пишете код не ядра, а прикладной программы (пространство пользователя), самый низкий уровень работы с клавиатурой, доступный вам, - это /dev/input. Это i/o устройство, предоставляемое ядром линукс и являющееся абстракцией над устройствами ввода. Там отражаются и все нажатия клавиш. Взаимодействовать с ним из кода на Си можно через заголовочный файл ядра линукс, linux/input.h, примерно так:

    #include <stdlib.h>
    #include <unistd.h>
    #include <fcntl.h>
    #include <errno.h>
    #include <linux/input.h>
    #include <stdio.h>
    
    int main(void)
    {
        const char *dev = "/dev/input/event0";
        struct input_event ev;
        ssize_t n;
        int fd;
    
        fd = open(dev, O_RDONLY);
        if (fd == -1) {
            fprintf(stderr, "Cannot open %s: %s.\n", dev, strerror(errno));
            return EXIT_FAILURE;
        }
    
        while (1) {
            n = read(fd, &ev, sizeof ev);
            if (n == (ssize_t)-1) {
                if (errno == EINTR)
                    continue;
                else
                    break;
            } else
            if (n != sizeof ev) {
                errno = EIO;
                break;
            }
    
            if (ev.type == EV_KEY)
                printf("%d 0x%04x\n", ev.value, ev.code);
        }
    
        return 0;
    }
    

    Но на настолько низком уровне взаимодействовать с клавиатурой не стоит (если вы только не пишете кейлоггер или ещё какой низкоуровневый специальный софт), по следующим причинам:

    • Право чтения /dev/input напрямую - это весьма неслабая привилегия. Простой юзер в линукс её лишён по соображениям безопасности (программу выше возможно запустить только от рута) тк иначе он сможет считывать с /dev/input ввод других юзеров.

    • В приложении обычно важно знать не физическую нажимаемую клавишу, а её символ (keysym), интерпретируемый из скан-кода клавиши. Например если вы жмёте "я" в русской раскладке, то в /dev/input это то же самое событие, что и нажатие "z".

    По приложению - имхо вы зря пишете цикл чтения клавиатурного ввода с нуля.
    Упражнение в чём-то полезное, но проще воспользоваться консольным фреймворком. Канонический - ncurses, правда это C а не C++. Он берёт на себя создание event loop, диспетчеризацию событий от клавиатуры, отрисовку итд. Клавиатурный ввод будете читать через Ncurses API.

    если кто знает как увеличить шрифт в терминале через C++ напишите в комменты

    Странное желание. Консольное приложение не должно хотеть/мочь манипулировать шрифтом. Оно оперирует вводом и выводом текстовых символов на матрице 80x25 (стандарт, у юзера мб другой размер), а как этот текст отображается - это вопрос настроек эмулятора терминала у юзера. Если юзеру покажется, что текст мелковат, пусть увеличивает у себя, это не ваша забота


  1. zankokun
    30.12.2022 13:42

    Хочется посоветовать посмотреть на ftxui и возможно использовать эту библиотеку для полной отрисовки ui , а возможно позаимствовать из нее работу с терминалом