Вся игра написана на классическом 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; }
Вот и всё. Надеюсь, предложенная мной архитектура вас заинтересовала, и в игру вы тоже поиграете.
ссылка на оригинал статьи https://habr.com/ru/companies/piter/articles/740422/

Добавить комментарий