Initial Push
This commit is contained in:
8
Assets/Darkmatter/Code/Services/Analytics.meta
Normal file
8
Assets/Darkmatter/Code/Services/Analytics.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5b95e7a64fe76415199b8f7f0a875416
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8c274558c24334f2b9c9ebd58dce91ad
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,17 @@
|
||||
using Darkmatter.Libs.Installers;
|
||||
using UnityEngine;
|
||||
using VContainer;
|
||||
using VContainer.Unity;
|
||||
|
||||
namespace Darkmatter.Services.Analytics
|
||||
{
|
||||
public class AnalyticsServiceModule : MonoBehaviour, IServiceModule
|
||||
{
|
||||
public void Register(IContainerBuilder builder)
|
||||
{
|
||||
#if FIREBASE_ANALYTICS_PRESENT
|
||||
builder.RegisterEntryPoint<FirebaseAnalyticsSystem>();
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4e89fdd4696924b7facccda23a94a978
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "Services.Analytics",
|
||||
"rootNamespace": "Darkmatter.Services.Analytics",
|
||||
"references": [
|
||||
"GUID:bd7ea2d41bfe64d229c22616f66e20f7",
|
||||
"GUID:c1c03c0e5b2f4412b9f2be1c20d6a9b1",
|
||||
"GUID:f51ebe6a0ceec4240a699833d6309b23",
|
||||
"GUID:b0214a6008ed146ff8f122a6a9c2f6cc",
|
||||
"GUID:f8c64bb88d959406689053ae3f31183d",
|
||||
"GUID:a0b1547602fc44f6da0a5e755ab3a7ef"
|
||||
],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 948fb74de8df14f81a7ee6f41676a8f2
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/Darkmatter/Code/Services/Analytics/Systems.meta
Normal file
8
Assets/Darkmatter/Code/Services/Analytics/Systems.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d9f4a46dc47ab4f95a47c9fdeda9e532
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,46 @@
|
||||
#if FIREBASE_ANALYTICS
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using Firebase.Crashlytics;
|
||||
using VContainer.Unity;
|
||||
|
||||
namespace Darkmatter.Services.Analytics
|
||||
{
|
||||
public class FirebaseAnalyticsSystem : IAsyncStartable
|
||||
{
|
||||
public async UniTask StartAsync(CancellationToken cancellation = new CancellationToken())
|
||||
{
|
||||
#if !UNITY_EDITOR
|
||||
await Firebase.FirebaseApp.CheckAndFixDependenciesAsync().ContinueWith(task =>
|
||||
{
|
||||
var dependencyStatus = task.Result;
|
||||
if (dependencyStatus == Firebase.DependencyStatus.Available)
|
||||
{
|
||||
// Create and hold a reference to your FirebaseApp,
|
||||
// where app is a Firebase.FirebaseApp property of your application class.
|
||||
// Crashlytics will use the DefaultInstance, as well;
|
||||
// this ensures that Crashlytics is initialized.
|
||||
Firebase.FirebaseApp app = Firebase.FirebaseApp.DefaultInstance;
|
||||
#if DEVELOPMENT_BUILD
|
||||
Firebase.Crashlytics.Crashlytics.SetCustomKey("environment", "dev");
|
||||
#else
|
||||
Firebase.Crashlytics.Crashlytics.SetCustomKey("environment", "prod");
|
||||
#endif
|
||||
// When this property is set to true, Crashlytics will report all
|
||||
// uncaught exceptions as fatal events. This is the recommended behavior.
|
||||
Crashlytics.ReportUncaughtExceptionsAsFatal = true;
|
||||
|
||||
// Set a flag here for indicating that your project is ready to use Firebase.
|
||||
}
|
||||
else
|
||||
{
|
||||
UnityEngine.Debug.LogError(System.String.Format(
|
||||
"Could not resolve all Firebase dependencies: {0}", dependencyStatus));
|
||||
// Firebase Unity SDK is not safe to use here.
|
||||
}
|
||||
}, cancellation);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 64d80817f416c45f8b9e32d38809632c
|
||||
8
Assets/Darkmatter/Code/Services/Assets.meta
Normal file
8
Assets/Darkmatter/Code/Services/Assets.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 87bd9530ab04c41b7950c7bf4d0d4695
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,524 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using Darkmatter.Core.Contracts.Services.Assets;
|
||||
using UnityEngine.ResourceManagement.AsyncOperations;
|
||||
using UnityEngine.ResourceManagement.ResourceLocations;
|
||||
using UnityEngine;
|
||||
using UnityEngine.AddressableAssets;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
namespace Darkmatter.Services.Assets
|
||||
{
|
||||
public class AddressableAssetProviderService : IAssetProviderService
|
||||
{
|
||||
private readonly AddressableLoadHandleTracker _loadHandleTracker = new();
|
||||
private readonly Dictionary<GameObject, AsyncOperationHandle<GameObject>> _spawnedInstances = new();
|
||||
|
||||
public void UnloadAsset(string assetId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(assetId))
|
||||
{
|
||||
Debug.LogError("[AddressablesAssetProvider] Asset key is null or empty.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!_loadHandleTracker.TryRelease(assetId))
|
||||
{
|
||||
Debug.LogWarning(
|
||||
$"[AddressablesAssetProvider] Tried to unload asset '{assetId}', but no tracked load handle was found.");
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError(
|
||||
$"[AddressablesAssetProvider] Failed to unload asset with key: {assetId}. Exception: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
public async UniTask LoadExternalCatalogAsync(string catalogUrl, IProgress<float> progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(catalogUrl))
|
||||
{
|
||||
Debug.LogError("[AddressablesAssetProvider] Catalog url is null or empty.");
|
||||
return;
|
||||
}
|
||||
|
||||
AsyncOperationHandle handle = default;
|
||||
|
||||
try
|
||||
{
|
||||
handle = Addressables.LoadContentCatalogAsync(catalogUrl);
|
||||
|
||||
while (!handle.IsDone)
|
||||
{
|
||||
progress?.Report(handle.PercentComplete);
|
||||
await UniTask.Yield(PlayerLoopTiming.Update, cancellationToken);
|
||||
}
|
||||
|
||||
await handle.ToUniTask(cancellationToken: cancellationToken);
|
||||
|
||||
progress?.Report(1f);
|
||||
|
||||
Debug.Log($"[AddressablesAssetProvider] Successfully mounted external mod catalog from {catalogUrl}");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError(
|
||||
$"[AddressablesAssetProvider] Failed to mount external catalog from {catalogUrl}. Exception: {e}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (handle.IsValid())
|
||||
Addressables.Release(handle);
|
||||
}
|
||||
}
|
||||
|
||||
public async UniTask<GameObject> InstantiateAsync(
|
||||
string assetId,
|
||||
Vector3 position,
|
||||
Quaternion rotation,
|
||||
IProgress<float> progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(assetId))
|
||||
{
|
||||
Debug.LogError("[AddressablesAssetProvider] Asset key is null or empty.");
|
||||
return null;
|
||||
}
|
||||
|
||||
AsyncOperationHandle<IList<IResourceLocation>> locationsHandle = default;
|
||||
AsyncOperationHandle<GameObject> handle = default;
|
||||
|
||||
try
|
||||
{
|
||||
locationsHandle = Addressables.LoadResourceLocationsAsync(assetId);
|
||||
await locationsHandle.ToUniTask(cancellationToken: cancellationToken);
|
||||
|
||||
if (locationsHandle.Result == null || locationsHandle.Result.Count == 0)
|
||||
{
|
||||
Debug.LogWarning(
|
||||
$"[AddressablesAssetProvider] No location found for key: {assetId}. Instantiate skipped.");
|
||||
return null;
|
||||
}
|
||||
|
||||
handle = Addressables.InstantiateAsync(assetId, position, rotation);
|
||||
while (!handle.IsDone)
|
||||
{
|
||||
progress?.Report(handle.PercentComplete);
|
||||
await UniTask.Yield(PlayerLoopTiming.Update, cancellationToken);
|
||||
}
|
||||
|
||||
await handle.ToUniTask(cancellationToken: cancellationToken);
|
||||
|
||||
progress?.Report(1f);
|
||||
|
||||
var instance = handle.Result;
|
||||
|
||||
if (instance != null)
|
||||
{
|
||||
_spawnedInstances[instance] = handle;
|
||||
return instance;
|
||||
}
|
||||
|
||||
Debug.LogError($"[AddressablesAssetProvider] Failed to load asset with key: {assetId}");
|
||||
return null;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (handle.IsValid())
|
||||
Addressables.Release(handle);
|
||||
|
||||
throw;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
if (handle.IsValid())
|
||||
Addressables.Release(handle);
|
||||
|
||||
Debug.LogError($"[AddressablesAssetProvider] Failed to instantiate asset with key: {assetId}. Exception: {e}");
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (locationsHandle.IsValid())
|
||||
Addressables.Release(locationsHandle);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async UniTask<GameObject> InstantiateAsync(string assetId, IProgress<float> progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await InstantiateAsync(assetId, Vector3.zero, Quaternion.identity, progress, cancellationToken);
|
||||
}
|
||||
|
||||
public async UniTask<T> LoadAssetAsync<T>(string assetId, IProgress<float> progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(assetId))
|
||||
{
|
||||
Debug.LogError("[AddressablesAssetProvider] Asset key is null or empty.");
|
||||
return default;
|
||||
}
|
||||
|
||||
AsyncOperationHandle<IList<IResourceLocation>> locationsHandle = default;
|
||||
AsyncOperationHandle<T> handle = default;
|
||||
|
||||
try
|
||||
{
|
||||
locationsHandle = Addressables.LoadResourceLocationsAsync(assetId);
|
||||
await locationsHandle.ToUniTask(cancellationToken: cancellationToken);
|
||||
|
||||
if (locationsHandle.Result == null || locationsHandle.Result.Count == 0)
|
||||
{
|
||||
Debug.LogWarning(
|
||||
$"[AddressablesAssetProvider] No location found for key: {assetId}. Returning default value.");
|
||||
return default;
|
||||
}
|
||||
|
||||
handle = Addressables.LoadAssetAsync<T>(assetId);
|
||||
while (!handle.IsDone)
|
||||
{
|
||||
progress?.Report(handle.PercentComplete);
|
||||
await UniTask.Yield(PlayerLoopTiming.Update, cancellationToken);
|
||||
}
|
||||
|
||||
await handle.ToUniTask(cancellationToken: cancellationToken);
|
||||
progress?.Report(1f);
|
||||
if (ReferenceEquals(handle.Result, null))
|
||||
{
|
||||
Addressables.Release(handle);
|
||||
return default;
|
||||
}
|
||||
|
||||
_loadHandleTracker.Track(handle, new[] { NormalizeReleaseKey(assetId) });
|
||||
return handle.Result;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (handle.IsValid())
|
||||
Addressables.Release(handle);
|
||||
|
||||
throw;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
if (handle.IsValid())
|
||||
Addressables.Release(handle);
|
||||
|
||||
Debug.LogError($"[AddressablesAssetProvider] Failed to load asset with key: {assetId}. Exception: {e}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (locationsHandle.IsValid())
|
||||
Addressables.Release(locationsHandle);
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
public async UniTask<IReadOnlyList<T>> LoadAssetsAsync<T>(
|
||||
IProgress<float> progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var locations = CollectLocationsForType<T>();
|
||||
if (locations.Count == 0)
|
||||
{
|
||||
Debug.LogWarning(
|
||||
$"[AddressablesAssetProvider] No addressable locations found for type {typeof(T).Name}. Returning empty result.");
|
||||
return Array.Empty<T>();
|
||||
}
|
||||
|
||||
AsyncOperationHandle<IList<T>> handle = default;
|
||||
|
||||
try
|
||||
{
|
||||
handle = Addressables.LoadAssetsAsync<T>(locations, null);
|
||||
while (!handle.IsDone)
|
||||
{
|
||||
progress?.Report(handle.PercentComplete);
|
||||
await UniTask.Yield(PlayerLoopTiming.Update, cancellationToken);
|
||||
}
|
||||
|
||||
await handle.ToUniTask(cancellationToken: cancellationToken);
|
||||
progress?.Report(1f);
|
||||
|
||||
if (handle.Result == null || handle.Result.Count == 0)
|
||||
{
|
||||
if (handle.IsValid())
|
||||
{
|
||||
Addressables.Release(handle);
|
||||
}
|
||||
|
||||
return Array.Empty<T>();
|
||||
}
|
||||
|
||||
var assets = new List<T>(handle.Result.Count);
|
||||
for (var i = 0; i < handle.Result.Count; i++)
|
||||
{
|
||||
if (handle.Result[i] != null)
|
||||
{
|
||||
assets.Add(handle.Result[i]);
|
||||
}
|
||||
}
|
||||
|
||||
if (assets.Count == 0)
|
||||
{
|
||||
if (handle.IsValid())
|
||||
{
|
||||
Addressables.Release(handle);
|
||||
}
|
||||
|
||||
return Array.Empty<T>();
|
||||
}
|
||||
|
||||
_loadHandleTracker.Track(handle, CollectLocationReleaseKeys(locations));
|
||||
return assets;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (handle.IsValid())
|
||||
Addressables.Release(handle);
|
||||
|
||||
throw;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
if (handle.IsValid())
|
||||
Addressables.Release(handle);
|
||||
|
||||
Debug.LogError(
|
||||
$"[AddressablesAssetProvider] Failed to load assets for type {typeof(T).Name}. Exception: {e}");
|
||||
}
|
||||
|
||||
return Array.Empty<T>();
|
||||
}
|
||||
|
||||
public async UniTask<IReadOnlyList<T>> LoadAssetsAsync<T>(
|
||||
object key,
|
||||
IProgress<float> progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (key == null)
|
||||
{
|
||||
Debug.LogError("[AddressablesAssetProvider] Asset key is null.");
|
||||
return Array.Empty<T>();
|
||||
}
|
||||
|
||||
AsyncOperationHandle<IList<IResourceLocation>> locationsHandle = default;
|
||||
AsyncOperationHandle<IList<T>> handle = default;
|
||||
|
||||
try
|
||||
{
|
||||
locationsHandle = Addressables.LoadResourceLocationsAsync(key, typeof(T));
|
||||
await locationsHandle.ToUniTask(cancellationToken: cancellationToken);
|
||||
|
||||
if (locationsHandle.Result == null || locationsHandle.Result.Count == 0)
|
||||
{
|
||||
Debug.LogWarning(
|
||||
$"[AddressablesAssetProvider] No addressable locations found for key '{key}' and type {typeof(T).Name}. Returning empty result.");
|
||||
return Array.Empty<T>();
|
||||
}
|
||||
|
||||
var locations = locationsHandle.Result;
|
||||
handle = Addressables.LoadAssetsAsync<T>(locations, null);
|
||||
while (!handle.IsDone)
|
||||
{
|
||||
progress?.Report(handle.PercentComplete);
|
||||
await UniTask.Yield(PlayerLoopTiming.Update, cancellationToken);
|
||||
}
|
||||
|
||||
await handle.ToUniTask(cancellationToken: cancellationToken);
|
||||
progress?.Report(1f);
|
||||
|
||||
if (handle.Result == null || handle.Result.Count == 0)
|
||||
{
|
||||
if (handle.IsValid())
|
||||
{
|
||||
Addressables.Release(handle);
|
||||
}
|
||||
|
||||
return Array.Empty<T>();
|
||||
}
|
||||
|
||||
var assets = new List<T>(handle.Result.Count);
|
||||
foreach (var item in handle.Result)
|
||||
{
|
||||
if (item != null)
|
||||
{
|
||||
assets.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (assets.Count == 0)
|
||||
{
|
||||
if (handle.IsValid())
|
||||
{
|
||||
Addressables.Release(handle);
|
||||
}
|
||||
|
||||
return Array.Empty<T>();
|
||||
}
|
||||
|
||||
_loadHandleTracker.Track(handle, new[] { NormalizeReleaseKey(key) });
|
||||
return assets;
|
||||
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (handle.IsValid())
|
||||
Addressables.Release(handle);
|
||||
throw;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
if (handle.IsValid())
|
||||
Addressables.Release(handle);
|
||||
|
||||
Debug.LogError(
|
||||
$"[AddressablesAssetProvider] Failed to load assets for key '{key}' and type {typeof(T).Name}. Exception: {e}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (locationsHandle.IsValid())
|
||||
Addressables.Release(locationsHandle);
|
||||
}
|
||||
|
||||
return Array.Empty<T>();
|
||||
}
|
||||
|
||||
public void ReleaseInstance(GameObject instance)
|
||||
{
|
||||
if (instance == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
if (_spawnedInstances.TryGetValue(instance, out var handle))
|
||||
{
|
||||
Addressables.ReleaseInstance(handle);
|
||||
_spawnedInstances.Remove(instance);
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning(
|
||||
$"[AddressablesAssetProvider] Tried to release {instance.name}, but it was not tracked by this service.");
|
||||
Object.Destroy(instance);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError(
|
||||
$"[AddressablesAssetProvider] Failed to release instance {instance.name}. Exception: {e}");
|
||||
Object.Destroy(instance);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<IResourceLocation> CollectLocationsForType<T>()
|
||||
{
|
||||
var locationsByKey = new Dictionary<string, IResourceLocation>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var locator in Addressables.ResourceLocators)
|
||||
{
|
||||
if (locator == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var key in locator.Keys)
|
||||
{
|
||||
if (key == null || !locator.Locate(key, typeof(T), out var locations) || locations == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
for (var i = 0; i < locations.Count; i++)
|
||||
{
|
||||
var location = locations[i];
|
||||
if (location == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var locationKey = GetLocationKey(location);
|
||||
locationsByKey.TryAdd(locationKey, location);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new List<IResourceLocation>(locationsByKey.Values);
|
||||
}
|
||||
|
||||
private static List<string> CollectLocationReleaseKeys(IReadOnlyList<IResourceLocation> locations)
|
||||
{
|
||||
var releaseKeys = new List<string>(locations.Count);
|
||||
var uniqueReleaseKeys = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
for (var i = 0; i < locations.Count; i++)
|
||||
{
|
||||
var releaseKey = ResolveLocationReleaseKey(locations[i]);
|
||||
if (string.IsNullOrWhiteSpace(releaseKey) || !uniqueReleaseKeys.Add(releaseKey))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
releaseKeys.Add(releaseKey);
|
||||
}
|
||||
|
||||
return releaseKeys;
|
||||
}
|
||||
|
||||
private static string NormalizeReleaseKey(object key)
|
||||
{
|
||||
return key switch
|
||||
{
|
||||
string stringKey => stringKey.Trim(),
|
||||
null => string.Empty,
|
||||
_ => key.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveLocationReleaseKey(IResourceLocation location)
|
||||
{
|
||||
if (location == null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(location.PrimaryKey))
|
||||
{
|
||||
return location.PrimaryKey;
|
||||
}
|
||||
|
||||
return GetLocationKey(location);
|
||||
}
|
||||
|
||||
private static string GetLocationKey(IResourceLocation location)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(location.PrimaryKey) && !string.IsNullOrWhiteSpace(location.InternalId))
|
||||
{
|
||||
return $"{location.PrimaryKey}|{location.InternalId}";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(location.PrimaryKey))
|
||||
{
|
||||
return location.PrimaryKey;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(location.InternalId))
|
||||
{
|
||||
return location.InternalId;
|
||||
}
|
||||
|
||||
return location.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cb67df01f142d4eb8a2e6d020ba455c3
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine.AddressableAssets;
|
||||
using UnityEngine.ResourceManagement.AsyncOperations;
|
||||
|
||||
namespace Darkmatter.Services.Assets
|
||||
{
|
||||
internal sealed class AddressableLoadHandleTracker
|
||||
{
|
||||
private readonly Dictionary<string, Stack<TrackedHandleLease>> _leasesByReleaseKey =
|
||||
new(System.StringComparer.Ordinal);
|
||||
|
||||
public void Track(AsyncOperationHandle handle, IEnumerable<string> releaseKeys)
|
||||
{
|
||||
if (!handle.IsValid() || releaseKeys == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var uniqueReleaseKeys = new List<string>();
|
||||
var seenReleaseKeys = new HashSet<string>(System.StringComparer.Ordinal);
|
||||
|
||||
foreach (var releaseKey in releaseKeys)
|
||||
{
|
||||
var normalizedReleaseKey = Normalize(releaseKey);
|
||||
if (string.IsNullOrWhiteSpace(normalizedReleaseKey) || !seenReleaseKeys.Add(normalizedReleaseKey))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
uniqueReleaseKeys.Add(normalizedReleaseKey);
|
||||
}
|
||||
|
||||
if (uniqueReleaseKeys.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var lease = new TrackedHandleLease(handle, uniqueReleaseKeys.Count);
|
||||
for (var i = 0; i < uniqueReleaseKeys.Count; i++)
|
||||
{
|
||||
var releaseKey = uniqueReleaseKeys[i];
|
||||
if (!_leasesByReleaseKey.TryGetValue(releaseKey, out var leases))
|
||||
{
|
||||
leases = new Stack<TrackedHandleLease>();
|
||||
_leasesByReleaseKey[releaseKey] = leases;
|
||||
}
|
||||
|
||||
leases.Push(lease);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryRelease(string releaseKey)
|
||||
{
|
||||
var normalizedReleaseKey = Normalize(releaseKey);
|
||||
if (string.IsNullOrWhiteSpace(normalizedReleaseKey) ||
|
||||
!_leasesByReleaseKey.TryGetValue(normalizedReleaseKey, out var leases))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
while (leases.Count > 0)
|
||||
{
|
||||
var lease = leases.Pop();
|
||||
if (lease.TryConsume())
|
||||
{
|
||||
if (leases.Count == 0)
|
||||
{
|
||||
_leasesByReleaseKey.Remove(normalizedReleaseKey);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
_leasesByReleaseKey.Remove(normalizedReleaseKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string Normalize(string releaseKey)
|
||||
{
|
||||
return releaseKey?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
private sealed class TrackedHandleLease
|
||||
{
|
||||
private readonly AsyncOperationHandle _handle;
|
||||
private int _remainingReleaseCount;
|
||||
private bool _released;
|
||||
|
||||
public TrackedHandleLease(AsyncOperationHandle handle, int remainingReleaseCount)
|
||||
{
|
||||
_handle = handle;
|
||||
_remainingReleaseCount = remainingReleaseCount;
|
||||
}
|
||||
|
||||
public bool TryConsume()
|
||||
{
|
||||
if (_released || _remainingReleaseCount <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_remainingReleaseCount--;
|
||||
if (_remainingReleaseCount == 0)
|
||||
{
|
||||
if (_handle.IsValid())
|
||||
{
|
||||
Addressables.Release(_handle);
|
||||
}
|
||||
|
||||
_released = true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 18a4f8e4a6db4a7fa43c517655dc87bf
|
||||
8
Assets/Darkmatter/Code/Services/Assets/Docs.meta
Normal file
8
Assets/Darkmatter/Code/Services/Assets/Docs.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ae3d74d6f9871478a9c165a6024c6e51
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
17
Assets/Darkmatter/Code/Services/Assets/Docs/AssetsService.md
Normal file
17
Assets/Darkmatter/Code/Services/Assets/Docs/AssetsService.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Assets Service
|
||||
|
||||
## Purpose
|
||||
- Provides Addressables-backed asset instantiation/loading helpers for runtime systems.
|
||||
- Centralizes load-handle tracking and asset-provider concerns in one infrastructure slice.
|
||||
|
||||
## Public Entry Points
|
||||
- `AddressableAssetProviderService`
|
||||
- `AddressableLoadHandleTracker`
|
||||
|
||||
## Dependencies
|
||||
- Consumed through asset-provider contracts from App and feature slices.
|
||||
- Must not depend on gameplay features; it only serves loading/instantiation responsibilities.
|
||||
|
||||
## Extension Notes
|
||||
- Keep provider-level caching and handle cleanup in this service rather than in callers.
|
||||
- Add cross-assembly contracts in Core when features need new asset-provider capabilities.
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5cc48daf3de404c458b7f47611690f26
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "Services.Assets",
|
||||
"rootNamespace": "Darkmatter.Services.Assets",
|
||||
"references": [
|
||||
"GUID:6a0a834eb41764f12ba55c3fb04a40cb",
|
||||
"GUID:f51ebe6a0ceec4240a699833d6309b23",
|
||||
"GUID:593a5b492d29ac6448b1ebf7f035ef33",
|
||||
"GUID:9e24947de15b9834991c9d8411ea37cf",
|
||||
"GUID:84651a3751eca9349aac36a66bba901b"
|
||||
],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: efcaa22887a6b4471829c3b3878147a2
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/Darkmatter/Code/Services/Audio.meta
Normal file
8
Assets/Darkmatter/Code/Services/Audio.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2a0b84e2e04354fe1b6085bd24882bfb
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
768
Assets/Darkmatter/Code/Services/Audio/AudioService.cs
Normal file
768
Assets/Darkmatter/Code/Services/Audio/AudioService.cs
Normal file
@@ -0,0 +1,768 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using Darkmatter.Core.Contracts.Services.Audio;
|
||||
using Darkmatter.Core.Data.Dynamic.Services.Audio;
|
||||
using Darkmatter.Core.Enums.Services.Audio;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Audio;
|
||||
using UnityEngine.Networking;
|
||||
using UnityEngine.Serialization;
|
||||
|
||||
namespace Darkmatter.Services.Audio
|
||||
{
|
||||
public class AudioService : MonoBehaviour, IAudioService
|
||||
{
|
||||
|
||||
[SerializeField] private int pooledSourceCount = 32;
|
||||
[SerializeField] private Transform sourceRoot;
|
||||
[SerializeField] private AudioListener listener;
|
||||
|
||||
[Tooltip(
|
||||
"Max simultaneously playing sources on AISfx channel. <=0 disables cap. Excess plays steal the farthest (or oldest) AISfx source.")]
|
||||
[SerializeField, Min(0)]
|
||||
private int maxAISfxInstances = 16;
|
||||
|
||||
[Header("Audio Mixer")] [SerializeField]
|
||||
private AudioMixer audioMixer;
|
||||
|
||||
[SerializeField] private AudioMixerSnapshot[] snapshots;
|
||||
|
||||
[SerializeField, Min(0f), FormerlySerializedAs("cameraFxTransitionSeconds")]
|
||||
private float snapshotTransitionDuration = 0.35f;
|
||||
|
||||
[SerializeField, FormerlySerializedAs("cameraFxEasing")]
|
||||
private AnimationCurve snapshotEasing = AnimationCurve.EaseInOut(0f, 0f, 1f, 1f);
|
||||
|
||||
[Header("Audio Mixer Groups")] [SerializeField]
|
||||
private AudioMixerGroup sfxMixerGroup;
|
||||
|
||||
[SerializeField] private AudioMixerGroup vehicleSfxMixerGroup;
|
||||
[SerializeField] private AudioMixerGroup engineMixerGroup;
|
||||
[SerializeField] private AudioMixerGroup aiSfx;
|
||||
[SerializeField] private AudioMixerGroup passengerChatterMixerGroup;
|
||||
[SerializeField] private AudioMixerGroup musicMixerGroup;
|
||||
[SerializeField] private AudioMixerGroup ambienceMixerGroup;
|
||||
[SerializeField] private AudioMixerGroup weatherMixerGroup;
|
||||
[SerializeField] private AudioMixerGroup bgmMixerGroup;
|
||||
[SerializeField] private AudioMixerGroup uiMixerGroup;
|
||||
[SerializeField] private AudioMixerGroup radioMixerGroup;
|
||||
[SerializeField] private AudioMixerGroup vehicleBrakeGroup;
|
||||
|
||||
private class SourceContext
|
||||
{
|
||||
public AudioSource Source;
|
||||
public ushort Generation;
|
||||
public AudioChannel Channel;
|
||||
public bool PausedByService;
|
||||
public float BaseVolume = 1f;
|
||||
public float StartTime;
|
||||
}
|
||||
|
||||
private readonly List<SourceContext> _sources = new();
|
||||
private readonly HashSet<AudioChannel> _pausedChannels = new();
|
||||
private readonly Dictionary<AudioChannel, float> _channelVolumes = new();
|
||||
|
||||
private AudioListener _cachedListener;
|
||||
|
||||
private CancellationTokenSource _lifetimeCts;
|
||||
private CancellationTokenSource _snapshotBlendCts;
|
||||
private Dictionary<AudioChannel, AudioMixerGroup> _mixerGroups;
|
||||
private float[] _currentWeights;
|
||||
|
||||
public UniTask InitializeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (sourceRoot == null) sourceRoot = transform;
|
||||
_lifetimeCts = new CancellationTokenSource();
|
||||
|
||||
InitializeChannelVolumes();
|
||||
ResolveAudioMixer();
|
||||
InitializeMixerGroups();
|
||||
InitializeSnapshots();
|
||||
if (HasSnapshots()) ApplyWeights(_currentWeights);
|
||||
|
||||
for (int i = 0; i < pooledSourceCount; i++)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
break;
|
||||
ExpandPool();
|
||||
}
|
||||
|
||||
return UniTask.CompletedTask;
|
||||
}
|
||||
|
||||
public async UniTask<AudioClip> LoadClipFromPath(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
{
|
||||
Debug.LogWarning($"[AudioService] File not found: {path}");
|
||||
return null;
|
||||
}
|
||||
|
||||
string url = "file://" + path;
|
||||
var audioType = GetAudioType(path);
|
||||
|
||||
using var request = UnityWebRequestMultimedia.GetAudioClip(url, audioType);
|
||||
if (request.downloadHandler is DownloadHandlerAudioClip handler)
|
||||
{
|
||||
handler.streamAudio = true;
|
||||
}
|
||||
|
||||
var operation = request.SendWebRequest();
|
||||
|
||||
while (!operation.isDone && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await UniTask.Yield(PlayerLoopTiming.Update, cancellationToken);
|
||||
}
|
||||
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw new OperationCanceledException(cancellationToken);
|
||||
}
|
||||
|
||||
if (request.result is UnityWebRequest.Result.ConnectionError or UnityWebRequest.Result.ProtocolError)
|
||||
{
|
||||
Debug.LogError($"[AudioService] Error loading: {request.error} ({path})");
|
||||
return null;
|
||||
}
|
||||
|
||||
AudioClip clip = DownloadHandlerAudioClip.GetContent(request);
|
||||
if (clip == null)
|
||||
{
|
||||
Debug.LogError($"[AudioService] Failed to decode audio clip: {path}");
|
||||
return null;
|
||||
}
|
||||
|
||||
clip.name = Path.GetFileNameWithoutExtension(path);
|
||||
|
||||
return clip;
|
||||
}
|
||||
|
||||
private static AudioType GetAudioType(string path)
|
||||
{
|
||||
return Path.GetExtension(path).ToLowerInvariant() switch
|
||||
{
|
||||
".wav" => AudioType.WAV,
|
||||
".ogg" => AudioType.OGGVORBIS,
|
||||
_ => AudioType.MPEG
|
||||
};
|
||||
}
|
||||
|
||||
private void InitializeChannelVolumes()
|
||||
{
|
||||
foreach (AudioChannel channel in Enum.GetValues(typeof(AudioChannel)))
|
||||
{
|
||||
if (!_channelVolumes.ContainsKey(channel))
|
||||
{
|
||||
_channelVolumes[channel] = 1f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void InitializeMixerGroups()
|
||||
{
|
||||
sfxMixerGroup = ResolveMixerGroup(sfxMixerGroup, "SFX", null);
|
||||
vehicleSfxMixerGroup = ResolveMixerGroup(vehicleSfxMixerGroup, "Vehicle SFX", sfxMixerGroup);
|
||||
engineMixerGroup = ResolveMixerGroup(engineMixerGroup, "Engine", sfxMixerGroup);
|
||||
passengerChatterMixerGroup =
|
||||
ResolveMixerGroup(passengerChatterMixerGroup, "Passenger Chatter", sfxMixerGroup);
|
||||
aiSfx = ResolveMixerGroup(aiSfx, "AI SFX", sfxMixerGroup);
|
||||
musicMixerGroup = ResolveMixerGroup(musicMixerGroup, "Music", null);
|
||||
ambienceMixerGroup = ResolveMixerGroup(ambienceMixerGroup, "Ambience", null);
|
||||
weatherMixerGroup = ResolveMixerGroup(weatherMixerGroup, "Weather", ambienceMixerGroup);
|
||||
bgmMixerGroup = ResolveMixerGroup(bgmMixerGroup, "BGM", null);
|
||||
uiMixerGroup = ResolveMixerGroup(uiMixerGroup, "UI", null);
|
||||
radioMixerGroup = ResolveMixerGroup(radioMixerGroup, "Radio", null);
|
||||
vehicleBrakeGroup = ResolveMixerGroup(vehicleBrakeGroup, "Vehicle Brake", sfxMixerGroup);
|
||||
|
||||
_mixerGroups = new Dictionary<AudioChannel, AudioMixerGroup>
|
||||
{
|
||||
{ AudioChannel.Sfx, sfxMixerGroup },
|
||||
{ AudioChannel.VehicleSfx, vehicleSfxMixerGroup },
|
||||
{ AudioChannel.Engine, engineMixerGroup },
|
||||
{ AudioChannel.AISfx, aiSfx },
|
||||
{ AudioChannel.PassengerChatter, passengerChatterMixerGroup },
|
||||
{ AudioChannel.Music, musicMixerGroup },
|
||||
{ AudioChannel.Ambience, ambienceMixerGroup },
|
||||
{ AudioChannel.Weather, weatherMixerGroup },
|
||||
{ AudioChannel.BGM, bgmMixerGroup },
|
||||
{ AudioChannel.UI, uiMixerGroup },
|
||||
{ AudioChannel.Radio, radioMixerGroup },
|
||||
{ AudioChannel.VehicleBrake, vehicleBrakeGroup }
|
||||
};
|
||||
}
|
||||
|
||||
private AudioMixer ResolveAudioMixer()
|
||||
{
|
||||
if (audioMixer != null)
|
||||
{
|
||||
return audioMixer;
|
||||
}
|
||||
|
||||
audioMixer = ResolveAudioMixerFromGroup(sfxMixerGroup)
|
||||
?? ResolveAudioMixerFromGroup(vehicleSfxMixerGroup)
|
||||
?? ResolveAudioMixerFromGroup(engineMixerGroup)
|
||||
?? ResolveAudioMixerFromGroup(passengerChatterMixerGroup)
|
||||
?? ResolveAudioMixerFromGroup(musicMixerGroup)
|
||||
?? ResolveAudioMixerFromGroup(ambienceMixerGroup)
|
||||
?? ResolveAudioMixerFromGroup(weatherMixerGroup)
|
||||
?? ResolveAudioMixerFromGroup(bgmMixerGroup)
|
||||
?? ResolveAudioMixerFromGroup(uiMixerGroup);
|
||||
|
||||
return audioMixer;
|
||||
}
|
||||
|
||||
private static AudioMixer ResolveAudioMixerFromGroup(AudioMixerGroup group)
|
||||
{
|
||||
return group != null ? group.audioMixer : null;
|
||||
}
|
||||
|
||||
private AudioMixerGroup ResolveMixerGroup(AudioMixerGroup configuredGroup, string groupName,
|
||||
AudioMixerGroup fallback)
|
||||
{
|
||||
if (configuredGroup != null)
|
||||
{
|
||||
return configuredGroup;
|
||||
}
|
||||
|
||||
AudioMixer mixer = ResolveAudioMixer();
|
||||
if (mixer == null)
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
AudioMixerGroup[] matches = mixer.FindMatchingGroups(groupName);
|
||||
for (int i = 0; i < matches.Length; i++)
|
||||
{
|
||||
if (matches[i] != null && matches[i].name == groupName)
|
||||
{
|
||||
return matches[i];
|
||||
}
|
||||
}
|
||||
|
||||
return matches.Length > 0 ? matches[0] : fallback;
|
||||
}
|
||||
|
||||
private void InitializeSnapshots()
|
||||
{
|
||||
if (snapshots != null && snapshots.Length > 0 && _currentWeights == null)
|
||||
{
|
||||
_currentWeights = new float[snapshots.Length];
|
||||
_currentWeights[0] = 1f;
|
||||
}
|
||||
}
|
||||
|
||||
private void ExpandPool()
|
||||
{
|
||||
var go = new GameObject($"AudioSource_{_sources.Count}");
|
||||
go.transform.SetParent(sourceRoot);
|
||||
var source = go.AddComponent<AudioSource>();
|
||||
source.playOnAwake = false;
|
||||
source.dopplerLevel = 0f;
|
||||
|
||||
var context = new SourceContext
|
||||
{
|
||||
Source = source,
|
||||
Generation = 0
|
||||
};
|
||||
_sources.Add(context);
|
||||
}
|
||||
|
||||
public AudioHandle Play(in AudioRequest request)
|
||||
{
|
||||
if (request.StopChannelBeforePlay)
|
||||
{
|
||||
StopChannel(request.Channel);
|
||||
}
|
||||
|
||||
var index = -1;
|
||||
if (HasInstanceCap(request.Channel, out int cap))
|
||||
{
|
||||
if (TryStealCappedChannelSource(request, cap, out int stolenIndex, out bool denyFarther))
|
||||
{
|
||||
index = stolenIndex;
|
||||
}
|
||||
else if (denyFarther)
|
||||
{
|
||||
return AudioHandle.Invalid;
|
||||
}
|
||||
}
|
||||
|
||||
if (index == -1)
|
||||
{
|
||||
for (int i = 0; i < _sources.Count; i++)
|
||||
{
|
||||
if (!_sources[i].Source.isPlaying && !_sources[i].PausedByService)
|
||||
{
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (index == -1)
|
||||
{
|
||||
ExpandPool();
|
||||
index = _sources.Count - 1;
|
||||
}
|
||||
}
|
||||
|
||||
var context = _sources[index];
|
||||
|
||||
unchecked
|
||||
{
|
||||
context.Generation++;
|
||||
}
|
||||
|
||||
if (context.Generation == 0) context.Generation = 1;
|
||||
|
||||
context.Channel = request.Channel;
|
||||
context.BaseVolume = request.Volume01;
|
||||
context.PausedByService = false;
|
||||
context.StartTime = Time.unscaledTime;
|
||||
var source = context.Source;
|
||||
|
||||
source.clip = request.Clip;
|
||||
source.outputAudioMixerGroup =
|
||||
_mixerGroups != null && _mixerGroups.TryGetValue(request.Channel, out var mixerGroup)
|
||||
? mixerGroup
|
||||
: null;
|
||||
source.volume = CalculateSourceVolume(context);
|
||||
source.pitch = request.Pitch;
|
||||
source.spatialBlend = request.Spatial3D ? 1f : 0f;
|
||||
source.minDistance = request.MinDistance;
|
||||
source.maxDistance = Mathf.Max(request.MinDistance + 0.01f, request.MaxDistance);
|
||||
source.loop = request.PlayMode == AudioPlayMode.Loop;
|
||||
|
||||
source.transform.localPosition = Vector3.zero;
|
||||
|
||||
source.Play();
|
||||
if (IsChannelPaused(request.Channel))
|
||||
{
|
||||
source.Pause();
|
||||
context.PausedByService = true;
|
||||
}
|
||||
|
||||
if (request.FollowTarget != null && _lifetimeCts != null)
|
||||
{
|
||||
FollowAudioTargetAsync(context, request.FollowTarget, context.Generation, _lifetimeCts.Token).Forget();
|
||||
}
|
||||
|
||||
return new AudioHandle(index + 1, context.Generation);
|
||||
}
|
||||
|
||||
public void Stop(AudioHandle handle, float fadeOutSeconds = 0)
|
||||
{
|
||||
if (TryGetSourceContext(handle, out var context))
|
||||
{
|
||||
context.PausedByService = false;
|
||||
if (fadeOutSeconds > 0 && _lifetimeCts != null)
|
||||
{
|
||||
FadeAndStopAsync(context, fadeOutSeconds, handle.Generation, _lifetimeCts.Token).Forget();
|
||||
}
|
||||
else
|
||||
{
|
||||
context.PausedByService = false;
|
||||
context.Source.Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void SetPitch(AudioHandle handle, float pitch)
|
||||
{
|
||||
if (TryGetSource(handle, out var source))
|
||||
{
|
||||
source.pitch = pitch;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetVolume(AudioHandle handle, float volume01)
|
||||
{
|
||||
if (TryGetSourceContext(handle, out var context))
|
||||
{
|
||||
context.BaseVolume = volume01;
|
||||
context.Source.volume = CalculateSourceVolume(context);
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsPlaying(AudioHandle handle)
|
||||
{
|
||||
return TryGetSourceContext(handle, out var context) &&
|
||||
(context.Source.isPlaying || context.PausedByService);
|
||||
}
|
||||
|
||||
public void SetChannelPaused(AudioChannel channel, bool paused)
|
||||
{
|
||||
if (paused)
|
||||
{
|
||||
if (!_pausedChannels.Add(channel))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var ctx in _sources)
|
||||
{
|
||||
if (!PauseChannelAffects(channel, ctx.Channel) || !ctx.Source.isPlaying)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.Source.Pause();
|
||||
ctx.PausedByService = true;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_pausedChannels.Remove(channel))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var ctx in _sources)
|
||||
{
|
||||
if (!ctx.PausedByService || IsChannelPaused(ctx.Channel))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.PausedByService = false;
|
||||
ctx.Source.UnPause();
|
||||
}
|
||||
}
|
||||
|
||||
public void SetChannelVolume(AudioChannel channel, float volume01)
|
||||
{
|
||||
volume01 = Mathf.Clamp01(volume01);
|
||||
_channelVolumes[channel] = volume01;
|
||||
|
||||
if (_mixerGroups != null && _mixerGroups.TryGetValue(channel, out var mixerGroup) && mixerGroup != null)
|
||||
{
|
||||
float dB = volume01 > 0 ? Mathf.Log10(volume01) * 20f : -80f;
|
||||
mixerGroup.audioMixer.SetFloat($"{channel}Volume", dB);
|
||||
}
|
||||
|
||||
foreach (var ctx in _sources)
|
||||
{
|
||||
if (ctx.Source != null && VolumeChannelAffects(channel, ctx.Channel))
|
||||
{
|
||||
ctx.Source.volume = CalculateSourceVolume(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void SetSnapshotWeights(params float[] weights)
|
||||
{
|
||||
if (!HasSnapshots()) return;
|
||||
|
||||
int count = snapshots.Length;
|
||||
var target = new float[count];
|
||||
for (int i = 0; i < count; i++)
|
||||
target[i] = i < weights.Length ? weights[i] : 0f;
|
||||
|
||||
_snapshotBlendCts?.Cancel();
|
||||
_snapshotBlendCts?.Dispose();
|
||||
_snapshotBlendCts = new CancellationTokenSource();
|
||||
|
||||
if (snapshotTransitionDuration <= 0f)
|
||||
{
|
||||
ApplyWeights(target);
|
||||
return;
|
||||
}
|
||||
|
||||
BlendSnapshotsAsync(target, _snapshotBlendCts.Token).Forget();
|
||||
}
|
||||
|
||||
public void TransitionSnapshotWeights(float duration, params float[] weights)
|
||||
{
|
||||
if (!HasSnapshots()) return;
|
||||
|
||||
_snapshotBlendCts?.Cancel();
|
||||
_snapshotBlendCts?.Dispose();
|
||||
_snapshotBlendCts = null;
|
||||
|
||||
int count = snapshots.Length;
|
||||
var target = new float[count];
|
||||
for (int i = 0; i < count; i++)
|
||||
target[i] = i < weights.Length ? weights[i] : 0f;
|
||||
|
||||
Array.Copy(target, _currentWeights, _currentWeights.Length);
|
||||
audioMixer.TransitionToSnapshots(snapshots, target, Mathf.Max(0f, duration));
|
||||
}
|
||||
|
||||
private bool HasSnapshots() => audioMixer != null && snapshots is { Length: > 0 };
|
||||
|
||||
private async UniTaskVoid BlendSnapshotsAsync(float[] targetWeights, CancellationToken cancellationToken)
|
||||
{
|
||||
float[] startWeights = (float[])_currentWeights.Clone();
|
||||
float elapsed = 0f;
|
||||
|
||||
try
|
||||
{
|
||||
while (elapsed < snapshotTransitionDuration)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
elapsed += Time.deltaTime;
|
||||
float t = Mathf.Clamp01(elapsed / snapshotTransitionDuration);
|
||||
float easedT = snapshotEasing != null ? snapshotEasing.Evaluate(t) : t;
|
||||
|
||||
for (int i = 0; i < _currentWeights.Length; i++)
|
||||
_currentWeights[i] = Mathf.Lerp(startWeights[i], targetWeights[i], easedT);
|
||||
|
||||
audioMixer.TransitionToSnapshots(snapshots, _currentWeights, 0f);
|
||||
await UniTask.Yield(PlayerLoopTiming.Update, cancellationToken);
|
||||
}
|
||||
|
||||
ApplyWeights(targetWeights);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyWeights(float[] weights)
|
||||
{
|
||||
if (!HasSnapshots()) return;
|
||||
Array.Copy(weights, _currentWeights, _currentWeights.Length);
|
||||
audioMixer.TransitionToSnapshots(snapshots, _currentWeights, 0f);
|
||||
}
|
||||
|
||||
private float CalculateSourceVolume(SourceContext context)
|
||||
{
|
||||
return context.BaseVolume * GetEffectiveChannelVolume(context.Channel);
|
||||
}
|
||||
|
||||
private float GetEffectiveChannelVolume(AudioChannel channel)
|
||||
{
|
||||
float volume = GetStoredChannelVolume(channel);
|
||||
|
||||
if (IsSfxChildChannel(channel))
|
||||
{
|
||||
volume *= GetStoredChannelVolume(AudioChannel.Sfx);
|
||||
}
|
||||
else if (channel == AudioChannel.Weather)
|
||||
{
|
||||
volume *= GetStoredChannelVolume(AudioChannel.Ambience);
|
||||
}
|
||||
|
||||
return volume;
|
||||
}
|
||||
|
||||
private float GetStoredChannelVolume(AudioChannel channel)
|
||||
{
|
||||
return _channelVolumes.TryGetValue(channel, out float volume) ? volume : 1f;
|
||||
}
|
||||
|
||||
private static bool VolumeChannelAffects(AudioChannel changedChannel, AudioChannel sourceChannel)
|
||||
{
|
||||
return changedChannel == sourceChannel ||
|
||||
changedChannel == AudioChannel.Sfx && IsSfxChildChannel(sourceChannel) ||
|
||||
changedChannel == AudioChannel.Ambience && sourceChannel == AudioChannel.Weather;
|
||||
}
|
||||
|
||||
private bool IsChannelPaused(AudioChannel channel)
|
||||
{
|
||||
return _pausedChannels.Contains(channel) ||
|
||||
_pausedChannels.Contains(AudioChannel.Sfx) && IsSfxChildChannel(channel) ||
|
||||
_pausedChannels.Contains(AudioChannel.Ambience) && channel == AudioChannel.Weather;
|
||||
}
|
||||
|
||||
private static bool PauseChannelAffects(AudioChannel changedChannel, AudioChannel sourceChannel)
|
||||
{
|
||||
return changedChannel == sourceChannel ||
|
||||
changedChannel == AudioChannel.Sfx && IsSfxChildChannel(sourceChannel) ||
|
||||
changedChannel == AudioChannel.Ambience && sourceChannel == AudioChannel.Weather;
|
||||
}
|
||||
|
||||
private static bool IsSfxChildChannel(AudioChannel channel)
|
||||
{
|
||||
return channel is AudioChannel.VehicleSfx or AudioChannel.Engine or AudioChannel.PassengerChatter;
|
||||
}
|
||||
|
||||
private AudioListener GetActiveListener()
|
||||
{
|
||||
if (_cachedListener != null && _cachedListener.isActiveAndEnabled) return _cachedListener;
|
||||
if (listener != null && listener.isActiveAndEnabled)
|
||||
{
|
||||
_cachedListener = listener;
|
||||
return _cachedListener;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private bool HasInstanceCap(AudioChannel channel, out int cap)
|
||||
{
|
||||
if (channel == AudioChannel.AISfx && maxAISfxInstances > 0)
|
||||
{
|
||||
cap = maxAISfxInstances;
|
||||
return true;
|
||||
}
|
||||
|
||||
cap = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool TryStealCappedChannelSource(in AudioRequest request, int cap, out int index, out bool denyFarther)
|
||||
{
|
||||
index = -1;
|
||||
denyFarther = false;
|
||||
int active = 0;
|
||||
int victim = -1;
|
||||
float victimScore = float.NegativeInfinity;
|
||||
float victimDistSq = 0f;
|
||||
float now = Time.unscaledTime;
|
||||
|
||||
var listener = GetActiveListener();
|
||||
Vector3 listenerPos = listener != null ? listener.transform.position : Vector3.zero;
|
||||
bool hasListener = listener != null;
|
||||
|
||||
for (int i = 0; i < _sources.Count; i++)
|
||||
{
|
||||
var ctx = _sources[i];
|
||||
if (ctx.Channel != request.Channel) continue;
|
||||
if (!ctx.Source.isPlaying && !ctx.PausedByService) continue;
|
||||
|
||||
active++;
|
||||
|
||||
float distSq = hasListener && ctx.Source.spatialBlend > 0f
|
||||
? (ctx.Source.transform.position - listenerPos).sqrMagnitude
|
||||
: 0f;
|
||||
float age = now - ctx.StartTime;
|
||||
float score = distSq * 1000f + age;
|
||||
|
||||
if (score > victimScore)
|
||||
{
|
||||
victimScore = score;
|
||||
victimDistSq = distSq;
|
||||
victim = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (active < cap || victim < 0) return false;
|
||||
|
||||
if (request.Spatial3D && hasListener && request.FollowTarget != null)
|
||||
{
|
||||
float requesterDistSq = (request.FollowTarget.position - listenerPos).sqrMagnitude;
|
||||
if (requesterDistSq >= victimDistSq)
|
||||
{
|
||||
denyFarther = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var v = _sources[victim];
|
||||
v.PausedByService = false;
|
||||
v.Source.Stop();
|
||||
index = victim;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void StopChannel(AudioChannel channel)
|
||||
{
|
||||
foreach (var ctx in _sources)
|
||||
{
|
||||
if ((ctx.Source.isPlaying || ctx.PausedByService) && ctx.Channel == channel)
|
||||
{
|
||||
ctx.PausedByService = false;
|
||||
ctx.Source.Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryGetSourceContext(AudioHandle handle, out SourceContext context)
|
||||
{
|
||||
context = null;
|
||||
if (!handle.IsValid) return false;
|
||||
|
||||
int index = handle.Id - 1;
|
||||
if (index < 0 || index >= _sources.Count) return false;
|
||||
|
||||
context = _sources[index];
|
||||
if (context.Source == null) return false;
|
||||
return context.Generation == handle.Generation;
|
||||
}
|
||||
|
||||
private bool TryGetSource(AudioHandle handle, out AudioSource source)
|
||||
{
|
||||
if (TryGetSourceContext(handle, out var context))
|
||||
{
|
||||
source = context.Source;
|
||||
return true;
|
||||
}
|
||||
|
||||
source = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private async UniTaskVoid FollowAudioTargetAsync(SourceContext context, Transform target, ushort generation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var source = context.Source;
|
||||
while (source != null &&
|
||||
(source.isPlaying || context.PausedByService) &&
|
||||
target != null &&
|
||||
context.Generation == generation)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
source.transform.position = target.position;
|
||||
await UniTask.Yield(PlayerLoopTiming.Update, cancellationToken);
|
||||
}
|
||||
|
||||
if (source != null && context.Generation == generation)
|
||||
{
|
||||
source.transform.localPosition = Vector3.zero;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private async UniTaskVoid FadeAndStopAsync(SourceContext context, float duration, ushort generation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var source = context.Source;
|
||||
float startVolume = source.volume;
|
||||
float time = 0;
|
||||
|
||||
try
|
||||
{
|
||||
while (time < duration && source != null && source.isPlaying && context.Generation == generation)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
time += Time.deltaTime;
|
||||
source.volume = Mathf.Lerp(startVolume, 0f, time / duration);
|
||||
await UniTask.Yield(PlayerLoopTiming.Update, cancellationToken);
|
||||
}
|
||||
|
||||
if (source != null && context.Generation == generation)
|
||||
{
|
||||
source.Stop();
|
||||
source.volume = CalculateSourceVolume(context);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (source != null && context.Generation == generation)
|
||||
{
|
||||
source.Stop();
|
||||
source.volume = CalculateSourceVolume(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
_snapshotBlendCts?.Cancel();
|
||||
_snapshotBlendCts?.Dispose();
|
||||
_snapshotBlendCts = null;
|
||||
|
||||
_lifetimeCts?.Cancel();
|
||||
_lifetimeCts?.Dispose();
|
||||
_lifetimeCts = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6e8080c47fe224496a2a0ea5afe3cdc2
|
||||
8
Assets/Darkmatter/Code/Services/Audio/Docs.meta
Normal file
8
Assets/Darkmatter/Code/Services/Audio/Docs.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cd14751fc8bba4de9861b58aa1b56cdf
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
32
Assets/Darkmatter/Code/Services/Audio/Docs/AudioService.md
Normal file
32
Assets/Darkmatter/Code/Services/Audio/Docs/AudioService.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Audio Service
|
||||
|
||||
## Purpose
|
||||
- Owns the shared runtime audio service used by gameplay and UI systems.
|
||||
- Provides a separate one-shot SFX path with a designer-friendly catalog.
|
||||
- Keeps global audio playback and configuration outside feature-specific code.
|
||||
|
||||
## Public Entry Points
|
||||
- `IAudioService -> AudioService` — full playback API: `Play(in AudioRequest)`, `Stop(handle, fadeOutSeconds)`, channel pause/volume, snapshot weight blending, async clip loading.
|
||||
- `ISfxPlayer -> SfxPlayer` — lightweight one-shot/loop entry keyed by `SfxId`; resolves clips and channels from `SfxCatalogSO`.
|
||||
- `SfxCatalogSO` (Core static data) — ScriptableObject of `SfxId -> (AudioClip, AudioChannel, volume, pitch, loop)` entries.
|
||||
- `SfxId` (Core enum) — current entries: `WiperUp`, `WiperDown`, `BlinkerTick`, `GearShift`, `ReverseBeep`.
|
||||
- `AudioChannel` (Core enum) — `Sfx`, `VehicleSfx`, `AISfx`, `Engine`, `PassengerChatter`, `Music`, `Ambience`, `Weather`, `BGM`, `UI`, `Radio`. Each maps to a mixer group on `AudioService`’s mixer.
|
||||
- `AudioRequest`, `AudioHandle`, `AudioPlayMode` — Core DTOs used by both `IAudioService` and `ISfxPlayer`.
|
||||
|
||||
## Registration
|
||||
- `AudioService` is serialized on `RootLifetimeScope` and registered as `IAudioService` via `RegisterComponent`.
|
||||
- `ISfxPlayer -> SfxPlayer` is registered as `Singleton` on `RootLifetimeScope` with the serialized `SfxCatalogSO` parameter.
|
||||
|
||||
## Mixer Channels
|
||||
- Channels are addressed by enum; pause/volume changes go through `IAudioService.SetChannelPaused` / `SetChannelVolume`.
|
||||
- New gameplay sounds should pick the most specific channel (e.g. `AudioChannel.Radio` for the in-game music player, `AudioChannel.Ambience` for environment loops/thunder, `AudioChannel.Weather` for rain/storm visuals).
|
||||
- Snapshot blending (`SetSnapshotWeights`, `TransitionSnapshotWeights`) is available for scene-level mixes.
|
||||
|
||||
## Dependencies
|
||||
- Registered from the App/root scope and consumed through Core audio contracts.
|
||||
- Should stay infrastructure-only and not branch on feature-specific rules.
|
||||
|
||||
## Extension Notes
|
||||
- Put shared playback helpers here; leave feature-owned mix rules in the feature when they are not broadly reusable.
|
||||
- When adding a new SFX, register the `SfxId` in Core, add a `SfxCatalogSO` entry, and call `ISfxPlayer.Play` from the feature rather than constructing `AudioRequest`s by hand.
|
||||
- If a new audio capability must cross assemblies, expose it through Core instead of direct concrete references.
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0fd85407df33e41faa16e61bdf0151fc
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
19
Assets/Darkmatter/Code/Services/Audio/Services.Audio.asmdef
Normal file
19
Assets/Darkmatter/Code/Services/Audio/Services.Audio.asmdef
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "Services.Audio",
|
||||
"rootNamespace": "Darkmatter.Services.Audio",
|
||||
"references": [
|
||||
"GUID:6a0a834eb41764f12ba55c3fb04a40cb",
|
||||
"GUID:f51ebe6a0ceec4240a699833d6309b23",
|
||||
"GUID:b0214a6008ed146ff8f122a6a9c2f6cc",
|
||||
"GUID:b4c9f7fbf1e144933a1797dc208ece5f"
|
||||
],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 432300682b1f64631b2cbf2db3b550f4
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
50
Assets/Darkmatter/Code/Services/Audio/SfxPlayer.cs
Normal file
50
Assets/Darkmatter/Code/Services/Audio/SfxPlayer.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using Darkmatter.Core.Contracts.Services.Audio;
|
||||
using Darkmatter.Core.Data.Dynamic.Services.Audio;
|
||||
using Darkmatter.Core.Data.Static.Services.Audio;
|
||||
using Darkmatter.Core.Enums.Services.Audio;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Darkmatter.Services.Audio
|
||||
{
|
||||
public class SfxPlayer : ISfxPlayer
|
||||
{
|
||||
private readonly IAudioService _audioService;
|
||||
private readonly SfxCatalogSO _catalog;
|
||||
|
||||
public SfxPlayer(IAudioService audioService, SfxCatalogSO catalog)
|
||||
{
|
||||
_audioService = audioService;
|
||||
_catalog = catalog;
|
||||
}
|
||||
|
||||
public AudioHandle Play(SfxId id, Transform follow = null) => PlayInternal(id, follow, forceLoop: false);
|
||||
|
||||
public AudioHandle PlayLoop(SfxId id, Transform follow = null) => PlayInternal(id, follow, forceLoop: true);
|
||||
|
||||
public void Stop(AudioHandle handle, float fadeOutSeconds = 0f) => _audioService.Stop(handle, fadeOutSeconds);
|
||||
|
||||
public bool IsPlaying(AudioHandle handle) => _audioService.IsPlaying(handle);
|
||||
|
||||
private AudioHandle PlayInternal(SfxId id, Transform follow, bool forceLoop)
|
||||
{
|
||||
if (_catalog == null || !_catalog.TryGet(id, out var entry))
|
||||
{
|
||||
Debug.LogWarning($"[SfxPlayer] No catalog entry for {id}");
|
||||
return AudioHandle.Invalid;
|
||||
}
|
||||
|
||||
bool loop = forceLoop || entry.Loop;
|
||||
var request = new AudioRequest(
|
||||
clip: entry.Clip,
|
||||
channel: entry.Channel,
|
||||
mode: loop ? AudioPlayMode.Loop : AudioPlayMode.OneShot,
|
||||
stopChannelBeforePlay: false,
|
||||
volume01: entry.Volume,
|
||||
pitch: entry.Pitch,
|
||||
spatial3D: false,
|
||||
followTarget: follow);
|
||||
|
||||
return _audioService.Play(request);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Darkmatter/Code/Services/Audio/SfxPlayer.cs.meta
Normal file
2
Assets/Darkmatter/Code/Services/Audio/SfxPlayer.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d99b9afb2b1d64629b2e29757059b18f
|
||||
3
Assets/Darkmatter/Code/Services/Scenes.meta
Normal file
3
Assets/Darkmatter/Code/Services/Scenes.meta
Normal file
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 923e67ec9a904d23a0b575e1c60cb447
|
||||
timeCreated: 1770642976
|
||||
8
Assets/Darkmatter/Code/Services/Scenes/Docs.meta
Normal file
8
Assets/Darkmatter/Code/Services/Scenes/Docs.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c839b982cf83a4bc4b77ec3c1d934096
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
16
Assets/Darkmatter/Code/Services/Scenes/Docs/ScenesService.md
Normal file
16
Assets/Darkmatter/Code/Services/Scenes/Docs/ScenesService.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Scenes Service
|
||||
|
||||
## Purpose
|
||||
- Provides the shared scene-loading service used by boot, menu, and gameplay orchestration.
|
||||
- Centralizes scene transitions and additive-load management.
|
||||
|
||||
## Public Entry Points
|
||||
- `SceneService`
|
||||
|
||||
## Dependencies
|
||||
- Consumed from App and feature orchestrators through Core scene contracts.
|
||||
- Must not depend on feature implementations.
|
||||
|
||||
## Extension Notes
|
||||
- Add generic loading/unloading helpers here; keep flow decisions in the orchestrators that call the service.
|
||||
- Preserve deterministic boot/load ordering by using direct DI calls from orchestrators.
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b743971046fb4448c884a421af7c2c7f
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
163
Assets/Darkmatter/Code/Services/Scenes/SceneService.cs
Normal file
163
Assets/Darkmatter/Code/Services/Scenes/SceneService.cs
Normal file
@@ -0,0 +1,163 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using Darkmatter.Core.Contracts.Services.Scenes;
|
||||
using Darkmatter.Core.Enums.Services.Scenes;
|
||||
using UnityEngine.AddressableAssets;
|
||||
using UnityEngine.ResourceManagement.AsyncOperations;
|
||||
using UnityEngine.ResourceManagement.ResourceProviders;
|
||||
using UnityEngine.SceneManagement;
|
||||
|
||||
namespace Darkmatter.Services.Scenes
|
||||
{
|
||||
public class SceneService : ISceneService
|
||||
{
|
||||
private readonly HashSet<GameScene> _loadedScenes = new();
|
||||
private readonly Dictionary<string, AsyncOperationHandle<SceneInstance>> _loadedAddressableScenes =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
public async UniTask LoadSceneAsync(GameScene scene, IProgress<float> progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (SceneManager.GetSceneByName(scene.ToString()).isLoaded) _loadedScenes.Add(scene);
|
||||
|
||||
if (_loadedScenes.Contains(scene))
|
||||
{
|
||||
progress?.Report(1f);
|
||||
return;
|
||||
}
|
||||
|
||||
await SceneManager.LoadSceneAsync(scene.ToString(), LoadSceneMode.Additive)
|
||||
.ToUniTask(progress: progress, cancellationToken: cancellationToken);
|
||||
|
||||
SceneManager.SetActiveScene(SceneManager.GetSceneByName(scene.ToString()));
|
||||
|
||||
_loadedScenes.Add(scene);
|
||||
}
|
||||
|
||||
public async UniTask UnloadSceneAsync(GameScene scene, IProgress<float> progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (SceneManager.GetSceneByName(scene.ToString()).isLoaded) _loadedScenes.Add(scene);
|
||||
|
||||
if (!_loadedScenes.Contains(scene))
|
||||
{
|
||||
progress?.Report(1f);
|
||||
return;
|
||||
}
|
||||
|
||||
await SceneManager.UnloadSceneAsync(scene.ToString())
|
||||
.ToUniTask(progress: progress, cancellationToken: cancellationToken);
|
||||
|
||||
_loadedScenes.Remove(scene);
|
||||
}
|
||||
|
||||
public async UniTask LoadSceneAsync(
|
||||
string sceneKey,
|
||||
IProgress<float> progress,
|
||||
CancellationToken cancellationToken,
|
||||
bool setActiveScene = true)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sceneKey))
|
||||
throw new ArgumentException("Scene key is required.", nameof(sceneKey));
|
||||
|
||||
if (_loadedAddressableScenes.TryGetValue(sceneKey, out AsyncOperationHandle<SceneInstance> loadedHandle))
|
||||
{
|
||||
if (loadedHandle.IsValid() && loadedHandle.Result.Scene.isLoaded)
|
||||
{
|
||||
if (setActiveScene)
|
||||
SceneManager.SetActiveScene(loadedHandle.Result.Scene);
|
||||
|
||||
progress?.Report(1f);
|
||||
return;
|
||||
}
|
||||
|
||||
_loadedAddressableScenes.Remove(sceneKey);
|
||||
}
|
||||
|
||||
AsyncOperationHandle<SceneInstance> handle =
|
||||
Addressables.LoadSceneAsync(sceneKey, LoadSceneMode.Additive, activateOnLoad: true);
|
||||
|
||||
try
|
||||
{
|
||||
await handle.ToUniTask(progress: progress, cancellationToken: cancellationToken);
|
||||
|
||||
if (setActiveScene && handle.Result.Scene.IsValid())
|
||||
SceneManager.SetActiveScene(handle.Result.Scene);
|
||||
|
||||
_loadedAddressableScenes[sceneKey] = handle;
|
||||
progress?.Report(1f);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (handle.IsValid())
|
||||
Addressables.Release(handle);
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async UniTask UnloadSceneAsync(string sceneKey, IProgress<float> progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sceneKey))
|
||||
throw new ArgumentException("Scene key is required.", nameof(sceneKey));
|
||||
|
||||
if (!_loadedAddressableScenes.TryGetValue(sceneKey, out AsyncOperationHandle<SceneInstance> loadedHandle))
|
||||
{
|
||||
Scene sceneByName = SceneManager.GetSceneByName(sceneKey);
|
||||
if (!sceneByName.isLoaded)
|
||||
{
|
||||
progress?.Report(1f);
|
||||
return;
|
||||
}
|
||||
|
||||
await SceneManager.UnloadSceneAsync(sceneKey)
|
||||
.ToUniTask(progress: progress, cancellationToken: cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
AsyncOperationHandle<SceneInstance> unloadHandle =
|
||||
Addressables.UnloadSceneAsync(loadedHandle, autoReleaseHandle: true);
|
||||
await unloadHandle.ToUniTask(progress: progress, cancellationToken: cancellationToken);
|
||||
progress?.Report(1f);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loadedAddressableScenes.Remove(sceneKey);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetLoadedScene(string sceneKey, out Scene scene)
|
||||
{
|
||||
scene = default;
|
||||
if (string.IsNullOrWhiteSpace(sceneKey))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_loadedAddressableScenes.TryGetValue(sceneKey, out AsyncOperationHandle<SceneInstance> loadedHandle) &&
|
||||
loadedHandle.IsValid())
|
||||
{
|
||||
Scene candidate = loadedHandle.Result.Scene;
|
||||
if (candidate.IsValid() && candidate.isLoaded)
|
||||
{
|
||||
scene = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Scene byName = SceneManager.GetSceneByName(sceneKey);
|
||||
if (!byName.IsValid() || !byName.isLoaded)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
scene = byName;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d733986c0a504cbabfea83c2ed4f03ca
|
||||
timeCreated: 1770642983
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "Services.Scenes",
|
||||
"rootNamespace": "Darkmatter.Services.Scenes",
|
||||
"references": [
|
||||
"GUID:6a0a834eb41764f12ba55c3fb04a40cb",
|
||||
"GUID:f51ebe6a0ceec4240a699833d6309b23",
|
||||
"GUID:593a5b492d29ac6448b1ebf7f035ef33",
|
||||
"GUID:9e24947de15b9834991c9d8411ea37cf",
|
||||
"GUID:84651a3751eca9349aac36a66bba901b"
|
||||
],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b3780357f6aee4cfe9c4f54941e0bf83
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user