Композиция и пакеты
Эта страница объясняет, как Vortex собирает приложение из пакетов и какие механизмы играют роль, аналогичную DI-контейнеру в других фреймворках.
Когда смотришь на Vortex впервые, в глаза бросаются DriverConfig, SdkSettings, AudioChannelsConfig, какие-то [DbRecord]-атрибуты, [SerializeReference]-дропдауны, scripting define symbols, asmdef'ы с defineConstraints. Эта мозаика выглядит как «много несвязанных утилитарных решений». На самом деле она реализует один связный механизм — package-composition-first архитектуру.
Базовый тезис
Vortex собирает приложение не в коде, а в ассетах и asmdef-границах.
Это значит, что в проекте на Vortex отсутствует то, что есть в любом DI-фреймворке — место явной регистрации зависимостей в коде (Container.Configure, LifetimeScope.Configure, services.AddSingleton<>). Его роль распределена между несколькими механизмами:
| Что делает DI-контейнер | Чем закрывает Vortex |
|---|---|
| Регистрация сервиса с реализацией | DriverConfig-ассет с выбором конкретного драйвера |
| Lifetime / scope управление | Singleton<T> + слоистая иерархия asmdef |
| Resolve зависимости | Прямой вызов через статическую шину |
| Подмена реализации в тестах | Тестовый DriverConfig-ассет |
| Включение/исключение модуля | Тогл в SdkSettings → scripting define symbol |
| Регистрация коллекции реализаций | [SerializeReference] + рефлексионный реестр (DriversGenericList) |
| Конфигурация сервиса параметрами | Поля в SO-конфиге, заполняемые в инспекторе |
Каждая строка таблицы — отдельный механизм. Дальше разбираем каждый и показываем, как они складываются в единое целое.
Слои как направленная иерархия
Первая ось композиции — направление зависимостей между слоями. В Vortex четыре слоя, и каждый знает только о нижестоящих:
AppLocale ← конкретный проект
│
▼
Sdk ← доменная логика семейства проектов (опционально)
│
▼
Unity ← адаптация ядра к Unity
│
▼
Core ← чистый C#, без Unity
Эта иерархия не «соглашение» и не «code review». Она enforced физически через asmdef-границы:
ru.vortex.system(Core) — references пуст, никаких внешних пакетов.ru.vortex.unity.app(Unity) — references на Core-пакеты.ru.vortex.sdk.game.core(Sdk) — references на Core и Unity.Assembly-CSharp(AppLocale) — references на всё нижнее.
Попытка добавить ссылку из Core на Unity-пакет в asmdef не пройдёт компиляцию. Архитектура держит направление сама, без участия человека.
Зачем это нужно для композиции:
- Переиспользование Core-кода.
Database,ReactiveValue,Settings— это чистый C#. Их можно вытащить в консольную утилиту, в сервер, в Blazor — не таща за собой Unity. - Изоляция доменов.
Sdk.Questsничего не знает проSdk.MiniGames. Если в проекте квесты не нужны — пакет можно отключить, и квестовая логика никак не отразится на остальном. - Лёгкая подмена крупных модулей. Заменить Unity-слой на платформонезависимую реализацию (Godot, MonoGame) теоретически возможно — Core не нужно трогать. На практике никто этого не делает, но возможность говорит о чистоте границ.
Слоистая иерархия — это верхний этаж механизма composition. Дальше — что и как выбирается внутри каждого слоя.
ScriptableObject-конфиги: ассеты как configuration source
Когда в DI-проекте программист пишет builder.Register<IInventoryService, NetworkInventoryService>(Lifetime.Singleton), в Vortex та же информация живёт в ассете на диске.
Канонические конфиги (все лежат в Assets/Resources/Settings/):
DriverConfig
Связывает каждую системную шину с её драйвером:
DriverConfig.asset
├── AudioController → AudioDriverUnity
├── VideoController → VideoDriverUnity
├── LocalizationController → LocalizationDriverNani
├── SaveController → FileSystemDriver
├── DatabaseController → AddressablesDriver
└── ...
Геймдизайнер открывает ассет, выбирает в дропдауне для каждой системы конкретный драйвер. После Save Config Vortex генерирует автогенерируемый файл Assets/DriversGenericList.cs с whitelist'ом пар Controller ↔ Driver. В рантайме SystemController<T, TD>.SetDriver(...) валидирует драйвер по whitelist'у — посторонний драйвер не пройдёт.
SdkSettings
Управляет включёнными SDK-пакетами через scripting define symbols:
SdkSettings.asset
├── [✓] USING_NANINOVELL
├── [ ] USING_SPINE
├── [✓] USING_STEAM
├── [✓] MapLevels
├── [ ] Quests
└── ...
Тогл → Apply Changes → PlayerSettings.ScriptingDefineSymbols обновляются → пакеты с defineConstraints: ["USING_NANINOVELL"] начинают/перестают компилироваться. Подробнее об этом в разделе «Autonomous packages».
AudioChannelsConfig
Список аудио-каналов проекта:
AudioChannelsConfig.asset
├── Music
├── Sfx
├── Voice
├── Ambient
└── UI
Эти имена становятся идентификаторами для атрибута [AudioChannelName] в коде, привязок AudioChannelVolumeSlider в UI, и для расчёта громкости в AudioDriver.
DatabaseSettings, StartSettings, прочие
Каждая система Vortex с конфигурируемым поведением имеет свой SO-конфиг в Resources/Settings/. CoreAssetsController авто-создаёт эти ассеты при первой компиляции (наследников ICoreAsset), SettingsDriver авто-создаёт SettingsPreset-наследники.
Почему ассеты, а не код
Хранение конфигурации в ассетах даёт три эффекта, которых не даёт код:
- Дизайнер настраивает без правок кода. Геймдиз меняет
DriverConfigилиSdkSettings, разработчик не вовлечён. - Прозрачность изменений. Изменение в конфиге — это diff в YAML-файле ассета. История правок видна в Git.
- Параллельные конфигурации. Можно иметь
DriverConfig.asset(продакшен),DriverConfig.Test.asset(тесты),DriverConfig.Demo.asset(демо-сборка). Переключение между ними — один drag-and-drop вResources/Settings/.
Type registration через атрибуты и реестры
Когда DriverConfig показывает дропдаун выбора драйвера, где он берёт список доступных драйверов?
Ответ — type scanning через рефлексию. При нажатии Reload в инспекторе DriverConfig'а Vortex сканирует все загруженные сборки и собирает наследников ISystemController и ISystemDriver. Аналогично собираются наследники RewardStrategy для [SerializeReference]-дропдаунов, наследники Record для [DbRecord(typeof(...))]-фильтров, и так далее.
Это даёт autonomous package extension: новый пакет добавляет наследников нужных абстрактных классов — и они автоматически появляются в дропдаунах конфигов. Никакой RegisterStrategy<>-call в installer'е не нужен.
DriversGenericList — автогенерируемый whitelist
Самый каноничный пример — генерация DriversGenericList.cs:
// Этот файл генерируется автоматически кнопкой Save Config в DriverConfig.
public static class DriversGenericList
{
public static Dictionary<string, string> WhiteList { get; } = new()
{
// ключ и значение — строки AssemblyQualifiedName контроллера и драйвера
{ "Vortex.Core.AudioSystem.AudioController, ru.vortex.audio, ...",
"Vortex.Unity.AudioSystem.AudioDriverUnity, ru.vortex.unity.audio, ..." },
{ "Vortex.Core.VideoSystem.VideoController, ru.vortex.video, ...",
"Vortex.Unity.VideoSystem.VideoDriverUnity, ru.vortex.unity.video, ..." },
// ... ровно столько строк, сколько пар выбрано в DriverConfig
};
}
В рантайме SystemController<T, TD>.SetDriver(driver) проверяет, что пара AssemblyQualifiedName контроллера и драйвера есть в WhiteList. Если посторонний код попытается подсунуть свой драйвер — попытка отклоняется. Это архитектурная защита: реализация системы не может быть подменена «случайно через рефлексию» — только через явный пункт в конфиге.
Атрибутные dropdown'ы
Помимо DriverConfig, дропдауны выбора типа есть в десятках мест:
[SerializeReference, HideReferenceObjectPicker](Odin) — для полиморфных полей, напримерRewardStrategy rewardStrategy.[DbRecord(typeof(T))]— фильтрованный picker GUID'ов изDatabaseпо типу записи.[ValueSelector("MethodName")]/[ValueDropdown]— произвольный список из метода.[AudioChannelName]— дропдаун изAudioChannelsConfig.- поле типа enum
LocaleChannels— выбор канала локализации (Default, Dialogue, Voice).
Эти атрибуты — точки входа для дизайнера. Везде, где нужно «сделать выбор», в инспекторе появляется уже фильтрованный список валидных вариантов. Дизайнер не запоминает GUID'ы, не вводит строки руками, не путает типы.
SdkSettings и autonomous packages
Один из самых тонких механизмов — полная автономность пакетов с условной компиляцией.
Каждый SDK-пакет может быть включён или выключен на уровне сборки, без правки кода. Механизм:
SdkSettings.assetсодержит тогл для каждого опционального пакета.Apply Changesзаписывает scripting define symbol в PlayerSettings.asmdefкаждого пакета содержитdefineConstraints: ["USING_NANINOVELL"].- Unity компилирует пакет только если define-символ активен.
- Зависимые на пакет места проверяют через
#if USING_NANINOVELLи сами отключаются.
Что это даёт:
- Лёгкий пакет в Asset Store. Vortex поставляется с интеграциями для Naninovel, Spine, Steam, но по умолчанию они выключены. У пользователя нет Naninovel → пакет
ru.vortex.nani.coreне компилируется, нет ошибок. - Mod-friendly сборка. Можно собрать билд без Quest-системы для одного SKU, с ней — для другого, не меняя кода.
- Чистая зависимость от opt-in пакетов.
Addressables(опциональный) →defineConstraints: ["ENABLE_ADDRESSABLES"]наAssetCacheSystemиAddressablesDriver. Нет Addressables в проекте — нет AssetCache, всё валидно.
Autonomous = no implicit dependencies
«Автономный» здесь значит точно одно: пакет может быть либо целиком, либо отсутствовать. Полу-состояния «пакет есть, но половина его типов отсутствует» — не бывает. Это даёт две гарантии:
- Reasoning: если пакет в проекте, его API доступен полностью.
- Refactor safety: убрав пакет, ты гарантированно убираешь все его типы — компилятор сразу покажет, где они использовались.
ComplexModel — самосборка доменной модели
Отдельный case package-composition — расширение центральной модели данных из пакетов через partial-классы.
SettingsModel в Vortex.Core.SettingsSystem — это чистый C# класс без полей. Каждый пакет, который хочет добавить настройки, делает partial-расширение:
// В пакете Vortex.Unity.AudioSystem:
public partial class SettingsModel
{
public BoolData SoundOn { get; } = new(true);
public FloatData MasterVolume { get; } = new(1f);
}
// В пакете Vortex.Unity.VideoSystem:
public partial class SettingsModel
{
public IntData ScreenWidth { get; } = new(1920);
public IntData ScreenHeight { get; } = new(1080);
public EnumData<ScreenMode> ScreenMode { get; } = new(ScreenMode.Windowed);
}
В рантайме SettingsModel — единый класс, в котором поля от всех пакетов слились через компилятор. Подключил пакет — его поля автоматически появились в модели. Отключил — поля исчезли вместе с пакетом.
Это и есть самосборка доменной модели. Никакого model.Register(audioSettings) или services.AddSettings<AudioSettings>() — Vortex доверяет компилятору C# собрать partial-классы в один тип.
Аналогичный приём используется в:
GameModel.IGameDataв Sdk — каждый игровой пакет регистрирует свойIGameDataвGameModel.DebugSettings— partial-расширения с флагами debug-режимов.ComplexModel-инфраструктура — для случаев, где partial не подходит (например, динамическая сборка).
Что это даёт системно
Все эти механизмы — слои, конфиги, type scanning, define symbols, partial-сборка — работают вместе и дают четыре системных эффекта.
1. Дизайнер собирает поведение приложения в инспекторе. Большинство архитектурных решений (какой драйвер инвентаря, какие пакеты включены, какие звуковые каналы, какая стартовая сцена) выражаются через ScriptableObject-ассеты. Это снимает с разработчика обязанность дублировать каждое решение дизайнера в коде.
2. Подмена реализации — это смена конфига, не правка кода. Хочешь перевести инвентарь с локальной модели на серверную? Создаёшь InventoryDriverNetwork : IInventoryDriver, в DriverConfig выбираешь его в дропдауне Inventory, Save Config. Стратегии наград, UI-биндинги, всё остальное — не правятся.
3. Тестовый конфиг — отдельный ассет. Тесты могут иметь свой DriverConfig.Test.asset с моками вместо боевых драйверов. Загрузка тестовой сцены с этим конфигом даёт полностью замоканную среду для playmode-тестов.
4. Подключение нового пакета — один тогл, не Container.Configure. Появился новый SDK-пакет (например, ваш собственный Achievements)? Включаешь define USING_ACHIEVEMENTS, добавляешь его в SdkSettings. Никаких регистраций сервисов, никаких installer'ов. Compose-friendly архитектура — +1 пакет = +1 тогл.
Когда package-composition-first работает, а когда нет
Этот подход — не серебряная пуля. У него есть зона эффективности.
Работает хорошо в:
- Проектах с глубокой кастомизацией под платформы / SKU / билды. Когда нужно собрать билд с одним набором фич для Steam, другим для мобайла, третьим для демо — package-composition даёт это «бесплатно». Set of toggles → set of builds.
- Командах со специализацией ролей. Геймдиз настраивает SO-конфиги, программист пишет код, художник работает с префабами. Каждый владеет своим слоем без блокировки остальных.
- Долгоживущих проектах с многократной сменой направления. Когда через год оказывается, что «нам не нужны квесты, но нужны achievement'ы», уровень рефакторинга — выключить один тогл, включить другой.
Работает хуже в:
- Маленьких прототипах. Когда весь проект — один разработчик и две недели работы, накладные расходы на SO-конфиги, asmdef-структуру и define-флаги не окупаются. Прототип на чистой статике написать быстрее.
- Проектах без дизайнерского участия. Если SO-конфиги настраивает тот же разработчик, что и пишет код, инспектор-native подход теряет половину смысла. Это не запрет, но trade-off становится менее выгодным.
- Архитектурах с одним монолитным сервером. Если приложение — это один сервис без модулярности, package-composition не находит применения. Нет пакетов — нет композиции.
Для типичного Unity-проекта среднего и большого размера эта зона эффективности — основная.
Заключение
Package-composition-first — это архитектура, в которой роль DI-контейнера распределена между несколькими механизмами: слоистая иерархия asmdef, ScriptableObject-конфиги, type scanning, define symbols, partial-сборка моделей. Каждый из них закрывает свою часть задачи, и вместе они дают inspector-native workflow, в котором композиция приложения выражается через ассеты, а не через Container.Register.
Это не «отрицание DI» и не «утрата явного dependency graph». Граф зависимостей полностью существует — он перенесён с runtime-объектов на assembly-ссылки, конфиги-ассеты и define-флаги. Видеть его можно через те же средства, что Unity показывает asmdef-граф или скриптовые define-символы в Player Settings.
Цена — больший порог входа на старте проекта (нужно понять, где какие ассеты лежат и как они связаны), но окупаемая в долгой дистанции лёгкостью изменений: подмена реализации, добавление пакета, конфигурация под платформу — всё это операции уровня ассета, не правки кода.
Связанные страницы:
- Философия Vortex — почему такой подход вообще выбран.
- Сравнение Vortex и VContainer — что в DI делается иначе.
- DriverManagerSystem — детали
DriverConfigи whitelist'а. - SdkSettingsSystem — детали тоглов и define-символов.
- ComplexModel — детали partial-сборки моделей.
- Первичная установка — практическая последовательность настройки конфигов.