// MIT License - Copyright (c) 2025 wallstop
// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE
namespace WallstopStudios.UnityHelpers.Core.DataStructure.Adapters
{
using System;
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using System.Runtime.Serialization;
using System.Text.Json.Serialization;
using ProtoBuf;
using UnityEngine;
using WallstopStudios.UnityHelpers.Core.Extension;
using WallstopStudios.UnityHelpers.Core.Serialization;
using WallstopStudios.UnityHelpers.Utils;
#if UNITY_EDITOR
using UnityEditor;
#endif
internal interface ISerializableSetInspector
{
Type ElementType { get; }
int UniqueCount { get; }
int SerializedCount { get; }
bool TryAddElement(object value, out object normalizedValue);
bool ContainsElement(object value);
bool RemoveElement(object value);
void ClearElements();
Array GetSerializedItemsSnapshot();
void SetSerializedItemsSnapshot(Array values, bool preserveSerializedEntries);
void SynchronizeSerializedState();
bool SupportsSorting { get; }
}
internal interface ISerializableSetEditorSync
{
void EditorAfterDeserialize();
}
///
/// Shared infrastructure for Unity-friendly serialized sets.
/// Synchronizes the serialized element array with a backing so Unity, ProtoBuf, and JSON stay in step with runtime mutations.
/// Extend this class to build custom set types with specialized equality logic or editor behavior.
///
///
/// >
/// {
/// public CaseInsensitiveTagSet()
/// : base(new HashSet(StringComparer.OrdinalIgnoreCase))
/// {
/// }
///
/// protected override bool TryGetValueCore(string equalValue, out string actualValue)
/// {
/// foreach (string value in Set)
/// {
/// if (string.Equals(value, equalValue, StringComparison.OrdinalIgnoreCase))
/// {
/// actualValue = value;
/// return true;
/// }
/// }
///
/// actualValue = equalValue;
/// return false;
/// }
/// }
/// ]]>
///
[Serializable]
[ProtoContract(IgnoreListHandling = true)]
public abstract class SerializableSetBase
: ISet,
IReadOnlyCollection,
ISerializationCallbackReceiver,
IDeserializationCallback,
ISerializable,
ISerializableSetInspector,
ISerializableSetEditorSync
where TSet : class, ISet, new()
{
static SerializableSetBase()
{
ProtobufUnityModel.EnsureInitialized();
}
protected internal bool HasDuplicatesOrNulls => _hasDuplicatesOrNulls;
internal bool PreserveSerializedEntries => _preserveSerializedEntries;
public int Count => _set.Count;
bool ICollection.IsReadOnly => _set.IsReadOnly;
protected TSet Set => _set;
protected internal T[] SerializedItems => _items;
protected virtual bool SupportsSorting => false;
[SerializeField]
[ProtoMember(1, OverwriteList = true)]
[JsonInclude]
protected internal T[] _items;
[ProtoIgnore]
protected internal TSet _set;
[NonSerialized]
protected internal bool _preserveSerializedEntries;
[NonSerialized]
protected internal bool _itemsDirty;
///
/// Tracks items added since the last serialization cycle, in insertion order.
/// This is used to preserve the order in which items were added during the next serialization.
///
[NonSerialized]
protected internal List _newItemsOrder;
[NonSerialized]
protected internal bool _hasDuplicatesOrNulls;
///
/// Initializes an empty set. Required for protobuf deserialization.
///
protected SerializableSetBase()
{
_set = new TSet();
}
protected SerializableSetBase(TSet set)
{
_set = set ?? throw new ArgumentNullException(nameof(set));
}
protected SerializableSetBase(
SerializationInfo serializationInfo,
StreamingContext streamingContext,
Func factory
)
{
if (serializationInfo == null)
{
throw new ArgumentNullException(nameof(serializationInfo));
}
if (factory == null)
{
throw new ArgumentNullException(nameof(factory));
}
_set = factory(serializationInfo, streamingContext);
}
///
/// Unity inspector helper for identifying serialized array property names.
///
internal static class SerializedPropertyNames
{
private sealed class NameHolder : SerializableHashSet
{
public const string ItemsName = nameof(_items);
}
internal const string ItemsNameInternal = NameHolder.ItemsName;
}
///
/// Adds an element to the set and updates the serialized cache when the value was not already present.
///
/// The element to insert.
/// true when the value was added.
///
/// abilities = new SerializableHashSet();
/// bool added = abilities.Add("Dash");
/// ]]>
///
public bool Add(T item)
{
bool added = _set.Add(item);
if (added)
{
TrackNewItem(item);
MarkSerializationCacheDirty();
}
return added;
}
///
/// Tracks a newly added item for order preservation during serialization.
///
private void TrackNewItem(T item)
{
_newItemsOrder ??= new List();
_newItemsOrder.Add(item);
}
void ICollection.Add(T item)
{
Add(item);
}
///
/// Adds all values from the provided sequence to the set.
///
/// Values to union into this set.
///
/// abilities = new SerializableHashSet();
/// string[] unlocks = new string[] { "Dash", "Grapple" };
/// abilities.UnionWith(unlocks);
/// ]]>
///
public void UnionWith(IEnumerable other)
{
if (other == null)
{
throw new ArgumentNullException(nameof(other));
}
// Track items that will be added for order preservation
foreach (T item in other)
{
if (_set.Add(item))
{
TrackNewItem(item);
}
}
MarkSerializationCacheDirty();
}
///
/// Removes any element that is not contained in the provided sequence.
///
/// Sequence that defines the intersection.
///
/// abilities = new SerializableHashSet();
/// string[] allowed = new string[] { "Dash" };
/// abilities.IntersectWith(allowed);
/// ]]>
///
public void IntersectWith(IEnumerable other)
{
if (other == null)
{
throw new ArgumentNullException(nameof(other));
}
_set.IntersectWith(other);
// Items may have been removed, so clear tracked new items
// (they may no longer be in the set)
_newItemsOrder?.Clear();
MarkSerializationCacheDirty();
}
///
/// Removes all elements that appear in the provided sequence.
///
/// Sequence whose members should be removed.
///
/// abilities = new SerializableHashSet();
/// string[] deprecated = new string[] { "Dash" };
/// abilities.ExceptWith(deprecated);
/// ]]>
///
public void ExceptWith(IEnumerable other)
{
if (other == null)
{
throw new ArgumentNullException(nameof(other));
}
_set.ExceptWith(other);
// Items may have been removed, so clear tracked new items
_newItemsOrder?.Clear();
MarkSerializationCacheDirty();
}
///
/// Modifies the set so it contains elements that appear in exactly one of the sequences.
///
/// Sequence whose elements are compared against the current set.
///
/// first = new SerializableHashSet();
/// first.Add("Dash");
/// string[] second = new string[] { "Dash", "Grapple" };
/// first.SymmetricExceptWith(second);
/// ]]>
///
public void SymmetricExceptWith(IEnumerable other)
{
if (other == null)
{
throw new ArgumentNullException(nameof(other));
}
_set.SymmetricExceptWith(other);
// Items may have been added or removed, so clear tracked new items
_newItemsOrder?.Clear();
MarkSerializationCacheDirty();
}
///
/// Determines whether the set is a subset of the provided sequence.
///
/// Sequence to compare against.
/// true when every element exists in the other sequence.
///
/// storyUnlocks = new SerializableHashSet();
/// bool subset = storyUnlocks.IsSubsetOf(new string[] { "Dash", "DoubleJump" });
/// ]]>
///
public bool IsSubsetOf(IEnumerable other)
{
return _set.IsSubsetOf(other);
}
///
/// Determines whether the set contains all values found in the provided sequence.
///
/// Sequence that must be contained in the set.
/// true when the set is a superset.
///
/// storyUnlocks = new SerializableHashSet();
/// bool superset = storyUnlocks.IsSupersetOf(new string[] { "Dash" });
/// ]]>
///
public bool IsSupersetOf(IEnumerable other)
{
return _set.IsSupersetOf(other);
}
///
/// Determines whether the set strictly contains all values from the other sequence and has additional elements.
///
/// Sequence that must be contained in the set.
/// true when the set is a proper superset.
///
/// storyUnlocks = new SerializableHashSet();
/// bool properSuperset = storyUnlocks.IsProperSupersetOf(new string[] { "Dash" });
/// ]]>
///
public bool IsProperSupersetOf(IEnumerable other)
{
return _set.IsProperSupersetOf(other);
}
///
/// Determines whether the set is strictly contained inside the provided sequence.
///
/// Sequence that must contain every element plus at least one additional element.
/// true when the set is a proper subset.
///
/// storyUnlocks = new SerializableHashSet();
/// bool properSubset = storyUnlocks.IsProperSubsetOf(new string[] { "Dash", "Grapple" });
/// ]]>
///
public bool IsProperSubsetOf(IEnumerable other)
{
return _set.IsProperSubsetOf(other);
}
///
/// Determines whether the set shares any element with the provided sequence.
///
/// Sequence to compare to.
/// true when at least one value is shared.
///
/// storyUnlocks = new SerializableHashSet();
/// bool overlaps = storyUnlocks.Overlaps(new string[] { "Dash" });
/// ]]>
///
public bool Overlaps(IEnumerable other)
{
return _set.Overlaps(other);
}
///
/// Determines whether this set and the provided sequence contain the exact same elements.
///
/// Sequence to compare against.
/// true when both contain identical members.
///
/// storyUnlocks = new SerializableHashSet();
/// bool matches = storyUnlocks.SetEquals(new string[] { "Dash", "DoubleJump" });
/// ]]>
///
public bool SetEquals(IEnumerable other)
{
return _set.SetEquals(other);
}
///
/// Removes every element from the set and clears the serialized cache.
///
///
/// storyUnlocks = new SerializableHashSet();
/// storyUnlocks.Clear();
/// ]]>
///
public void Clear()
{
if (_set.Count == 0)
{
return;
}
_set.Clear();
_newItemsOrder?.Clear();
MarkSerializationCacheDirty();
}
///
/// Determines whether the set contains the specified element.
///
/// The element to look up.
/// true when the element exists.
///
/// storyUnlocks = new SerializableHashSet();
/// bool hasDash = storyUnlocks.Contains("Dash");
/// ]]>
///
public bool Contains(T item)
{
return _set.Contains(item);
}
///
/// Retrieves the stored value that compares equal to the supplied value.
///
/// The candidate value.
/// Receives the canonical value from the set.
/// true when a matching element is found.
///
/// storyUnlocks = new SerializableHashSet();
/// string normalized;
/// bool found = storyUnlocks.TryGetValue("Dash", out normalized);
/// ]]>
///
public bool TryGetValue(T equalValue, out T actualValue)
{
if (TryGetValueCore(equalValue, out T resolved))
{
actualValue = resolved;
return true;
}
EqualityComparer comparer = EqualityComparer.Default;
foreach (T value in _set)
{
if (comparer.Equals(value, equalValue))
{
actualValue = value;
return true;
}
}
actualValue = default;
return false;
}
///
/// Allows derived types to substitute custom lookup behavior (for example, when values are wrapped).
///
/// The candidate value.
/// Receives the resolved value.
/// true when the derived type resolved a match.
///
///
///
protected virtual bool TryGetValueCore(T equalValue, out T actualValue)
{
actualValue = default;
return false;
}
///
/// Creates a new array containing all elements in the set's natural iteration order.
///
///
///
/// Returns elements in the order determined by the underlying 's iteration order.
/// This matches the behavior of enumerating a and standard set semantics.
///
///
/// The returned array is always a defensive copy - modifications to it do not affect the set.
/// For empty sets, is returned.
///
///
/// To retrieve elements in their user-defined serialization order (as shown in the Unity inspector),
/// use instead.
///
///
/// A new array containing all elements in set iteration order.
///
/// abilities = new SerializableHashSet();
/// abilities.Add("Dash");
/// abilities.Add("Jump");
/// string[] abilityArray = abilities.ToArray();
/// ]]>
///
public virtual T[] ToArray()
{
int count = _set.Count;
if (count == 0)
{
return Array.Empty();
}
// Return elements in set iteration order (from the underlying set)
T[] result = new T[count];
_set.CopyTo(result, 0);
return result;
}
///
/// Creates a new array containing all elements in their user-defined serialization order.
///
///
///
/// Returns elements in the order they appear in the serialized backing array, which reflects
/// the user-defined order from the Unity inspector. This order is preserved across domain
/// reloads and serialization cycles.
///
///
/// The returned array is always a defensive copy - modifications to it do not affect the set.
/// For empty sets, is returned.
///
///
/// To retrieve elements in the set's natural iteration order, use instead.
///
///
/// A new array containing all elements in serialization order.
///
/// abilities = new SerializableHashSet();
/// // After inspector reordering, elements might be in a custom order
/// string[] abilityArray = abilities.ToPersistedOrderArray(); // Returns elements in inspector order
/// ]]>
///
public virtual T[] ToPersistedOrderArray()
{
int count = _set.Count;
if (count == 0)
{
return Array.Empty();
}
// Ensure serialized state is current before reading from _items
if (_items == null || _itemsDirty || _items.Length != count)
{
OnBeforeSerialize();
}
// Return a defensive copy preserving user-defined order
T[] result = new T[count];
Array.Copy(_items, result, count);
return result;
}
///
/// Copies the elements into the provided array starting at index zero.
///
/// Destination array.
///
/// storyUnlocks = new SerializableHashSet();
/// string[] snapshot = new string[storyUnlocks.Count];
/// storyUnlocks.CopyTo(snapshot);
/// ]]>
///
public void CopyTo(T[] array)
{
CopyTo(array, 0);
}
///
/// Copies the elements into the provided array starting at the given index.
///
/// Destination array.
/// Index in where copying begins.
///
/// storyUnlocks = new SerializableHashSet();
/// string[] snapshot = new string[storyUnlocks.Count + 2];
/// storyUnlocks.CopyTo(snapshot, 1);
/// ]]>
///
public void CopyTo(T[] array, int arrayIndex)
{
_set.CopyTo(array, arrayIndex);
}
///
/// Removes a single element from the set and updates the serialized cache.
///
/// The element to remove.
/// true when the value existed.
///
/// storyUnlocks = new SerializableHashSet();
/// bool removed = storyUnlocks.Remove("Dash");
/// ]]>
///
public bool Remove(T item)
{
bool removed = _set.Remove(item);
if (removed)
{
// Remove from tracked new items if present
_newItemsOrder?.Remove(item);
MarkSerializationCacheDirty();
}
return removed;
}
///
/// Removes every element that satisfies the provided predicate.
///
/// Condition that determines which elements are removed.
/// The number of elements removed.
///
/// storyUnlocks = new SerializableHashSet();
/// int removed = storyUnlocks.RemoveWhere(id => id.Contains("Beta"));
/// ]]>
///
public int RemoveWhere(Predicate match)
{
if (match == null)
{
throw new ArgumentNullException(nameof(match));
}
int removed = RemoveWhereInternal(match);
if (removed > 0)
{
// Remove matching items from tracked new items
_newItemsOrder?.RemoveAll(match);
MarkSerializationCacheDirty();
}
return removed;
}
///
/// Allows derived types to customize how batch removals are handled.
///
/// The predicate describing which elements to remove.
/// The number of removed elements.
protected virtual int RemoveWhereInternal(Predicate match)
{
using PooledResource> bufferResource = Buffers.List.Get(out List buffer);
foreach (T value in _set)
{
if (match(value))
{
buffer.Add(value);
}
}
foreach (T value in buffer)
{
_set.Remove(value);
}
return buffer.Count;
}
///
IEnumerator IEnumerable.GetEnumerator()
{
return _set.GetEnumerator();
}
///
IEnumerator IEnumerable.GetEnumerator()
{
return ((IEnumerable)_set).GetEnumerator();
}
///
/// Copies the live set contents into the serialized backing array before Unity or ProtoBuf serialization.
///
///
///
/// When a serialized array already exists from a previous deserialization, this method preserves its
/// order while synchronizing with the runtime set. This ensures that the user-defined order of elements
/// as shown in the Unity inspector is maintained across domain reloads and serialization cycles.
///
///
/// The synchronization process:
///
/// - Existing elements: Kept in their original order if still in the set
/// - Removed elements: Filtered out from the array
/// - New elements: Appended to the end of the array
///
///
///
///
///
/// hashSet.OnBeforeSerialize();
/// var snapshot = hashSet.SerializedItems;
///
///
public void OnBeforeSerialize()
{
// If we have valid items with duplicates/nulls and should preserve them,
// skip sync entirely to maintain the inspector's view of problematic data.
if (
_preserveSerializedEntries
&& _items != null
&& !_itemsDirty
&& _hasDuplicatesOrNulls
)
{
return;
}
// If we have valid items and should preserve order, sync while maintaining order
if (_preserveSerializedEntries && _items != null && !_itemsDirty)
{
SyncSerializedItemsPreservingOrder();
return;
}
// If items exist but are dirty, try to preserve order while applying changes
if (_items != null && _itemsDirty)
{
SyncSerializedItemsPreservingOrder();
_itemsDirty = false;
_preserveSerializedEntries = true;
return;
}
// No existing items - build from scratch (set's natural order)
int count = _set.Count;
_items = new T[count];
_set.CopyTo(_items, 0);
_preserveSerializedEntries = true;
_itemsDirty = false;
}
///
/// Synchronizes the serialized items array with the set while preserving the existing order.
/// New items are appended, removed items are filtered out.
///
private void SyncSerializedItemsPreservingOrder()
{
int setCount = _set.Count;
int arrayLength = _items.Length;
// Fast path: if counts match, all array items are unique, and all items still exist in the set, no changes needed.
// We must check for uniqueness because duplicate items in the array can make counts match by coincidence
// (e.g., array has {3, 3} with setCount=2 after adding item 4, but the array should become {3, 4}).
if (setCount == arrayLength)
{
using PooledResource> fastPathSeenResource = Buffers.HashSet.Get(
out HashSet fastPathSeenItems
);
bool allItemsMatchAndUnique = true;
for (int i = 0; i < arrayLength; i++)
{
T item = _items[i];
// Check both that the item exists in the set AND that it's not a duplicate in the array
if (!_set.Contains(item) || !fastPathSeenItems.Add(item))
{
allItemsMatchAndUnique = false;
break;
}
}
if (allItemsMatchAndUnique)
{
// Clear any tracked new items since no changes needed
_newItemsOrder?.Clear();
return;
}
}
// Need to rebuild array while preserving order of existing items
using PooledResource> itemsResource = Buffers.List.Get(out List newItems);
using PooledResource> seenResource = Buffers.HashSet.Get(
out HashSet seenItems
);
// First pass: keep existing items that still exist in the set, in their original order
for (int i = 0; i < arrayLength; i++)
{
T item = _items[i];
if (_set.Contains(item) && seenItems.Add(item))
{
newItems.Add(item);
}
}
// Second pass: append new items in the order they were added (if tracked)
if (_newItemsOrder is { Count: > 0 })
{
foreach (T item in _newItemsOrder)
{
// Only add if it still exists in the set and wasn't already seen
if (_set.Contains(item) && seenItems.Add(item))
{
newItems.Add(item);
}
}
}
else
{
// Fallback: iterate over the set for items not in the original array
// (order may not match insertion order)
foreach (T item in _set)
{
if (seenItems.Add(item))
{
newItems.Add(item);
}
}
}
// Rebuild array
_items = newItems.ToArray();
// Clear the tracked new items since they're now in the serialized array
_newItemsOrder?.Clear();
}
///
/// Reconstructs the live set from the serialized array after Unity or ProtoBuf deserialization.
///
///
///
/// The serialized array represents the user-defined order of elements as they appear in the Unity inspector.
/// This order is preserved across domain reloads and serialization cycles. The internal set maintains its
/// natural ordering for efficient operations, but the serialized array always reflects the user's intended order.
///
///
///
///
/// hashSet.OnAfterDeserialize();
/// bool contains = hashSet.Contains(item);
///
///
public void OnAfterDeserialize()
{
OnAfterDeserializeInternal(suppressWarnings: false);
}
void ISerializableSetEditorSync.EditorAfterDeserialize()
{
OnAfterDeserializeInternal(suppressWarnings: true);
}
private void OnAfterDeserializeInternal(bool suppressWarnings)
{
if (_items == null)
{
_preserveSerializedEntries = false;
_itemsDirty = true;
_set.Clear();
return;
}
_set.Clear();
bool hasDuplicates = false;
bool encounteredNullReference = false;
bool supportsNullCheck = TypeSupportsNullReferences(typeof(T));
for (int index = 0; index < _items.Length; index++)
{
T value = _items[index];
if (supportsNullCheck && ReferenceEquals(value, null))
{
encounteredNullReference = true;
if (!suppressWarnings)
{
LogNullEntrySkip(index);
}
continue;
}
if (!_set.Add(value) && !hasDuplicates)
{
hasDuplicates = true;
}
}
// Always preserve the serialized array after deserialization to maintain user-defined order.
// The array represents the order as it appears in the Unity inspector, which should not
// change due to domain reloads. Only runtime modifications via Add/Remove/Clear should
// trigger array rebuilding (handled by MarkSerializationCacheDirty).
_preserveSerializedEntries = true;
_itemsDirty = false;
// Clear tracked new items since we're starting fresh after deserialization
_newItemsOrder?.Clear();
// Track if we have duplicates/nulls that require special handling in the editor
_hasDuplicatesOrNulls = hasDuplicates || encounteredNullReference;
}
private static bool TypeSupportsNullReferences(Type type)
{
return type != null
&& (!type.IsValueType || typeof(UnityEngine.Object).IsAssignableFrom(type));
}
private static void LogNullEntrySkip(int index)
{
#if UNITY_EDITOR
if (!EditorShouldLog())
{
return;
}
#endif
Debug.LogError(
$"SerializableSet<{typeof(T).FullName}> skipped serialized entry at index {index} because the value reference was null."
);
}
#if UNITY_EDITOR
private static bool EditorShouldLog()
{
try
{
return EditorApplication.isPlayingOrWillChangePlaymode;
}
catch (UnityException)
{
return false;
}
}
#endif
[ProtoBeforeSerialization]
protected internal void OnProtoBeforeSerialization()
{
OnBeforeSerialize();
}
[ProtoAfterSerialization]
protected internal void OnProtoAfterSerialization()
{
if (_preserveSerializedEntries)
{
return;
}
_items = null;
}
[ProtoAfterDeserialization]
protected internal void OnProtoAfterDeserialization()
{
if (_set == null)
{
_set = new TSet();
}
if (_items == null)
{
T[] rebuiltItems = new T[_set.Count];
_set.CopyTo(rebuiltItems, 0);
_items = rebuiltItems;
_preserveSerializedEntries = true;
_itemsDirty = false;
}
OnAfterDeserialize();
}
///
/// Completes deserialization after data has been applied.
///
/// Reserved for future use.
public void OnDeserialization(object sender)
{
if (_set is IDeserializationCallback callback)
{
callback.OnDeserialization(sender);
}
}
///
/// Writes the serialized representation of the set into a instance.
///
/// The serialization store to populate.
/// Context for the serialization process.
public void GetObjectData(SerializationInfo info, StreamingContext context)
{
if (_set is ISerializable serializable)
{
serializable.GetObjectData(info, context);
}
}
protected void MarkSerializationCacheDirty()
{
_preserveSerializedEntries = false;
_itemsDirty = true;
// Clear the duplicates/nulls flag since we're invalidating the serialization cache.
// After a mutation, the set's internal state becomes the source of truth, and a Set
// data structure cannot contain duplicates by definition. The flag will be recalculated
// during the next OnAfterDeserialize if needed.
_hasDuplicatesOrNulls = false;
// Note: We intentionally do NOT null out _items here to preserve order information
// for SyncSerializedItemsPreservingOrder() during the next OnBeforeSerialize() call.
}
///
/// Returns a JSON string describing the serialized items for quick debugging.
///
/// A JSON representation of the set.
///
/// storyUnlocks = new SerializableHashSet();
/// string snapshot = storyUnlocks.ToString();
/// ]]>
///
public override string ToString()
{
return this.ToJson();
}
Type ISerializableSetInspector.ElementType => typeof(T);
int ISerializableSetInspector.UniqueCount => _set.Count;
int ISerializableSetInspector.SerializedCount => _items?.Length ?? _set.Count;
bool ISerializableSetInspector.SupportsSorting => SupportsSorting;
bool ISerializableSetInspector.TryAddElement(object value, out object normalizedValue)
{
if (!TryConvertToElement(value, out T converted))
{
normalizedValue = default(T);
return false;
}
bool added = _set.Add(converted);
if (added)
{
MarkSerializationCacheDirty();
}
normalizedValue = converted;
return added;
}
bool ISerializableSetInspector.ContainsElement(object value)
{
if (!TryConvertToElement(value, out T converted))
{
return false;
}
return _set.Contains(converted);
}
bool ISerializableSetInspector.RemoveElement(object value)
{
if (!TryConvertToElement(value, out T converted))
{
return false;
}
bool removed = _set.Remove(converted);
if (removed)
{
MarkSerializationCacheDirty();
}
return removed;
}
void ISerializableSetInspector.ClearElements()
{
if (_set.Count == 0 && (_items == null || _items.Length == 0))
{
return;
}
_set.Clear();
_items = null;
_preserveSerializedEntries = false;
}
Array ISerializableSetInspector.GetSerializedItemsSnapshot()
{
if (_items is { Length: > 0 })
{
return (T[])_items.Clone();
}
if (_set.Count == 0)
{
return Array.Empty();
}
T[] snapshot = new T[_set.Count];
_set.CopyTo(snapshot, 0);
return snapshot;
}
void ISerializableSetInspector.SetSerializedItemsSnapshot(
Array values,
bool preserveSerializedEntries
)
{
if (values == null || values.Length == 0)
{
_items = null;
_preserveSerializedEntries = false;
_hasDuplicatesOrNulls = false;
_itemsDirty = false;
_set.Clear();
return;
}
int length = values.Length;
T[] convertedItems = new T[length];
for (int index = 0; index < length; index++)
{
object raw = values.GetValue(index);
if (!TryConvertToElement(raw, out T converted))
{
converted = default;
}
convertedItems[index] = converted;
}
_items = convertedItems;
_preserveSerializedEntries = preserveSerializedEntries;
_itemsDirty = false;
bool hasDuplicates = false;
bool hasNulls = false;
bool supportsNullCheck = TypeSupportsNullReferences(typeof(T));
_set.Clear();
foreach (T convertedItem in convertedItems)
{
if (supportsNullCheck && ReferenceEquals(convertedItem, null))
{
hasNulls = true;
}
else if (!_set.Add(convertedItem))
{
hasDuplicates = true;
}
}
_hasDuplicatesOrNulls = hasDuplicates || hasNulls;
}
void ISerializableSetInspector.SynchronizeSerializedState()
{
OnBeforeSerialize();
}
private bool TryConvertToElement(object value, out T result)
{
if (value is T typedValue)
{
result = typedValue;
return true;
}
if (value == null)
{
if (default(T) == null)
{
result = default;
return true;
}
result = default;
return false;
}
Type elementType = typeof(T);
if (elementType.IsInstanceOfType(value))
{
result = (T)value;
return true;
}
try
{
if (elementType.IsEnum)
{
if (value is string enumName)
{
result = (T)Enum.Parse(elementType, enumName);
return true;
}
object enumValue = Enum.ToObject(elementType, value);
result = (T)enumValue;
return true;
}
if (value is IConvertible)
{
object converted = Convert.ChangeType(
value,
elementType,
CultureInfo.InvariantCulture
);
result = (T)converted;
return true;
}
}
catch
{
// Fallback handled below.
}
result = default;
return false;
}
}
///
/// Unity-serializable hash set that keeps elements deduplicated while remaining compatible with ProtoBuf and System.Text.Json.
/// Perfect for authoring unlock lists, feature flags, and other boolean membership data in the inspector.
///
///
/// unlockedLevels = new SerializableHashSet();
///
/// public bool HasUnlocked(string levelId)
/// {
/// return unlockedLevels.Contains(levelId);
/// }
/// }
/// ]]>
///
[Serializable]
public class SerializableHashSet : SerializableSetBase>
{
private sealed class StorageSet : HashSet
{
///
/// Initializes an empty storage set using the default comparer.
///
public StorageSet() { }
///
/// Initializes an empty storage set that uses the provided comparer.
///
/// Comparer passed to .
public StorageSet(IEqualityComparer comparer)
: base(comparer) { }
///
/// Initializes the storage set with the supplied elements and comparer.
///
/// Elements to copy into the backing set.
/// Comparer used to determine uniqueness.
public StorageSet(IEnumerable collection, IEqualityComparer comparer)
: base(collection, comparer) { }
///
/// Deserialization constructor used by .
///
/// Serialized data describing the set.
/// Context describing the serialization source.
public StorageSet(SerializationInfo info, StreamingContext context)
: base(info, context) { }
}
///
/// Initializes an empty hash set compatible with Unity and ProtoBuf serialization.
///
public SerializableHashSet()
: base(new StorageSet()) { }
///
/// Initializes an empty hash set using the supplied equality comparer.
///
/// Comparer used to evaluate set membership. Defaults to when null.
public SerializableHashSet(IEqualityComparer comparer)
: base(new StorageSet(comparer ?? EqualityComparer.Default)) { }
///
/// Initializes the set with elements copied from the provided collection.
///
/// Sequence whose elements are added to the set. is used when null.
public SerializableHashSet(IEnumerable collection)
: base(new StorageSet(collection ?? Array.Empty(), EqualityComparer.Default)) { }
///
/// Initializes the set with elements copied from the provided collection and comparer.
///
/// Sequence whose elements are added to the set. is used when null.
/// Comparer used to evaluate set membership. Defaults to when null.
public SerializableHashSet(IEnumerable collection, IEqualityComparer comparer)
: base(
new StorageSet(
collection ?? Array.Empty(),
comparer ?? EqualityComparer.Default
)
) { }
protected SerializableHashSet(SerializationInfo info, StreamingContext context)
: base(
info,
context,
(serializationInfo, streamingContext) =>
new StorageSet(serializationInfo, streamingContext)
) { }
///
/// Gets the equality comparer used by the underlying hash set.
///
public IEqualityComparer Comparer => Set.Comparer;
///
/// Creates a new populated with this set's contents.
///
/// A copy of the hash set's current state.
public HashSet ToHashSet()
{
HashSet copy = new(Set, Set.Comparer);
return copy;
}
///
/// Returns an enumerator that iterates through the set.
///
public HashSet.Enumerator GetEnumerator()
{
return Set.GetEnumerator();
}
///
/// Copies a subset of elements into the provided array starting at the specified index.
///
/// Destination array that receives items.
/// Zero-based index indicating where copying starts.
/// Number of elements to copy.
public void CopyTo(T[] array, int arrayIndex, int count)
{
Set.CopyTo(array, arrayIndex, count);
}
///
/// Resizes the underlying hash set to remove unused capacity.
///
public void TrimExcess()
{
Set.TrimExcess();
}
protected override int RemoveWhereInternal(Predicate match)
{
return Set.RemoveWhere(match);
}
protected override bool TryGetValueCore(T equalValue, out T actualValue)
{
return Set.TryGetValue(equalValue, out actualValue);
}
}
internal static class SerializableHashSetSerializedPropertyNames
{
internal const string Items = SerializableHashSet
.SerializedPropertyNames
.ItemsNameInternal;
}
}