Статус: Ready — отражает код на 2026-06-19


Как куски кода находят друг друга. Ты просил разобрать DI особенно подробно — поэтому здесь от самых азов («что это и зачем») до наших конкретных скоупов и приёмов VContainer. Во второй половине — события (MessagePipe vs C#-event), потому что это вторая половина «проводки».

Связано с 00. Обзор и карта кода, 02. Детерминированная симуляция и тик.


Часть 1. Dependency Injection (VContainer)

1.1 Простыми словами

Проблема. Классу CombatSimulation нужны генератор случайностей, пространственный хэш и десяток систем. Откуда он их берёт? Три варианта:

  • Плохо: создаёт сам внутри (new XorShiftRng(...)). Тогда его не подменить в тесте, и он намертво привязан к конкретным реализациям.
  • Плохо: берёт из глобального синглтона (RngService.Instance). Тогда везде скрытые связи, тестировать ад, порядок инициализации — лотерея.
  • Хорошо (DI): кто-то снаружи передаёт ему готовые зависимости в конструктор. Класс просто объявляет «мне нужно вот это», а сборкой занимается отдельный «сборщик».

Dependency Injection = «не создавай свои зависимости сам, попроси их в конструкторе, пусть их даст контейнер». «Контейнер» — это и есть сборщик, который знает, как построить весь граф объектов.

Аналогия: ты не добываешь электричество сам — ты втыкаешь вилку в розетку. DI-контейнер — это электросеть дома: он знает, что куда подключено.

Почему мы вообще отказались от синглтонов (явное правило проекта, 5. Технологический стек и архитектура): синглтоны — это глобальные переменные в костюме. Они прячут зависимости (по сигнатуре класса не видно, что он трогает пол-проекта), ломают тесты (нельзя подменить) и создают хаос порядка инициализации. На прошлом проекте это больно укусило — поэтому здесь зависимости только через инъекцию.

1.2 Наш инструмент — VContainer

VContainer — быстрый DI-контейнер для Unity. Ключевые понятия:

  • LifetimeScopeMonoBehaviour, который описывает «какие сервисы существуют и сколько живут». Вешается на объект в сцене.
  • Configure(IContainerBuilder builder) — здесь регистрируешь сервисы.
  • builder.Register<T>() — «контейнер умеет делать T». При запросе он сам разрешит конструктор T, подставив зарегистрированные зависимости (constructor injection).
  • Lifetime — время жизни: Singleton (один на контейнер), Scoped (один на скоуп), Transient (новый каждый раз).
  • Дочерние скоупы — один scope может быть родителем другого. Дочерний видит сервисы родителя, но не наоборот.

1.3 Наши два скоупа

Проект использует два уровня жизни, и это сознательное решение, завязанное на структуру сцен (persistent CoreScene + аддитивная BattleScene):

RootLifetimeScope — живёт всю сессию

Assets/_Project/Scripts/Game/RootLifetimeScope.cs

protected override void Configure(IContainerBuilder builder)
{
    builder.Register<IRngService>(_ => new XorShiftRng(GenerateRootSeed()), Lifetime.Singleton);
    builder.Register<UnityAudioService>(Lifetime.Singleton).As<IAudioService>();
    builder.Register<SceneLoader>(Lifetime.Singleton);
    builder.Register<GameFlow>(Lifetime.Singleton);
 
    var options = builder.RegisterMessagePipe();
    builder.RegisterBuildCallback(c => GlobalMessagePipe.SetProvider(c.AsServiceProvider()));
}

Здесь сервисы, которые живут всю игровую сессию: сессионный RNG, аудио, загрузчик сцен, макро-флоу, шина сообщений. Они переживают вход/выход из боёв.

Обрати внимание на .As<IAudioService>(): класс регистрируется, но выдаётся по интерфейсу. Игровая логика просит IAudioService, не зная, что внутри UnityAudioService (а завтра — FmodAudioService). Это прямое следствие правила «FMOD всегда за интерфейсом» из CLAUDE.md.

CombatLifetimeScope — живёт один бой

Assets/_Project/Scripts/Game/CombatLifetimeScope.csдочерний от Root. Создаётся при входе в BattleScene, умирает при выходе. Поэтому всё боевое (RNG боя, системы, симуляция) автоматически уничтожается в конце боя — не надо вручную чистить состояние.

protected override void Configure(IContainerBuilder builder)
{
    RegisterRng(builder);              // свой XorShiftRng на бой (суб-сид)
    RegisterCombatSystems(builder);    // SpatialHash + все *System
    RegisterSimulation(builder);       // CombatSimulation, фабрика, loop
    RegisterPresentation(builder);     // презентеры из иерархии сцены
}

Зачем два скоупа, а не один. Боевое состояние должно рождаться и умирать вместе с боем. Если бы CombatSimulation жил в Root как синглтон, второй бой унаследовал бы мусор первого, и пришлось бы вручную всё ресетить. Дочерний скоуп решает это структурно: новый бой = новый скоуп = чистые сервисы. А IRngService боя, зарегистрированный в дочернем скоупе, перекрывает родительский для всех, кто его просит внутри боя.

1.4 Три способа регистрации (и почему каждый)

В коде встречаются три формы — это не случайность, у каждой своя причина:

(а) По типу — builder.Register<T>(Lifetime.Scoped) — самый чистый. Контейнер сам найдёт конструктор и подставит зависимости.

builder.Register<TargetingSystem>(Lifetime.Scoped);   // нет хитрых аргументов

(б) С WithParameter — для не-инъектируемых аргументов. CombatSimulation берёт float armorK — примитив, который контейнер не умеет разрешать. Но остальные 10 зависимостей — умеет. Поэтому:

builder.Register<CombatSimulation>(Lifetime.Scoped)
       .WithParameter("armorK", _armorK);   // armorK задаём явно, остальное — авто

🔧 Это недавнее упрощение (правка ⑨). Раньше тут была ручная лямбда new CombatSimulation(r.Resolve<…>(), …) на все 10 зависимостей. Минус: добавил систему в конструктор — изволь не забыть дописать Resolve в скоупе. WithParameter снимает этот бойлерплейт: VContainer сам разрешает граф, а руками задаём только примитив. Именно поэтому добавление RegenSystem в конструктор не потребовало правок в скоупе — достаточно было его зарегистрировать.

(в) Фабричная лямбда — когда нужен контроль над созданием.

builder.Register<SpatialHash>(_ => new SpatialHash(cellSize), Lifetime.Scoped);

SpatialHash берёт float cellSize из инспектора скоупа — лямбда захватывает его в замыкание. Здесь лямбда оправдана (примитив в конструкторе). Сравни с пунктом (б): RuntimeUnitFactory пока тоже на лямбде, потому что берёт StatsConfig (ассет из [SerializeField]).

1.5 Entry Points — автозапуск без MonoBehaviour

CombatLoopService — чистый C#-класс, но он должен сам стартовать, когда собрался бой. VContainer даёт IAsyncStartable:

builder.RegisterEntryPoint<CombatLoopService>(Lifetime.Scoped).AsSelf();

Контейнер сам вызовет StartAsync(CancellationToken) после построения скоупа, а CancellationToken отменится при уничтожении скоупа (конце боя). Так пульс боя живёт без отдельного MonoBehaviour.Update. Подробно про сам цикл — 02. Детерминированная симуляция и тик.

1.6 Инъекция в объекты сцены

Презентеры — это MonoBehaviour на объектах BattleScene, их нельзя «создать» контейнером, они уже в сцене. Для них:

builder.RegisterComponentInHierarchy<CombatPresenter>();

Контейнер найдёт компонент в иерархии и впрыснет в него зависимости через метод с [Inject]:

[Inject]
public void Construct(CombatSimulation simulation, IPublisher<DamageDealtEvent> publisher, …) { … }

Это method injection (в отличие от constructor injection у чистых классов) — потому что Unity создаёт MonoBehaviour сама, и конструктор недоступен.


Часть 2. События и развязка

2.1 Простыми словами

Когда юнит получает урон, об этом должны узнать многие: HP-бар, спаунер цифр урона, аудио, тряска камеры, может — статистика. Если бы CombatSimulation сам дёргал их всех, он бы знал про UI, звук и камеру — то есть «мозг» зависел бы от «тела». Это ровно то, чего мы избегаем.

Решение — публикация событий. Мозг кричит в пустоту: «нанесён урон 30 юниту №5». Кто хочет — подписан и реагирует. Мозг не знает, кто слушает и слушает ли вообще.

2.2 Два канала — и почему так

В проекте два механизма событий, и это не дублирование, а слоёная развязка:

CombatSimulation ──C#-event──► CombatPresenter ──MessagePipe──► Audio / VFX / UI
   (мозг, Combat)               (мост, Presentation)            (подписчики)

1) C#-event Action внутри CombatSimulation (OnDamageDealt, OnUnitDied, OnUnitSpawned, OnBattleEnded). Почему просто event, а не MessagePipe? Потому что Combat — нижний слой, он не должен зависеть от пакета MessagePipe. Голый event — это нулевая зависимость, чистый C#. Мозг остаётся переносимым и тестируемым.

2) MessagePipe — pub/sub поверх VContainer (IPublisher<T>/ISubscriber<T>). Только один класс — CombatPresenter — подписан на C#-события сима и ретранслирует их в MessagePipe как типизированные сообщения (DamageDealtEvent, UnitDiedEvent…). Дальше Audio/VFX/UI подписываются на MessagePipe и живут полностью независимо от симуляции.

private void HandleDamageDealt(RuntimeUnit source, RuntimeUnit target, DamageResult result)
{
    if (_views.TryGetValue(target.Id, out var view))
        view.OnDamageReceived(result.TotalDamage);     // прямой визуал
    _damageNumbers?.Spawn(target.Position, result.TotalDamage);
    _damageDealtPublisher.Publish(new DamageDealtEvent(source, target, result));  // в шину
}

Почему «один мост», а не «MessagePipe везде». Можно было бы публиковать в MessagePipe прямо из симуляции — но тогда Combat тянет MessagePipe, и «мозг» перестаёт быть автономным. Один мост в Presentation — это шов: всё, что выше моста, развязано через шину; всё, что ниже (сам сим) — чисто. CLAUDE.md говорит «MessagePipe для развязки Combat → UI/Audio/VFX» — на практике развязка реализована именно этим мостом, и это правильно.

2.3 Не путать с внутренними боевыми событиями

Есть третий, отдельный механизм — CombatEvent/CombatEventData внутри симуляции (вампиризм, шипы реагируют на «нанесён/получен урон»). Это не презентационные события и не MessagePipe — это внутренняя FIFO-очередь самой симуляции, с защитой от бесконечной реентрантности. Она разобрана в 04. Эффекты и способности, потому что относится к мозгу, а не к проводке наружу.

КаналГдеДля когоЗачем отдельно
C#-eventCombatSimulationCombatPresenterНулевая зависимость, мозг чист
MessagePipePresentation+Audio/VFX/UIРазвязка многих подписчиков
CombatEvent-очередьвнутри симареактивные компоненты эффектовДетерминизм + кап реентрантности

Итог документа

  • DI = «не создавай зависимости сам, проси в конструкторе»; контейнер собирает граф. Никаких синглтонов.
  • Два скоупа: Root (сессия) и Combat (бой, дочерний) — боевое состояние рождается и умирает с боем.
  • Регистрация: по типу (чисто) / WithParameter (примитивы) / лямбда (полный контроль). Недавно упростили CombatSimulation до WithParameter.
  • IAsyncStartable — автозапуск чистых классов; RegisterComponentInHierarchy + [Inject]-метод — инъекция в объекты сцены.
  • События в два канала: голый event держит мозг чистым, MessagePipe развязывает подписчиков; мост между ними — CombatPresenter.