Композиция и пакеты

Эта страница объясняет, как 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-наследники.

Почему ассеты, а не код

Хранение конфигурации в ассетах даёт три эффекта, которых не даёт код:

  1. Дизайнер настраивает без правок кода. Геймдиз меняет DriverConfig или SdkSettings, разработчик не вовлечён.
  2. Прозрачность изменений. Изменение в конфиге — это diff в YAML-файле ассета. История правок видна в Git.
  3. Параллельные конфигурации. Можно иметь 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-пакет может быть включён или выключен на уровне сборки, без правки кода. Механизм:

  1. SdkSettings.asset содержит тогл для каждого опционального пакета.
  2. Apply Changes записывает scripting define symbol в PlayerSettings.
  3. asmdef каждого пакета содержит defineConstraints: ["USING_NANINOVELL"].
  4. Unity компилирует пакет только если define-символ активен.
  5. Зависимые на пакет места проверяют через #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.

Цена — больший порог входа на старте проекта (нужно понять, где какие ассеты лежат и как они связаны), но окупаемая в долгой дистанции лёгкостью изменений: подмена реализации, добавление пакета, конфигурация под платформу — всё это операции уровня ассета, не правки кода.


Связанные страницы: