Danro-Jump/GUIDES/architecture/extenject-dependency-injection
Danil Iarosh cefb2fd345 Build initial vertical platformer foundation
Подробные изменения:
- Добавлены сцены Bootstrap, MainMenu и Game с текущей вертикальной 9:16 структурой проекта.
- Добавлены runtime-скрипты gameplay-архитектуры: DI через Extenject, спавнер платформ, прогрессия этапов, one-way платформы, игрок, камера и оборудование JumpPad.
- Добавлены ScriptableObject-этапы сложности, weighted-настройки платформ, оборудования, противников и collectibles.
- Добавлены UI Toolkit ресурсы главного меню, preview-материалы, шрифты, спрайты, анимации и игровые prefab-ассеты.
- Перенесена и оформлена новая COM-система для работы с аппаратными портами и StreamingAssets.
- Обновлены проектные гайды, AGENTS.md, правила Git/Git LFS, .gitignore, Unity Packages и ProjectSettings.
- Удалён локальный skill start-required-mcp-servers из репозитория.
2026-05-11 07:41:14 +03:00
..

Dependency Injection через Extenject

Зачем это нужно

Dependency Injection через Extenject нужен, чтобы:

  • разделять игровую логику и Unity-обвязку
  • уменьшать количество жёстких ссылок между компонентами
  • легче тестировать код
  • проще собирать разные режимы проекта: игра, сервисный режим, призовой контур, аппаратный слой

Для Danro Jump это особенно полезно, потому что проект со временем будет сочетать:

  • аркадный геймплей
  • работу с COM-портами
  • сервисные экраны для персонала
  • призовую витрину
  • сценарии выдачи через QR-коды

Без DI такие подсистемы быстро начинают зависеть друг от друга напрямую и усложняют сопровождение.

Когда Extenject действительно полезен

Extenject особенно хорошо подходит, если в проекте есть:

  • несколько отдельных подсистем
  • сервисы с долгим жизненным циклом
  • необходимость подменять реализации
  • разные режимы запуска
  • желание держать доменную логику вне MonoBehaviour

Для Danro Jump типичные кандидаты на DI:

  • IGameSessionService
  • IScoreService
  • IPrizeService
  • IPrizeShowcaseGateway
  • IQrRewardService
  • IComPortGateway
  • IServiceModeAccessPolicy
  • IGameBalanceProvider

Как использовать legacy-код из Assets/Temp/Legacy

В Assets/Temp/Legacy сохранён старый код, который может пригодиться для будущей реализации. Его не нужно подключать к runtime целиком: он должен быть источником идей, API-контрактов, протоколов и моделей.

Самые полезные legacy-кандидаты для DI:

  • Assets/Temp/Legacy/Scripts/Controls/Inputs/ComSystem — основа для аппаратного слоя, COM, handshake, ролей устройств и призовой витрины
  • Assets/Temp/Legacy/Scripts/Managers/PaymentManager.cs — reference для кредитов, оплаты и free play режима
  • Assets/Temp/Legacy/Scripts/Managers/PrizeSystemManager.cs — reference для orchestration призов
  • Assets/Temp/Legacy/Scripts/Prizes — модели и исполнители призов
  • Assets/Temp/Legacy/Scripts/Settings — база для сервисных настроек персонала
  • Assets/Temp/Legacy/Scripts/Utilities/QRCodeUtils.cs — генерация QR и модели QRCodeInfo

Правило переноса:

  • сначала выделить интерфейс новой подсистемы
  • затем перенести минимальную доменную модель
  • после этого завернуть legacy-алгоритм в новую реализацию сервиса
  • только в конце подключить реализацию через installer

Пример: вместо прямого переноса ComSystemManager лучше выделить контракт:

public interface IComDeviceService
{
    IReadOnlyList<string> ConnectedDeviceIds { get; }
    UniTask InitializeAsync();
    UniTask SendAsync(string deviceId, string command);
}

А старую связку ComHub, ComDeviceClient, ComNativeWrapper использовать как reference для реализации ComDeviceService.

Legacy как Anti-Corruption Layer

Если старый код нужно подключить временно, его лучше обернуть anti-corruption layer:

public sealed class LegacyPrizeShowcaseGateway : IPrizeShowcaseGateway, IDisposable
{
    private readonly LegacyPrizeStandAdapter _adapter;

    public LegacyPrizeShowcaseGateway(LegacyPrizeStandAdapter adapter)
    {
        _adapter = adapter;
    }

    public UniTask OpenAsync(int prizeSlotId)
    {
        return _adapter.TryOpenSlotAsync(prizeSlotId);
    }

    public void Dispose()
    {
        _adapter.Dispose();
    }
}

Так новый код зависит от IPrizeShowcaseGateway, а не от старых RR.Common.* классов.

Базовая модель Extenject

В Extenject важно понимать три уровня:

  • ProjectContext — глобальные зависимости на весь runtime
  • SceneContext — зависимости внутри конкретной сцены
  • GameObjectContext — локальный контейнер для prefab или отдельного объекта

Практическое правило:

  • в ProjectContext складывай только по-настоящему глобальные вещи
  • в SceneContext складывай зависимости конкретного режима или сцены
  • GameObjectContext используй только там, где действительно нужен локальный composition root

Рекомендуемая стратегия для Danro Jump

ProjectContext

В ProjectContext обычно стоит держать:

  • конфигурацию приложения
  • логирование
  • фабрики системного уровня
  • глобальные настройки локализации
  • сервисы, которые переживают смену сцен

Примеры:

Container.BindInterfacesAndSelfTo<AppLifetime>().AsSingle();
Container.BindInterfacesAndSelfTo<GameSettingsProvider>().AsSingle();
Container.Bind<IComPortGateway>().To<ComPortGateway>().AsSingle();
Container.Bind<IQrRewardService>().To<QrRewardService>().AsSingle();
Container.Bind<IPrizeService>().To<PrizeService>().AsSingle();
Container.Bind<IPaymentService>().To<PaymentService>().AsSingle();

SceneContext

В SceneContext конкретной сцены лучше держать:

  • локальный gameplay flow
  • сценовые контроллеры
  • scene-specific presenters
  • экранные сервисы

Пример для игровой сцены:

public sealed class GameplayInstaller : MonoInstaller
{
    public override void InstallBindings()
    {
        Container.BindInterfacesAndSelfTo<GameSessionService>().AsSingle();
        Container.BindInterfacesAndSelfTo<PlatformSpawner>().AsSingle();
        Container.BindInterfacesAndSelfTo<PlayerDeathHandler>().AsSingle();
        Container.BindInterfacesAndSelfTo<GameHudPresenter>().AsSingle();
    }
}

Отдельный сервисный режим

Сервисный режим для персонала лучше оформлять отдельной сценой или отдельным сценовым контекстом, а не примешивать к gameplay-сцене напрямую.

Тогда там можно поднимать:

  • настройки стоимости игры
  • настройки сложности
  • настройки призов
  • диагностику COM-оборудования
  • ручные тесты витрины и выдачи

Composition Root

Главное правило Extenject:

  • контейнер знает, как собрать объект
  • сам объект не должен знать, где искать свои зависимости

Это значит:

  • не использовать FindObjectOfType
  • не использовать singleton-поиск как основной способ сборки
  • не прокидывать контейнер в доменную логику как сервис-локатор

Хорошо:

public sealed class PrizeService : IPrizeService
{
    private readonly IPrizeRepository _repository;
    private readonly IPrizeShowcaseGateway _showcaseGateway;

    public PrizeService(
        IPrizeRepository repository,
        IPrizeShowcaseGateway showcaseGateway)
    {
        _repository = repository;
        _showcaseGateway = showcaseGateway;
    }
}

Плохо:

public sealed class PrizeService
{
    public PrizeService()
    {
        var gateway = ProjectContext.Instance.Container.Resolve<IPrizeShowcaseGateway>();
    }
}

Что именно инжектить

Обычно через Extenject лучше инжектить:

  • интерфейсы
  • чистые C# сервисы
  • конфигурационные объекты
  • фабрики
  • адаптеры к внешним системам

С осторожностью:

  • MonoBehaviour
  • ScriptableObject
  • runtime-created prefab-объекты

Если зависимость является Unity-компонентом сцены, лучше явно понимать её жизненный цикл.

Типичный набор ролей в проекте

Для Danro Jump удобно делить классы так:

Domain / Core

  • правила игры
  • подсчёт наград
  • проверка eligibility для выдачи призов
  • политика сложности

Application / Services

  • orchestration игровых сценариев
  • координация сервисного режима
  • работа с QR
  • orchestration аппаратных операций

Infrastructure

  • COM-порт
  • Unity scene loading
  • persistence
  • конкретная работа с UI и устройствами

Presentation

  • HUD presenters
  • menu presenters
  • service panel presenters

Extenject особенно хорошо работает, когда связывает эти уровни без прямой жёсткой сцепки.

Рекомендуемые binding-паттерны

BindInterfacesAndSelfTo

Удобен для сервисов с несколькими ролями:

Container.BindInterfacesAndSelfTo<GameSessionService>().AsSingle();

FromInstance

Хорошо подходит для конфигурации:

Container.Bind<GameBalanceConfig>().FromInstance(_balanceConfig).AsSingle();

FromComponentInHierarchy

Подходит только для сценовых объектов, которые действительно уже существуют:

Container.Bind<GameplayHudView>().FromComponentInHierarchy().AsSingle();

Factory

Подходит для runtime-создания:

  • врагов
  • платформ
  • popup-элементов
  • наградных визуальных объектов

Жизненные циклы

Для сервисов в Extenject чаще всего пригодятся:

  • IInitializable
  • ITickable
  • IDisposable

Практика:

  • IInitializable используй для явного старта
  • ITickable только там, где реально нужен цикл обновления
  • IDisposable обязательно для внешних ресурсов, особенно COM-соединений и подписок

Пример:

public sealed class ComPortGateway : IComPortGateway, IInitializable, IDisposable
{
    public void Initialize()
    {
        // Открытие и подготовка порта
    }

    public void Dispose()
    {
        // Корректное закрытие порта
    }
}

Связка с управлением сценами

Для сцен в Extenject важны два сценария:

  • передача зависимостей через ProjectContext
  • передача параметров при переходах

Если нужно передавать параметры между сценами, удобнее не использовать статические поля, а оформлять отдельный переходный сервис или ZenjectSceneLoader.

Это особенно важно для переходов вида:

  • из attract/menu режима в игровую сессию
  • из игры в экран награды
  • из игры в сервисный режим

Чего лучше избегать

  • делать ProjectContext свалкой всех сервисов
  • инжектить всё подряд в MonoBehaviour
  • дублировать bindings в разных installers без правил
  • смешивать доменную логику и прямую работу с Unity API
  • решать все проблемы через один огромный installer

Практическая схема для этого проекта

На старте проекта удобно держать такой набор installers:

  • ProjectInstaller
  • GameplayInstaller
  • MenuInstaller
  • ServiceModeInstaller
  • PrizeFlowInstaller
  • HardwareInstaller

Пример разбиения ответственности:

  • ProjectInstaller — глобальные настройки и shared-сервисы
  • GameplayInstaller — игровой цикл и gameplay-сервисы
  • ServiceModeInstaller — настройки персонала
  • HardwareInstaller — COM, диагностика, витрина

Рекомендуемая карта legacy -> новые сервисы

Legacy source Новый слой
ComSystemManager, ComHub, ComDeviceClient IComDeviceService, IComPortGateway, IHardwareDiagnosticsService
PrizeStandController IPrizeShowcaseGateway
PrizeSystemManager, PrizeBase, PrizeDatabase IPrizeService, IPrizeRepository, IPrizeExecutor
PaymentManager, credit-события из ComSystemManager IPaymentService, ICreditWallet
SettingsManager, SettingsDatabase IServiceSettingsService, ISettingsRepository
QRCodeUtils, QRCodeInfo, QRCodePrize IQrRewardService, IQrTextureFactory

Эта карта не означает прямое копирование классов. Она показывает, какие обязанности старого кода стоит сохранить в новой архитектуре.

Как внедрять DI поэтапно

Не нужно переводить весь проект на Extenject за один раз.

Лучше идти по шагам:

  1. Выбрать один legacy-блок из Assets/Temp/Legacy, например COM или Prize.
  2. Описать новый интерфейс без зависимости от RR.Common.*.
  3. Перенести минимальные модели данных.
  4. Написать новую реализацию или временный adapter.
  5. Подключить её через installer.
  6. Отделить UI от прикладной логики.
  7. Только потом строить более широкую сетку installers и фабрик.

Для каких задач это даст максимальную пользу в Danro Jump

  • для сервисного режима
  • для аппаратной интеграции
  • для призовой логики
  • для разделения игровых режимов
  • для тестируемой бизнес-логики без прямой привязки к MonoBehaviour

Официальные и полезные источники