// 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.Concurrent; using System.Collections.Generic; using System.Reflection; using System.Runtime.Serialization; using System.Text.Json; using System.Text.Json.Serialization; using ProtoBuf; using UnityEngine; using WallstopStudios.UnityHelpers.Core.Attributes; using WallstopStudios.UnityHelpers.Core.Helper; /// /// Unity-serializable alternative to that supports ProtoBuf and JSON. /// Enables authoring optional value types in the inspector without custom drawer code. /// /// /// respawnDelay = new SerializableNullable(5f); /// /// public bool TryGetRespawnDelay(out float seconds) /// { /// return respawnDelay.TryGet(out seconds); /// } /// } /// ]]> /// /// The underlying value type. [Serializable] [ProtoContract] [JsonConverter(typeof(SerializableNullableJsonConverterFactory))] public struct SerializableNullable : IEquatable>, IEquatable, IEquatable, ISerializationCallbackReceiver, ISerializable where T : struct { [SerializeField] [ProtoMember(1)] private bool _hasValue; [SerializeField] [ProtoMember(2, IsRequired = false)] [WShowIf(nameof(_hasValue))] private T _value; /// /// Initializes a new instance of the struct. /// /// Value to assign. public SerializableNullable(T value) { _hasValue = true; _value = value; } /// /// Initializes a new instance of the struct. /// /// Nullable value to copy. public SerializableNullable(T? value) { _hasValue = value.HasValue; _value = value.GetValueOrDefault(); } private SerializableNullable(SerializationInfo info, StreamingContext context) { _hasValue = info.GetBoolean(nameof(_hasValue)); if (_hasValue) { object boxed = info.GetValue(nameof(_value), typeof(T)); _value = boxed != null ? (T)boxed : default; } else { _value = default; } } /// /// Gets a value indicating whether the instance currently stores a value. /// public bool HasValue => _hasValue; /// /// Gets the stored value, throwing when the value is absent. /// public T Value { get { if (!_hasValue) { throw new InvalidOperationException("Nullable object must have a value."); } return _value; } } /// /// Attempts to copy the stored value. /// /// Receives the stored value when present. /// True when a value is available. public bool TryGetValue(out T result) { if (_hasValue) { result = _value; return true; } result = default; return false; } /// /// Clears the stored value. /// public void Clear() { _hasValue = false; _value = default; } /// /// Assigns a new value. /// /// Value to assign. public void SetValue(T newValue) { _hasValue = true; _value = newValue; } /// /// Returns the stored value or the underlying type default. /// public T GetValueOrDefault() { return _value; } /// /// Returns the stored value or a provided default. /// /// Fallback value. public T GetValueOrDefault(T defaultValue) { return _hasValue ? _value : defaultValue; } /// public override string ToString() { return _hasValue ? _value.ToString() : string.Empty; } /// public override int GetHashCode() { if (!_hasValue) { return 0; } EqualityComparer comparer = EqualityComparer.Default; return comparer.GetHashCode(_value); } /// public override bool Equals(object obj) { if (obj is SerializableNullable otherNullable) { return Equals(otherNullable); } if (obj is T otherValue) { return Equals(otherValue); } return obj == null && !_hasValue; } /// public bool Equals(SerializableNullable other) { if (_hasValue != other._hasValue) { return false; } if (!_hasValue) { return true; } EqualityComparer comparer = EqualityComparer.Default; return comparer.Equals(_value, other._value); } /// public bool Equals(T? other) { if (_hasValue != other.HasValue) { return false; } if (!_hasValue) { return true; } EqualityComparer comparer = EqualityComparer.Default; return comparer.Equals(_value, other.GetValueOrDefault()); } /// public bool Equals(T other) { if (!_hasValue) { return false; } EqualityComparer comparer = EqualityComparer.Default; return comparer.Equals(_value, other); } /// /// Implicit conversion from value to nullable wrapper. /// public static implicit operator SerializableNullable(T value) { SerializableNullable wrapper = new(value); return wrapper; } /// /// Implicit conversion from to wrapper. /// public static implicit operator SerializableNullable(T? value) { SerializableNullable wrapper = new(value); return wrapper; } /// /// Implicit conversion to . /// public static implicit operator T?(SerializableNullable value) { if (!value._hasValue) { return null; } return value._value; } /// /// Explicit conversion to the underlying value. /// public static explicit operator T(SerializableNullable value) { return value.Value; } /// void ISerializationCallbackReceiver.OnBeforeSerialize() { if (!_hasValue) { _value = default; } } /// void ISerializationCallbackReceiver.OnAfterDeserialize() { if (!_hasValue) { _value = default; } } /// void ISerializable.GetObjectData(SerializationInfo info, StreamingContext context) { info.AddValue(nameof(_hasValue), _hasValue); if (_hasValue) { info.AddValue(nameof(_value), _value, typeof(T)); } } internal static class SerializedPropertyNames { internal const string HasValue = nameof(_hasValue); internal const string Value = nameof(_value); } internal void ForceStateForTesting(bool hasValue, T rawValue) { _hasValue = hasValue; _value = rawValue; } internal static bool TryGetValueFieldAttribute(out WShowIfAttribute attribute) { attribute = ValueFieldAttribute ??= ResolveValueFieldAttribute(); return attribute != null; } private static WShowIfAttribute ValueFieldAttribute; private static WShowIfAttribute ResolveValueFieldAttribute() { FieldInfo valueField = typeof(SerializableNullable).GetField( SerializedPropertyNames.Value, BindingFlags.Instance | BindingFlags.NonPublic ); return ReflectionHelpers.TryGetAttributeSafe( valueField, out WShowIfAttribute attribute, inherit: false ) ? attribute : null; } } internal static class SerializableNullableSerializedPropertyNames { internal const string HasValue = SerializableNullable.SerializedPropertyNames.HasValue; internal const string Value = SerializableNullable.SerializedPropertyNames.Value; } internal sealed class SerializableNullableJsonConverterFactory : JsonConverterFactory { private static readonly ConcurrentDictionary ConverterCache = new(); /// /// Determines whether the provided type is a wrapper. /// /// The type requested by . /// true when the factory can create a converter. /// /// )) != null; /// ]]> /// public override bool CanConvert(Type typeToConvert) { if (!typeToConvert.IsGenericType) { return false; } Type genericType = typeToConvert.GetGenericTypeDefinition(); return genericType == typeof(SerializableNullable<>); } /// /// Creates a concrete converter for the inner value type. /// /// The requested type. /// Serializer options requesting the converter. /// A converter instance that can read and write the wrapper. /// /// )); /// ]]> /// public override JsonConverter CreateConverter(Type type, JsonSerializerOptions options) { if (type == null) { throw new ArgumentNullException(nameof(type)); } Type[] arguments = type.GetGenericArguments(); Type valueType = arguments[0]; return ConverterCache.GetOrAdd( valueType, value => { Type converterType = typeof(SerializableNullableJsonConverter<>).MakeGenericType(value); Func creator = ReflectionHelpers.GetParameterlessConstructor( converterType ); return (JsonConverter)creator(); } ); } } internal sealed class SerializableNullableJsonConverter : JsonConverter> where T : struct { /// /// Reads a from JSON by delegating to the wrapped value converter. /// /// /// value = /// JsonSerializer.Deserialize>("42", options); /// ]]> /// public override SerializableNullable Read( ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options ) { if (reader.TokenType == JsonTokenType.Null) { SerializableNullable wrapper = default; wrapper.Clear(); return wrapper; } T deserialized = JsonSerializer.Deserialize(ref reader, options); SerializableNullable result = new(deserialized); return result; } /// /// Writes the wrapped value or null depending on . /// /// /// value = new SerializableNullable(5); /// string json = JsonSerializer.Serialize(value, options); /// ]]> /// public override void Write( Utf8JsonWriter writer, SerializableNullable value, JsonSerializerOptions options ) { if (!value.HasValue) { writer.WriteNullValue(); return; } JsonSerializer.Serialize(writer, value.Value, options); } } }