### Введение

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

Для хранения таких данных существует много способов - кто-то хранит их в таблицах, в xml или json файлах, которые редактируют собственными инструментами. Unity предоставляет свой способ - Scriptable Objects (SO), которые мне нравится тем, что для их визуального представления не нужно писать свой редактор, легко делать ссылки на ассеты игры и друг на друга, а с появлением Addressables эти данные можно легко и удобно хранить вне игры и обновлять отдельно.

В этой статье я хотел бы рассказать о своей библиотеке SODatabase, с помощью которой можно удобно создавать, редактировать и использовать в игре (редактировать и сериализовать) scriptable objects.
<cut />
### Создание и редактирование SO

Создание и редактирование SOшек я веду в отдельном окне, которое чем-то похоже на окна проекта с инспектором - слева находится дерево папок (папка, в которой находятся все SOшки - группа в addressables), а справа - инспектор выделенной SOшки. 

![Интерфейс](https://habrastorage.org/webt/g-/at/3_/g-at3_ewbaje3clfmpwy2fdv9fw.png)

Для отрисовки такого WindowEditor’а я использую библиотеку Odin Inspector. Кроме того, я использую сериализацию для SO из этой библиотеки - она значительно расширяет стандартную юнитиевскую сериализацию, позволяя хранить полиморфные классы, глубокую вложенность, ссылки на классы.

![Создание SO](https://habrastorage.org/webt/kw/8z/6k/kw8z6kpamvb8eq2k5mkissvo4m8.png)

Создание новых SO происходит через нажатие кнопки в этом окне - там нужно выбрать тип нужной сошки, и она создаётся в папке. Для того, чтобы тип SO появился в этом окне в качестве варианта, SO должен наследоваться от DataNode, который имеет лишь одно дополнительное поле к ScriptableObject
```csharp
public string FullPath { get; }
```
Это путь к данной SO, с помощью которого к ней можно будет обратиться в рантайме.

### Доступ к SO в игре

В рантайме обычно нужно либо получить какую-то конкретную модель, например, SO со списком настроек какого-либо окна, либо набор моделей из папки - например, список айтемов, где модель каждого айтема представляет собой отдельный SO.
Для этого в static классе SODatabase есть два основных метода, которые возвращают либо весь список моделей из нужной папки, либо конкретную модель из папки с определённым именем.

```csharp
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*, помечен как 
```csharp
[JsonObject(MemberSerialization.OptIn, IsReference = true)]
```
и все его *JsonProperty* сериализуются в файл save.txt при сохранении игры. Соответственно при инициализации *SODatabase* кроме запроса данных об изменении addressables происходит *JsonConvert.PopulateObject* для каждой динамической модели из *SODatabase*, используя данные из этого файла.

Для того, чтобы это работало гладко, я сериализую ссылки на SO (которые могут являтся динамическими полями, помеченными как JsonProperty) в строку-путь, и потом десериализую обратно в ссылки на SO при загрузке. Есть ограничение - данные на игровые ассеты динамическими быть не могут. Но это не фундаментальное ограничение, просто у меня ещё не было случая, когда такие динамические данные потребовались бы, поэтому я не реализовывал специальную сериализацию для таких данных.

### Примеры

В классе-стартере игры инициализация и загрузка данных
```csharp
async void Awake()
{
    await SODatabase.InitAsync(null, null);
    await SODatabase.LoadAsync();
}
```
и сохранение стейта при выходе
```csharp
private void OnApplicationPause(bool pauseStatus)
{
    if (pauseStatus)
        SODatabase.Save();
}

private void OnApplicationQuit()
{
    SODatabase.Save();
}        
```

В рпг для хранения информации об игроке я прямо создаю *PlayerSO*, в котором одни только динамические поля - имя, количество экспы игрока, кристаллов и так далее. Также хорошей практикой по-моему является создание статической строки с путём, по которому я сохраняю данную модель в SODatabase, чтобы потом обращаться к ней в рантайме. 
 ```csharp
public class PlayerSO : DataNode
{
    public static string Path => "PlayerInfo/Player";

    [JsonProperty]
    public string Title = string.Empty;

    [JsonProperty]
    public int Experience;
}
```
 
 Точно также для инвентаря игрока я создаю *PlayerInventorySO*, где храню список ссылок на айтемы игрока (каждый айтем представляет собой ссылку на статичный SO из SODatabase).
 
  ```csharp
 public class PlayerInventorySO : DataNode
 {
     public static string Path => "PlayerInfo/PlayerInventory";
 
     [JsonProperty]
     public List<ItemSO> Items = new List<ItemSO>();
 }
 ```       

Бывают наполовину статические, наполовину динамические данные - например, квесты. Возможно, это не лучший подход, но я прямо в моделях *QuestSO* со статической информацией о квестах (название, описание, цели и т.д.) храню динамическую информацию по прогрессу в этом квесте. Таким образом гейм-дизайнер в одном инспекторе видит всю инфу о текущем состоянии квеста и его описание.

```csharp
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 их не сериализовал.
Доступ к этим данным выглядит следующим образом
```csharp
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 я перешёл совсем недавно для использования в текущем проекте (на разработку которой [я ищу в команду второго программиста](https://gamedev.ru/projects/forum/?id=254030) в партнёры). В данный момент идёт активное допиливание этой библиотеки под нужды этой игры.

Библиотека лежит в открытом доступе на github. Написана с использованием Nullable из c# 8, соответственно требует Unity 2020.1.4 в качестве минимальной версиии.

[https://github.com/NuclearBand/UnityScriptableObjectDatabase](https://github.com/NuclearBand/UnityScriptableObjectDatabase)