Addressables Demo
Демо показывает, как интегрировать WorldGraphEditor со стеком Addressables + Zenject: собственная реализация ITransitionManager, разделение ответственности на маленькие сервисы, двухслойная загрузка сцен (низкоуровневый addressables-адаптер + высокоуровневый scene-менеджер жизненного цикла).
Настройка графа
- 15 игровых сцен — все ноды помечены как Addressables (
TargetSceneAddressзаполнен,TargetSceneBuildIndexне используется). - 1 Boot-сцена — обычная сцена в Build Settings (index 0), не Addressable и не часть графа. Единственная задача — стартовать игру и загрузить первую игровую сцену.
Обзор сервисов
| Сервис | Отвечает за |
|---|---|
TransitionManagerService | Управление переходом: валидация, fade, загрузка сцены, spawn-позиция, graph-состояние |
SceneLoader | Жизненный цикл текущей сцены: load-first, unload-after |
AddressablesLoader | Тонкая stateless-обёртка над Addressables API |
GameplaySceneLoader | Загружает первую сцену из Boot, в обход TransitionManagerService |
PlayerLifecycleService | Спавн/pull игрока по данным менеджера |
GraphDataService | Visited-порты и shortcut-прогресс |
GameCompletionViewService | HUD: имя сцены, прогресс shortcut'ов |
ScreenFadeService | Fade-анимация с поддержкой отмены |
TransitionManagerService
Реализует ITransitionManager, IDisposable. Graph инжектится как IWorldGraph (не конкретный класс).
GoFromAsync(currentPortGuid) — обычный переход. Спрашивает IGraphDataService.IsPortVisited, затем Graph.CanPassTransition — граф сам решает, разрешён ли проход (visited-состояние, направление связи). Отказ — не ошибка, а LogWarning. В интерфейсе нет ignoreShortcuts — эта логика инкапсулирована в сервисе через IGraphDataService; кто хочет явный контроль, передаёт флаг через ITransitionContext.
GoToAsync(currentPortGuid, targetPortGuid) — прямой телепорт без CanPassTransition, для нестандартных переходов (fast travel и т.п.).
GoInternalAsync — общий пайплайн: TransitionStarted → fade in → SetPortVisited(source) → LoadTargetSceneAsync → резолв OutputTransitionComponent и NextSpawnPosition → SetPortVisited(target) → SceneLoaded → fade out → TransitionEnded.
Обёрнут в try/catch с двумя ветками: OperationCanceledException — штатная отмена (например, Dispose() во время перехода), логируется как Info; остальные Exception — баги, LogException с полным стектрейсом. Try/catch на границе fire-and-forget, потому что порты могут вызывать GoFromAsync без await.
SceneLoader
Реализует ISceneLoader:
Task LoadAsync(string address, CancellationToken token);
Task LoadAsync(AssetReference reference, CancellationToken token);
Два входа — по адресу (TransitionManagerService, адрес из данных графа) и по AssetReference (GameplaySceneLoader, сериализованное поле в инспекторе). Оба сводятся к общему LoadInternalAsync. Единственное место в проекте, где хранится SceneInstance текущей сцены.
Порядок операций — load-first, unload-after: сначала грузится новая сцена additive и становится активной, потом выгружается предыдущая (через AddressablesLoader, если она была addressable, иначе через SceneManager — так выгружается стартовая Boot-сцена).
AddressablesLoader
Низкоуровневая обёртка над Addressables.LoadSceneAsync / UnloadSceneAsync, без состояния, тривиально мокается. Возвращает nullable SceneInstance? вместо исключения — SceneLoader по null понимает, что загрузка не удалась, и логирует адрес.
Разделение на два слоя даёт: SceneLoader не завязан на Addressables API (можно подставить другую реализацию IAddressablesLoader), AddressablesLoader не завязан на "текущую сцену".
Boot-сцена и GameplaySceneLoader
Boot ничего не содержит, кроме GameplaySceneLoader. UI (fade, HUD) там не лежит — забинжен в ProjectContext через инсталлеры, живёт в DontDestroyOnLoad и переживает выгрузку Boot автоматически.
public async void Start()
{
await _sceneLoader.LoadAsync(_firstScene, CancellationToken.None);
}
AssetReference, а не строка — стартовая сцена задаётся дизайнером в инспекторе, drag'n'drop и валидация из коробки.
Инициальная загрузка идёт мимо TransitionManagerService: концептуально это не переход, у неё нет source-порта и graph-логики. Boot выгружается автоматически внутри SceneLoader.LoadInternalAsync, как только первая игровая сцена загружена.
PlayerLifecycleService
Игрок не спавнится — он уже лежит как GameObject в каждой игровой сцене. PlayerSceneInstaller (биндится через SceneContext конкретной сцены, не через ProjectContext) резолвит его через FromComponentInHierarchy() и биндит PlayerLifecycleService как scene-scoped синглтон.
Container.Bind<AddressablesPlayerController>()
.FromComponentInHierarchy()
.AsSingle();
Container.BindInterfacesAndSelfTo<PlayerLifecycleService>()
.AsSingle()
.NonLazy();
Благодаря scene-scope сервис и подписки на события живут ровно столько, сколько живёт сцена: при выгрузке SceneContext уничтожается, Dispose() отписывает обработчики автоматически — не нужно вручную синхронизировать lifecycle игрока с lifecycle сцены.
public PlayerLifecycleService(ITransitionManager manager, AddressablesPlayerController controller)
{
_manager = manager;
_controller = controller;
_manager.TransitionStarted += OnTransitionStarted;
_manager.SceneLoaded += OnSceneLoaded;
_controller.EnableMovement();
}
EnableMovement() в конструкторе — это включение управления для сцены, в которую игрок загрузился "холодно" (первая сцена из Boot, где TransitionManagerService вообще не участвовал, событий не было).
OnTransitionStarted срабатывает на текущем (ещё не выгруженном) игроке — отключает управление и применяет pull-force от InputTransitionComponent, если тот IPuller. OnSceneLoaded срабатывает уже на новом игроке (он успевает подписаться в конструкторе SceneContext'а новой сцены до того, как менеджер вызовет событие) — переставляет его в NextSpawnPosition и применяет push-force от OutputTransitionComponent, если тот IPusher.
GraphDataService
Хранит игровое состояние графа в памяти (между сессиями не сохраняется — для реальной игры тут нужен был бы файл сохранения). Два HashSet<string>: _visitedPorts (все посещённые guid'ы) и _shortcuts (подмножество visited, где порт — shortcut-destination). Разделение на два множества даёт OpenedShortcuts за O(1) вместо пересчёта по всем visited.
SetPortVisited вызывается менеджером дважды за переход — для input и output порта. Публичный контракт (IGraphDataService) — три члена: OpenedShortcuts, SetPortVisited, IsPortVisited.
GameCompletionViewService
View-сервис для HUD: слушает TransitionManager.SceneLoaded, обновляет текст текущей сцены и прогресса shortcut'ов. Т.к. это MonoBehaviour, инжект идёт через [Inject]-метод (не конструктор) — Zenject вызывает его сразу после Awake.
_shortcutsCount (общее число shortcut-рёбер в графе) считается один раз в конструкторе и кэшируется. В OnSceneLoaded guid текущего порта берётся из OutputTransitionComponent, а на первом запуске (до первого перехода, когда он ещё null) — fallback на TransitionComponentUtility.FindAny().
ScreenFadeService
Fade-анимация экрана: ShowAsync(CancellationToken) / HideAsync(CancellationToken), оба принимают токен отмены — чтобы TransitionManagerService.Dispose() мог прервать анимацию, если переход отменяется по ходу.