Architecture RU - antonprv/LoneBrawler GitHub Wiki
- Обзор
- Game State Machine
- Dependency Injection - Zenjex
- Addressables и управление ассетами
- InstallerFactory и последовательность запуска
Архитектурный хребет проекта держат три системы: Game State Machine, управляющая жизненным циклом приложения; кастомный DI-фреймворк Zenjex, связывающий сервисы; и Unity Addressables с кеширующим слоем для загрузки ассетов по требованию. Вместе они задают последовательность запуска, владение рантаймовыми объектами и контракты между системами.
GameStateMachine - единственная точка управления всеми крупными переходами в приложении. В каждый момент активно ровно одно состояние, и все загрузки сцен, рестарты и переходы в меню проходят только через неё.
Два интерфейса покрывают все состояния:
public interface IGameState : IGameExitableState
{
void Enter();
}
public interface IGamePayloadedState<TPayload> : IGameExitableState
{
void Enter(TPayload payload);
}IGameExitableState добавляет Exit() и StateType Type { get; }, поэтому машина всегда выходит из текущего состояния до входа в следующее, вне зависимости от конкретного класса.
BootstrapperState
│ загружает Addressables, строит DI-контейнер, инициализирует сервисы
▼
LoadProgressState
│ читает PlayerPrefs / облачный сейв, валидирует GameProgress
▼
MainMenuState
│ создаёт UI главного меню, ждёт ввода игрока
▼
LoadLevelState <- получает string payload (ключ уровня)
│ сохраняет прогресс, загружает сцену, спаунит игрока
▼
GameLoopState
│ запускает LiveProgressSync, активирует геймплей
Машина не содержит геймплейной логики. Она вызывает Exit() на уходящем состоянии, резолвит новое через StateFactory и вызывает Enter() или Enter(payload):
public void EnterState<TState>() where TState : class, IGameState
{
IGameState gameState = ChangeState<TState>();
gameState.Enter();
}
public void EnterState<TState, TPayload>(TPayload payload)
where TState : class, IGamePayloadedState<TPayload>
{
IGamePayloadedState<TPayload> gameState = ChangeState<TState>();
gameState.Enter(payload);
}
private TState ChangeState<TState>() where TState : class, IGameExitableState
{
_activeState?.Exit();
TState gameState = _stateFactory.CreateState<TState>();
_activeState = gameState;
return gameState;
}StateFactory резолвит состояния из DI-контейнера, поэтому каждое состояние получает зависимости через конструкторную инъекцию - никаких service locator-вызовов внутри логики состояний.
GetCurrentState() возвращает значение enum StateType, позволяя любой системе проверить текущую фазу приложения без прямой ссылки на внутренности машины.
Zenjex подробно разобран в отдельной статье: Zenjex - DI Framework.
Кратко для контекста: все сервисы, зарегистрированные в ProjectRootInstaller.InstallBindings(), доступны глобально через RootContext. Компоненты ZenjexBehaviour получают зависимости автоматически до вызова OnAwake(). Двухфазный бутстрап (OnContainerReady затем OnGameLaunched) гарантирует инъекцию в асинхронно загруженные объекты до первого обращения к ним.
AssetLoader - единственный шлюз для всех рантаймовых операций с ассетами. Оборачивает Unity Addressables двумя слоями внутреннего состояния:
-
_completedHandles- кешируетAsyncOperationHandleпо GUID или адресу после первой успешной загрузки. Повторные вызовыLoadAsync<T>с тем же ключом минуют Addressables и возвращают кешированный результат. -
_calledHandles- хранит все когда-либо запрошенные хэндлы; используется вCleanup()для единовременного освобождения всех хэндлов. -
_instantiatedObjects- хранит хэндлы изInstantiateAsync.Cleanup()вызываетAddressables.ReleaseInstance()на каждом - уничтожает GameObject и одновременно освобождает ссылку на бандл.
UniTask<T> LoadAsync<T>(AssetReference assetReference)
UniTask<T> LoadAsync<T>(string assetAddress)
UniTask<GameObject> InstantiateAsync(string address)
UniTask<GameObject> InstantiateAsync(string address, Transform parent)
UniTask<GameObject> InstantiateAsync(AssetReference assetReference)
UniTask<GameObject> InstantiateAsync(AssetReference assetReference, Transform parent)
T Load<T>(string path) // синхронный fallback через Resources
void Cleanup()AssetsPreloader запускается при старте внутри LoadProgressState и прогревает фиксированный список Addressable-адресов до начала геймплея. Это убирает задержки при первом обращении к часто используемым ассетам - префабам врагов, VFX и другим.
Все Addressable-ключи - строковые константы в статических классах AssetAddresses, по одному на каждый фиче-домен. Никаких строковых литералов по всей кодовой базе, и опечатка в адресе становится ошибкой рефакторинга, а не рантаймовым падением.
InstallerFactory обрабатывает самые первые моменты запуска приложения - до существования DI-контейнера и до активации любого ZenjexBehaviour.
Фабрика предоставляет два корутин-метода, которые нужно вызвать по порядку:
1. CreateLoadingScreenRoutine(onComplete)
Загружает и инстанциирует префаб загрузочного экрана через Addressables. GameObject помечается DontDestroyOnLoad. Компонент ILoadScreen возвращается через onComplete, чтобы вызывающий код мог зарегистрировать его в DI-контейнере до создания GameInstance.
2. CreateGameInstanceRoutine(onBeforeActivate, onComplete)
Загружает префаб GameInstance. До вызова Instantiate выставляется prefab.SetActive(false) - это останавливает Unity от вызова Awake() на компонентах ZenjexBehaviour до регистрации DI-биндингов. onBeforeActivate срабатывает с ещё неактивным инстансом, давая окно для регистрации оставшихся биндингов. Только после этого вызывается go.SetActive(true) - и все Awake() срабатывают с полностью заполненным контейнером.
CreateLoadingScreenRoutine()
│ Addressables.LoadAsync -> Instantiate -> DontDestroyOnLoad
│ onComplete(ILoadScreen) -> зарегистрировать в контейнере
▼
CreateGameInstanceRoutine()
│ Addressables.LoadAsync -> prefab.SetActive(false) -> Instantiate
│ onBeforeActivate(instance) -> зарегистрировать рантаймовые биндинги
│ go.SetActive(true) -> ZenjexBehaviour.Awake() срабатывает
▼
GameInstance.Awake()
│ IGameStateMachine резолвится -> EnterState<BootstrapperState>()
Если бы GameInstance инстанциировался активным, Unity немедленно вызвал бы Awake() на каждом компоненте. Компоненты, читающие [Zenjex]-поля в Awake(), получили бы null - нужные биндинги ещё не зарегистрированы. Временная деактивация прерывает цепочку Awake и даёт коду бутстрапа безопасное окно для завершения связывания до первого тика.