Однажды я написал игру-паззл Blockdown (страница на Steam). Она интересна тем, что у неё свой собственный игровой движок. Filament берёт на себя всю сложную работу, связанную с графикой, поэтому игра не требует значительных усилий со стороны художника. Игровой физики здесь также считай нет. На самом деле, отсутствие гравитации здесь играет очень важную роль, поскольку игрок перекатывает плитки, которые могут принимать всевозможные варианты ориентации:

image

Вся игра написана на классическом C++, в том числе, вся логика для паззлов. Притом, насколько мне нравятся встраиваемые языки, такие как Lua и Wren, я решил ими не пользоваться. Опасался, что слишком много времени потрачу на написание обёрток и склеивающего кода.

Более того, я знал, что в моих паззлах придётся выполнять большой объём векторной математики, и намеревался воспользоваться для этого шейдер-подобным синтаксисом. Математическая библиотека Filament, написанная на C++, уже поддерживает синтаксис GLSL, так зачем же прибегать к чему-то иному?

Кстати, и редактор уровней я тоже не писал. Здесь вы уже можете заподозрить, что я сошёл с ума: как же в таком случае я собираюсь перебрать и отладить все паззлы? Что же, постоянно пересобирать игру?

Динамическое связывание


Я оказался в одной из тех редких ситуаций, где задача в самом деле отлично решается при помощи динамического связывания. В файле CMake для этой игры определяется две цели: одна для исполняемого файла игры (который статически связывает Filament и другие зависимости) и одна для небольшой библиотеки, в которой содержатся спецификации паззла. Поскольку библиотека паззлов маленькая, она компилируется очень быстро… настолько быстро, что по ощущениям это не отличается от срабатывания скрипта.

В следующем видеоролике показано, как я редактирую паззл и наблюдаю перезагрузку уровня в режиме реального времени.

Не приходится беспокоиться о том, как вручную загрузить ворох указателей функций из библиотеки, так как она предоставляет всего одну функцию: get_level_specs(). Эта функция наполняет массив «спецификаций», по одной для каждого уровня игры. Каждая спецификация – это небольшой набор функций обратных вызовов. Например, обратный вызов prepare() задаёт начальное расположение плиток, а обратный вызов animate() выполняется в игровом цикле.

В сборках для macOS, не идущих в продакшен, игровой движок опрашивает метку времени этой библиотеки при помощи stat(). Если оказывается, что метка времени уже новая, то библиотека повторно открывается, из неё выбирается входная точка, и к ней следует вызов. Код выглядит примерно так:

int HotLoaderImpl::reload(LevelSpec* specs, GameServices* services) {
    if (_dlhandle) {
        dlclose(_dlhandle);
    }

    _dlhandle = dlopen(_dlpath.c_str(), RTLD_LOCAL | RTLD_LAZY);
    if (!_dlhandle) {
        error("Unable to load level specs library: {}", dlerror());
        exit(EXIT_FAILURE);
    }

    _get_level_specs = (GET_LEVEL_SPECS_CB) dlsym(_dlhandle, "get_level_specs");
    if (_get_level_specs == nullptr) {
        error("Unable to load level specs function.");
        exit(EXIT_FAILURE);
    }

    return _get_level_specs(specs, services);
}

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

Также добавлю: чтобы дополнительно оптимизировать поток задач, я отслеживаю изменения в папке с исходниками при помощи fswatch и по мере необходимости вызываю сборочный инструмент.

Например, следующий код командной строки наблюдает за папкой puzzles_src на предмет изменений в коде C++. При каждом изменении он пересобирает цель CMake под названием puzzles_dll. При опросе, происходящем в сборке для разработчика, новая библиотека обнаруживается и перезагружается.

% fswatch -o puzzles_src | xargs -I {} \
        cmake --build .release -- puzzles_dll

В целом я вполне удовлетворён таким подходом к «скриптингу» игрового движка… и, да, строго говоря, это совсем не скриптинг.

API игрового движка


Функции обратных вызовов, определённые в библиотеке паззлов, взаимодействуют с игровым движком через грубо очерченные объекты API, такие как Grid, Player и GameServices.

Все объекты API Blockdown составлены строго из чистых виртуалов. На производительности это не сказывается, поскольку я удостоверился, что все объекты API грубые. Например, Grid предоставляет доступ ко всем плиткам на определённом уровне, но в API нет объекта Tile. Плитки в Blockdown многочисленны, поэтому предоставляются скорее в стиле ECS, а не как отдельные объекты API.

Пользуясь в API только чистыми виртуалами, я вынужден держать всю приватную информацию вне заголовков. Так я не только добиваюсь чистоты кода, но и избегаю проблем с «множественными определениями», учитывая, как именно построена библиотека паззлов.

Вот как выглядит API GameServices. Любой другой класс в игре (напр., Grid) выдержан в очень схожем стиле.

class GameServices {
   public:
    static GameServices* create();
    static void destroy(GameServices*);

    virtual Environment* environment() = 0;
    virtual Grid* grid() = 0;
    virtual Player* player() = 0;
    virtual LocalSettings* settings() = 0;

    virtual ~GameServices() = default;
};

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

class GameImpl : public GameServices {
   public:
    GameImpl() { ... }
    ~GameImpl() { ... }

    Environment* environment() final { ... }
    Grid* grid() final { ... }
    Player* player() final { ... }
    LocalSettings* settings() final { ... }
};

GameServices* GameServices::create() { return new GameImpl(); }
void GameServices::destroy(GameServices* game) { delete game; }

Вот и всё. Надеюсь, предложенная мной архитектура вас заинтересовала, и в игру вы тоже поиграете.

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


  1. domix32
    07.06.2023 13:21
    -4

    Опасался, что слишком много времени потрачу на написание обёрток и склеивающего кода.

    Жаль конечно этого бедолагу.