RecordMarksSystem
Namespace: Vortex.Sdk.RecordMarksSystem.*
Assembly: ru.vortex.sdk.recordmarks
Область применения
Пакет закрывает один класс задач: нужно помнить факт про запись Database — что с ней уже произошло, — и факт этот должен пережить сцену, сессию, а иногда и слот сохранения.
Типовой пример — галерея. Иллюстрации лежат в Database отдельными пресетами, у каждого свой GUID. Игре нужно знать две вещи: какие карточки открыты и какие из открытых игрок уже смотрел (бейдж «новое»). Обе — булевы факты на GUID, и обе бессмысленно держать рядом с самим контентом: иллюстрация неизменна, меняется только отношение игрока к ней.
Пакет хранит множества GUID под именами gallery.unlocked и gallery.viewed, отвечает на IsMarked / GetMarked и сам решает, куда эти множества пишутся — в слот или в аккаунт. Что показывает карточка, в каком порядке идут иллюстрации и как выглядит бейдж — задача галереи, не пакета.
Те же очертания у соседних задач:
| Задача | Метка | Где хранится |
|---|---|---|
| Иллюстрация открыта навсегда | gallery.unlocked |
аккаунт (globalMarks) |
| Карточку уже смотрели | gallery.viewed |
слот (slotMarks) |
| Запись бестиария встречена | codex.unlocked |
слот |
| Катсцена просмотрена, можно пропускать | cutscene.viewed |
аккаунт |
| Товар когда-либо покупался | shop.everPurchased |
слот |
Выбор между слотом и аккаунтом — это выбор смысла факта: «в этом прохождении» или «у этого игрока вообще». Один и тот же GUID может нести обе метки сразу.
Когда пакет не подходит:
- факт не булев, а со значением — счётчик, прогресс, дата, уровень: это своя модель в
IGameData; - контент прячется не по действиям игрока, а по изданию сборки: это
ContentTagsSystem; - нужен сам разблокированный контент — пакет хранит только принадлежность GUID к метке.
Назначение
Реестр именованных булев-меток на GUID пресетов Database. Метка — факт «для этой записи произошло такое событие» (разблокировано, посмотрено, покупалось). Хранит только принадлежность GUID к метке — контент разблокированных сущностей вне ответственности пакета.
Возможности:
- Именованные метки. Каждое имя декларируется в SO-конфиге и живёт либо per-slot, либо per-account.
- Ratchet-семантика:
Markидемпотентен,Unmarkдоступен, но по контракту — для debug/cheat. - Роутинг сохранения.
slotMarksпишутся вSaveControllerчерезIGameData;globalMarks— вGlobalSaveControllerчерезIGlobalData. - События изменения.
OnMarked/OnUnmarkedфаерятся в конце кадра батчем черезTimeController.Accumulate. - Реактивный пайплайн Vortex.
GameController.CallUpdateEventподнимается вместе с батчем событий. - Editor-окно. Read-only дамп текущего состояния через
Tools/Vortex/Record Marks/Index. - Static Bootstrap. Инициализируется через
[RuntimeInitializeOnLoadMethod], не входит вLoader/DriverConfig.
Вне ответственности:
- Контент разблокированных сущностей (галерея, катсцены, записи бестиария).
- Подсчёт полного списка кандидатов (для прогресса «X из Y») — задача consumer'а.
- UI-хендлеры и представление меток.
- Driver-подстановка. Пакет фиксирован на связку
SaveController+GlobalSaveController.
Зависимости
| Зависимость | Назначение |
|---|---|
Vortex.Sdk.Core (GameController, GameModel.IGameData) |
Резолв слот-модели, батчинг обновлений |
Vortex.Core.SaveSystem (GlobalSaveController, IGlobalData) |
Резолв global-модели, commit после мутации |
Vortex.Core.LoggerSystem |
Диагностика |
Vortex.Unity.AppSystem.System.TimeSystem (TimeController.Accumulate) |
Батчинг событий за кадр |
Vortex.Unity.CoreAssetsSystem (ICoreAsset) |
Автосоздание SO настроек в Resources/Settings/ |
| Unity Engine | ScriptableObject, Resources.Load, RuntimeInitializeOnLoadMethod, EditorWindow |
Использование
Настройка
- SO настроек создаётся автоматически (реализует
ICoreAsset):Tools → Vortex → Debug → Check Core Assets, либо на перезагрузке домена при включённом авто-режиме. Вручную —Create → Vortex → Settings → RecordMarksвAssets/Resources/Settings/. - Заполнить
slotMarksиglobalMarksуникальными именами. Рекомендуемая форма — дот-неймспейс:"gallery.unlocked","codex.viewed","shop.everPurchased".
Потребитель
public class GalleryCardView : MonoBehaviour
{
[SerializeField] private string _cardGuid;
private void OnEnable()
{
RecordMarksBus.OnMarked += HandleMarked;
Refresh();
}
private void OnDisable()
{
RecordMarksBus.OnMarked -= HandleMarked;
}
private void HandleMarked(string mark, string guid)
{
if (mark == "gallery.unlocked" && guid == _cardGuid)
Refresh();
}
private void Refresh()
{
var unlocked = RecordMarksBus.IsMarked("gallery.unlocked", _cardGuid);
// переключить UI
}
}
Мутация
// Штатное — только Mark. Идемпотентно.
RecordMarksBus.Mark("gallery.unlocked", cardGuid);
// Debug/cheat.
RecordMarksBus.Unmark("gallery.unlocked", cardGuid);
Прогресс
// RecordMarks знает только сколько помечено. Полный список кандидатов —
// на стороне consumer'а (обычно перечислением пресетов Database).
var unlockedCount = RecordMarksBus.GetMarked("gallery.unlocked").Count;
var total = Database.GetRecords<GalleryCard>().Length;
Debug.Log($"{unlockedCount} / {total}");
Editor-окно
Tools → Vortex → Record Marks → Index — read-only дамп текущего состояния Bus. Работает только в Play Mode после Bootstrap. В шапке — Ready и Session; без сессии над per-slot метками подсказка, что они не учитываются. Фильтр по имени, foldout по каждой метке со списком GUID.
Tools → Vortex → Record Marks → Settings — подсветить SO-конфиг в Project window.
Архитектура
[Resources/Settings/RecordMarksSettings.asset] — SO с двумя списками имён
│
▼
RecordMarksBus (static) — точка входа, API + события
│
├──► RecordMarksSlotData : IGameData — Dictionary<mark, List<guid>>
│ (per-slot, в GameModel)
│
└──► RecordMarksGlobalData : IGlobalData — Dictionary<mark, List<guid>>
(per-account, в GlobalModel)
Bootstrap, готовность и игровая сессия:
[RuntimeInitializeOnLoadMethod BeforeSceneLoad]— сбрасывает статик-состояние, читает SO, валидирует имена меток, подписывается наGlobalSaveController.OnInit,GameController.OnNewGame/OnLoadGameиGameController.OnGameStateChanged.- Готовность (
IsReady) выставляется по факту резолва per-account модели (GlobalSaveController.OnInit) — пакет работает и вне игры (главное меню, галерея до старта игры). ДоIsReady = trueлюбойMark/Unmark— warning + no-op. - Per-slot метки учитываются только при открытой игровой сессии (
HasSession): слот привязывается наOnNewGame/OnLoadGameи отвязывается, когда игра переходит вGameStates.Off(ExitGame, выход в меню). Вне сессии per-slot метки пусты (IsMarked—false,GetMarked— пусто),Mark/Unmarkпо ним — warning + no-op. Данные слота вGameModelпри отвязке не трогаются — сохранение и загрузка штатные.
Runtime-кеш Dictionary<mark, HashSet<guid>> держит O(1)-операции над множеством GUID. Модели хранят List<guid> — потому что сериализатор Vortex обрабатывает только IList, не ISet. Bus поддерживает List и HashSet синхронно при каждой мутации.
Merge-семантика Vortex-сериализатора: при загрузке слота внешний Dictionary<mark, List<guid>> мержится (новые метки из SO остаются с пустыми списками), внутренний List<guid> заменяется целиком (state берётся из сейва).
События OnMarked / OnUnmarked не фаерятся синхронно. Каждая мутация добавляет запись в очередь _pending и планирует FlushEvents через TimeController.Accumulate — очередь опустошается один раз за LateUpdate, вместе с ней поднимается GameController.CallUpdateEvent.
Контракт
Вход
Assets/Resources/Settings/RecordMarksSettings.asset— SO со спискамиslotMarks: string[]иglobalMarks: string[]. Имена уникальны в пределах каждого списка и не пересекаются между списками. Формат имён не валидируется — только на непустоту.GlobalSaveController— готов (проходитIProcess) — достаточно для работы per-account меток.GameController— прошёлNewGameилиLoadGameи не вышел вGameStates.Off— нужно только для per-slot меток.
Выход
RecordMarksBus.IsMarked(mark, guid)— есть ли метка на GUID.RecordMarksBus.GetMarked(mark)— все GUID под меткой.- События
OnReady,OnMarksLoaded,OnMarked,OnUnmarked. - Мутация
RecordMarksSlotData.Data/RecordMarksGlobalData.Data— только через Bus. ПолеData—internal, снаружи сборки недоступно. - Сериализация — стандартный контракт
IGameData/IGlobalData; ручного save-кода в пакете нет.
Гарантии
- Ratchet.
Mark(m, g)при уже помеченном(m, g)— тихий no-op, событие не фаерится. - Батчинг.
OnMarked/OnUnmarkedфаерятся один раз заLateUpdate, в порядке накопления. Между мутацией и событием есть задержка в 1 кадр. - Owner-lock. Единственный писатель
_slotCache/_globalCache/_pending— Bus. Модели закрыты структурно на уровне сборки: полеData—internal+[IsPOCO], снаружи сборки его нельзя ни прочитать, ни заменить; сериализатор Vortex работает с ним рефлексией. - Идемпотентность повторного Bootstrap. Fast Enter Play не удваивает подписки:
-= then +=перед каждой. - Идемпотентность OnGlobalReady / OnSlotChanged. Дублирующий вызов пересинхронизирует состояние с SO без потери данных.
- Изоляция слотов. При
OnLoadGame/OnNewGameи при выходе из игры (GameStates.Off)_pending.Clear()— событие предыдущей сессии не долетит до consumer'а следующей. - Вне игры — только аккаунт. Без игровой сессии per-slot метки не учитываются: данные прошлого прохождения не просачиваются в меню.
- Fail-safe диагностика. Unknown mark, empty guid, mutation до
IsReady, мутация per-slot метки вне сессии, пересекающаяся метка, метка из save не в SO — все дают Log-warning / Log-error, не крашат систему.
Ограничения
| Ограничение | Причина |
|---|---|
Один SO на проект в Resources/Settings/ |
Bootstrap читает единственный ассет по фиксированному пути |
| Имя метки — глобально уникальная строка | SlotMarks ∩ GlobalMarks = ∅, дубликат внутри списка тоже исключается из индекса |
| Смена имени метки между релизами теряет её state в сейве | Стабильный ключ — контракт разработчика |
| Live-editing SO в рантайме не поддерживается | Bootstrap читает SO один раз |
| Отсутствие SO отключает пакет | SO авто-создаётся как ICoreAsset; если всё же нет — LogError + все Mark/Unmark возвращают warning |
| Смерть GUID (удалён пресет) не чистит его из HashSet автоматически | Cleanup — задача Editor-инструмента или миграции |
| Не потокобезопасен | Все операции — Unity main-thread |
API
RecordMarksBus (static)
| Член | Описание |
|---|---|
bool IsReady { get; } |
Per-account модель резолвлена, SO прочитан, пакет готов (в том числе вне игры) |
bool HasSession { get; } |
Открыта игровая сессия — per-slot метки привязаны к слоту и учитываются |
event Action OnReady |
Первый раз, когда IsReady перешёл в true |
event Action OnMarksLoaded |
После привязки слота (NewGame / LoadGame) и после его отвязки (выход в GameStates.Off). Consumer перечитывает состояние |
event Action<string, string> OnMarked |
(mark, guid) — метка выставлена; батч в конце кадра |
event Action<string, string> OnUnmarked |
(mark, guid) — метка снята; батч в конце кадра |
void Mark(string mark, string guid) |
Пометить GUID меткой. Ratchet: повторный вызов — no-op |
void Unmark(string mark, string guid) |
Снять метку. Публичен, но по контракту — для debug/cheat |
bool IsMarked(string mark, string guid) |
Проверка наличия; per-slot метка вне сессии — false |
IReadOnlyCollection<string> GetMarked(string mark) |
Все GUID под меткой; live-обёртка над внутренним HashSet, no-alloc; per-slot метка вне сессии — пусто |
RecordMarksSettings (SO)
| Поле | Описание |
|---|---|
SlotMarks : IReadOnlyList<string> |
Метки, живущие per-slot (SaveController) |
GlobalMarks : IReadOnlyList<string> |
Метки, живущие per-account (GlobalSaveController) |
Создание: автоматически — SO реализует ICoreAsset, контроллер Core Assets кладёт экземпляр в Resources/Settings/ (Tools → Vortex → Debug → Check Core Assets, либо на перезагрузке домена при включённом авто-режиме). Вручную — Create → Vortex → Settings → RecordMarks.
Модели (для рефлексии реестров, не для прямой работы)
| Тип | Роль |
|---|---|
RecordMarksSlotData : GameModel.IGameData |
Регистрируется рефлексией в GameModel |
RecordMarksGlobalData : IGlobalData (GetGlobalKey() = "Vortex.RecordMarks.Global") |
Регистрируется рефлексией в GlobalSaveController |
Поле Data обеих моделей — internal + [IsPOCO]: снаружи сборки пакета его не заменить и не прочитать (owner-lock у RecordMarksBus), а сериализатор Vortex работает с ним рефлексией.
Граничные случаи
| Ситуация | Поведение |
|---|---|
SO не найден в Resources/Settings/RecordMarksSettings |
В норме не бывает — SO авто-создаётся (ICoreAsset); если авто-создание не отработало: LogError одноразово в Bootstrap, IsReady навсегда false, каждый Mark/Unmark даёт warning |
Метка присутствует и в slotMarks, и в globalMarks |
LogError, исключается из обоих списков; операции с этим именем возвращают no-op |
| Пустая строка / whitespace в списке | LogWarning, пропускается |
| Дубликат в пределах одного списка | LogWarning, второе и последующие пропускаются |
Mark(unknown, guid) |
LogWarning, no-op |
Mark(mark, "") |
LogWarning, no-op |
Mark до IsReady |
LogWarning, no-op |
Mark / Unmark per-slot метки вне игровой сессии |
LogWarning, no-op |
IsMarked / GetMarked per-slot метки вне игровой сессии |
false / пусто |
Выход из игры (GameStates.Off) |
Слот отвязывается, _pending.Clear(), OnMarksLoaded; данные слота в GameModel не трогаются |
Mark(m, g) при уже помеченном |
Тихий no-op, событие не фаерится |
Unmark(m, g) при непомеченном |
Тихий no-op, событие не фаерится |
| Метка в сейве, отсутствующая в SO | LogWarning, ключ удаляется из модели при Sync |
| Метка в SO, отсутствующая в сейве | Ключ создаётся с пустым списком |
| GUID помеченного пресета удалён из Database | Мёртвый ID в HashSet, вреда не наносит; чистка — задача миграции |
NewGame во время активной сессии |
_pending.Clear() первой строкой OnSlotChanged, событие предыдущей сессии не долетит |
| Bus обращается из другого треда | Не поддерживается; операции ожидаются с main-thread |
| Fast Enter Play, статик-состояние не обнулилось | -= then += перед подпиской — не удваивает регистрацию |
Consumer вызывает Mark из handler'а OnMarked |
Nested Mark добавляется в текущую волну FlushEvents; ratchet-гард гарантирует конечность |
GetMarked возвращает HashSet через IReadOnlyCollection |
Downcast к HashSet<string> и мутация нарушают контракт read-only |