Initial Push

This commit is contained in:
Savya Bikram Shah
2026-05-26 16:47:16 +05:45
commit f901f70c10
219 changed files with 11642 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5b95e7a64fe76415199b8f7f0a875416
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8c274558c24334f2b9c9ebd58dce91ad
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4e89fdd4696924b7facccda23a94a978

View File

@@ -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
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 948fb74de8df14f81a7ee6f41676a8f2
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d9f4a46dc47ab4f95a47c9fdeda9e532
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 64d80817f416c45f8b9e32d38809632c

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 87bd9530ab04c41b7950c7bf4d0d4695
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cb67df01f142d4eb8a2e6d020ba455c3

View File

@@ -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;
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 18a4f8e4a6db4a7fa43c517655dc87bf

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ae3d74d6f9871478a9c165a6024c6e51
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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.

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 5cc48daf3de404c458b7f47611690f26
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: efcaa22887a6b4471829c3b3878147a2
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 2a0b84e2e04354fe1b6085bd24882bfb
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6e8080c47fe224496a2a0ea5afe3cdc2

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: cd14751fc8bba4de9861b58aa1b56cdf
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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.

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 0fd85407df33e41faa16e61bdf0151fc
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 432300682b1f64631b2cbf2db3b550f4
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d99b9afb2b1d64629b2e29757059b18f

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 923e67ec9a904d23a0b575e1c60cb447
timeCreated: 1770642976

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c839b982cf83a4bc4b77ec3c1d934096
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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.

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: b743971046fb4448c884a421af7c2c7f
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: d733986c0a504cbabfea83c2ed4f03ca
timeCreated: 1770642983

View File

@@ -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
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: b3780357f6aee4cfe9c4f54941e0bf83
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant: