Об игре

Mind Over Magic — это симуляционная игра, разработанная Sparkypants и изданная Klei Publishing.

Проектируйте, стройте и оптимизируйте каждый аспект своей идеальной школы магии! Держите всех в живых и довольных, готовя студентов к исследованию ужасов Подшколы — смертоносного подземного испытания. Изучайте утраченные арканы, выращивайте экзотические растения, варите зелья и поднимайте нежить. Но не забывайте останавливать смертоносный туман, поглощающий все на своем пути. — с официальной страницы Steam.

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

Физическое расположение файлов и форматы

Mind Over Magic разработана на Unity, поэтому весь скомпилированный код находится в папке игры /MindOverMagic/mindovermagic_Data/Managed, а игровые ресурсы — на уровень выше. Игровые данные, включая таблицы и настройки, хранятся в более чем трехстах .yaml файлах в папке StreamingAssets.

YAML — это текстовый формат, уникальный среди текстовых форматов, так как все реализации сериализаторов уникальны и частично совместимы.

В проекте также используется сериализация в JSON посредством Newtonsoft.JSON. Однако, скорее всего, формат YAML был выбран из-за его более компактного синтаксиса и возможности работы с многострочным текстом.

Загрузка игровых данных

Статичные игровые данные состоят из конфигураций сущностей Config и определений IDefinition.

Для каждого YAML файла существует специальный класс-каталог, наследующий DefinitionCatalog<T>, где T — это класс, описывающий структуру данных каждой записи в каталоге. Например, SpellConfigCatalog наследует DefinitionCatalog<SpellConfig> и предоставляет методы доступа к коллекции, а SpellConfig своими полями описывает структуру каждого заклинания (название, описание, эффекты, стоимость маны и т.д.).

Данные из этих файлов доступны в игровом коде через глобальные синглтоны DefinitionCatalog<T>.Instance, которые инициализируются классом ConfigBundle, который, в свою очередь, создается IConfigBundleProvider.

Конфигурации сущностей доступны через глобальный синглтон ConfigData.Instance и класс Archetype. Archetype определяет компоненты, которые будут иметь создаваемые ECS-сущности по этой конфигурации.

Каждый файл загружается в отдельном Task, и затем все задачи ожидаются через Task.WaitAll, с кодом, похожим на следующий:

DefinitionCatalog[] allCatalogs = GetAllCatalog();
Task loadTasks = allCatalogs.ConvertAll(catalog =>
{
    catalog.Load(parserType);
});
Task.WaitAll(loadTasks);

// post processing for catalogs
ResearchTechCatalog.Finalize();

Всего таких классов определений 68 штук еще 95 классов-конфигов к компонентам ECS и 26 классов валидаторов для отлавливания ошибок в конфигутациях. Все эти классы написаны и поддерживаются программистами.

Либо всё тоже самое в однопоточном исполнении. В качестве параметра передается parserType, который имеет три варианта: Yaml.Net, Fast, Fast Single Thread. Варианты парсеров указывают на попытки найти наиболее быстрое решение для загрузки данных в игру. Реализация Fast выглядит как кастомный YAML парсер, с токенизатором, содержащим более 1000 строк кода, что подчеркивает сложность YAML формата.

Десериализация в объекты также выполнена кастомно, с использованием рефлексии без кеширования вызовов получения метаданных (например, type.GetProperties вызывается для каждой записи) и без оптимизаций через генерацию кода, Linq.Expressions или генерацию MSIL. Этот прямой подход к сериализации работает на всех платформах, но является самым медленным.

На моем ПК этот процесс занимает 2944.104ms согласно логам и встроенному в игру профайлеру.

Доступ к конфигурациям и определениям из кода

Как уже упоминалось, все каталоги конфигураций доступны через глобальный экземпляр класса DefinitionCatalog<T>.Instance и Simulation.Instance.Configs. Зависимость между вариантами обращения не ясна; возможно, это разделение между режимом боя и строительства, а может и просто легаси.

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

var gamePressure = DefinitionCatalog<GamePressureDefinition>
  .Instance
  .TryGetDefinitionFromStringKey("Relaxed", out var id);

Связи между конфигурациями, когда одна ссылается на другую, обрабатываются аналогично через передачу DefId другому DefinitionCatalog<T>.Instance:

DefinitionCatalog<ResearchTechDefinition>.Instance.TryGetDefId("TechTreeRoot", out var techTreeRootId);

var subResearches = DefinitionCatalog<ResearchTechDefinition>
      .Instance
      .AllDefinitions()
      .Where(subResearch => subResearch.ParentTech == techTreeRootId);

Как это можно было реализовать

Изучая, как другие проектируют, хранят и используют статичные игровые данные, я стремлюсь улучшить свой редактор игровых данных — Харон (Charon).

В случае этой игры я смоделировал около 15 определений с помощью встроенного в редактор ИИ помощника:

Для начала я конвертировал все данные из YAML в JSON, что дало около 8MiB данных. Определения, которые я смоделировал ранее, захватывают около 1MiB оригинальных данных игры. Из редактора Charon я экспортировал все данные в один JSON файл и дополнительный MessagePack файл размером 550KiB для сравнения скорости загрузки.

Charon генерирует C# код с классами для всех определений (называемых схемами) и встроенными форматтерами для JSON и MessagePack. Так что все упаковано внутри сгенерированного кода, без внешних зависимостей.

Для тестирования скорости загрузки я создал простое консольное приложение на C# и загрузил игровые данные, используя следующий код:

var jsonGameDataPath = @"\publication.json";
var messagePackGameDataPath = @"\publication.msgpack";

var sw = Stopwatch.StartNew();
var gameData = new GameData(File.OpenRead(jsonGameDataPath), new Formatters.GameDataLoadOptions { Format = Formatters.GameDataFormat.Json });

Console.WriteLine($"Load JSON: {sw.ElapsedMilliseconds:F2}ms");

sw = Stopwatch.StartNew();
gameData = new GameData(File.OpenRead(messagePackGameDataPath), new Formatters.GameDataLoadOptions { Format = Formatters.GameDataFormat.MessagePack });
Console.WriteLine($"Load MesagePack: {sw.ElapsedMilliseconds:F2}ms");

Результат был следующим:

Load JSON: 78.00ms
Load MessagePack: 16.00ms

Весьма неплохо. О том как я ускорил JSON форматтер я писал в моей предыдущей статье. Если экстраполировать это на все конфигурации и определения игры, которые занимают около 8MB, то в игре они загрузились бы за 624ms вместо 2944ms.

Для доступа ко всем игровым данным понадобился бы один класс GameData, подобный уже используемому ConfigBundle.

Для получения конкретных документов можно использовать сгенерированные «Id классы» вместо строковых литералов:

var champion1 = gd.Badges.Get(BadgeId.Champion1);

Плюс таких «Id классов» в том, что когда документ удаляют, код перестает компилироваться, а со строковой константой он просто тихо сломается уже в продакшене.

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

// Original code
DefinitionCatalog<BadgeRewardDefinition>
  .Instance
  .TryGetDefinitionFromStringKey("Heart_Tier1Reward_1", out var heartTier1);

DefinitionCatalog<BadgeCategoriesDefinition>
  .Instance
  .TryGetDefinition(heartTier1.CategoryId, out var heartTier1Category);

// Charon generated code
BadgeCategory heartTier1Category = gd.BadgeRewards
  .Get(BadgeRewardId.HeartTier1Reward1)
  .CategoryId;

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

Поддержка моддинга

Хотя в оригинальном игровом коде нет информации о моддинге, моддеры всё равно найдут способ. Между тем, Charon поддерживает загрузку патчей поверх игровых данных, что позволяет модифицировать игру.

Например, с такими игровыми данными:

"Badge": [
      {
        "Id": "SoloRecreator_1",
        "DisplayName": "Recreating like a wild - Bronze",
        "CategoryId": "HeartCenter"
     }
]

Моддер №1 может создать патч, чтобы изменить название этого значка:

"Badge": {
      "SoloRecreator_1": {
        "DisplayName": "Recreating like a boss - Bronze"
     }
}

А моддер №2 может удалить текущий значок и добавить новый:

"Badge": {
      "SoloRecreator_1": null,
      "SleekDancer_1": {
        "Id": "SleekDancer_1",
        "DisplayName": "Dancing like a wild - Bronze",
        "CategoryId": "HeartCenter"
     }
}

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

var gd = new GameData(File.OpenRead(jsonGameDataPath), 
    new Formatters.GameDataLoadOptions {
        Format = Formatters.GameDataFormat.Json,
        Patches = new Stream[] {
            File.OpenRead("Patch1.json"),
            File.OpenRead("Patch2.json"),
        }
    });

Это позволяет легко модифицировать игровые конфигурации. Сам редактор Charon можно свободно распространять вместе с игрой, и создание патчей может быть одно-кнопочным процессом в редакторе, что упрощает моддинг игры до элементарного уровня.

Заключение

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

Update: поправил ресеч на изучение, спасибо @DarthVictor

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


  1. voldemar_d
    15.07.2024 07:29

    ресеч, исследование

    Идём в переводчик и видим, что research - это и есть исследование. В чем отличие, и зачем этот термин вместо русского слова?


    1. shai_hulud Автор
      15.07.2024 07:29

      Я не смог найти в русском слово для exploration. И когда рядом research и exploration надо выбирать как это написать что бы не получилось "исследования и исследования".


      1. voldemar_d
        15.07.2024 07:29

        Просто исследование мира, как содержащее в себе оба понятия, не подойдет?

        Можно, наверное, написать "исследование и освоение мира", если уж хочется два разных действия охватить.


        1. shai_hulud Автор
          15.07.2024 07:29

          Исследование мира в играх это когда надо выбираться из уютной и удобной базы во внешний мир и открывать новое. А просто исследования, это процесс открытия технологий за время и деньги. Две разные механики. Но это занудство, да, можно было выбросить одну из предложения.


          1. voldemar_d
            15.07.2024 07:29

            Я, конечно, не геймер, но не уверен, что слово "ресеч" (кстати, почему не "рисёч" ?) прямо однозначно для всех понятно.


      1. DarthVictor
        15.07.2024 07:29
        +2

        "Изучение и исследование"