Перейти к основному содержимому

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 игрока по данным менеджера
GraphDataServiceVisited-порты и shortcut-прогресс
GameCompletionViewServiceHUD: имя сцены, прогресс shortcut'ов
ScreenFadeServiceFade-анимация с поддержкой отмены

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 и NextSpawnPositionSetPortVisited(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() мог прервать анимацию, если переход отменяется по ходу.