// MIT License - Copyright (c) 2025 wallstop // Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE namespace WallstopStudios.UnityHelpers.Utils { using System; using System.Collections.Generic; using UnityEngine; using WallstopStudios.UnityHelpers.Core.Attributes; using WallstopStudios.UnityHelpers.Core.Helper; #if UNITY_EDITOR using UnityEditor; #endif #if ODIN_INSPECTOR using Sirenix.OdinInspector; #endif /// /// Provides a global, lazily loaded singleton pattern for assets. /// Ensures that exactly one asset instance of is used at runtime. /// /// /// Lookup order (lazy): /// 1) Load from a custom Resources subfolder when the type is decorated with /// . /// 2) Load from a folder named after the type (Resources/<TypeName>). /// 3) Load by exact type name in Resources root, then fallback to all matches in Resources. /// /// If multiple assets are found, a warning is logged and the first result ordered by name is returned. /// The editor utility “ScriptableObject Singleton Creator” automatically creates and relocates assets to /// the correct path on editor load — see docs/features/editor-tools/editor-tools-guide.md#scriptableobject-singleton-creator. /// /// ODIN compatibility: When the ODIN_INSPECTOR symbol is defined, this class derives from /// Sirenix.OdinInspector.SerializedScriptableObject; otherwise it derives from . /// /// Concrete singleton ScriptableObject type that derives from this base. /// /// Thread-safety notes: /// /// /// The property must only be accessed from the main thread (enforced by ). /// /// /// Warning deduplication uses two independent locks: _metadataFolderWarnings and _missingInstanceWarnings. /// These locks are never nested and are only held briefly to check/add to their respective HashSets. /// No deadlock risk exists because each lock guards an independent data structure with no cross-dependencies. /// /// /// public abstract class ScriptableObjectSingleton : #if ODIN_INSPECTOR SerializedScriptableObject #else ScriptableObject #endif where T : ScriptableObjectSingleton { private static ScriptableObjectSingletonMetadata _metadataAsset; private static bool _metadataLoadAttempted; private static bool _metadataMissingWarningLogged; private static bool _metadataLoadFailureWarningLogged; private static readonly HashSet _metadataFolderWarnings = new( StringComparer.Ordinal ); private static readonly HashSet _missingInstanceWarnings = new( StringComparer.Ordinal ); #if UNITY_EDITOR private static bool _duplicateMetadataWarningLogged; #endif static ScriptableObjectSingleton() { ScriptableObjectSingletonRegistry.Register(ClearInstance); } private static string GetResourcesPath() { Type type = typeof(T); if ( ReflectionHelpers.TryGetAttributeSafe( type, out ScriptableSingletonPathAttribute attribute, inherit: false ) && !string.IsNullOrWhiteSpace(attribute.resourcesPath) ) { return attribute.resourcesPath; } // Return empty string to search from Resources root when no attribute is specified return string.Empty; } /// /// Clears the cached singleton instance, allowing a fresh load on next access. /// If an instance was loaded, is invoked before clearing. /// internal static void ClearInstance() { if (!_lazyInstance.IsValueCreated) { return; } T value = _lazyInstance.Value; if (value != null) { value.OnInstanceCleared(); } _lazyInstance = CreateLazy(); } /// /// Called when the singleton instance is being cleared via . /// Override in derived classes to handle cleanup when the instance is cleared. /// Called automatically by . /// /// /// This method is intentionally named differently from Unity's OnDisable() magic method /// to avoid confusion. Unity's OnDisable() is called by the engine when a ScriptableObject /// is disabled or destroyed, whereas this method is only called when explicitly clearing the /// singleton instance via the registry. /// protected virtual void OnInstanceCleared() { // Default no-op; derived classes may override } protected internal static Lazy _lazyInstance = CreateLazy(); internal static Lazy CreateLazy() { return new Lazy(() => { Type type = typeof(T); List candidates = new(); bool metadataHit = TryPopulateCandidatesFromMetadata(type, candidates); if (!metadataHit) { string resourcesPath = GetResourcesPath(); TryAddCandidate(candidates, LoadFromResourcesPath(resourcesPath, type.Name)); if (candidates.Count == 0) { TryAddCandidate(candidates, Resources.Load(type.Name)); } #if UNITY_EDITOR AddEditorCandidates(type, resourcesPath, candidates); #endif if (candidates.Count == 0) { AddRuntimeDiscoveredCandidates(type, candidates); } if (candidates.Count == 0) { AddGlobalResourcesCandidates(type, candidates); } } return ResolveCandidates(type, candidates); }); } private static bool TryPopulateCandidatesFromMetadata(Type type, List candidates) { if (!TryGetMetadataEntry(type, out ScriptableObjectSingletonMetadata.Entry entry)) { WarnMetadataMissing(type); return false; } bool loadPathAttempted = false; string loadPath = entry.resourcesLoadPath; if (!string.IsNullOrWhiteSpace(loadPath)) { loadPathAttempted = true; T direct = Resources.Load(loadPath); if (direct != null) { TryAddCandidate(candidates, direct); #if UNITY_EDITOR WarnDuplicateSingletonAssets(type, entry); #endif return candidates.Count > 0; } } string folder = entry.resourcesPath; if (!string.IsNullOrWhiteSpace(folder)) { T[] scoped = Resources.LoadAll(folder); if (scoped is { Length: > 0 }) { foreach (T candidate in scoped) { TryAddCandidate(candidates, candidate); } if (candidates.Count > 0) { return true; } } else { WarnMetadataFolderEmpty(type, folder); } } if (loadPathAttempted) { WarnMetadataLoadFailure(type, loadPath); } return false; } private static void TryAddCandidate(List candidates, T candidate) { if (candidate == null || candidates == null) { return; } if (!candidates.Contains(candidate)) { candidates.Add(candidate); } } private static T LoadFromResourcesPath(string resourcesPath, string typeName) { string loadPath = BuildLoadPath(resourcesPath, typeName); return string.IsNullOrEmpty(loadPath) ? null : Resources.Load(loadPath); } private static string BuildLoadPath(string resourcesPath, string typeName) { if (string.IsNullOrWhiteSpace(typeName)) { return null; } if (string.IsNullOrWhiteSpace(resourcesPath)) { return typeName; } string trimmed = resourcesPath.Trim().Trim('/'); return string.IsNullOrEmpty(trimmed) ? typeName : $"{trimmed}/{typeName}"; } #if UNITY_EDITOR private static void AddEditorCandidates(Type type, string resourcesPath, List candidates) { string typeName = type.Name; List candidatePaths = new(); if (!string.IsNullOrWhiteSpace(resourcesPath)) { candidatePaths.Add($"Assets/Resources/{resourcesPath}/{typeName}.asset"); } candidatePaths.Add($"Assets/Resources/{typeName}.asset"); foreach (string candidate in candidatePaths) { T atPath = AssetDatabase.LoadAssetAtPath(candidate); if (atPath != null) { TryAddCandidate(candidates, atPath); return; } string guid = AssetDatabase.AssetPathToGUID(candidate); if (string.IsNullOrEmpty(guid)) { continue; } UnityEngine.Object[] allAtPath = AssetDatabase.LoadAllAssetsAtPath(candidate); if (allAtPath is { Length: > 0 }) { foreach (UnityEngine.Object obj in allAtPath) { if (obj == null || !type.IsInstanceOfType(obj)) { continue; } TryAddCandidate(candidates, (T)obj); return; } } } } #endif private static void AddRuntimeDiscoveredCandidates(Type type, List candidates) { T[] found = Resources.FindObjectsOfTypeAll(); if (found is not { Length: > 0 }) { return; } foreach (T candidate in found) { if (candidate == null || !type.IsInstanceOfType(candidate)) { continue; } TryAddCandidate(candidates, candidate); } } private static void AddGlobalResourcesCandidates(Type type, List candidates) { T[] all = Resources.LoadAll(string.Empty); if (all is not { Length: > 0 }) { return; } foreach (T candidate in all) { if (candidate == null || !type.IsInstanceOfType(candidate)) { continue; } TryAddCandidate(candidates, candidate); } } private static T ResolveCandidates(Type type, List candidates) { if (candidates == null || candidates.Count == 0) { WarnNoInstancesFound(type); return null; } if (candidates.Count == 1) { return candidates[0]; } Debug.LogWarning( $"Found multiple ScriptableSingletons of type {type.Name}, defaulting to first by name." ); candidates.Sort(UnityObjectNameComparer.Instance); return candidates[0]; } private static bool TryGetMetadataEntry( Type type, out ScriptableObjectSingletonMetadata.Entry entry ) { ScriptableObjectSingletonMetadata metadata = _metadataAsset; if (metadata == null && !_metadataLoadAttempted) { _metadataLoadAttempted = true; metadata = Resources.Load( ScriptableObjectSingletonMetadata.ResourcePath ); _metadataAsset = metadata; } if (metadata == null) { entry = default; return false; } return metadata.TryGetEntry(type, out entry); } private static void WarnMetadataMissing(Type type) { #if UNITY_EDITOR // Suppress warning during early initialization before singleton creator has run if (!ScriptableObjectSingletonInitState.InitialEnsureCompleted) { return; } #endif string message = $"ScriptableObjectSingleton metadata entry not found for {type.FullName}. Falling back to heuristic Resources search."; LogMetadataWarning(message, ref _metadataMissingWarningLogged); } private static void WarnMetadataLoadFailure(Type type, string path) { #if UNITY_EDITOR // Suppress warning during early initialization - asset may not be created yet if (!ScriptableObjectSingletonInitState.InitialEnsureCompleted) { return; } #endif string message = $"ScriptableObjectSingleton metadata entry for {type.FullName} points to '{path}', but the asset could not be loaded."; LogMetadataWarning(message, ref _metadataLoadFailureWarningLogged); } private static void WarnMetadataFolderEmpty(Type type, string folder) { #if UNITY_EDITOR || DEVELOPMENT_BUILD if (string.IsNullOrWhiteSpace(folder)) { return; } #if UNITY_EDITOR // Suppress warning during early initialization - asset may not be created yet if (!ScriptableObjectSingletonInitState.InitialEnsureCompleted) { return; } #endif string key = $"{type.FullName}|{folder}"; lock (_metadataFolderWarnings) { if (!_metadataFolderWarnings.Add(key)) { return; } } Debug.LogWarning( $"ScriptableObjectSingleton metadata entry for {type.FullName} points to folder '{folder}', but no assets were found there. Falling back to heuristic search." ); #else _ = type; _ = folder; #endif } private static void WarnNoInstancesFound(Type type) { #if UNITY_EDITOR || DEVELOPMENT_BUILD #if UNITY_EDITOR // Suppress warning during early initialization - asset may not be created yet if (!ScriptableObjectSingletonInitState.InitialEnsureCompleted) { return; } #endif string key = type.FullName ?? type.Name; lock (_missingInstanceWarnings) { if (!_missingInstanceWarnings.Add(key)) { return; } } Debug.LogWarning( $"ScriptableObjectSingleton could not locate any asset for {type.FullName}. Returning null." ); #else _ = type; #endif } private static void LogMetadataWarning(string message, ref bool flag) { #if UNITY_EDITOR || DEVELOPMENT_BUILD if (!flag) { flag = true; Debug.LogWarning(message); } #else flag = true; _ = message; #endif } /// /// Gets a value indicating whether the lazy instance has been created and is non‑null. /// public static bool HasInstance => _lazyInstance.IsValueCreated && _lazyInstance.Value != null; /// /// Gets the global asset instance, loading it from Resources on first access. /// /// /// /// [ScriptableSingletonPath("Settings/Audio")] /// public sealed class AudioSettings : ScriptableObjectSingleton<AudioSettings> /// { /// public float musicVolume = 0.8f; /// } /// /// // Access anywhere /// float volume = AudioSettings.Instance.musicVolume; /// /// public static T Instance { get { if (_lazyInstance.IsValueCreated) { return _lazyInstance.Value; } UnityMainThreadGuard.EnsureMainThread(); return _lazyInstance.Value; } } #if UNITY_EDITOR private static void WarnDuplicateSingletonAssets( Type type, ScriptableObjectSingletonMetadata.Entry entry ) { if (_duplicateMetadataWarningLogged) { return; } string folder = entry.resourcesPath; string loadPath = entry.resourcesLoadPath; if (string.IsNullOrWhiteSpace(folder) || string.IsNullOrWhiteSpace(loadPath)) { return; } string assetFolder = $"Assets/Resources/{folder}".Replace("\\", "/").TrimEnd('/'); if (!AssetDatabase.IsValidFolder(assetFolder)) { return; } string canonicalAssetPath = BuildCanonicalAssetPath(loadPath); string[] guids = AssetDatabase.FindAssets("t:" + type.Name, new[] { assetFolder }); if (guids == null || guids.Length <= 1) { return; } List duplicates = new(); foreach (string guid in guids) { string assetPath = AssetDatabase.GUIDToAssetPath(guid); if ( string.IsNullOrWhiteSpace(assetPath) || string.Equals( assetPath, canonicalAssetPath, StringComparison.OrdinalIgnoreCase ) ) { continue; } UnityEngine.Object candidate = AssetDatabase.LoadAssetAtPath(assetPath, type); if (candidate != null) { duplicates.Add(assetPath); } } if (duplicates.Count == 0) { return; } _duplicateMetadataWarningLogged = true; string canonicalLabel = string.IsNullOrWhiteSpace(canonicalAssetPath) ? loadPath : canonicalAssetPath; Debug.LogWarning( $"ScriptableObjectSingleton detected duplicate assets for {type.FullName} under '{assetFolder}'. Using '{canonicalLabel}'. Remove extra copies or add [AllowDuplicateCleanup] attribute for automatic cleanup:{Environment.NewLine} - {string.Join(Environment.NewLine + " - ", duplicates)}" ); } private static string BuildCanonicalAssetPath(string loadPath) { if (string.IsNullOrWhiteSpace(loadPath)) { return null; } string sanitized = loadPath.Replace("\\", "/").Trim('/'); if (string.IsNullOrEmpty(sanitized)) { return null; } return $"Assets/Resources/{sanitized}.asset".Replace("//", "/"); } #endif } }