diff --git a/Assets/PerfectWorld/Scripts/Managers/EC_ManMatter.cs b/Assets/PerfectWorld/Scripts/Managers/EC_ManMatter.cs index 6860f41640..aff1af0f7e 100644 --- a/Assets/PerfectWorld/Scripts/Managers/EC_ManMatter.cs +++ b/Assets/PerfectWorld/Scripts/Managers/EC_ManMatter.cs @@ -1,5 +1,6 @@ using BrewMonster; using BrewMonster.Network; +using BrewMonster.Scripts; using BrewMonster.Scripts.World; using CSNetwork; using CSNetwork.GPDataType; @@ -260,7 +261,8 @@ namespace PerfectWorld.Scripts.Managers CECMatter pMatter = GetMatter(mid); if (pMatter != null) { - UnityEngine.Object.Destroy(pMatter.gameObject); + //UnityEngine.Object.Destroy(pMatter.gameObject); + PoolManager.Instance.Despawn(pMatter.gameObject); m_MatterTab.Remove(mid); } //TODO: Might need to implement later @@ -345,4 +347,4 @@ namespace PerfectWorld.Scripts.Managers return null; } } -} \ No newline at end of file +} diff --git a/Assets/PerfectWorld/Scripts/Objet/CECMatter.cs b/Assets/PerfectWorld/Scripts/Objet/CECMatter.cs index b7b313e319..dbdb6bf855 100644 --- a/Assets/PerfectWorld/Scripts/Objet/CECMatter.cs +++ b/Assets/PerfectWorld/Scripts/Objet/CECMatter.cs @@ -161,10 +161,11 @@ namespace PerfectWorld.Scripts var fileMatterValue = fileMatterField.GetValue(matterData); string filePath = ByteToStringUtils.ByteArrayToCP936String((byte[])fileMatterValue); - var matterPrefab = await AddressableManager.Instance.LoadPrefabAsync(AFile.NormalizePath(filePath.ToLower(), true)); - if (matterPrefab != null) + //var matterPrefab = await AddressableManager.Instance.LoadPrefabAsync(AFile.NormalizePath(filePath.ToLower(), true)); + var matterObject = await PoolManager.Instance.SpawnAsync(AFile.NormalizePath(filePath.ToLower(), true), Vector3.zero, Quaternion.identity, 15f); + if (matterObject != null) { - var matterObject = Instantiate(matterPrefab); + //var matterObject = Instantiate(matterPrefab); matterObject.name = $"Matter {matterObject.name} {matterInfo.tid} {matterInfo.mid}"; matterObject.transform.position = new Vector3(Info.pos.x, Info.pos.y, Info.pos.z); matterObject.transform.localScale = new Vector3(1f, 1f, 1f); diff --git a/Assets/PerfectWorld/Scripts/PoolingManager.meta b/Assets/PerfectWorld/Scripts/PoolingManager.meta new file mode 100644 index 0000000000..6d3bf283d0 --- /dev/null +++ b/Assets/PerfectWorld/Scripts/PoolingManager.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a86dd2e474fb3c34cb8214c766f0b941 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/PerfectWorld/Scripts/PoolingManager/IPoolable.cs b/Assets/PerfectWorld/Scripts/PoolingManager/IPoolable.cs new file mode 100644 index 0000000000..93f9d4a3a2 --- /dev/null +++ b/Assets/PerfectWorld/Scripts/PoolingManager/IPoolable.cs @@ -0,0 +1,11 @@ +namespace BrewMonster.Scripts +{ + /// + /// Implement this on pooled prefab components that need to reset state between uses. + /// + public interface IPoolable + { + void OnSpawn(); + void OnDespawn(); + } +} diff --git a/Assets/PerfectWorld/Scripts/PoolingManager/IPoolable.cs.meta b/Assets/PerfectWorld/Scripts/PoolingManager/IPoolable.cs.meta new file mode 100644 index 0000000000..0c0f5b2b61 --- /dev/null +++ b/Assets/PerfectWorld/Scripts/PoolingManager/IPoolable.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7c09b2b8b565f2946bb4e9f09e2966af diff --git a/Assets/PerfectWorld/Scripts/PoolingManager/ObjectPool.cs b/Assets/PerfectWorld/Scripts/PoolingManager/ObjectPool.cs new file mode 100644 index 0000000000..2423b5c286 --- /dev/null +++ b/Assets/PerfectWorld/Scripts/PoolingManager/ObjectPool.cs @@ -0,0 +1,270 @@ +using System.Collections; +using System.Collections.Generic; +using System.Threading.Tasks; +using UnityEngine; + +namespace BrewMonster.Scripts +{ + internal sealed class ObjectPool + { + private readonly string _addressableKey; + private readonly PoolManager _owner; + private readonly AddressableManager _addressableManager; + private readonly Transform _poolRoot; + private readonly Stack _idleInstances = new(); + private readonly HashSet _activeInstances = new(); + private readonly HashSet _knownInstances = new(); + private readonly Dictionary _spawnVersions = new(); + + private GameObject _prefab; + private Task _loadTask; + private Coroutine _releaseCoroutine; + private float _memoryReleaseTTL; + + public ObjectPool( + string addressableKey, + PoolManager owner, + AddressableManager addressableManager, + Transform poolRoot, + float memoryReleaseTTL) + { + _addressableKey = addressableKey; + _owner = owner; + _addressableManager = addressableManager; + _poolRoot = poolRoot; + _memoryReleaseTTL = Mathf.Max(0f, memoryReleaseTTL); + } + + public string AddressableKey => _addressableKey; + public int ActiveCount => _activeInstances.Count; + public int IdleCount => _idleInstances.Count; + + public void UpdateMemoryReleaseTTL(float memoryReleaseTTL) + { + _memoryReleaseTTL = Mathf.Max(0f, memoryReleaseTTL); + } + + public async Task SpawnAsync(Vector3 position, Quaternion rotation, Transform parent) + { + CancelReleaseCountdown(); + + GameObject instance = GetIdleInstance(); + if (instance == null) + { + GameObject prefab = await LoadPrefabAsync(); + if (prefab == null) + { + return null; + } + + instance = Object.Instantiate(prefab); + _knownInstances.Add(instance); + _spawnVersions[instance] = 0; + } + + _activeInstances.Add(instance); + _spawnVersions[instance]++; + instance.transform.SetParent(parent, true); + instance.transform.SetPositionAndRotation(position, rotation); + instance.SetActive(true); + NotifyPoolablesSpawned(instance); + + return instance; + } + + public bool Despawn(GameObject instance) + { + if (instance == null || !_activeInstances.Remove(instance)) + { + return false; + } + + NotifyPoolablesDespawned(instance); + _spawnVersions[instance]++; + instance.SetActive(false); + instance.transform.SetParent(_poolRoot, false); + _idleInstances.Push(instance); + + if (_activeInstances.Count == 0) + { + StartReleaseCountdown(); + } + + return true; + } + + public bool IsActiveInstance(GameObject instance, int spawnVersion) + { + return instance != null + && _activeInstances.Contains(instance) + && _spawnVersions.TryGetValue(instance, out int currentVersion) + && currentVersion == spawnVersion; + } + + public int GetSpawnVersion(GameObject instance) + { + return instance != null && _spawnVersions.TryGetValue(instance, out int version) ? version : -1; + } + + public IEnumerable GetKnownInstances() + { + return _knownInstances; + } + + public void ReleaseNow() + { + CancelReleaseCountdown(); + DestroyAllInstances(); + ReleasePrefabAsset(); + } + + private GameObject GetIdleInstance() + { + while (_idleInstances.Count > 0) + { + GameObject instance = _idleInstances.Pop(); + if (instance != null) + { + return instance; + } + } + + return null; + } + + private async Task LoadPrefabAsync() + { + if (_prefab != null) + { + return _prefab; + } + + if (_loadTask != null) + { + return await _loadTask; + } + + _loadTask = LoadPrefabInternalAsync(); + GameObject prefab = await _loadTask; + if (prefab == null) + { + _loadTask = null; + } + + return prefab; + } + + private async Task LoadPrefabInternalAsync() + { + if (_addressableManager == null) + { + BMLogger.LogError($"ObjectPool: AddressableManager is not available for '{_addressableKey}'."); + return null; + } + + await _addressableManager.WaitUntilInitializedAsync(); + + GameObject prefab = await _addressableManager.LoadPrefabAsync(_addressableKey); + if (prefab == null) + { + BMLogger.LogError($"ObjectPool: Failed to load Addressable prefab '{_addressableKey}'."); + return null; + } + + _prefab = prefab; + return _prefab; + } + + private void StartReleaseCountdown() + { + CancelReleaseCountdown(); + _releaseCoroutine = _owner.StartCoroutine(ReleaseMemoryCountdown()); + } + + private void CancelReleaseCountdown() + { + if (_releaseCoroutine == null) + { + return; + } + + _owner.StopCoroutine(_releaseCoroutine); + _releaseCoroutine = null; + } + + private IEnumerator ReleaseMemoryCountdown() + { + if (_memoryReleaseTTL > 0f) + { + yield return new WaitForSecondsRealtime(_memoryReleaseTTL); + } + + _releaseCoroutine = null; + if (_activeInstances.Count > 0) + { + yield break; + } + + _owner.RemovePool(this); + DestroyAllInstances(); + ReleasePrefabAsset(); + } + + private void DestroyAllInstances() + { + foreach (GameObject instance in _knownInstances) + { + if (instance == null) + { + continue; + } + + if (_activeInstances.Contains(instance)) + { + NotifyPoolablesDespawned(instance); + } + + Object.Destroy(instance); + } + + _idleInstances.Clear(); + _activeInstances.Clear(); + _knownInstances.Clear(); + _spawnVersions.Clear(); + + if (_poolRoot != null) + { + Object.Destroy(_poolRoot.gameObject); + } + } + + private void ReleasePrefabAsset() + { + if (_prefab != null && _addressableManager != null) + { + _addressableManager.ReleaseAsset(_addressableKey); + } + + _prefab = null; + _loadTask = null; + } + + private static void NotifyPoolablesSpawned(GameObject instance) + { + IPoolable[] poolables = instance.GetComponentsInChildren(true); + for (int i = 0; i < poolables.Length; i++) + { + poolables[i].OnSpawn(); + } + } + + private static void NotifyPoolablesDespawned(GameObject instance) + { + IPoolable[] poolables = instance.GetComponentsInChildren(true); + for (int i = 0; i < poolables.Length; i++) + { + poolables[i].OnDespawn(); + } + } + } +} diff --git a/Assets/PerfectWorld/Scripts/PoolingManager/ObjectPool.cs.meta b/Assets/PerfectWorld/Scripts/PoolingManager/ObjectPool.cs.meta new file mode 100644 index 0000000000..691cec0bfa --- /dev/null +++ b/Assets/PerfectWorld/Scripts/PoolingManager/ObjectPool.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8fc34059fd203384d9c5aa022301e68f diff --git a/Assets/PerfectWorld/Scripts/PoolingManager/PoolManager.cs b/Assets/PerfectWorld/Scripts/PoolingManager/PoolManager.cs new file mode 100644 index 0000000000..4ff5714461 --- /dev/null +++ b/Assets/PerfectWorld/Scripts/PoolingManager/PoolManager.cs @@ -0,0 +1,244 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Threading.Tasks; +using UnityEngine; + +namespace BrewMonster.Scripts +{ + public sealed class PoolManager : MonoSingleton + { + private readonly Dictionary _pools = new(); + private readonly Dictionary _instanceToPool = new(); + + private AddressableManager _addressableManager; + private Transform _poolContainer; + + protected override void Initialize() + { + base.Initialize(); + + _addressableManager = AddressableManager.Instance; + _addressableManager.OnDispose += ReleaseAllPools; + + _poolContainer = new GameObject("Addressables Object Pools").transform; + _poolContainer.SetParent(transform, false); + } + + /// + /// Spawns an Addressables prefab from its pool. The returned task completes after the prefab is loaded if needed. + /// + public async Task SpawnAsync( + string addressableKey, + Vector3 position, + Quaternion rotation, + float memoryReleaseTTL, + float autoDespawnTime = 0f, + Transform parent = null) + { + if (string.IsNullOrEmpty(addressableKey)) + { + BMLogger.LogError("PoolManager: Cannot spawn with a null or empty Addressables key."); + return null; + } + + ObjectPool pool = GetOrCreatePool(addressableKey, memoryReleaseTTL); + pool.UpdateMemoryReleaseTTL(memoryReleaseTTL); + + GameObject instance = await pool.SpawnAsync(position, rotation, parent); + if (instance == null) + { + return null; + } + + _instanceToPool[instance] = pool; + + if (autoDespawnTime > 0f) + { + StartCoroutine(AutoDespawnAfter(pool, instance, pool.GetSpawnVersion(instance), autoDespawnTime)); + } + + return instance; + } + + /// + /// Coroutine-friendly spawn API for callers that do not use async/await. + /// + public void Spawn( + string addressableKey, + Vector3 position, + Quaternion rotation, + float memoryReleaseTTL, + float autoDespawnTime, + Action onComplete, + Transform parent = null) + { + StartCoroutine(SpawnRoutine(addressableKey, position, rotation, memoryReleaseTTL, autoDespawnTime, onComplete, parent)); + } + + public bool Despawn(string addressableKey, GameObject instance) + { + if (string.IsNullOrEmpty(addressableKey) || instance == null) + { + return false; + } + + if (!_pools.TryGetValue(addressableKey, out ObjectPool pool)) + { + return false; + } + + bool despawned = pool.Despawn(instance); + if (despawned) + { + _instanceToPool[instance] = pool; + } + + return despawned; + } + + public bool Despawn(GameObject instance) + { + if (instance == null || !_instanceToPool.TryGetValue(instance, out ObjectPool pool)) + { + return false; + } + + return pool.Despawn(instance); + } + + public bool TryGetPoolCounts(string addressableKey, out int activeCount, out int idleCount) + { + activeCount = 0; + idleCount = 0; + + if (!_pools.TryGetValue(addressableKey, out ObjectPool pool)) + { + return false; + } + + activeCount = pool.ActiveCount; + idleCount = pool.IdleCount; + return true; + } + + public void ReleasePool(string addressableKey) + { + if (!_pools.TryGetValue(addressableKey, out ObjectPool pool)) + { + return; + } + + UnregisterPoolInstances(pool); + pool.ReleaseNow(); + _pools.Remove(addressableKey); + } + + public void ReleaseAllPools() + { + List pools = new(_pools.Values); + for (int i = 0; i < pools.Count; i++) + { + pools[i].ReleaseNow(); + } + + _pools.Clear(); + _instanceToPool.Clear(); + } + + internal void RemovePool(ObjectPool pool) + { + if (pool == null) + { + return; + } + + UnregisterPoolInstances(pool); + _pools.Remove(pool.AddressableKey); + } + + private IEnumerator SpawnRoutine( + string addressableKey, + Vector3 position, + Quaternion rotation, + float memoryReleaseTTL, + float autoDespawnTime, + Action onComplete, + Transform parent) + { + Task spawnTask = SpawnAsync(addressableKey, position, rotation, memoryReleaseTTL, autoDespawnTime, parent); + while (!spawnTask.IsCompleted) + { + yield return null; + } + + if (spawnTask.Exception != null) + { + BMLogger.LogError($"PoolManager: Spawn failed for '{addressableKey}': {spawnTask.Exception}"); + onComplete?.Invoke(null); + yield break; + } + + onComplete?.Invoke(spawnTask.Result); + } + + private IEnumerator AutoDespawnAfter(ObjectPool pool, GameObject instance, int spawnVersion, float autoDespawnTime) + { + yield return new WaitForSeconds(autoDespawnTime); + + if (pool != null && pool.IsActiveInstance(instance, spawnVersion)) + { + Despawn(instance); + } + } + + private ObjectPool GetOrCreatePool(string addressableKey, float memoryReleaseTTL) + { + if (_pools.TryGetValue(addressableKey, out ObjectPool pool)) + { + return pool; + } + + Transform poolRoot = new GameObject(GetPoolRootName(addressableKey)).transform; + poolRoot.SetParent(_poolContainer, false); + + pool = new ObjectPool(addressableKey, this, _addressableManager, poolRoot, memoryReleaseTTL); + _pools[addressableKey] = pool; + return pool; + } + + private void UnregisterPoolInstances(ObjectPool pool) + { + foreach (GameObject instance in pool.GetKnownInstances()) + { + if (instance != null) + { + _instanceToPool.Remove(instance); + } + } + } + + private static string GetPoolRootName(string addressableKey) + { + string name = addressableKey.Replace('\\', '/'); + int lastSlash = name.LastIndexOf('/'); + if (lastSlash >= 0 && lastSlash < name.Length - 1) + { + name = name.Substring(lastSlash + 1); + } + + return $"Pool - {name}"; + } + + protected override void OnDestroy() + { + if (_addressableManager != null) + { + _addressableManager.OnDispose -= ReleaseAllPools; + } + + ReleaseAllPools(); + base.OnDestroy(); + } + } +} diff --git a/Assets/PerfectWorld/Scripts/PoolingManager/PoolManager.cs.meta b/Assets/PerfectWorld/Scripts/PoolingManager/PoolManager.cs.meta new file mode 100644 index 0000000000..b215665d99 --- /dev/null +++ b/Assets/PerfectWorld/Scripts/PoolingManager/PoolManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b8e790a183861dd40bd580421c793038 diff --git a/Documentation/addressable-manager.md b/Documentation/addressable-manager.md new file mode 100644 index 0000000000..3e331550f4 --- /dev/null +++ b/Documentation/addressable-manager.md @@ -0,0 +1,201 @@ +# AddressableManager Public API + +`AddressableManager` is a Unity `MonoSingleton` wrapper around Unity Addressables. It initializes the Addressables system, loads selected asset types, caches loaded handles, and exposes methods for delayed or immediate release. + +This document lists only the public functions from `Assets/PerfectWorld/Scripts/Addressable/AddressableManager.cs`. + +## `bool IsInitialized()` + +Returns whether the Addressables initialization callback has completed successfully. + +Use this when another system needs to quickly check if `AddressableManager` is ready before requesting assets. It returns `true` only after `Addressables.InitializeAsync()` finishes with `AsyncOperationStatus.Succeeded`. + +If initialization fails, this method remains `false`. + +## `UniTask WaitUntilInitializedAsync()` + +Waits until `AddressableManager` has finished initializing Unity Addressables. + +If initialization has already completed, this returns `UniTask.CompletedTask` immediately. Otherwise, it waits on the internal initialization completion source that is resolved by the Addressables initialization callback. + +Call this before loading assets from systems that may start before Addressables is ready. + +Example usage: + +```csharp +await AddressableManager.Instance.WaitUntilInitializedAsync(); +``` + +## `Task LoadTextAssetAsync(string assetPath)` + +Loads a `TextAsset` asynchronously through Unity Addressables. + +`assetPath` must match a valid Addressables key, normally the asset address. The method first increases the internal reference count for this path and removes the path from the delayed-release list so the asset will not be released while it is being used again. + +If the text asset was already loaded and the cached handle is still valid, the method returns the cached `TextAsset` immediately. If no valid cached handle exists, it calls: + +```csharp +Addressables.LoadAssetAsync(assetPath) +``` + +After loading completes, the handle is stored in the text asset cache and the loaded `TextAsset` is returned. + +If loading throws an exception, the method logs an error and returns `null`. + +Important behavior: + +- The loaded asset is cached by `assetPath`. +- Repeated calls for the same valid path reuse the cached handle. +- The method may return `null` when the asset cannot be loaded. +- Release is not automatic after loading. Call a release method when the asset is no longer needed. + +## `Task LoadPrefabAsync(string assetPath)` + +Loads a prefab asynchronously through Unity Addressables and returns it as a `GameObject`. + +`assetPath` must match a valid Addressables key for a `GameObject` prefab. Before loading, the method removes the path from the delayed-release list. It then calls `KeyExists(assetPath, typeof(GameObject))`; if the key is considered missing, it logs a warning and returns `null`. + +If the prefab is already cached and the cached handle is valid, the method returns the cached prefab immediately. Otherwise, it calls: + +```csharp +Addressables.LoadAssetAsync(assetPath) +``` + +After loading completes successfully, the handle is stored in the prefab cache and the prefab `GameObject` is returned. + +If the Addressables operation reports an exception, or if an exception is thrown while loading, the method logs the failure and returns `null`. In the Unity Editor, failed paths are added to an invalid-path cache so later `KeyExists` checks can reject them quickly. + +Important behavior: + +- This loads the prefab asset itself, not an instantiated scene object. +- Callers that need an instance should instantiate the returned prefab separately. +- The loaded prefab handle is cached by `assetPath`. +- The method may return `null` when the key does not exist or loading fails. + +## `Task LoadAudioClipAsync(string assetPath)` + +Loads an `AudioClip` asynchronously through Unity Addressables. + +`assetPath` must match a valid Addressables key for an audio asset. The method removes the path from the delayed-release list before checking the cache. + +If the audio clip is already cached and the cached handle is valid, the method returns the cached `AudioClip` immediately. Otherwise, it calls: + +```csharp +Addressables.LoadAssetAsync(assetPath.Trim()) +``` + +After loading completes successfully, the handle is stored in the audio cache and the loaded `AudioClip` is returned. + +If the Addressables operation reports an exception, or if an exception is thrown while loading, the method logs the failure and returns `null`. + +Important behavior: + +- The Addressables load uses `assetPath.Trim()`. +- The cache key is the original `assetPath` string. +- Repeated calls for the same cached path can return immediately. +- The method may return `null` when loading fails. + +## `bool TryGetCachedAudioClip(string assetPath, out AudioClip clip)` + +Attempts to retrieve an already loaded `AudioClip` from the internal audio cache without starting a new Addressables load. + +If `assetPath` is null or empty, the method returns `false` and sets `clip` to `null`. + +If the audio cache contains a valid handle for `assetPath` and the handle has a non-null result, the method assigns that cached `AudioClip` to `clip` and returns `true`. + +If no valid cached audio clip exists, the method returns `false` and leaves `clip` as `null`. + +Use this when a caller wants to play audio immediately if it is already loaded, while avoiding an async load path. + +## `void ReleaseAsset(string assetPath)` + +Marks an asset path for delayed release. + +This method does not immediately call `Addressables.Release`. Instead, it records a timestamp in the delayed-release dictionary. During `Update`, once the configured timeout has elapsed, the manager force releases the asset by path. + +If the same path is released multiple times before it is force released, the earliest release timestamp is kept. + +Important behavior: + +- This is a delayed release request. +- The asset remains cached until the release timeout expires. +- If the asset is loaded again before timeout, load methods remove it from the delayed-release list. +- The path must match the same key used to cache the asset. + +## `void ForceReleaseAsset(string assetPath)` + +Immediately releases a cached asset by path. + +The method checks the prefab cache first, then the text asset cache, then the audio cache. If the path exists in one of those caches and the stored handle is valid, it calls: + +```csharp +Addressables.Release(handle) +``` + +After releasing, the method removes the path from the matching cache. For audio assets, it also logs that the audio asset was force released. + +Important behavior: + +- This releases immediately, unlike `ReleaseAsset(string assetPath)`. +- Only one matching cache entry is released because the method uses an `if / else if` chain. +- If the path is not found in any cache, the method does nothing. + +## `void ReleaseAsset(AsyncOperationHandle handle)` + +Releases a specific `GameObject` Addressables operation handle directly. + +If the provided handle is valid, the method calls: + +```csharp +Addressables.Release(handle) +``` + +This overload does not remove entries from `AddressableManager`'s internal prefab cache because it does not know which `assetPath` the handle belongs to. Prefer releasing by `assetPath` when the asset was loaded through `AddressableManager` and should also be removed from its cache. + +## `void ReleaseAllAssets()` + +Immediately releases all cached prefab and audio assets. + +The method iterates through the prefab cache and releases each valid prefab handle. It then clears the prefab cache. After that, it iterates through the audio cache, releases each valid audio handle, and clears the audio cache. + +Important behavior: + +- This releases prefab and audio caches. +- It does not currently release cached text assets from `_loadedTextAssets`. +- It logs `"AddressableManager: Released all assets"` after completing. +- `OnDestroy` calls this method automatically. + +## `bool IsAssetLoaded(string assetPath)` + +Checks whether a prefab asset path is currently loaded in the prefab cache. + +The method returns `true` only when `_loadedPrefabAssets` contains `assetPath` and the stored handle is valid. + +Important behavior: + +- This checks only loaded prefab assets. +- It does not check cached text assets. +- It does not check cached audio assets. + +## `static bool KeyExists(object key, System.Type type = null)` + +Checks whether an Addressables key exists. + +In non-editor builds, the method iterates through all loaded Addressables resource locators and calls: + +```csharp +locator.Locate(key, type, out var locations) +``` + +It returns `true` when a locator finds at least one matching resource location. If no locator finds a match, it returns `false`. + +The optional `type` parameter can restrict the lookup to a specific asset type, such as `typeof(GameObject)`. + +In the Unity Editor, this method currently returns `true` unless the key string exists in the manager's `_invalidAssetPaths` set. That means editor behavior is based on paths that previously failed to load, not a full catalog lookup. + +Important behavior: + +- Runtime builds perform a locator-based catalog lookup. +- Editor builds use the invalid-path cache. +- `LoadPrefabAsync` uses this method before attempting to load a prefab. +- Passing a type helps avoid matching an address that exists for a different asset kind. diff --git a/Documentation/poolingManager.md b/Documentation/poolingManager.md new file mode 100644 index 0000000000..a586763d7e --- /dev/null +++ b/Documentation/poolingManager.md @@ -0,0 +1,74 @@ +# Pooling Manager Execution Flow + +## Purpose + +The pooling system recycles Addressables-backed `GameObject` instances instead of repeatedly calling `Instantiate` and `Destroy`. Each Addressables key owns one runtime pool that tracks active objects, idle objects, and a memory-release timer. + +`PoolManager` loads prefab assets through `AddressableManager`, so it uses the same Addressables initialization path and loaded-prefab cache as the rest of the project. Runtime instances are ordinary instantiated `GameObject`s owned by the pool. + +## Runtime Files + +- `IPoolable.cs`: prefab-side lifecycle interface. +- `ObjectPool.cs`: owns instances and the loaded prefab reference for one Addressables key. +- `PoolManager.cs`: singleton service used by gameplay, server packet handlers, UI code, and effects systems. + +## Spawn Flow + +1. Call `PoolManager.Instance.SpawnAsync(...)` or `PoolManager.Instance.Spawn(...)`. +2. `PoolManager` finds or creates an `ObjectPool` for the Addressables key. +3. The pool updates its `memoryReleaseTTL` from the spawn parameter. +4. If a memory release countdown is running, the pool cancels it. +5. The pool reuses an idle object when available. +6. If no idle object exists, the pool waits for `AddressableManager` initialization, loads the prefab with `AddressableManager.LoadPrefabAsync()`, then instantiates it. +7. The instance is parented, positioned, rotated, activated, and all `IPoolable.OnSpawn()` hooks are called. +8. If `autoDespawnTime > 0`, `PoolManager` starts a version-checked auto-despawn coroutine. +9. The spawned `GameObject` is returned to the caller. + +## Despawn Flow + +1. Call `PoolManager.Instance.Despawn(gameObject)` or `PoolManager.Instance.Despawn(addressableKey, gameObject)`. +2. The target pool validates that the object is currently active. +3. All `IPoolable.OnDespawn()` hooks are called. +4. The object is deactivated, parented under the pool root, and pushed into the idle stack. +5. When the active count reaches zero, the pool starts the memory release countdown. + +## Memory Release Flow + +1. The countdown waits for the latest `memoryReleaseTTL` value supplied by spawn calls for that key. +2. A new spawn for the same key cancels the countdown and keeps the prefab plus idle instances available. +3. If the countdown completes while active count is still zero, the pool: + - unregisters itself from `PoolManager`; + - destroys all pooled instances; + - calls `AddressableManager.ForceReleaseAsset(addressableKey)` to release the cached prefab asset; + - destroys the pool root object. + +## Auto Despawn Safety + +Auto despawn stores the instance spawn version when the coroutine starts. If the object is manually despawned and reused before the timer completes, the version changes and the old coroutine will not despawn the new lifecycle. + +## Shutdown Flow + +`PoolManager` subscribes to `AddressableManager.OnDispose`. When the Addressables manager is disposed, all pools release their instances and prefab assets before `AddressableManager.ReleaseAllAssets()` runs. `PoolManager.OnDestroy()` also releases all pools as a fallback. + +## Example + +```csharp +GameObject fx = await PoolManager.Instance.SpawnAsync( + "effects/fireball.prefab", + hitPosition, + Quaternion.identity, + memoryReleaseTTL: 15f, + autoDespawnTime: 2f); +``` + +For coroutine-based callers: + +```csharp +PoolManager.Instance.Spawn( + "effects/fireball.prefab", + hitPosition, + Quaternion.identity, + memoryReleaseTTL: 15f, + autoDespawnTime: 2f, + onComplete: spawned => { /* use spawned */ }); +```