SaveSystem (Core)
Namespace: Vortex.Core.SaveSystem, Vortex.Core.SaveSystem.Bus, Vortex.Core.SaveSystem.Abstraction, Vortex.Core.SaveSystem.Model
Сборка: ru.vortex.save
Платформа: .NET Standard 2.1+
Назначение
Система сохранения и загрузки данных. Две независимые шины:
SaveController— слоты сохранения: процессы save/load, реестрISaveable-модулей, асинхронный сбор/раздача данных. Данные хранятся какDictionary<string, Dictionary<string, string>>— иерархия модуль → ключ → значение (строки).GlobalSaveController— глобальное хранилище межсессионных данных, которые переживают слоты, новые игры и перезапуски. Описано в разделе Глобальное хранилище.
Возможности слотов:
SaveController— шина:Save(),Load(),Remove(),GetIndex()- Очередь операций:
Save/Load/Removeисполняются строго по одной, со слиянием однотипных операций одного GUID ISaveable— интерфейс для модулей, данные которых подлежат сохранению- Асинхронный сбор данных при
Save, асинхронная раздача приLoad(UniTask) SaveProcessData— двухуровневый прогресс (глобальный + модульный)- События:
OnSaveRequest,OnSaveStart,OnSaveComplete,OnLoadStart,OnLoadComplete,OnRemove SaveSummary— сводка сохранения (имя, дата, версия приложения, необязательные метаблоки, XML-сериализуемая)- Метаблоки сводки (
SaveController.AddMeta) — превью, маркеры и прочие данные от независимых контроллеров, без правки имени сейва SaveReactor— блок коррекции устаревшего сейва: правка сырой строки до разбора в окне версий- Автогенерация GUID для новых сохранений
Вне ответственности:
- Физическое хранение (PlayerPrefs, файлы) — Unity-слой (драйверы)
- Сжатие тела слота — Unity-слой (драйвер слотов); контейнер глобального хранилища кодирует Core-контроллер
- Шифрование — не выполняется; для данных глобального хранилища — забота модуля
- UI-отображение прогресса — Unity-слой
Зависимости
| Зависимость | Назначение |
|---|---|
Vortex.Core.System |
SystemController<T, TD>, ProcessData |
Vortex.Core.Extensions |
Crypto.GetNewGuid(), DictionaryExt.AddNew() |
Vortex.Core.LoggerSystem |
Log.Print() при ошибках |
Vortex.Core.ComplexModelSystem |
ComplexModel<T> — основа GlobalModel |
Vortex.Core.AppSystem |
App.OnStateChanged (немедленная запись в Unfocused/Stopping), App.Exit() (fail-fast) |
Vortex.Core.SettingsSystem |
Settings.Data() — число копий и fail-fast; расширение SettingsModel через asmref |
| UniTask | UniTask, CancellationToken (в ISaveable, IProcess) |
Использование
Реализация ISaveable
public class InventoryController : ISaveable
{
private ProcessData _processData = new("Inventory");
public string GetSaveId() => "Inventory";
public async UniTask<Dictionary<string, string>> GetSaveData(CancellationToken ct)
{
var data = new Dictionary<string, string>();
data["items"] = JsonUtility.ToJson(items);
data["gold"] = gold.ToString();
return data;
}
public ProcessData GetProcessInfo() => _processData;
public async UniTask OnLoad(CancellationToken ct)
{
var data = SaveController.GetData("Inventory");
if (data.TryGetValue("items", out var json))
items = JsonUtility.FromJson<ItemList>(json);
if (data.TryGetValue("gold", out var g))
gold = int.Parse(g);
}
}
Сохранение / загрузка
// Регистрация
SaveController.Register(inventoryController);
// Сохранение (новый GUID). Операция встаёт в очередь; await — до её завершения
var guid = await SaveController.Save("Quick Save"); // null — отклонено, отменено или не удалось
// Сохранение (перезапись)
await SaveController.Save("Quick Save", existingGuid);
// Загрузка
await SaveController.Load(guid);
// Список сохранений
var saves = SaveController.GetIndex();
foreach (var (guid, summary) in saves)
Debug.Log($"{summary.Name} — {summary.Date}");
// Удаление
SaveController.Remove(guid).Forget();
Ждать операцию не обязательно: Forget() оставляет её в очереди, порядок исполнения от этого не меняется.
Метаданные сводки
Независимый контроллер подписывается на событие сохранения и пишет свой метаблок — точке сохранения о нём знать не нужно:
public static class DifficultySaveMeta
{
public const string MetaKey = "difficulty";
[RuntimeInitializeOnLoadMethod]
private static void Init()
{
SaveController.OnSaveRequest -= AddMeta;
SaveController.OnSaveRequest += AddMeta;
}
private static void AddMeta() => SaveController.AddMeta(MetaKey, Difficulty.Current.ToString());
}
// Чтение — из сводки, без загрузки сейва
var difficulty = summary.GetMeta(DifficultySaveMeta.MetaKey); // null — блока нет
Сохранение может исполниться не сразу, а после операций, стоящих в очереди раньше. Поэтому окон записи метаблока два:
| Событие | Когда | AddMeta пишет |
Для чего |
|---|---|---|---|
OnSaveRequest |
синхронно внутри Save, до постановки в очередь |
в метаданные запроса — они едут с операцией и попадают в сводку при исполнении | состояние момента запроса: превью, режим, тип сейва |
OnSaveStart |
на исполнении, после BeginSummary и метаданных запроса |
прямо в сводку исполняемого цикла | состояние момента записи |
Вне этих окон AddMeta бесполезен — запись сбросится следующим циклом. Повторный ключ в OnSaveStart перезаписывает значение из запроса. Отклонённый запрос OnSaveRequest не поднимает. Вызывать Save из обработчика OnSaveRequest нельзя — запрос отклоняется с ошибкой в лог. Выключенные в настройках блоки драйвер отбрасывает (Unity-слой, SaveSettings).
Архитектура
SaveController : SystemController<SaveController, IDriver>
├── SaveDataIndex: Dictionary<string, Dictionary<string, string>>
│ └── модуль (SaveId) → { ключ → значение }
├── Saveables: HashSet<ISaveable>
├── State: SaveControllerStates
├── ProcessData: SaveProcessData
│
├── Save(name, guid?) → UniTask<string> ← постановка в очередь
│ ├── загрузка в очереди/исполняется → null
│ ├── OnSaveRequest ← метаданные запроса (AddMeta)
│ └── слияние с ожидающим Save того же GUID или новая операция
├── Load(guid) → UniTask ← постановка в очередь
├── Remove(guid) → UniTask ← постановка в очередь; отменяет ожидающие Save GUID
│
├── Очередь (SaveController.Queue.cs): по одной операции, FIFO
│ ├── Save: State = Saving, Driver.BeginSummary(), метаданные запроса, OnSaveStart,
│ │ foreach ISaveable → GetSaveData() → SaveDataIndex, Driver.Save(name, guid)
│ ├── Load: State = Loading, OnLoadStart, Driver.Load(guid), foreach ISaveable → OnLoad()
│ ├── Remove: Driver.Remove(guid)
│ └── очередь пуста → State = Idle; событие завершения; результат ожидающим
│
├── GetData(id) → Dictionary<string, string>
├── GetIndex() → Driver.GetIndex()
├── AddMeta(key, metadata) → метаданные запроса (в OnSaveRequest) или Driver.AddMeta
├── Register(ISaveable) / UnRegister(ISaveable)
└── GetProcessData() → SaveProcessData
ISaveable
├── GetSaveId() → string
├── GetSaveData(CancellationToken) → UniTask<Dictionary<string, string>>
├── GetProcessInfo() → ProcessData
└── OnLoad(CancellationToken) → UniTask
IDriver : ISystemDriver
├── Save(name, guid)
├── Load(guid)
├── Remove(guid)
├── SetIndexLink(Dictionary<string, Dictionary<string, string>>)
├── GetIndex() → Dictionary<string, SaveSummary>
├── GetNumberLastSave() → int
├── BeginSummary() ← необязательный (пустая реализация по умолчанию)
└── AddMeta(key, metadata) ← необязательный (пустая реализация по умолчанию)
BeginSummary и AddMeta — расширение контракта с реализацией по умолчанию: драйвер без поддержки метаданных компилируется без правок и пишет сейвы без метаблоков. Драйвер с поддержкой держит сводку текущего цикла: BeginSummary заводит новую сводку с новым списком (копия прошлой в индексе сейвов ссылается на прежний список — очистка стёрла бы его), AddMeta пишет прямо в неё, Save дописывает имя, дату и версию и записывает.
Формат данных
SaveDataIndex: Dictionary<string, Dictionary<string, string>>
└── "ModuleA" → { "key1" → "json1", "key2" → "json2" }
└── "ModuleB" → { "key1" → "json1" }
Каждый ISaveable возвращает свой GetSaveId() (идентификатор модуля) и Dictionary<string, string> (ключ → JSON-строка). SaveController агрегирует все модули в SaveDataIndex.
Очередь операций
Save, Load и Remove не исполняются сразу: они ставят операцию в очередь и возвращают задачу, которая завершится вместе с ней. Операции исполняются строго по одной, в порядке постановки; следующая начинается после завершения текущей. Так save и load не пересекаются, а сбор данных не идёт поверх загрузки.
Батчинг — по последней операции того же GUID (исполняемая операция сливается только там, где это сказано):
| Запрос | Что происходит |
|---|---|
Save(guid), последняя операция по GUID — ожидающий Save |
Слияние: имя и метаданные — от нового запроса, результат общий для всех ожидающих |
Save() без GUID |
Новый сейв, ни с чем не сливается |
Save при исполняемой или ожидающей загрузке |
Отклоняется: null и предупреждение в лог. После загрузки состояние уже другое — записывать его под запрошенным именем бессмысленно. Сохранения, запрошенные до загрузки, исполняются до неё |
Load(guid), последняя операция всей очереди (ожидающая или исполняемая) — Load того же GUID |
Слияние. С более ранней загрузкой того же GUID не сливается: загрузка меняет состояние всей игры, перестановка (A, B, A → B) недопустима |
Remove(guid) |
Сначала отменяются ожидающие Save этого GUID с конца очереди до первой иной операции по нему (их результат — null): запись перед удалением бессмысленна, а сохранения до загрузки определяют, что она загрузит. Затем слияние с ожидающим или исполняемым Remove того же GUID |
Операции, поставленные из обработчиков событий и из продолжений await завершённых операций, подхватывает тот же цикл обработки — порядок остаётся FIFO. Продолжения ожидающих выполняются синхронно, сразу после события завершения, до старта следующей операции.
Исключение в обработчике любого события логируется с именем обработчика и не мешает остальным подписчикам и очереди.
Жизненный цикл Save
- Запрос (синхронно внутри
Save): загрузка в очереди или исполняется →null; иначеOnSaveRequestсобирает метаданные запроса; операция ставится в очередь или сливается - Исполнение:
State = Saving Driver.BeginSummary()— новая сводка: метаблоки прошлого цикла, в т.ч. неудачного, не переносятся- Метаданные запроса →
Driver.AddMeta OnSaveStart— метаблоки момента исполнения черезAddMetaSaveDataIndex.Clear()- Для каждого
ISaveable—await GetSaveData(token)→ добавление вSaveDataIndex - Генерация GUID если не передан
Driver.Save(name, guid)— физическое сохранение вместе со сводкой цикла- Очередь пуста →
State = Idle;OnSaveComplete; результат (GUID, при сбое —null) ожидающим
Жизненный цикл Load
- Запрос: слияние с хвостом очереди или постановка
- Исполнение:
State = Loading,OnLoadStart Driver.Load(guid)— драйвер читает тело сейва, прогоняет реакторы коррекции (см. ниже) и заполняетSaveDataIndex- Для каждого
ISaveable—await OnLoad(token)(модуль читает изSaveController.GetData()) - Очередь пуста →
State = Idle;OnLoadComplete
Жизненный цикл Remove
- Запрос: отмена ожидающих
SaveGUID, слияние или постановка - Исполнение:
Driver.Remove(guid);Stateне меняется - Очередь пуста →
State = Idle; при успехе —OnRemove
Если очередь пуста, Remove исполняется синхронно в стеке вызывающего — как до появления очереди.
SaveProcessData — двухуровневый прогресс
| Уровень | Поле | Описание |
|---|---|---|
| Глобальный | Global.Progress / Global.Size |
Текущий модуль / всего модулей |
| Модульный | Module.Progress / Module.Size |
Прогресс внутри текущего модуля |
Структуры данных
| Тип | Назначение |
|---|---|
SaveData |
struct: Id, Data — единица данных |
SaveFolder |
struct: Id, SaveData[] DataSet — папка модуля |
SaveSummary |
struct: Name, Date, UnixTimestamp, Version, Meta — сводка сохранения (XML-сериализуемая). Version — строковая версия приложения (Application.version), которая была активна на момент создания записи. Полезна для миграций и фильтрации старых сейвов в UI. Meta — необязательный список метаблоков (null у старых сейвов); чтение — GetMeta(key), запись — SetMeta(key, value) (повторный ключ перезаписывает) |
SaveMeta |
struct: Key, Value — метаблок сводки; в XML — <Meta><Entry Key="…">значение</Entry></Meta> |
SaveControllerStates |
enum: Idle, Saving, Loading — тип исполняемой операции; Idle — очередь пуста. Между операциями очереди Idle не выставляется |
Контракт
Вход
ISaveable-модули регистрируются черезRegister()Save(name, guid?)/Load(guid)/Remove(guid)ставят операции в очередь
Выход
GetData(id)— данные модуля после загрузкиGetIndex()— все существующие сохранения (Dictionary<string, SaveSummary>)- События:
OnSaveRequest,OnSaveStart,OnSaveComplete,OnLoadStart,OnLoadComplete,OnRemove
API
| Метод | Описание |
|---|---|
SaveController.Save(name, guid?) |
Сохранение в очередь, UniTask<string>: GUID записанного сейва; null — отклонено (загрузка в очереди или исполняется), отменено удалением этого сейва или не удалось |
SaveController.Load(guid) |
Загрузка в очередь, UniTask — завершается после загрузки |
SaveController.Remove(guid) |
Удаление в очередь, UniTask — завершается после удаления |
SaveController.GetData(id) |
Данные модуля по SaveId |
SaveController.GetIndex() |
Все сохранения |
SaveController.AddMeta(key, metadata) |
Метаблок: в OnSaveRequest — в метаданные запроса, в OnSaveStart — в сводку исполняемого сохранения |
SaveController.Register(ISaveable) |
Регистрация модуля |
SaveController.UnRegister(ISaveable) |
Отмена регистрации |
SaveController.GetNumberLastSave() |
Номер-инкремент последнего сохранения |
SaveController.GetProcessData() |
Данные прогресса |
Ограничения
| Ограничение | Причина |
|---|---|
| Операции исполняются по одной | Очередь FIFO; повтор не отклоняется, а ждёт своей очереди или сливается |
Save при загрузке отклоняется |
Состояние после загрузки другое — см. Очередь операций |
| Данные — только строки | Dictionary<string, string>, JSON-сериализация на стороне модуля |
Save/Load/Remove возвращают UniTask |
Можно ждать через await либо запускать fire-and-forget через .Forget(). Исключения логируются внутри |
| Нового сейва (без GUID) очередь не дедуплицирует | Два запроса — два сейва. Защита от повторного клика — на точке сохранения |
| Нет таймаута операции | Зависший GetSaveData/OnLoad останавливает всю очередь |
CancellationToken объявлен, но не используется |
Зарезервирован на будущее |
Граничные случаи
| Ситуация | Поведение |
|---|---|
Save во время сохранения |
Встаёт в очередь; ожидающий Save того же GUID — слияние |
Save во время загрузки или при ожидающей загрузке |
Отклоняется: null, предупреждение в лог, OnSaveRequest не поднимается |
Load во время загрузки |
Встаёт в очередь; тот же GUID в хвосте очереди — слияние |
Load после ожидающего Remove того же GUID |
Исполняется после удаления — драйвер не найдёт сейв (ошибка в лог), OnLoadComplete вызывается |
Remove при ожидающем Save того же GUID |
Save отменяется (результат null), затем удаление |
Save из обработчика OnSaveRequest |
Отклоняется с ошибкой в лог |
| Исключение в обработчике события | Ошибка в лог с именем обработчика; остальные подписчики и очередь продолжают |
GetData с несуществующим id |
Log.Print(Error), возвращает пустой Dictionary |
GUID не передан в Save |
Генерируется Crypto.GetNewGuid() |
Исключение в GetSaveData / OnLoad |
Log.Print(Error), событие Complete вызывается; Save возвращает null |
| Сбой записи, проглоченный драйвером | Save возвращает GUID: Core о сбое не узнаёт — обработка ошибок на стороне драйвера |
ISaveable не зарегистрирован |
Данные не собираются/не раздаются |
SaveSummary — XML-сериализация |
Date как UnixTimestamp (long), DateTime.FromFileTimeUtc |
Сводка без <Meta> (старый сейв, драйвер без метаданных) |
Meta == null, GetMeta возвращает null |
| Новая сводка в старой сборке | Неизвестный элемент <Meta> пропускается XML-сериализатором |
AddMeta вне окон OnSaveRequest / OnSaveStart |
Пишет в черновик сводки, который заменится следующим BeginSummary — в сейв не попадёт |
AddMeta с повторным ключом |
Значение перезаписывается |
Сбой сохранения до Driver.Save |
Сводка цикла не пишется; следующий цикл начинает с новой |
Реакторы коррекции сейвов
Механизм правки устаревших сейвов. Формат данных знает модуль, а не система сохранений, поэтому Core даёт только шов: точку вызова, порядок и правило применимости. Конкретные правки пишет владелец данных.
Как работает
Драйвер слота при загрузке распаковывает тело сейва и до разбора отдаёт строку в SaveReactors.Apply(raw, saveVersion, reactors). Реакторы применяются по порядку списка, каждый возвращает строку после своих правок; результат уходит в разбор.
Правка по сырой строке выбрана осознанно: после разбора чинить нечего — сборка уже не прочитает снятый тип или переименованное поле. Типизированной стадии в системе сохранений нет: SavePreset состоит из строк, в объекты их разбирают сами модули.
Применимость
Окно задаётся версией сборки, которой записан сейв (SaveSummary.Version): реактор применяется, когда версия не старше minVersion и не новее maxVersion. Пустая граница — без ограничения с этой стороны.
Сравнение (SaveVersion.Compare) — посегментно по точке, число сегментов произвольное. Из сегмента берётся числовой префикс до первого нецифрового символа (260910-rc1 → 260910), недостающий сегмент считается нулём (1.0 = 1.0.0). Пустая версия — самая старая: так читаются сейвы, записанные до появления поля в сводке.
Реализация реактора
[Serializable]
public class RenameQuestField : SaveReactor
{
public override string TransformRaw(string raw) =>
raw.Replace("<Id>oldName</Id>", "<Id>newName</Id>");
}
Реактор добавляется в список SaveSettings (Unity-слой), там же задаются границы окна. Список по умолчанию пуст.
Гарантии и ограничения
- Сейв на диске не переписывается: коррекция живёт только в загруженных данных. Версия в сводке меняется при следующем сохранении.
- Коррекция выполняется на каждой загрузке — результат не кешируется.
- Сейв, не попавший ни в одно окно (в том числе более новый, чем сборка), читается как есть.
- Реактор не знает о других реакторах и о структуре чужих модулей — нужный кусок он находит в строке сам.
- Исключение внутри реактора прекращает коррекцию: в лог уходит имя блока и стек, разбор идёт по строке, полученной до сбойного блока.
- Глобальное хранилище механизмом не охвачено: у
GlobalContainerверсии нет, формат данных там — забота модуля.
Глобальное хранилище
GlobalSaveController хранит межсессионные данные: купленные дополнения, пройденные квесты, общую статистику, время в приложении. Данные делятся на модули (IGlobalData); хранилище находит их рефлексией, как GameModel — модули IGameData. Слоты (SaveController) глобальных данных не касаются: новая игра, загрузка и удаление сейва их не трогают, в списке сейвов хранилище не показывается.
Архитектура
GlobalSaveController : SystemController<GlobalSaveController, IGlobalSaveDriver>, IProcess (partial)
├── GlobalModel : ComplexModel<IGlobalData> ← все модули, найденные рефлексией
├── ключ модуля → модуль ← только модули с уникальным непустым ключом
│
├── RunAsync() (Loader, фаза Starting)
│ ├── индекс ключей; модули → значения по умолчанию (в тех же экземплярах)
│ ├── Driver.Read → Decompress → XML → папки модулей
│ │ └── нечитаемо → самая свежая целая копия (при копиях > 0)
│ ├── папки → существующие экземпляры модулей
│ └── CallOnInit() → IsInit = true, шлюз OnInit
│
├── Commit<T>() → запись в конце кадра (Driver.ScheduleFlush); Unfocused/Stopping — сразу
├── Reset<T>() / Reset(Type) / ResetAll() → значения по умолчанию на месте → запись сразу
├── OnChanged(IReadOnlyList<Type>) ← одно уведомление на запись
└── Stopping → запись незаписанного + резервная копия (одна за запуск)
IGlobalData [POCO]
└── GetGlobalKey() → string
IGlobalSaveDriver : ISystemDriver
├── Read(out data) → GlobalReadStatus (Ok / NoData / Error)
├── Write(data) ← атомарно
├── WriteCopy(id, data) / GetCopies() / ReadCopy(id, out data) / DeleteCopy(id)
└── ScheduleFlush(Action) ← конец кадра, повтор заменяет предыдущий
Драйвер — «глупое» хранилище строк: формат контейнера, выбор целой копии и ротацию копий знает только контроллер. Конец кадра — понятие движка, поэтому откладывание записи тоже делает драйвер. Драйверы — Unity-слой (GlobalFileDriver, GlobalPrefsDriver); принятый драйвер ставит контроллер в очередь Loader.
Готовность объявляет контроллер по завершении чтения, драйвер OnInit не поднимает. Поэтому IsInit означает «хранилище прочитано», а WaitingFor(typeof(GlobalSaveController)) в Loader ждёт именно чтения.
Формат
GlobalContainer — XML по образцу SavePreset: список SaveData «ключ модуля → строка сериализатора Vortex». Контейнер сжимается Compress(…, "global"), как тело слота. Шифрования нет: файл открывается архиватором, защита от правки — забота модуля. Версии приложения в контейнере нет: формат данных — забота модуля.
Жизненный цикл
- До загрузки
Get<T>()отдаёт значения по умолчанию — те, что задал конструктор модуля. - Загрузка (Starting). Сохранённые данные заливаются в существующие экземпляры модулей — ссылки, полученные раньше, остаются рабочими. Нет контейнера — первый запуск, значения по умолчанию. Нечитаемый контейнер при включённых копиях поднимается из самой свежей целой копии и сразу перезаписывается ею.
- Готовность.
IsInit = true, срабатывают подписчикиOnInit. Хранилище готово раньше загрузки любых слотов. - Фиксация. Модуль меняет данные и вызывает
Commit<T>(). Все фиксации кадра — одна запись и одноOnChanged. ВUnfocusedиStoppingзапись и уведомление сразу: после этих переходов кадров может не быть, а порядок обработчиковStoppingне определён. - Завершение. Незаписанное пишется сразу; при числе копий > 0 делается одна копия за запуск, самые старые удаляются.
Настройки
| Где | Параметр | По умолчанию |
|---|---|---|
DriverConfig, строка GlobalSaveController |
Драйвер: файл или PlayerPrefs | — (без драйвера хранилище не загружается) |
SaveSettings (Unity) → SettingsModel.GlobalSaveBackups |
Число резервных копий | 0 — копий нет |
SaveSettings (Unity) → SettingsModel.GlobalSaveFolder |
Папка файла (файловый драйвер) | Global |
DebugSettings (Unity) → SettingsModel.GlobalSaveFailFast |
Fail-fast для ошибок чтения, дублей и пустых ключей; только редактор, от DebugMode не зависит |
включён |
Fail-fast: загрузка останавливается (App.Exit()), шлюз не открывается, запись заблокирована — испорченный файл не перезаписывается. В билде fail-fast всегда выключен: значения по умолчанию и лог.
API
| Член | Описание |
|---|---|
Get<T>() |
Модуль с текущими значениями. До загрузки — значения по умолчанию |
HasStoredData<T>() |
Данные модуля были в прочитанном контейнере — для миграций |
Commit<T>() / Commit(Type) |
Зафиксировать изменения модуля. До загрузки — отклоняется с ошибкой. Перегрузка по типу — для инструментов |
Reset<T>() / Reset(Type) |
Модуль — к значениям по умолчанию в том же экземпляре, запись сразу, уведомление |
ResetAll() |
То же для всех модулей |
OnInit / IsInit |
Шлюз и признак готовности (хранилище прочитано) |
OnChanged |
Записаны изменения: типы модулей. Приходит независимо от успеха записи |
Modules |
Модули по ключу — для инструментов |
Модуль данных: инструкция для авторов
public class QuestFlagsData : IGlobalData
{
public List<string> Completed { get; internal set; } = new();
public string GetGlobalKey() => "MyGame.QuestFlags";
}
// Изменение — только контроллер своего пакета
var flags = GlobalSaveController.Get<QuestFlagsData>();
if (!flags.Completed.Contains(questId))
{
flags.Completed.Add(questId);
GlobalSaveController.Commit<QuestFlagsData>();
}
// Реакция: после загрузки и на изменения
GlobalSaveController.OnInit += RefreshBackground;
GlobalSaveController.OnChanged += types =>
{
if (types.Contains(typeof(QuestFlagsData)))
RefreshBackground();
};
Правила:
- Конструктор без параметров обязателен — он задаёт значения по умолчанию. Модуль без него не попадёт в реестр.
- Ключ стабилен. Смена ключа после релиза — потеря данных модуля. Переименование и перенос класса модуля безопасны: при чтении маркер типа подменяется текущим типом. Имена вложенных POCO-типов — забота модуля.
- Данные — свойства с getter и setter (правила сериализатора). Поля не сохраняются и при сбросе не сбрасываются.
- Когда фиксировать, решает модуль: факт квеста — в момент прохождения, кэш покупок — при ответе платформы.
- Смена формата — забота модуля: недостающие свойства берут значения по умолчанию, лишние игнорируются; словари сливаются с текущими, списки и массивы заменяются.
- Миграции — через
HasStoredData<T>(). Пример —AppTimeDataв SDK GameCore: перенос изPlayerPrefs, старый ключ удаляется, когда следующий запуск прочитал перенесённое значение. - Шифрование — на модуле.
Crypto.SetCryptoPackвыполняет PBKDF2 на каждый вызов (порядка сотен миллисекунд), поэтому шифровать стоит только при изменении данных. - Ссылку на модуль держать можно: загрузка и сброс меняют значения в том же экземпляре.
- Проверка модуля — окно
Tools/Vortex/SaveData/Global Index(Unity SaveSystem): вне Play Mode покажет модуль, его ключ и проблемы, из-за которых хранилище его пропустит; в Play Mode — текущие значения с правкой на лету.
Ограничения
| Ограничение | Причина |
|---|---|
| Нет шифрования | Защита данных — забота модуля; файл правится вручную |
| При числе копий 0 испорченный контейнер не восстанавливается | Разработчик отказался от страховки |
| Данные отключённых пакетов при следующей записи пропадают | Настройки пакетов структурообразующие: отключение считается окончательным |
Копия делается только при Stopping |
Процесс, убитый без Stopping, копии за этот запуск не оставит |
| Облачная синхронизация не объединяет данные устройств | Вне области пакета |
Граничные случаи
| Ситуация | Поведение |
|---|---|
Нет драйвера в DriverConfig |
Загрузки нет, IsInit = false; Get — значения по умолчанию, фиксации отклоняются с ошибкой |
| Первый запуск (контейнера нет) | Значения по умолчанию; контейнер создаёт первая фиксация |
| Основной контейнер нечитаем, копии есть | Самая свежая целая копия, предупреждение в лог, основной контейнер сразу перезаписывается ею |
| Нечитаем, целых копий нет (или копии выключены) | Ошибка в лог, значения по умолчанию; первая фиксация перезаписывает контейнер. Редактор с fail-fast — загрузка останавливается |
| Нечитаемые данные одного модуля | Модуль по умолчанию, ошибка в лог, остальные читаются; fail-fast — как выше |
| Два модуля с одним ключом | Оба по умолчанию, не читаются и не пишутся; ошибка в лог; fail-fast — как выше |
Пустой ключ или исключение в GetGlobalKey |
Модуль вне индекса; ошибка в лог; fail-fast — как выше |
| Данные в контейнере без модуля | Предупреждение; не читаются, при следующей записи пропадают |
| Фиксация или сброс до загрузки | Ошибка в лог, операция отклонена |
| Серия фиксаций за кадр | Одна запись, одно OnChanged |
| Ошибка записи | Ошибка в лог; данные в памяти, следующая фиксация повторит запись; OnChanged всё равно приходит |
| Рестарт без выгрузки домена | Экземпляры модулей те же, значения сбрасываются к умолчанию перед чтением |