// 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 Core.Extension;
using Core.Helper;
using UnityEngine;
using UnityEngine.Serialization;
///
/// Selects the MonoBehaviour lifecycle events that should trigger prefab instantiation.
///
[Flags]
public enum ChildSpawnMethod
{
///
/// No child creation will occur. Mainly retained for serialization compatibility.
///
[Obsolete]
None = 0,
///
/// Spawn children during .
///
Awake = 1 << 0,
///
/// Spawn children when the component is enabled.
///
OnEnabled = 1 << 1,
///
/// Spawn children during .
///
Start = 1 << 2,
}
///
/// Instantiates a curated list of prefabs as children of the current GameObject while ensuring
/// duplicates across scenes are avoided and optional DontDestroyOnLoad behaviour is applied.
///
[DisallowMultipleComponent]
public sealed class ChildSpawner : MonoBehaviour
{
private static readonly HashSet SpawnedPrefabs = new();
[FormerlySerializedAs("dontDestroyOnLoad")]
[SerializeField]
internal bool _dontDestroyOnLoad = true;
[SerializeField]
internal ChildSpawnMethod _spawnMethod = ChildSpawnMethod.Start;
///
/// Prefabs that are spawned in all environments where the component executes.
///
[SerializeField]
internal GameObject[] _prefabs = Array.Empty();
///
/// Prefabs spawned when running inside the Unity editor only.
///
[SerializeField]
internal GameObject[] _editorOnlyPrefabs = Array.Empty();
///
/// Prefabs spawned when running in the editor or a development build.
///
[SerializeField]
internal GameObject[] _developmentOnlyPrefabs = Array.Empty();
private readonly HashSet _spawnedPrefabs = new();
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void ClearSpawnedPrefabs()
{
SpawnedPrefabs.Clear();
}
private void Awake()
{
if (_spawnMethod.HasFlagNoAlloc(ChildSpawnMethod.Awake))
{
Spawn();
}
}
private void OnEnable()
{
if (_spawnMethod.HasFlagNoAlloc(ChildSpawnMethod.OnEnabled))
{
Spawn();
}
}
private void Start()
{
if (_spawnMethod.HasFlagNoAlloc(ChildSpawnMethod.Start))
{
Spawn();
}
}
///
/// Checks all prefab arrays for duplicates and logs an error if any are found.
/// Uses pooled collections to avoid allocations. Null prefabs are skipped.
///
private void CheckForDuplicatePrefabs()
{
GameObject[] prefabs = _prefabs ?? Array.Empty();
GameObject[] editorOnlyPrefabs = _editorOnlyPrefabs ?? Array.Empty();
GameObject[] developmentOnlyPrefabs =
_developmentOnlyPrefabs ?? Array.Empty();
int totalCount =
prefabs.Length + editorOnlyPrefabs.Length + developmentOnlyPrefabs.Length;
if (totalCount == 0)
{
return;
}
using PooledResource> seenLease = Buffers.HashSet.Get(
out HashSet seen
);
using PooledResource> duplicatesLease =
Buffers.HashSet.Get(out HashSet duplicates);
for (int i = 0; i < prefabs.Length; i++)
{
GameObject prefab = prefabs[i];
if (prefab == null)
{
continue;
}
if (!seen.Add(prefab))
{
duplicates.Add(prefab);
}
}
for (int i = 0; i < editorOnlyPrefabs.Length; i++)
{
GameObject prefab = editorOnlyPrefabs[i];
if (prefab == null)
{
continue;
}
if (!seen.Add(prefab))
{
duplicates.Add(prefab);
}
}
for (int i = 0; i < developmentOnlyPrefabs.Length; i++)
{
GameObject prefab = developmentOnlyPrefabs[i];
if (prefab == null)
{
continue;
}
if (!seen.Add(prefab))
{
duplicates.Add(prefab);
}
}
if (duplicates.Count == 0)
{
return;
}
using PooledResource> namesLease = Buffers.GetList(
duplicates.Count,
out List duplicateNames
);
foreach (GameObject prefab in duplicates)
{
duplicateNames.Add(prefab.name);
}
this.LogError($"Duplicate child prefab detected: {string.Join(",", duplicateNames)}");
}
///
/// Performs the spawning process for all configured prefab collections, applying naming
/// suffixes and duplicate checks for each group.
///
private void Spawn()
{
TrySetDontDestroyOnLoad();
CheckForDuplicatePrefabs();
int count = 0;
foreach (GameObject prefab in _prefabs)
{
GameObject child = Spawn(prefab);
if (child != null)
{
child.name = $"{child.name} ({count++:00})";
}
}
foreach (GameObject prefab in _prefabs)
{
if (prefab != null)
{
_spawnedPrefabs.Add(prefab);
}
}
#if UNITY_EDITOR
if (Application.isEditor)
{
foreach (GameObject prefab in _editorOnlyPrefabs)
{
GameObject child = Spawn(prefab);
if (child != null)
{
child.name = $"{child.name} (EDITOR-ONLY {count++:00})";
}
}
foreach (GameObject prefab in _editorOnlyPrefabs)
{
if (prefab != null)
{
_spawnedPrefabs.Add(prefab);
}
}
}
#endif
if (Application.isEditor || Debug.isDebugBuild)
{
foreach (GameObject prefab in _developmentOnlyPrefabs)
{
GameObject child = Spawn(prefab);
if (child != null)
{
child.name = $"{child.name} (DEVELOPMENT-ONLY {count++:00})";
}
}
foreach (GameObject prefab in _developmentOnlyPrefabs)
{
if (prefab != null)
{
_spawnedPrefabs.Add(prefab);
}
}
}
}
///
/// Removes Unity's default "(Clone)" suffix from instantiated prefab names.
///
/// The instantiated child whose name should be cleaned.
private static void CleanName(GameObject child)
{
child.name = child.name.Replace("(Clone)", string.Empty);
}
///
/// Instantiates as a child of this component if it has not been
/// spawned previously, guarding against duplicate DontDestroyOnLoad instances.
///
/// Prefab to spawn.
/// The instantiated child instance, or null if the spawn is skipped.
private GameObject Spawn(GameObject prefab)
{
if (prefab == null)
{
this.LogError($"Unexpectedly null prefab - cannot spawn.");
return null;
}
if (_spawnedPrefabs.Contains(prefab))
{
return null;
}
if (SpawnedPrefabs.Contains(prefab))
{
return null;
}
GameObject child = Instantiate(prefab, transform);
CleanName(child);
if (
child.IsDontDestroyOnLoad()
|| gameObject.IsDontDestroyOnLoad()
|| prefab.IsDontDestroyOnLoad()
)
{
SpawnedPrefabs.Add(prefab);
}
return child;
}
///
/// Applies when configured to
/// keep the spawner alive between scene loads.
///
private void TrySetDontDestroyOnLoad()
{
if (_dontDestroyOnLoad && Application.isPlaying && !gameObject.IsDontDestroyOnLoad())
{
DontDestroyOnLoad(gameObject);
}
}
}
}