fr.jeanf.scenemanagement
Version:
This package contains two scene loading system, one is additive using the addressable system, the other is to load subscenes. Both system are living side-by-side. The dynamic systems handles the loading of all static content (environment) using subscenes
1,364 lines (1,063 loc) • 62.8 kB
Markdown
# Addressable Content Loading System
### jeanf.ContentManagement
A Unity addressable-based content loading system with ScriptableObject data containers,
a generic async loader base class, and concrete implementations for cosmetics and scenes.
---
## Architecture Overview
```
App Start (Single Point of Execution)
│
▼
ClientAppStartup
│ reads GameInitConfig (startRegion, startZone, systemsConfig)
▼
InitializationCoordinator ◄──── IInitializable systems Register() in Awake
│ owns fade, loading messages, completion gate
│
│ [Phase 1] ContentRegistry.Initialize()
│ ├─ CosmeticContentLoader (label: "cosmetic")
│ └─ SceneContentLoader (label: "scene" — metadata & report only)
│
│ [Phase 2] WorldManager.LoadWorldDependencies()
│
│ [Phase 3] WorldManager.LoadRegion(startRegion)
│ └─ SceneLoader → Region.dependenciesInThisRegion (unchanged)
│
│ [Phase 4] Player.Spawn(startRegion.SpawnPosOnInit)
│
│ [Phase 5] WorldManager.SetInitialLocation(startRegion, startZone)
│ └─ VolumeSystem enabled (transition-only from this point)
│
│ [Phase 6] Await all RequiredSystemsConfig.requiredSystemIds → ReportReady()
│
└── FadeIn → InitComplete event → gameplay
```
```
IContentRegistry ◄──────────────────────
│ │
▼ │
ContentRegistry ───────────────────────┘
│ │
▼ ▼
CosmeticContent SceneContent
Loader Loader
│ │
▼ ▼
CosmeticContent SceneContent ← metadata / report only
SO SO ← NOT the runtime scene-loading path
└───────┬──────────┘
▼
GameContentSo
```
**Runtime scene loading path** (unchanged):
```
WorldManager → SceneLoader → Region.dependenciesInThisRegion (List<SceneReference>)
```
---
## Initialization Design
### Phased Loading Sequence
| Phase | Owner | Action | Blocks next phase |
| ----- | --------------------------- | ----------------------------------------------------------------------------------------------------------------- | ----------------- |
| 1 | `InitializationCoordinator` | `ContentRegistry.Initialize()` — loads all Addressable SO metadata | Yes |
| 2 | `WorldManager` | `LoadWorldDependencies()` — persistent subscenes | Yes |
| 3 | `WorldManager` | `LoadRegion(startRegion)` — region dependency scenes via `Region.dependenciesInThisRegion` | Yes |
| 4 | `WorldManager` | `SpawnPlayer(startRegion.SpawnPosOnInit)` | Yes |
| 5 | `WorldManager` | `SetInitialLocation(startRegion, startZone)` — force-sets `currentRegion` / `currentZone`, enables `VolumeSystem` | Yes |
| 6 | `InitializationCoordinator` | Await all `requiredSystemIds` in `RequiredSystemsConfig` reporting ready | Yes |
**Key invariant:** `VolumeSystem` only detects zone _transitions_. It is NOT responsible for setting the initial zone. Phase 5 sets the initial state explicitly before the ECS system is enabled.
### Who Owns What
| Responsibility | Owner |
| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| Sequence orchestration, loading messages, completion gate | `InitializationCoordinator` |
| Loading state signal (drives FadeMask) | `NoPeeking.SetIsLoadingState(bool)` |
| Visual fade animation | `FadeMask.SetStateLoading/Clear/HeadInWall()` |
| Custom pass fade overlay (region transitions) | `BoolFloatEventChannelSO FadeEventChannel` in `WorldManager` |
| Head-in-wall desaturation | `NoPeeking` (FixedUpdate physics check) |
| Content metadata (cosmetics, scene SOs) | `ContentRegistry` |
| Scene loading (additive Unity scenes) | `WorldManager` → `SceneLoader` |
| Player spawn position | `WorldManager` (reads `Region.SpawnPosOnInit`) |
| Initial zone/region state | `WorldManager.SetInitialLocation()` |
| Zone transition detection (ongoing) | `VolumeSystem` (ECS, post-init only) |
| Cross-region doorway definition | `RegionConnectivity.zoneConnections` (cross-region `ZoneNeighborData`) |
| Zone lock state & access check | `Zone.contentId` + `WorldManager._zoneRegistry` |
| ECS-triggered (walkthrough) region transition | `WorldManager.OnRegionChangedFromECS()` — no fade, no teleport, no unload, loads new scenes only |
| Manual (elevator/UI) region transition | `WorldManager.OnRegionChange()` — full fade + unload + load + teleport |
| Map broadcast on region change | `WorldManager.PublishCurrentRegion` delegate |
| Start region/zone configuration | `GameInitConfig` SO |
| Required systems list | `RequiredSystemsConfig` SO |
| Per-scenario fade enable/disable | `Scenario.enableFadeOnLoad` (new field) |
### Migration Path — Existing Region/Zone SOs
**Two additive fields added to existing SOs:** `Scenario.enableFadeOnLoad` and `Zone.contentId` (zone access control). All other SO fields are untouched.
- `Region.dependenciesInThisRegion: List<SceneReference>` remains the runtime scene-loading source
- `SceneContentSo` is a **new, additive metadata layer** — it describes scenes for reporting and addressable asset management but does NOT replace `SceneReference` in regions
- `SceneContentLoader` loads `SceneContentSo` assets by label for querying and report generation only
- All 3 classes (`Region`, `Zone`, `Scenario`) keep their existing fields and serialized data
**Optional future migration** (not required for launch):
Replace `List<SceneReference>` in `Region` with `List<SceneContentSo>` to unify the two layers.
This can be done region-by-region without breaking anything — `SceneContentSo` wraps `SceneReference`.
---
## Fade System
Two parallel mechanisms handle all visual fade/loading states. Do not confuse them — they serve different purposes and must both be preserved.
### Mechanism 1 — FadeMask + NoPeeking (post-processing)
**Files:** `FadeMask.cs`, `NoPeeking.cs` (in `VR_Player` package)
`FadeMask` drives URP/HDRP `ColorAdjustments` via reflection. It has three visual states animated by LitMotion at `_fadeTime` (default `0.2s`):
| State | `colorFilter` | `saturation` | Trigger |
| ------------ | ------------- | ------------ | ------------------------ |
| `Loading` | `Color.black` | `0` | Screen is fully black |
| `Clear` | `Color.white` | `0` | Normal view |
| `HeadInWall` | `Color.white` | `-100` | Fully desaturated / gray |
`NoPeeking` runs in `FixedUpdate` and is the **only** class that should call `FadeMask.SetState*()` after init. It reads a static `_isSceneLoading` flag and a `Physics.CheckSphere` head-position check:
```
_isSceneLoading == true → FadeMask.SetStateLoading()
_isSceneLoading == false
+ head inside wall collider → FadeMask.SetStateHeadInWall()
+ head clear → FadeMask.SetStateClear()
```
State transitions are deduplicated — `NoPeeking` only calls `FadeMask` when the derived state changes.
**Public control API (call sites only):**
```csharp
NoPeeking.SetIsLoadingState(bool isLoading) // the single knob for loading vs. clear
NoPeeking.IsCurrentlyLoading() // read-only
```
`_isSceneLoading` is initialized to `true` at field declaration, so the screen starts black before any code runs.
### Mechanism 2 — FadeEventChannel (BoolFloatEventChannelSO)
**Field in `WorldManager`:** `[SerializeField] private BoolFloatEventChannelSO FadeEventChannel`
An event channel added during URP/HDRP compatibility work. It raises `bool + float` (show/hide,
duration) alongside the `FadeMask` calls during region transitions. It does **not** replace or
duplicate the `FadeMask + NoPeeking` logic — the core fade behavior remains owned by that system.
Do not remove or reroute these calls; they are part of the existing fade coordination.
```csharp
FadeEventChannel?.RaiseEvent(false, 1.0f) // show = false (fade out), 1s
FadeEventChannel?.RaiseEvent(true, 0.1f) // show = true (fade in), 0.1s
```
### Current Fade Flows
#### Game initialization
```
FadeMask.Awake()
→ SetStateLoadingImmediate() — instant black, no animation
WorldManager.Awake()
→ NoPeeking.SetIsLoadingState(true) — sync NoPeeking state
→ FadeMask.SetStateLoading() — redundant guard
... all phases complete ...
→ NoPeeking.SetIsLoadingState(false) — NoPeeking sees clear → FadeMask.SetStateClear() (animated)
→ FadeEventChannel?.RaiseEvent(false, 1.0f)
```
#### Region transition (post-init)
```
OnRegionChange()
→ FadeMask.SetStateLoading() — immediate black
→ await 0.2s
→ FadeEventChannel?.RaiseEvent(true, 0.1f)
→ _isRegionTransitioning = true
... scene unload/load ...
→ CompleteRegionTransition()
→ FadeMask.SetStateClear() — animated clear
→ FadeOnRegionChange() coroutine (1s delay)
→ FadeEventChannel?.RaiseEvent(false, 1.0f)
```
`NoPeeking.SetIsLoadingState()` is NOT called during region transitions — `_isRegionTransitioning`
guards against `ScenarioManager` interfering with the loading state during this window.
#### Scenario load/unload
```
LoadScenario() — transitioning from an active scenario
→ NoPeeking.SetIsLoadingState(true) — screen goes black
... unload old, load new scenes ...
→ NoPeeking.SetIsLoadingState(false) — NoPeeking drives fade back to clear
(only if !IsRegionTransitioning)
ScenarioRestartAsync()
→ NoPeeking.SetIsLoadingState(true)
→ UnloadScenario() + await 500ms + LoadScenario()
→ NoPeeking.SetIsLoadingState(false)
```
#### Head in wall (ongoing, always active after init)
```
NoPeeking.FixedUpdate()
→ Physics.CheckSphere(headPosition, sphereCheckSize, collisionLayer)
→ true → FadeMask.SetStateHeadInWall() — desaturate to -100
→ false → FadeMask.SetStateClear() — restore saturation to 0
```
### Per-Scenario Fade Config (new)
Add `enableFadeOnLoad` to `Scenario.cs`. `ScenarioManager` checks this flag before calling
`NoPeeking.SetIsLoadingState()`. When disabled, scene loading still happens — only the fade
is skipped.
**Change to `Scenario.cs`** (one new field, no other changes):
```csharp
public bool enableFadeOnLoad = true;
```
**Change to `ScenarioManager`** — wrap all `NoPeeking.SetIsLoadingState(true)` calls at
scenario load time:
```csharp
if (scenario.enableFadeOnLoad)
NoPeeking.SetIsLoadingState(true);
// ... load scenes ...
if (scenario.enableFadeOnLoad)
NoPeeking.SetIsLoadingState(false);
```
The `ScenarioRestartAsync` path follows the same guard.
---
## Adjacent Regions & Cross-Region Doorways
### Two kinds of region transition
| Trigger | Path | Fade | Teleport | Scene unload | Scene load |
| ---------------------------- | --------------------------------------- | ----------- | ------------------------------------- | ----------------- | --------------------------------------------- |
| Elevator / UI request | `OnRegionChange(region)` — existing | Yes (black) | Yes (`SpawnPosOnRegionChangeRequest`) | Old region scenes | New region scenes |
| Player walks through doorway | `OnRegionChangedFromECS()` — simplified | **None** | **None** | **None** | New dependencies only (if not already loaded) |
If the player walked there, they are already there. No fade, no teleport, no unload. The ECS `VolumeSystem` already creates `RegionChangeNotificationComponent` when the player crosses into a zone that belongs to a different region. The only fix needed is that `WorldManager.OnRegionChangedFromECS()` currently calls `OnRegionChange(region)` unconditionally, which triggers the full transition. It must be its own lightweight path.
### Doorway zones
A cross-region doorway is defined by a `ZoneNeighborData` entry in `RegionConnectivity` where `zoneA` and `zoneB` belong to different regions. Multiple entries between the same region pair model multiple doorways — there is no limit.
```
Region A Region B
┌────────────┐ ┌────────────┐
│ zone_a1 │◄─────────►│ zone_b1 │ ← doorway 1
│ zone_a2 │ │ zone_b2 │
│ zone_a3 │◄─────────►│ zone_b3 │ ← doorway 2
└────────────┘ └────────────┘
```
For `VolumeSystem` to detect cross-region doorway zones, the `PrecomputedVolumeData` generator must include the neighbor zone in each doorway zone's `checkableZoneIds`. If `zone_a1` ↔ `zone_b1` is a `ZoneNeighborData` connection, `zone_b1` must appear in `zone_a1`'s checkable set and vice versa. The existing generator already handles this if the connections are configured.
### Simplified `OnRegionChangedFromECS`
ECS-triggered = player physically walked there = already positioned = no transition overhead needed.
```csharp
private void OnRegionChangedFromECS(FixedString128Bytes id)
{
var regionId = id.ToString();
if (regionId == _lastNotifiedRegion) return;
if (string.IsNullOrEmpty(regionId)) return;
if (!_regionDictionary.TryGetValue(regionId, out var region)) return;
_lastNotifiedRegion = regionId;
_currentPlayerRegion = region;
PublishCurrentRegionId?.Invoke(region.id);
PublishCurrentRegion?.Invoke(region);
RequestLoadForRegionDependencies(region);
}
```
`RequestLoadForRegionDependencies(region)` is safe to call even if scenes are already loaded —
`SceneLoader` deduplicates requests. The old region's scenes are kept loaded (player may walk back).
Only the manual `OnRegionChange` path (elevator/UI) unloads the previous region.
### Map per region
Each region carries its own floor map. `WorldManager` broadcasts the full `Region` object whenever
the current region changes so any subscriber (map UI, minimap, etc.) can react.
**New field on `Region.cs`:**
```csharp
[Header("Map")]
public Sprite map;
```
**New delegate on `WorldManager`:**
```csharp
public delegate void BroadcastRegion(Region region);
public static BroadcastRegion PublishCurrentRegion;
```
`PublishCurrentRegion?.Invoke(region)` is fired alongside `PublishCurrentRegionId` in:
- `OnRegionChange()` (manual/elevator transition)
- `OnRegionChangedFromECS()` — walkthrough path
- `SetInitialLocation()` — init
**Map UI usage:**
```csharp
private void OnEnable() => WorldManager.PublishCurrentRegion += OnRegionChanged;
private void OnDisable() => WorldManager.PublishCurrentRegion -= OnRegionChanged;
private void OnRegionChanged(Region region)
{
mapImage.sprite = region.map;
}
```
### Setup checklist
- [ ] Add cross-region `ZoneNeighborData` entries in `RegionConnectivity` for every physical doorway between regions (bidirectional, one entry per doorway opening)
- [ ] Regenerate `PrecomputedVolumeData` after adding any cross-region zone connections
- [ ] Assign a `Sprite` to `Region.map` for every region that should display a map
- [ ] Subscribe to `WorldManager.PublishCurrentRegion` in your map UI
---
## Zone Access Control
Zones may be locked — only accessible once the player's progression has unlocked them. Locking affects _zone tracking and content interaction_; it does NOT affect the ECS-level scene streaming, which remains position-based only.
### Data — `Zone.cs`
One additive field using the same `UnlockableContent` enum as `GameContentSo`:
```csharp
public UnlockableContent contentId = UnlockableContent.UC_UNLOCKED;
```
`UC_UNLOCKED = 0` means always accessible. Any other value is checked against `ContentUnlockService`:
```csharp
public bool IsAccessible()
{
if (contentId == UnlockableContent.UC_UNLOCKED) return true;
return ContentUnlockService.IsUnlocked((int)contentId);
}
```
### Zone Registry
`WorldManager` builds `_zoneRegistry : Dictionary<string, Zone>` when regions load.
Source: `Region.zonesInThisRegion` from each loaded region. Keys are `zone.id.ToString()`.
### Lock Check on Zone Entry
When `WorldManager` processes a `ZoneChangeNotificationComponent`:
1. Look up `Zone` SO from `_zoneRegistry` by zone ID string
2. If not found or `zone.IsAccessible()` → proceed normally (update current zone, load content)
3. If `!zone.IsAccessible()` → fire `ZoneLockedEventChannel?.RaiseEvent(zone)`, do **not** update `_currentPlayerZone`
`VolumeSystem` and physical scene streaming are unaffected — they load/unload by position regardless of lock state.
### Unlock Flow
When progression unlocks a zone:
1. `ZoneUnlockBroadcastSO` event channel raises the unlocked `Zone`
2. `WorldManager` subscribes and re-evaluates the player's current physical zone against the updated lock state
3. If the player is already inside the now-unlocked zone, the zone change is processed immediately as if the player just entered
### Who Owns What (zone locking)
| Responsibility | Owner |
| ----------------------------------- | -------------------------------------------------------------------- |
| Zone lock state | `Zone.contentId` + `ContentUnlockService.IsUnlocked()` |
| Zone registry (id → SO) | `WorldManager._zoneRegistry` (built from `Region.zonesInThisRegion`) |
| Lock check on ECS zone entry | `WorldManager` (after `ZoneChangeNotificationComponent`) |
| Locked zone UI feedback | `ZoneLockedEventChannel` subscriber |
| Unlock notification to WorldManager | `ZoneUnlockBroadcastSO` → `WorldManager` |
### Setup checklist (zone locking)
- [ ] Add `UnlockableContent contentId` and `IsAccessible()` to `Zone.cs`
- [ ] Add `_zoneRegistry` build logic to `WorldManager` (populate from each loaded region)
- [ ] Wire `ZoneLockedEventChannel` for UI feedback when player enters a locked zone
- [ ] Wire `ZoneUnlockBroadcastSO` for re-evaluation on unlock
---
## 1. Enums
### UnlockableContent.cs
```csharp
namespace jeanf.ContentManagement.ProgressionSystem.Data
{
public enum UnlockableContent
{
UC_UNLOCKED = 0,
// Add your unlockable content IDs here
UC_COSMETIC_001,
UC_COSMETIC_002,
// ...
}
}
```
### ContentUnlockService.cs
Static bridge between content lock checks and whatever persistence system provides unlock state.
Defaults to "all unlocked" until a persistence system (SaveSystem or equivalent) wires in its check.
```csharp
using System;
namespace jeanf.ContentManagement
{
public static class ContentUnlockService
{
public static Func<int, bool> CheckUnlocked = _ => true;
public static bool IsUnlocked(int contentId) => CheckUnlocked(contentId);
}
}
```
When a SaveSystem is added, wire it at startup:
```csharp
ContentUnlockService.CheckUnlocked = id => SaveSystem.CheckContent(id);
```
---
## 2. ScriptableObject Data Containers
### GameContentSo.cs
```csharp
using jeanf.ContentManagement.ProgressionSystem.Data;
using UnityEngine;
namespace jeanf.ContentManagement
{
public class GameContentSo : ScriptableObject
{
public UnlockableContent ContentId = UnlockableContent.UC_UNLOCKED;
public bool IsUnlocked()
{
if (ContentId == UnlockableContent.UC_UNLOCKED) return true;
return ContentUnlockService.IsUnlocked((int)ContentId);
}
public bool IsBaseUnlocked() => ContentId == UnlockableContent.UC_UNLOCKED;
}
}
```
### CosmeticContentSo.cs
```csharp
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.Localization;
namespace jeanf.ContentManagement
{
public enum CosmeticType
{
CHARACTER,
OBJECT,
ENVIRONMENT,
UI,
VFX,
DISCUSSION,
QUEST
}
[CreateAssetMenu(fileName = "Empty Cosmetic Config", menuName = "SceneManagment/New Cosmetic Config")]
public class CosmeticContentSo : GameContentSo
{
public int Id;
public CosmeticType Type;
public AssetReference CosmeticPrefab;
public LocalizedString slug;
public Sprite Icon;
}
}
```
### SceneContentSo.cs
Metadata-only layer for Addressable scene assets. Does NOT replace `Region.dependenciesInThisRegion`.
Adapt `SceneReference` as needed — see https://github.com/starikcetin/Eflatun.SceneReference for reference.
```csharp
using jeanf.SceneManagement;
using UnityEngine;
namespace jeanf.ContentManagement
{
[CreateAssetMenu(menuName = "SceneManagment/New Scene Info", fileName = "Empty Scene Info")]
public class SceneContentSo : GameContentSo
{
public SceneReference Scene;
public bool IsActive = true;
[TextArea(3, 10)]
public string Comments;
}
}
```
### GameInitConfig.cs
Defines where and how the game starts. Attach to `ClientAppStartup`.
```csharp
using jeanf.SceneManagement;
using UnityEngine;
namespace jeanf.ContentManagement
{
[CreateAssetMenu(menuName = "SceneManagment/Game Init Config", fileName = "GameInitConfig")]
public class GameInitConfig : ScriptableObject
{
public Region startRegion;
public Zone startZone;
public RequiredSystemsConfig systemsConfig;
}
}
```
`startZone` may be null. If null, `WorldManager.SetInitialLocation` falls back to the first zone in `startRegion.zonesInThisRegion`.
### RequiredSystemsConfig.cs
SO that defines the ordered init sequence and the completion gate.
Configure one per game mode or context (main game, editor test, tutorial, etc.).
```csharp
using System;
using System.Collections.Generic;
using UnityEngine;
namespace jeanf.ContentManagement
{
[CreateAssetMenu(menuName = "SceneManagment/Required Systems Config", fileName = "RequiredSystemsConfig")]
public class RequiredSystemsConfig : ScriptableObject
{
[Serializable]
public struct SystemEntry
{
public string systemId;
public string loadingMessage;
}
public List<SystemEntry> requiredSystems;
}
}
```
Each `SystemEntry.systemId` must match the `SystemId` property of a registered `IInitializable`.
`loadingMessage` is shown on the loading screen while that system is initializing.
---
## 3. Content Loader Base Class
### ContentLoader.cs
```csharp
using System;
using System.Collections.Generic;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
namespace jeanf.ContentManagement
{
public abstract class ContentLoader<T> : IDisposable
{
public bool Loaded { get; private set; }
public int TotalCount { get; private set; }
public int LoadedCount { get; private set; }
public float Progress => TotalCount > 0 ? (float)LoadedCount / TotalCount : (Loaded ? 1f : 0f);
public event Action OnProgressChanged;
private readonly string[] _contentTags;
private UniTask<bool>? _loadingTask;
private AsyncOperationHandle<IList<T>> _assetHandle;
protected ContentLoader(params string[] tags)
{
_contentTags = tags;
}
// ──────────────────────────────────────────────────────────────
// Public API
// ──────────────────────────────────────────────────────────────
public async UniTask Initialize()
{
var isCancelled = await InternalInitialize();
if (isCancelled)
{
Debug.LogError("Failed to load cosmetics");
return;
}
Loaded = true;
}
// ──────────────────────────────────────────────────────────────
// Internal initialization — deduplicates concurrent calls
// ──────────────────────────────────────────────────────────────
private async UniTask<bool> InternalInitialize()
{
// Already fully loaded — no cancellation
if (Loaded) return false;
// A load is already in-flight — await it instead of starting a new one
if (_loadingTask.HasValue)
{
return await _loadingTask.Value;
}
_loadingTask = LoadResource().SuppressCancellationThrow();
return await _loadingTask.Value;
}
// ──────────────────────────────────────────────────────────────
// Addressables loading
// ──────────────────────────────────────────────────────────────
private async UniTask LoadResource()
{
// 1. Resolve addresses matching ALL supplied tags (intersection)
var locHandle = Addressables.LoadResourceLocationsAsync(
_contentTags,
Addressables.MergeMode.Intersection,
typeof(T));
if (!locHandle.IsValid())
{
throw new Exception("Failed to load Loc Handle for " + typeof(T).Name);
}
await locHandle.Task.AsUniTask();
if (locHandle.Status != AsyncOperationStatus.Succeeded)
{
Addressables.Release(locHandle);
throw new Exception("Status not succeeded for Loc Handle " + typeof(T).Name);
}
// 2. Load all assets at those locations, routing each one through the callback
TotalCount = locHandle.Result.Count;
var assetHandle = Addressables.LoadAssetsAsync<T>(locHandle.Result, resource =>
{
HandleLoadResource(resource);
LoadedCount++;
OnProgressChanged?.Invoke();
});
if (!assetHandle.IsValid())
{
Addressables.Release(locHandle);
throw new Exception("Failed to load asset handle for " + typeof(T).Name);
}
await assetHandle.Task.AsUniTask();
// Location handle is no longer needed after assets start loading
Addressables.Release(locHandle);
if (assetHandle.Status != AsyncOperationStatus.Succeeded)
{
assetHandle.Release();
Debug.LogError(assetHandle.OperationException.Message);
throw new Exception("Status not succeeded for asset handle" + typeof(T).Name);
}
OnPostProcessResource();
_assetHandle = assetHandle;
}
// ──────────────────────────────────────────────────────────────
// Abstract / virtual hooks for subclasses
// ──────────────────────────────────────────────────────────────
protected abstract void HandleLoadResource(T resource);
protected virtual void OnPostProcessResource() { }
// ──────────────────────────────────────────────────────────────
// IDisposable
// ──────────────────────────────────────────────────────────────
public virtual void Dispose()
{
if (!Loaded) return;
if (_assetHandle.IsValid())
Addressables.Release(_assetHandle);
Loaded = false;
_loadingTask = null;
TotalCount = 0;
LoadedCount = 0;
}
}
}
```
---
## 4. Concrete Loaders
### CosmeticContentLoader.cs
```csharp
using System.Collections.Generic;
using UnityEngine;
namespace jeanf.ContentManagement
{
public class CosmeticContentLoader : ContentLoader<CosmeticContentSo>
{
public const int EMPTY_COSMETIC = 0;
public const int BOT_SKIN = 1;
private readonly Dictionary<CosmeticType, List<CosmeticContentSo>> _cosmeticTypeMap = new();
private readonly Dictionary<int, CosmeticContentSo> _cosmeticList = new();
public CosmeticContentLoader(params string[] tags) : base(tags) { }
// ── HandleLoadResource ─────────────────────────────────────────
protected override void HandleLoadResource(CosmeticContentSo resource)
{
// Index by type
if (_cosmeticTypeMap.TryGetValue(resource.Type, out var list))
{
list.Add(resource);
}
else
{
_cosmeticTypeMap.Add(resource.Type, new List<CosmeticContentSo> { resource });
}
// Index by unique ID — log if a duplicate is found
if (!_cosmeticList.TryAdd(resource.Id, resource))
{
Debug.LogError("Duplicated Cosmetic!!");
}
}
// ── Query API ─────────────────────────────────────────────────
public CosmeticContentSo Find(int key)
=> _cosmeticList.GetValueOrDefault(key);
public IReadOnlyList<CosmeticContentSo> FindByType(CosmeticType cosmeticType)
=> _cosmeticTypeMap.GetValueOrDefault(cosmeticType).AsReadOnly();
// ── IDisposable ───────────────────────────────────────────────
public override void Dispose()
{
if (!Loaded) return;
_cosmeticTypeMap.Clear();
_cosmeticList.Clear();
base.Dispose();
}
}
}
```
### SceneContentLoader.cs
Loads `SceneContentSo` assets for metadata queries and report generation.
Does NOT load actual Unity scenes — that remains `WorldManager` → `SceneLoader`.
```csharp
using System.Collections.Generic;
using System.Text;
using UnityEngine;
namespace jeanf.ContentManagement
{
public class SceneContentLoader : ContentLoader<SceneContentSo>
{
private readonly List<SceneContentSo> _scenes = new();
public SceneContentLoader(params string[] tags) : base(tags) { }
// ── HandleLoadResource ─────────────────────────────────────────
protected override void HandleLoadResource(SceneContentSo resource)
{
if (!resource.IsActive) return;
_scenes.Add(resource);
}
// ── Query API ─────────────────────────────────────────────────
public IReadOnlyList<SceneContentSo> GetAll() => _scenes;
public SceneContentSo Find(string sceneName)
=> _scenes.Find(s => s.Scene.Name == sceneName);
// ── Reporting ─────────────────────────────────────────────────
public void WriteReport(StringBuilder csv)
{
csv.AppendLine("SceneName,ContentId,IsActive,Comments");
foreach (var info in _scenes)
{
csv.AppendLine($"{info.Scene.Name},{info.ContentId},{info.IsActive},{info.Comments}");
}
}
// ── IDisposable ───────────────────────────────────────────────
public override void Dispose()
{
if (!Loaded) return;
_scenes.Clear();
base.Dispose();
}
}
}
```
---
## 5. Content Registry
The `ContentRegistry` is an **in-memory asset registry**: all Addressable SOs are loaded once at startup and queried synchronously at runtime. Nothing is reloaded during gameplay. `IContentRegistry` exists for testability — swap with a stub in tests.
### IContentRegistry.cs
```csharp
using Cysharp.Threading.Tasks;
namespace jeanf.ContentManagement
{
public interface IContentRegistry
{
UniTask Initialize();
void Dispose();
}
}
```
### ContentRegistry.cs
```csharp
using Cysharp.Threading.Tasks;
using UnityEngine;
namespace jeanf.ContentManagement
{
public class ContentRegistry : IContentRegistry
{
// ── Addressable label constants ────────────────────────────────
private const string TAG_COSMETIC = "cosmetic";
private const string TAG_SCENE = "scene";
// ── Loaders ───────────────────────────────────────────────────
public CosmeticContentLoader Cosmetics { get; private set; }
public SceneContentLoader Scenes { get; private set; }
public ContentRegistry()
{
Cosmetics = new CosmeticContentLoader(TAG_COSMETIC);
Scenes = new SceneContentLoader(TAG_SCENE);
}
// ── Aggregated progress (for loading UI) ──────────────────────
public int ContentLoadedCount => Cosmetics.LoadedCount + Scenes.LoadedCount;
public int ContentTotalCount => Cosmetics.TotalCount + Scenes.TotalCount;
public float ContentProgress => ContentTotalCount > 0
? (float)ContentLoadedCount / ContentTotalCount
: 0f;
// ── IContentRegistry ──────────────────────────────────────────
public async UniTask Initialize()
{
// Load both loaders in parallel
await UniTask.WhenAll(
Cosmetics.Initialize(),
Scenes.Initialize()
);
Debug.Log("[ContentRegistry] All content loaded.");
}
public void Dispose()
{
Cosmetics?.Dispose();
Scenes?.Dispose();
}
}
}
```
---
## 6. Initialization System
### IInitializable.cs
Any system that needs to participate in the initialization gate implements this interface.
Call `InitializationCoordinator.Register(this)` in `Awake`.
Call `InitializationCoordinator.ReportReady(SystemId)` when initialization is complete.
Call `InitializationCoordinator.ReportProgress(SystemId, 0f–1f)` during work to feed the loading UI.
`DisplayName` and `Progress` have default implementations — systems that don't report granular
progress can ignore them entirely.
```csharp
namespace jeanf.ContentManagement
{
public interface IInitializable
{
string SystemId { get; }
string DisplayName => SystemId;
float Progress => 0f;
}
}
```
### LoadingEntry.cs
Read-only snapshot of a single entry in the loading UI list.
```csharp
namespace jeanf.ContentManagement
{
public enum LoadingState { Pending, Loading, Complete, Failed }
public class LoadingEntry
{
public string Id { get; internal set; }
public string DisplayName { get; internal set; }
public string Group { get; internal set; }
public LoadingState State { get; internal set; }
public float Progress { get; internal set; }
public int LoadedCount { get; internal set; }
public int TotalCount { get; internal set; }
}
}
```
| Group | Entries |
| ----------- | --------------------------------------------------------------------------- |
| `"Content"` | One entry per `ContentLoader` subtype (Cosmetics, Scene Metadata) |
| `"World"` | Three entries: World Dependencies, Region (`startRegion.levelName`), Player |
| `"Systems"` | One entry per registered `IInitializable` in `RequiredSystemsConfig` |
### InitializationCoordinator.cs
Orchestrates the full init sequence: fade, phased loading, loading messages, completion gate,
and all loading progress state consumed by `LoadingProgressUI`.
```csharp
using System.Collections.Generic;
using System.Linq;
using Cysharp.Threading.Tasks;
using jeanf.EventSystem;
using jeanf.SceneManagement;
using UnityEngine;
namespace jeanf.ContentManagement
{
public class InitializationCoordinator : MonoBehaviour
{
[SerializeField] private GameInitConfig _config;
[SerializeField] private StringEventChannelSO _loadingMessageChannel;
private static InitializationCoordinator _instance;
private ContentRegistry _contentRegistry;
private readonly Dictionary<string, IInitializable> _registered = new();
private readonly HashSet<string> _readySystems = new();
private readonly Dictionary<string, LoadingEntry> _entries = new();
public static event System.Action OnInitComplete;
public static event System.Action OnProgressChanged;
public static IReadOnlyCollection<LoadingEntry> Entries
=> _instance?._entries.Values;
private void Awake()
{
_instance = this;
}
// ── Registration ──────────────────────────────────────────────
public static void Register(IInitializable system)
{
if (_instance == null) return;
_instance._registered[system.SystemId] = system;
}
public static void ReportReady(string systemId)
{
if (_instance == null) return;
_instance._readySystems.Add(systemId);
_instance.SetEntry(systemId, LoadingState.Complete, 1f);
_instance.CheckComplete();
}
public static void ReportProgress(string systemId, float progress)
{
if (_instance == null) return;
_instance.SetEntry(systemId, LoadingState.Loading, progress);
}
// ── Sequence ──────────────────────────────────────────────────
public async UniTask Run(ContentRegistry contentRegistry)
{
_contentRegistry = contentRegistry;
BuildEntries();
SubscribeToContentProgress();
await RunSequence();
}
private async UniTask RunSequence()
{
NoPeeking.SetIsLoadingState(true);
SetEntry("content.cosmetics", LoadingState.Loading, 0f);
SetEntry("content.scenes", LoadingState.Loading, 0f);
BroadcastMessage("Loading content...");
await _contentRegistry.Initialize();
SetEntry("content.cosmetics", LoadingState.Complete, 1f);
SetEntry("content.scenes", LoadingState.Complete, 1f);
SetEntry("world.dependencies", LoadingState.Loading, 0f);
BroadcastMessage("Loading world...");
await WorldManager.LoadWorldDependencies();
SetEntry("world.dependencies", LoadingState.Complete, 1f);
SetEntry("world.region", LoadingState.Loading, 0f);
BroadcastMessage($"Loading {_config.startRegion.levelName}...");
await WorldManager.LoadRegion(_config.startRegion);
SetEntry("world.region", LoadingState.Complete, 1f);
SetEntry("world.player", LoadingState.Loading, 0f);
BroadcastMessage("Placing player...");
WorldManager.SpawnPlayer(_config.startRegion.SpawnPosOnInit);
WorldManager.SetInitialLocation(_config.startRegion, _config.startZone);
SetEntry("world.player", LoadingState.Complete, 1f);
BroadcastMessage("Initializing systems...");
await WaitForRequiredSystems();
NoPeeking.SetIsLoadingState(false);
OnInitComplete?.Invoke();
}
// ── Entry management ──────────────────────────────────────────
private void BuildEntries()
{
AddEntry("content.cosmetics", "Cosmetics", "Content");
AddEntry("content.scenes", "Scene Metadata", "Content");
AddEntry("world.dependencies", "World Dependencies", "World");
AddEntry("world.region", _config.startRegion.levelName, "World");
AddEntry("world.player", "Player", "World");
foreach (var entry in _config.systemsConfig.requiredSystems)
{
var displayName = _registered.TryGetValue(entry.systemId, out var sys)
? sys.DisplayName
: entry.systemId;
AddEntry(entry.systemId, displayName, "Systems");
}
}
private void SubscribeToContentProgress()
{
_contentRegistry.Cosmetics.OnProgressChanged += () =>
{
SetEntry("content.cosmetics", LoadingState.Loading,
_contentRegistry.Cosmetics.Progress,
_contentRegistry.Cosmetics.LoadedCount,
_contentRegistry.Cosmetics.TotalCount);
};
_contentRegistry.Scenes.OnProgressChanged += () =>
{
SetEntry("content.scenes", LoadingState.Loading,
_contentRegistry.Scenes.Progress,
_contentRegistry.Scenes.LoadedCount,
_contentRegistry.Scenes.TotalCount);
};
}
private void AddEntry(string id, string displayName, string group)
{
_entries[id] = new LoadingEntry
{
Id = id, DisplayName = displayName, Group = group,
State = LoadingState.Pending, Progress = 0f
};
}
private void SetEntry(string id, LoadingState state, float progress,
int loaded = 0, int total = 0)
{
if (!_entries.TryGetValue(id, out var entry)) return;
entry.State = state;
entry.Progress = progress;
entry.LoadedCount = loaded;
entry.TotalCount = total;
OnProgressChanged?.Invoke();
}
// ── Completion gate ───────────────────────────────────────────
private async UniTask WaitForRequiredSystems()
{
var required = _config.systemsConfig.requiredSystems.Select(e => e.systemId).ToList();
if (required.All(id => _readySystems.Contains(id))) return;
foreach (var entry in _config.systemsConfig.requiredSystems)
{
if (!_readySystems.Contains(entry.systemId))
BroadcastMessage(entry.loadingMessage);
}
await UniTask.WaitUntil(() => required.All(id => _readySystems.Contains(id)));
}
private void CheckComplete()
{
var required = _config.systemsConfig.requiredSystems.Select(e => e.systemId);
if (!required.All(id => _readySystems.Contains(id))) return;
NoPeeking.SetIsLoadingState(false);
OnInitComplete?.Invoke();
}
private void BroadcastMessage(string message)
{
_loadingMessageChannel?.RaiseEvent(message);
}
}
}
```
---
## 7. Loading Progress UI
### LoadingProgressUI.cs
Subscribes to `InitializationCoordinator.OnProgressChanged` and renders one row per `LoadingEntry`,
grouped by `Entry.Group`. Purely a reader — no coupling back to the coordinator or loaders.
Expected UI layout:
```
┌─ Content ──────────────────────────────────────────┐
│ Cosmetics [████████░░] 8 / 10 │
│ Scene Metadata [██████████] 5 / 5 ✓ │
├─ World ────────────────────────────────────────────┤
│ World Dependencies [██████████] ✓ │
│ Forest Region [████░░░░░░] Loading... │
│ Player [░░░░░░░░░░] Pending │
├─ Systems ──────────────────────────────────────────┤
│ VolumeSystem [████████░░] Loading... │
│ MyGameSystem [██████████] ✓ │
└────────────────────────────────────────────────────┘
```
```csharp
using System.Collections.Generic;
using UnityEngine;
namespace jeanf.ContentManagement
{
public class LoadingProgressUI : MonoBehaviour
{
[SerializeField] private LoadingGroupUI cosmeticGroup;
[SerializeField] private LoadingGroupUI worldGroup;
[SerializeField] private LoadingGroupUI systemsGroup;
private void OnEnable()
{
InitializationCoordinator.OnProgressChanged += Refresh;
InitializationCoordinator.OnInitComplete += Hide;
}
private void OnDisable()
{
InitializationCoordinator.OnProgressChanged -= Refresh;
InitializationCoordinator.OnInitComplete -= Hide;
}
private void Refresh()
{
var entries = InitializationCoordinator.Entries;
if (entries == null) return;
cosmeticGroup.Render(Filter(entries, "Content"));
worldGroup.Render(Filter(entries, "World"));
systemsGroup.Render(Filter(entries, "Systems"));
}
private void Hide() => gameObject.SetActive(false);
private static IEnumerable<LoadingEntry> Filter(
IEnumerable<LoadingEntry> entries, string group)
{
foreach (var e in entries)
if (e.Group == group) yield return e;
}
}
}
```
### LoadingGroupUI.cs
Renders a single group heading and one `LoadingRowUI` per entry.
Spawns/recycles rows from a pool keyed by `entry.Id`.
```csharp
using System.Collections.Generic;
using UnityEngine;
namespace jeanf.ContentManagement
{
public class LoadingGroupUI : MonoBehaviour
{
[SerializeField] private LoadingRowUI rowPrefab;
[SerializeField] private Transform rowParent;
private readonly Dictionary<string, LoadingRowUI> _rows = new();
public void Render(IEnumerable<LoadingEntry> entries)
{
foreach (var entry in entries)
{
if (!_rows.TryGetValue(entry.Id, out var row))
{
row = Instantiate(rowPrefab, rowParent);
_rows[entry.Id] = row;
}
row.Render(entry);
}
}
}
}
```
### LoadingRowUI.cs
One row: display name on the left, progress bar in the middle, state label on the right.
The `X / Y` count is shown only when `TotalCount > 0`.
```csharp
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace jeanf.ContentManagement
{
public class LoadingRowUI : MonoBehaviour
{
[SerializeField] private TMP_Text nameLabel;
[SerializeField] private Slider progressBar;
[SerializeField] private TMP_Text stateLabel;
public void Render(LoadingEntry entry)
{
nameLabel.text = entry.DisplayName;
progressBar.value = entry.Progress;
stateLabel.text = entry.State switch
{
LoadingState.Pending => "Pending",
LoadingState.Loading => entry.TotalCount > 0
? $"{entry.LoadedCount} / {entry.TotalCount}"
: "Loading...",
LoadingState.Complete => "✓",
LoadingState.Failed => "✗",
_ => string.Empty
};
}
}
}
```
---
## 8. WorldManager Integration
New public surface required on `WorldManager`. All existing region-change and scenario logic is unchanged except for `OnRegionChangedFromECS` which gets the adjacency branch (see Adjacent Regions section).
```csharp
// ── Init sequence hooks (called by InitializationCoordinator) ─────────────
public static UniTask LoadWorldDependencies() { ... }
public static UniTask LoadRegion(Region region) { ... }
public static void SpawnPlayer(SpawnPos spawnPos) { ... }
public static void SetInitialLocation(Region region, Zone zone)
{
_currentPlayerRegion = region;
_currentPlayerZone = zone ?? region.zonesInThisRegion.FirstOrDefault();
PublishCurrentRegionId?.Invoke(region.id);
PublishCurrentRegion?.Invoke(region);
PublishCurrentZoneId?.Invoke(_currentPlayerZone?.id ?? default);
IsInitialized = true;
}
// ── ECS-triggered region change (walkthrough — no fade, no teleport) ─────
public static bool IsInitialized { get; private set; }
public delegate void BroadcastRegion(Region region);
public static BroadcastRegion PublishCurrentRegion;
private void OnRegionChangedFromECS(FixedString128Bytes id)
{
var regionId = id.ToString();
if (regionId == _lastNotifiedRegion) return;
if (string.IsNullOrEmpty(reg