Введение
В каждой игре есть данные, с которыми работают гейм-дизайнеры. В рпг — это база данных айтемов, в матч-3 — стоимость в кристаллах инструментов из магазина, в экшенах — количество хп, на которое лечит аптечка.
Для хранения таких данных существует много способов — кто-то хранит их в таблицах, в xml или json файлах, которые редактируют собственными инструментами. Unity предоставляет свой способ — Scriptable Objects (SO), которые мне нравится тем, что для их визуального представления не нужно писать свой редактор, легко делать ссылки на ассеты игры и друг на друга, а с появлением Addressables эти данные можно легко и удобно хранить вне игры и обновлять отдельно.
В этой статье я хотел бы рассказать о своей библиотеке SODatabase, с помощью которой можно удобно создавать, редактировать и использовать в игре (редактировать и сериализовать) scriptable objects.
Создание и редактирование SO
Создание и редактирование SOшек я веду в отдельном окне, которое чем-то похоже на окна проекта с инспектором — слева находится дерево папок (папка, в которой находятся все SOшки — группа в addressables), а справа — инспектор выделенной SOшки.
Для отрисовки такого WindowEditor’а я использую библиотеку Odin Inspector. Кроме того, я использую сериализацию для SO из этой библиотеки — она значительно расширяет стандартную юнитиевскую сериализацию, позволяя хранить полиморфные классы, глубокую вложенность, ссылки на классы.
Создание новых SO происходит через нажатие кнопки в этом окне — там нужно выбрать тип нужной сошки, и она создаётся в папке. Для того, чтобы тип SO появился в этом окне в качестве варианта, SO должен наследоваться от DataNode, который имеет лишь одно дополнительное поле к ScriptableObject
public string FullPath { get; }
Это путь к данной SO, с помощью которого к ней можно будет обратиться в рантайме.
Доступ к SO в игре
В рантайме обычно нужно либо получить какую-то конкретную модель, например, SO со списком настроек какого-либо окна, либо набор моделей из папки — например, список айтемов, где модель каждого айтема представляет собой отдельный SO.
Для этого в static классе SODatabase есть два основных метода, которые возвращают либо весь список моделей из нужной папки, либо конкретную модель из папки с определённым именем.
public static T GetModel<T>(string path) where T : DataNode
public static List<T> GetModels<T>(string path, bool includeSubFolders = false) where T : DataNode
Уточню, что один раз в начале игры перед запросом моделей SODatabase нужно проинициализировать, чтобы обновились и прогрузились данные из Addressables.
Загрузка и сохранение
Один из недостатков ScriptableObject по сравнению с хранением данных с сериализацией в собственном формате является то, что в них нельзя записывать данные из игры в рантайме. То есть по сути ScriptableObject предназначены для хранения статичных данных. Но любой игре нужна загрузка и сохранение, и я реализую это через те же самые SO из базы данных.
Возможно это не идиоматичный способ — совмещать базу статичных моделей игры с загрузкой и сохранением динамических данных, но в моём опыте ещё ни разу не было случая, когда это создало бы какие-то неудобства, но при этом есть ряд ощутимых плюсов. Например, с помощью тех же инспекторов SOшек можно смотреть игровые данные в эдиторе и менять их. Можно удобно загружать сейвы игроков, смотреть их содержимое и редактировать в unity, не используя никаких внешних утилит и собственных редакторов для визуализации xml или других форматов.
Я достигаю этого, сериализуя динамические поля в ScriptableObject с помощью JSON.
Класс DataNode — родительский класс всех SO, хранящихся в SODatabase, помечен как
[JsonObject(MemberSerialization.OptIn, IsReference = true)]
и все его JsonProperty сериализуются в файл save.txt при сохранении игры. Соответственно при инициализации SODatabase кроме запроса данных об изменении addressables происходит JsonConvert.PopulateObject для каждой динамической модели из SODatabase, используя данные из этого файла.
Для того, чтобы это работало гладко, я сериализую ссылки на SO (которые могут являтся динамическими полями, помеченными как JsonProperty) в строку-путь, и потом десериализую обратно в ссылки на SO при загрузке. Есть ограничение — данные на игровые ассеты динамическими быть не могут. Но это не фундаментальное ограничение, просто у меня ещё не было случая, когда такие динамические данные потребовались бы, поэтому я не реализовывал специальную сериализацию для таких данных.
Примеры
В классе-стартере игры инициализация и загрузка данных
async void Awake()
{
await SODatabase.InitAsync(null, null);
await SODatabase.LoadAsync();
}
и сохранение стейта при выходе
private void OnApplicationPause(bool pauseStatus)
{
if (pauseStatus)
SODatabase.Save();
}
private void OnApplicationQuit()
{
SODatabase.Save();
}
В рпг для хранения информации об игроке я прямо создаю PlayerSO, в котором одни только динамические поля — имя, количество экспы игрока, кристаллов и так далее. Также хорошей практикой по-моему является создание статической строки с путём, по которому я сохраняю данную модель в SODatabase, чтобы потом обращаться к ней в рантайме.
public class PlayerSO : DataNode
{
public static string Path => "PlayerInfo/Player";
[JsonProperty]
public string Title = string.Empty;
[JsonProperty]
public int Experience;
}
Точно также для инвентаря игрока я создаю PlayerInventorySO, где храню список ссылок на айтемы игрока (каждый айтем представляет собой ссылку на статичный SO из SODatabase).
public class PlayerInventorySO : DataNode
{
public static string Path => "PlayerInfo/PlayerInventory";
[JsonProperty]
public List<ItemSO> Items = new List<ItemSO>();
}
Бывают наполовину статические, наполовину динамические данные — например, квесты. Возможно, это не лучший подход, но я прямо в моделях QuestSO со статической информацией о квестах (название, описание, цели и т.д.) храню динамическую информацию по прогрессу в этом квесте. Таким образом гейм-дизайнер в одном инспекторе видит всю инфу о текущем состоянии квеста и его описание.
public class QuestNode : BaseNode
{
public static string Path = "QuestNodes";
//Editor
public virtual string Title { get; } = string.Empty;
public virtual string Description { get; } = string.Empty;
public int TargetCount;
//Runtime
[JsonProperty]
private bool finished;
public bool Finished
{
get => finished;
set => finished = value;
}
}
Вообще, лучше поля с JsonProperty делать приватными, чтобы SO их не сериализовал.
Доступ к этим данным выглядит следующим образом
var playerSO = SODatabase.GetModel<PlayerSO>(PlayerSO.Path);
var playerInventorySO = SODatabase.GetModel<PlayerInventorySO>(PlayerInventorySO.Path);
var questNodes = SODatabase.GetModels<QuestNode>(QuestNode.Path, true);
Текущее состояние библиотеки
В продакшене несколько лет использовался прообраз этой библиотеки — в ней аналогичное окно-проводник для создания/редактирования моделей, которые содержали статичные и динамические данные, но все эти модели не использовали SO, а были целиком в json. Из-за этого для каждой модели приходилось писать свой эдитор вручную, ссылки моделей друг на друга и игровые ассеты(спрайты и т.д.) делались довольно неудобными способами. Переход на SO совершён в прошлом году, и пока всего одна игра с SODatabase ушла в релиз, но в ней не использовались Addressables.
На addressables я перешёл совсем недавно для использования в текущем проекте (на разработку которой я ищу в команду второго программиста в партнёры). В данный момент идёт активное допиливание этой библиотеки под нужды этой игры.
Библиотека лежит в открытом доступе на github. Написана с использованием Nullable из c# 8, соответственно требует Unity 2020.1.4 в качестве минимальной версии.
KonH
Начинание хорошее, но советую добавить в проект лицензию, сейчас этот код юридически никто другой использовать не может
Tr0sT Автор
Спасибо. Добавил лицензию MIT.