Skip to main content

Addressables Demo

The demo shows how to integrate WorldGraphEditor with the Addressables + Zenject stack: a custom ITransitionManager implementation, responsibilities split into small services, and a two-layer scene loading approach (a low-level Addressables adapter + a high-level scene lifecycle manager).

Graph setup

  • 15 gameplay scenes — all nodes marked as Addressables (TargetSceneAddress filled in, TargetSceneBuildIndex not used).
  • 1 Boot scene — a regular scene in Build Settings (index 0), not Addressable and not part of the graph. Its only purpose: start the game and load the first gameplay scene.

Service overview

ServiceResponsible for
TransitionManagerServiceTransition management: validation, fade, scene loading, spawn position, graph state
SceneLoaderCurrent scene lifecycle: load-first, unload-after
AddressablesLoaderThin stateless wrapper over Addressables API
GameplaySceneLoaderLoads the first scene from Boot, bypassing TransitionManagerService
PlayerLifecycleServicePlayer spawn/pull based on manager data
GraphDataServiceVisited ports and shortcut progress
GameCompletionViewServiceHUD: scene name, shortcut progress
ScreenFadeServiceFade animation with cancellation support

TransitionManagerService

Implements ITransitionManager, IDisposable. Graph is injected as IWorldGraph (not the concrete class).

GoFromAsync(currentPortGuid) — regular transition. Queries IGraphDataService.IsPortVisited, then Graph.CanPassTransition — the graph itself decides whether passage is allowed (visited state, connection direction). Refusal is not an error, but a LogWarning. There is no ignoreShortcuts in the interface — that logic is encapsulated in the service via IGraphDataService; those who need explicit control pass a flag through ITransitionContext.

GoToAsync(currentPortGuid, targetPortGuid) — direct teleport without CanPassTransition, for non-standard transitions (fast travel, etc.).

GoInternalAsync — common pipeline: TransitionStarted → fade in → SetPortVisited(source)LoadTargetSceneAsync → resolve OutputTransitionComponent and NextSpawnPositionSetPortVisited(target)SceneLoaded → fade out → TransitionEnded.

Wrapped in try/catch with two branches: OperationCanceledException — normal cancellation (e.g. Dispose() during a transition), logged as Info; all other Exception — bugs, LogException with full stack trace. Try/catch at the fire-and-forget boundary, because ports may call GoFromAsync without await.


SceneLoader

Implements ISceneLoader:

Task LoadAsync(string address, CancellationToken token);
Task LoadAsync(AssetReference reference, CancellationToken token);

Two entry points — by address (TransitionManagerService, address from graph data) and by AssetReference (GameplaySceneLoader, serialized inspector field). Both route to the shared LoadInternalAsync. The only place in the project that stores the current SceneInstance.

Operation order — load-first, unload-after: the new scene is loaded additive and made active, then the previous one is unloaded (via AddressablesLoader if it was addressable, otherwise via SceneManager — that is how the Boot scene is unloaded).


AddressablesLoader

Low-level wrapper over Addressables.LoadSceneAsync / UnloadSceneAsync, stateless, trivially mockable. Returns nullable SceneInstance? instead of throwing — SceneLoader treats null as a failed load and logs the address.

Splitting into two layers means: SceneLoader is not coupled to the Addressables API (another IAddressablesLoader implementation can be swapped in), AddressablesLoader is not coupled to "current scene".


Boot scene and GameplaySceneLoader

Boot contains nothing but GameplaySceneLoader. UI (fade, HUD) is not there — bound in ProjectContext via installers, lives in DontDestroyOnLoad and survives Boot's unloading automatically.

public async void Start()
{
await _sceneLoader.LoadAsync(_firstScene, CancellationToken.None);
}

AssetReference, not a string — the starting scene is set by a designer in the inspector, with drag-and-drop and built-in validation.

The initial load bypasses TransitionManagerService: conceptually this is not a transition — it has no source port and no graph logic. Boot is unloaded automatically inside SceneLoader.LoadInternalAsync once the first gameplay scene is loaded.


PlayerLifecycleService

The player is not spawned — it already exists as a GameObject in each gameplay scene. PlayerSceneInstaller (bound through that scene's SceneContext, not ProjectContext) resolves it via FromComponentInHierarchy() and binds PlayerLifecycleService as a scene-scoped singleton.

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

Container.BindInterfacesAndSelfTo<PlayerLifecycleService>()
.AsSingle()
.NonLazy();

Scene-scope means the service and event subscriptions live exactly as long as the scene: when the scene is unloaded, SceneContext is destroyed, Dispose() unsubscribes handlers automatically — no need to manually synchronize the player's lifecycle with the scene's lifecycle.

public PlayerLifecycleService(ITransitionManager manager, AddressablesPlayerController controller)
{
_manager = manager;
_controller = controller;
_manager.TransitionStarted += OnTransitionStarted;
_manager.SceneLoaded += OnSceneLoaded;

_controller.EnableMovement();
}

EnableMovement() in the constructor — this enables controls for the scene the player loaded into "cold" (the first scene from Boot, where TransitionManagerService was not involved at all, no events fired).

OnTransitionStarted fires on the current (not-yet-unloaded) player — disables movement and applies pull-force from InputTransitionComponent, if it is an IPuller. OnSceneLoaded fires on the new player (it manages to subscribe in the new scene's SceneContext constructor before the manager fires the event) — moves it to NextSpawnPosition and applies push-force from OutputTransitionComponent, if it is an IPusher.


GraphDataService

Stores the graph's game state in memory (not persisted between sessions — a real game would need a save file here). Two HashSet<string>: _visitedPorts (all visited GUIDs) and _shortcuts (a subset of visited, where the port is a shortcut-destination). Splitting into two sets gives O(1) OpenedShortcuts instead of recalculating from all visited.

SetPortVisited is called by the manager twice per transition — for the input and output port. The public contract (IGraphDataService) — three members: OpenedShortcuts, SetPortVisited, IsPortVisited.


GameCompletionViewService

View service for HUD: listens to TransitionManager.SceneLoaded, updates current scene name and shortcut progress text. Since it is a MonoBehaviour, injection goes through an [Inject] method (not constructor) — Zenject calls it immediately after Awake.

_shortcutsCount (total shortcut edges in the graph) is computed once in the constructor and cached. In OnSceneLoaded the current port GUID is taken from OutputTransitionComponent, and on first launch (before the first transition, when it is still null) — fallback to TransitionComponentUtility.FindAny().


ScreenFadeService

Screen fade animation: ShowAsync(CancellationToken) / HideAsync(CancellationToken), both accept a cancellation token — so that TransitionManagerService.Dispose() can interrupt the animation if a transition is cancelled mid-way.