Об игре
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
voldemar_d
Идём в переводчик и видим, что research - это и есть исследование. В чем отличие, и зачем этот термин вместо русского слова?
shai_hulud Автор
Я не смог найти в русском слово для exploration. И когда рядом research и exploration надо выбирать как это написать что бы не получилось "исследования и исследования".
voldemar_d
Просто исследование мира, как содержащее в себе оба понятия, не подойдет?
Можно, наверное, написать "исследование и освоение мира", если уж хочется два разных действия охватить.
shai_hulud Автор
Исследование мира в играх это когда надо выбираться из уютной и удобной базы во внешний мир и открывать новое. А просто исследования, это процесс открытия технологий за время и деньги. Две разные механики. Но это занудство, да, можно было выбросить одну из предложения.
voldemar_d
Я, конечно, не геймер, но не уверен, что слово "ресеч" (кстати, почему не "рисёч" ?) прямо однозначно для всех понятно.
DarthVictor
"Изучение и исследование"