Storybook: живая игровая площадка для собственных компонентов
Storybook — это браузер компонентов, встроенный в sus-core: укажите ему на UIDocument, и он выведет список всех объявленных стори, смонтирует реальный экземпляр компонента на сцене и сформирует полную панель управления прямо из публичных полей Prop<T> вашего компонента — без необходимости вручную писать и синхронизировать список контролов.
Он поставляется как собственная сборка com.sharq-it.sus.core.storybook (Runtime/Storybook/com.sharq-it.sus.core.storybook.asmdef), которая ссылается только на com.sharq-it.sus.core. Он не зависит от sus-router — навигация между стори — это малюсенький самостоятельный слой (ниже), а не маршрутизированное приложение. Ничто другое в ядре не ссылается на эту сборку (autoReferenced: false), так что сборка игрока, которая никогда не импортирует сэмпл стори, не содержит её: ни .dll, ни стилей, 0 байт. Сборка появляется в билде в тот момент, когда стори какого-нибудь пакета импортированы и на них ссылаются — цена уплачивается по выбору, за проект, а не за покупателя.
Как открыть
- Добавьте
UIDocumentна сцену и поместитеSusStorybookBehaviourна тот же GameObject (Add Component → SUS → Storybook Host). Нажмите Play (или используйте Editor's UI Toolkit preview) — оболочка смонтируется и выведет список всех пакетов, у которых объявлены стори. - Downstreamные UI-пакеты поставляют собственные стори компонентов тем же способом: папка
Samples~, чья собственнаяasmdefссылается наcom.sharq-it.sus.core.storybookплюс этот пакет. Импортируйте сэмплStoriesиз записи Package Manager пакета и откройте сцену, которую он добавит (UIDocument+SusStorybookBehaviour) — в доках пакета указано имя сэмпла и имя файла сцены. Сцена никогда сама не выводит список стори, поэтому она не растёт и не становится устаревшей вместе с каталогом. - Стори каждого такого пакета поступают тем же способом — сэмпл поставляется по выбору за пакет, и проект платит только за те, которые он импортирует.
Маркер SusStoryAssembly пакета также говорит о том, для чего предназначена сборка (ниже), и вкладка следует из этого:
- Product, нет объявленных стори — пакет всё равно получает вкладку с табличкой, говорящей об этом, вместо того чтобы исчезнуть молча, так что «я импортировал неправильный сэмпл» и «в этом пакете нет стори» читаются как два разных события. Вот где табличка заслуживает своё место: стори, которые приходят через ручной импорт папки
Samples~(kit, game, пакет скина). Проект может также запросить эту же вкладку и табличку, прежде чем пакет вообще поставляет какую-либо стори, без маркера, черезSusStoryRegistry.DeclarePackage(key, packageId, version). - Тестовый стенд (
Kind = SusStoryPackageKind.Fixture) по умолчанию не получает вкладку и табличку — его стори существуют, чтобы быть доступными по прямому адресу из сьюта. Скрыто по умолчанию не то же, что исчезло: менюWindow/SUS/Storybook/Show Engine Fixturesвыводит все пакеты-стенды на этой машине, и открытие адреса с?fixtures=1тоже их выводит, так что ссылка, поделённая с кем-то, чьё меню переключено, всё равно открывает вкладку стенда. - Нет маркера, и нет вызова
DeclarePackageдля этого ключа — нет вкладки.
Писать свою стори
using Sharq.Core.Storybook;
[SusStory("myapp/atoms/price-tag", Name = "Price Tag", Purpose = "shows a price with currency")]
public sealed class PriceTagStory : ISusStory
{
public SusComponent Create() => new PriceTag();
public void Configure(SusStoryContext ctx)
{
var tag = (PriceTag)ctx.Component;
tag.Amount.Value = 19.99f;
// Пропс, который стори намеренно оставляет без контрола, ДОЛЖЕН объяснить почему —
// исключение без причины — это дыра, а не решение:
ctx.Exclude(nameof(PriceTag.InternalSku), "debug-only, not buyer-facing");
}
}Вот весь контракт: класс, реализующий ISusStory, помеченный [SusStory], в сборке, которая согласится с [assembly: SusStoryAssembly] (один раз на сборку, в AssemblyInfo.cs):
[assembly: Sharq.Core.Storybook.SusStoryAssembly(Package = "myapp")]SusStoryAssembly также имеет Kind, по умолчанию SusStoryPackageKind.Product. Оставьте его как есть для пакета, который импортирует покупатель — этот стандарт — это то, что дарует сборке её вкладку (и её табличку пустого пакета, выше). Тестовый стенд, который существует только, чтобы дать сьюту адресуемые стори, вместо этого устанавливает Kind = SusStoryPackageKind.Fixture: он выпадает из списка вкладок, дерева и развёртки по умолчанию, и его стори всегда разрешаются по прямому адресу независимо — но «по умолчанию» не означает «никогда»: то же самое переключение меню и адрес ?fixtures=1, описанные выше, возвращают его в список.
Адрес <package>/<group>/<slug> — постоянный ID стори — это то, на что ориентируются глубокие ссылки и снимки экрана, поэтому переименование обращайте как переименование публичного API. Реестр находит стори, сканируя только сборки с SusStoryAssembly (не все сборки проекта), и строит только тип, который уже есть как System.Type из этого сканирования — никогда из строки — поэтому ничего не зависит от того, как stripped/IL2CPP-билд разрешает имена.
Если ваши стори генерируются, а не по принципу один класс на стори (пресеты скинов, управляемые данными композиции), вместо этого реализуйте ISusStoryProvider.Enumerate() — она обнаруживается тем же способом.
Как выводится панель управления
Панель не знает тип вашего компонента. Она задаёт три вопроса, на которые отвечает каждый SusComponent — DescribeProps(), DescribeAllowed(), DescribeEvents() — и строит контрол для каждого пропса на основе его объявленного типа:
| Форма пропса | Контрол |
|---|---|
Prop<bool> | переключатель |
Prop<string> с допустимым набором (UseAllowed) | сегментированные кнопки (≤ 5 значений) или выпадающее меню |
Prop<string> с именем, заканчивающимся на Icon | палитра иконок (сетка глифов, с поиском) |
Prop<string>, всё остальное | текстовое поле |
Prop<int> / Prop<float> | ползунок + числовое поле, диапазон из [SusRange] (0…100 по умолчанию) |
Prop<Color> | цветовой образец, сначала токены скина, потом произвольный цвет |
Prop<List<T>> | редактор количества строк с разворотом |
| что угодно read-only / обычный объект | вывод только значения, обновляется в реальном времени из событий |
поле события On* | строка в логе событий (зона E) при его срабатывании, с аргументом |
Нижний колонтитул всегда показывает пропсы N · контролы M. M ниже N без соответствующей причины в ctx.Exclude(...) — дефект стори, а не косметический пробел — панель это называет (dead props:, uncovered:) вместо молчания.
Диапазоны и зависимости
[SusRange(0, 5, 0.5)] public Prop<float> Rating = new(0);
[SusRange(0, 100, "%")] public Prop<int> Progress = new(0);
[SusDependsOn(nameof(ContentMode), "icon")] public Prop<string> Icon = new("");
[SusDependsOn(nameof(Clearable))] public Prop<string> ClearIcon = new("x");[SusRange] даёт ползунку реальные границы вместо угаданных 0…100. [SusDependsOn] отключает контрол и называет причину («видно когда ContentMode = icon») вместо рендеринга контрола, который молча ничего не делает — несколько атрибутов на одном поле комбинируются с AND.
Окружение (зона B)
Ряд чипсов позволяет переключать брейкпоинт, плотность, тему, масштаб и устройство ввода не покидая стори — переключение оси никогда не сбрасывает контролы, которыми вы управляли. Пакет скина может зарегистрировать свою собственную ось (например skin) через SusStoryEnvAxisRegistry; ядро само по себе не поставляет ось скина, поэтому чип для неё появляется только когда такой пакет её регистрирует. То же верно для locale — её провайдер — это какой бы то ни было пакет локализации, который присутствует.
Публикация стори: адресная строка
Адрес стори — это #/<package>/<group>/<slug>?Prop=val&env.axis=val — запрос несёт только то, чем отличается от собственных значений по умолчанию в стори, поэтому неизменённая стори имеет короткую стабильную ссылку, а поделённая говорит ровно то, что изменилось. Share копирует этот текст; back / forward (также Alt+← / Alt+→) перемещаются по истории в памяти — ничто из этого не зависит от сетевого запроса, поэтому это работает так же в Editor как и в браузерном билде. Открытие сайта со ссылкой уже в адресной строке подсвечивает правильную запись в боковой панели и монтирует правильную стори без перезагрузки страницы.
Матрица и зонд (зоны C и E)
Ниже панели управления матрица Variant × State рендерит один реальный экземпляр на ячейку (не рисунок) для каждого значения оси Variant компонента против шести колонок: rest, hover, active, focused, disabled, error. Только компонент, поставляющий класс-двойник sus-state-hover / sus-state-active, получает колонки hover и active — Unity UI Toolkit не имеет способа навязать pseudo-class из кода, поэтому колонка без двойника выпадает, а не показывается неправильно. Выше 24 ячеек, или на стори, помеченной Weight = SusStoryWeight.Heavy (таблица данных, инвентарь, боевая сетка), матрица начинает свёрнутой и ничего не строит, пока вы об этом не попросите.
Полоса зонда под реальным экземпляром выводит каждое событие, которое компонент вызвал в этой сессии (с его аргументом), и счётчик здоровья из того же детектора аномалий, что использует MCP-зонд. Сравнение фреймов выводит —, а не ложный зелёный: диффинг скриншотов canon-vs-live — это конвейер захвата, который подключается к этому же контракту, но приземлился на момент написания — зонд честно говорит «не знаю» вместо того чтобы сообщать о сравнении, которое он не может выполнить.
Чем это не является, на сегодня
- Старые per-package storybook оболочки (
Samples~/Storybookв kit и game) всё ещё существуют рядом с этим движком, пока стори мигрируют на него по одной на пакет — сэмпл с именемStorybookи один с именемStoriesпока не одно и то же. - Полный набор иконок Phosphor не поставляется. Палитра иконок читает то, что зарегистрировано в
SusIconRegistry— собственные ~127 иконок ядра всегда есть; 9000+ глифов Phosphor появляются только после того, как проект импортирует отдельный сэмплPhosphorIconsизsus-core.