GalleryStoriesSystem
Namespace: Vortex.NaniExtensions.GalleryStoriesSystem.*
Assembly: ru.vortex.nani.gallery.stories (define: USING_NANINOVELL)
Тип пакета: consumer для GallerySystem — нарративные галлерейные карточки
Назначение
Третий тип галлерейного контента — нарративные карточки: превью в галлерейной сетке, при клике игрок повторно «проходит» короткий nani-скрипт (сцену диалога, воспоминание, флешбэк). Пример: галлерея сцен в визуальной новелле, где ранее просмотренная реплика или диалог доступны для повторного проигрывания.
Возможности:
GalleryStoryModelреализуетIGalleryEntryиз GallerySystem — попадает в галлерейный пул автоматически.GalleryStoryPresetдержит стандартные поляRecordPreset<T>+ строковый путь к nani-скрипту (Script.Path).- Driver-нейтральный путь вместо прямой ссылки на
Naninovel.Script. Причина: naninovel сам управляет жизненным циклом Script-инстансов (ScriptPlayer.ResetService()вOnLoadGameможет выгрузить загруженный скрипт), после чего прямая ссылка становится fake-null. Строка живёт всегда. - Дизайнерский UX: вспомогательное поле
Naninovel.Script script(non-serialized) в инспекторе. Бросил ассет — Odin-callback скопировалPathв сериализуемое поле и обнулил ссылку. - InfoBox-валидация: пресет показывает статус пути (пусто / ok / не найден) с найденным именем скрипта.
- Прямой вызов
NaniWrapperбез промежуточного bus/viewer'а:model.Show()→NaniWrapper.PlayScript(scriptPath),model.Hide()→ScriptPlayer.Stop(). Naninovel сам держит UI, координировать нечего.
Вне ответственности:
- UI показа nani-скрипта — Naninovel предоставляет свой UI (
ScriptPlayer+TextPrinter+ прочие сервисы) целиком. Пакет туда не вмешивается. - Разблокировка карточек —
RecordMarksSystem, пакет туда не пишет. - Управление ScriptPlayer — делегируется в
NaniWrapper.PlayScript(тот шлюзует Load→Play, глушит ложные Stop-события, корректно обрабатывает пустой путь).
Зависимости
| Зависимость | Назначение |
|---|---|
Vortex.Sdk.GallerySystem |
Контракт IGalleryEntry |
Vortex.Core.DatabaseSystem |
Record, попадание в галлерейный пул |
Vortex.Unity.DatabaseSystem |
RecordPreset<T> |
Vortex.Core.Extensions.LogicExtensions |
ObjectExtCopy.CopyFrom |
Vortex.NaniExtensions.Core |
NaniWrapper.PlayScript, NaniWrapper.ScriptPlayer.Stop |
| Naninovel | Script (auto-referenced) |
| Odin Inspector | [ShowInInspector], [OnValueChanged], [InfoBox] (auto-referenced) |
| UniTask | Для Forget() fire-and-forget |
Создание карточки (для дизайнера)
- Создать пресет: Project → Create → Vortex → Presets → Gallery → Story. Получится
GalleryStoryPreset. - Заполнить базовые поля: Name, Description, Icon (превью для галлерейной плитки).
- Перетащить nani-скрипт в поле
scriptв инспекторе. Odin-callback сразу:- Копирует
script.Pathв полеscriptPath(сериализуемое). - Обнуляет
script(не сериализуется). - Помечает пресет dirty для сохранения.
- Копирует
- Проверить InfoBox под полем
scriptPath:- Зелёный (Info) — «OK:
<имя_скрипта>(Path:<путь>)» — путь корректен, скрипт найден в проекте. - Жёлтый (Warning) — «Путь к скрипту не задан».
- Красный (Error) — «Скрипт с Path не найден в проекте» — путь есть, но nani-скрипт с таким Path не существует. Возможно, скрипт был удалён или переименован.
- Зелёный (Info) — «OK:
- Метка разблокировки: в
RecordMarksSettingsдолжна быть метка, которой сюжет пометитGuidPresetкарточки. Дизайнер галлерейной сцены указывает эту метку вGalleryView.marks.
Карточка автоматически попадает в GalleryView, если её тип разрешён allowedTypes, а GuidPreset помечен нужной меткой. Клик «Смотреть» в галлерейной карточке (GalleryCardView.Show) вызывает entry.Show() → nani-скрипт стартует.
Архитектура
GalleryStoryPreset (SO)
├── [SerializeField] Icon (превью) — из базы RecordPreset<T>
├── [SerializeField, ReadOnly, InfoBox] — driver-нейтральная строка Script.Path
│ private string scriptPath
└── [NonSerialized, ShowInInspector, OnValueChanged] — UX-помощник (drag&drop)
private Script script
│
│ OnValueChanged: scriptPath = script.Path; script = null
│
│ Database.GetRecords → RecordPreset<T>.GetData()
│ ↓
▼
GalleryStoryModel (Record, IGalleryEntry)
├── GuidPreset, Icon, Name, Description — публичные (из Record + CopyFrom)
├── internal ScriptPath — копия строки из пресета
├── [NonSerialized] _active — наш скрипт реально стартовал (гонка-гард)
├── Show() → Show(CancellationToken.None)
├── Show(ct) → _active=false; +sub OnNaniStart/OnNaniStop → PlayScript(ScriptPath, token: ct).Forget()
│ │
│ ├─ HandleNaniStart ─(PlayedScript.Path == наш)─→ _active=true; -unsub Start
│ │
│ └─ HandleAutoStop ─(!_active — чужой стоп до старта)─→ игнор
│ └─(_active && наш скрипт больше не играет)─→ -unsub оба → Hide() (no-op)
│
└── Hide() → если PlayedScript.Path == ScriptPath → ScriptPlayer.Stop() иначе no-op
│
▼
Naninovel (сам держит UI-канал: принтеры, актёры, звук — вне ответственности пакета)
По сравнению с GallerySpritesSystem — на два уровня короче: нет bus, нет viewer'а, нет controller-extension'а. Naninovel сама себе UI — координировать нечего.
Контракт
GalleryStoryModel : Record, IGalleryEntry
public class GalleryStoryModel : Record, IGalleryEntry
{
public Sprite Icon { get; protected set; }
internal string ScriptPath { get; set; }
public bool CopyFrom(SoData source); // копирует Path из пресета
public void Show(); // NaniWrapper.PlayScript(ScriptPath).Forget()
public void Show(CancellationToken ct); // с токеном отмены
public void Hide(); // Stop только если PlayedScript.Path == ScriptPath
}
GalleryStoryPreset : RecordPreset<GalleryStoryModel>
public class GalleryStoryPreset : RecordPreset<GalleryStoryModel>
{
[SerializeField, ReadOnly, InfoBox(...)] private string scriptPath;
[NonSerialized, ShowInInspector, OnValueChanged(nameof(OnScriptChanged))]
private Script script;
public string ScriptPathTemplate => scriptPath;
}
Инварианты
- I1.
ScriptPath— internal; извне сборки недоступен. - I2. В сериализованном SO хранится только строка
scriptPath; ссылки наNaninovel.Scriptне сохраняются. - I3.
Show/Hide— синхронные (требованиеIGalleryEntry). АсинхронныйPlayScriptуходит вForget()— модель не блокирует вызывающего. - I4.
Showподписывает модель наNaniWrapper.OnNaniStartиOnNaniStop; при завершении нашего скрипта — авто-Hide(). Lifecycle симметричен:Show → (nani закончилась) → Hide(), без ручного вмешательства. - I5. Auto-
Hideидемпотентен с ручнымHide: если игрок нажал «Закрыть» →Stop()→ OnNaniStop → HandleAutoStop →Hide()— второйHideno-op'нется (PlayedScript уже null). - I6. Гонка-гард
_active: авто-Hideсрабатывает только после того, как наш скрипт реально стартовал (OnNaniStartсPlayedScript.Path == ScriptPath→_active = true, отписка от Start). ЧужойOnNaniStopв окне междуShowи стартом нашего скрипта (покаPlayScriptшлюзует Load→Play) при_active == falseигнорируется — подписка не рвётся преждевременно._active—[NonSerialized], в сейв не входит; сбрасывается в начале каждогоShow, а стейл-подписка при_active == falseинертна.
Edge cases
| Ситуация | Поведение |
|---|---|
Пустой ScriptPath |
NaniWrapper.PlayScript выведет LogError и вернётся; скрипт не запустится |
ScriptPath указывает на удалённый nani-скрипт |
Naninovel бросит «Failed to get resource» в лог; NaniWrapper обработает как отсутствие |
| Дизайнер перетащил Script вручную | OnValueChanged копирует Path в scriptPath и очищает script. Дальше видимо только сериализованное поле |
| Nani-скрипт выгружен между показами | Пакет хранит только строку; следующий Show заставит Naninovel перезагрузить скрипт по пути. Проблема выгрузки решена этой архитектурой |
| Клик «Смотреть» во время активного скрипта | NaniWrapper.PlayScript шлюзует запуски: пока один Load→Play не завершился, следующий ждёт |
| Наш скрипт реально стартовал | OnNaniStart с PlayedScript.Path == ScriptPath → _active = true, отписка от OnNaniStart |
Чужой OnNaniStop между Show и стартом нашего скрипта |
_active == false → HandleAutoStop игнорирует; подписка сохраняется, ждём наш OnNaniStart (гонка-гард) |
Hide() без активного скрипта |
Проверка PlayedScript == null — no-op |
Hide() пока играет чужой скрипт (нарративный запуск между Show и Hide) |
Проверка PlayedScript.Path != ScriptPath — no-op; чужой скрипт не трогается |
| Nani-скрипт завершился сам (кончились команды) | _active == true → OnNaniStop fires → HandleAutoStop отписывается от обоих + вызывает Hide() (no-op в этой точке, но симметрично замыкает цикл) |
| Пока играет наш скрипт, стартовал другой (@goto на внешний скрипт) | OnNaniStop fires с новым PlayedScript.Path != ScriptPath → HandleAutoStop отписывается + Hide() no-op |
Повторный Show того же инстанса |
-= перед += в Show защищает от двойной подписки |
Проект без USING_NANINOVELL |
Пакет не компилируется (define constraint) — карточки-истории просто не подключены |