Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sharing some solution to handle Unity serialization #33

Open
Lythom opened this issue Aug 26, 2024 · 2 comments
Open

Sharing some solution to handle Unity serialization #33

Lythom opened this issue Aug 26, 2024 · 2 comments

Comments

@Lythom
Copy link

Lythom commented Aug 26, 2024

Hey! I've been trying TinkStateSharp on some projects to replace my home-made observable state solution that requires manual binding so that the bindings are auto-generated. It works mostly great so far; the biggest friction I had was serializing the state and driving it from a Unity MonoBehaviour.

This message is to share what I've come up with, in case it can help :)

I succeeded first by hacking the source code, breaking encapsulation, so injecting serialization code into the base code works but is not a very clean solution. Then I came up with a wrapper that can work as standalone and could be used without modifying the source code. Maybe something like this could be provided with the TinkState-Unity package?

Assets/Scripts/SerializableState.cs:

using System;
using System.Collections.Generic;
using TinkState;

[Serializable]
public class SerializableState<T> : State<T> // implements State
#if UNITY_2020_3_OR_NEWER
    , UnityEngine.ISerializationCallbackReceiver
#endif
{
    private State<T> _state;

#if UNITY_2020_3_OR_NEWER
    [UnityEngine.SerializeField]
#endif
    private T _v;

    public SerializableState(T initialValue) {
        _state = Observable.State(initialValue); // Use State as the underlying interface / implementation
        _v = initialValue; // use _v to serialize the value
    }

    public T Value {
        get => _state.Value;
        set => SetValue(value);
    }

    public static implicit operator T(SerializableState<T> value) {
        return value.Value;
    }

    private bool SetValue(T nextValue) {
        _state.Value = nextValue;
        _v = _state.Value;
        return true;
    }

    public override string ToString() {
        return _state.ToString();
    }

    public IDisposable Bind(Action<T> callback, IEqualityComparer<T>? comparer = null, Scheduler? scheduler = null) {
        return _state.Bind(callback!, comparer!, scheduler);
    }

    public Observable<TOut> Map<TOut>(Func<T, TOut> transform, IEqualityComparer<TOut>? comparer = null) {
        return _state.Map(transform, comparer!);
    }


    public void OnBeforeSerialize() {
    }

    public void OnAfterDeserialize() {
        Value = _v; // On unity deserialize hook, initialise the State object with the serialized value
    }
}

This wrapper implements the State interface but provides a concrete implementation that Unity can serialize. MessagePack or JSON.NET annotations could be added here as well. I use an alternative version with MessagePack that also works in a .NET Core environment, which is why Unity references are conditioned by macros. I removed MessagePack here to suggest code that doesn't have any hard dependencies, but it's very straightforward to modify.

The wrapper allows the value to be serialized and driven using the Unity inspector, which is very convenient while debugging: because all values are bound, any change in the inspector automatically triggers updates where needed. It can, of course, break the game state or integrity, but that's the point of debugging tools.

Also, because the value is wrapped, the inspector displays a not-so-practical drawer:

public class ExperimentState : MonoBehaviour {
    [Required]
    public GameObject Target = null!;

    public SerializableState<float> Scale = new(0.1f);
    public SerializableState<Vector2> Position = new(Vector2.zero);
}

image

A solution is to use a custom value drawer. With the help of Odin Inspector, we can have the following editor code and result:
Assets\Editor\Scripts\SerializableStateOdinDrawer.cs:

using Sirenix.OdinInspector.Editor;
using UnityEngine;

[DrawerPriority(DrawerPriorityLevel.SuperPriority)]
public class SerializableStateOdinDrawer<T> : OdinValueDrawer<SerializableState<T>> {
    private InspectorProperty? _v;

    protected override void Initialize() {
        _v = Property.Children["_v"];
    }

    protected override void DrawPropertyLayout(GUIContent label) {
        if (_v == null) {
            GUILayout.Label(label.text + " = null");
            return;
        }

        _v.Draw(label);
        if (GUI.changed && ValueEntry.SmartValue != null) ValueEntry.SmartValue.Value = (T) _v.ValueEntry.WeakSmartValue;
    }
}

image

Additional note: The wrapper doesn't handle custom equality comparers (only the default one can be instantiated by Unity). Because Unity (same for MessagePack and JSON.NET) requires a default constructor to be able to handle serialization, I see two possible solutions (not implemented yet):

  • Implement a custom "SerializableState" for each type to serialize that would provide the matching equality comparer.
  • Implement an "EqualityComparerResolver" that the user could fill to provide per-type implementation, and that the wrapper could query when instantiating the internal state. (This is what MessagePack-CSharp does, for example, to handle per-type specific formatters).

I plan to keep using TinkStateSharp in the future. Let me know if this feedback was useful and if you can benefit from further inputs.

Feel free to use the provided code and informations however you like.

@nadako
Copy link
Owner

nadako commented Aug 28, 2024

Hey, thanks for the great and detailed contribution! I was also experimenting with a similar approach last time I played with unity serialization and editor integration, so something like this is likely the way to go :)

I did not push it (and other features like the subscription tracker) to the repo so far because I've been quite a purist with this one, in a way that I don't want to push non-battle-tested code, and funnily enough, I'm not currently using this library in the project I'm working on so far (although I hopefully will soon), so I have very limited opportunities to battle-test.

Anyway I'll give it another look and thought, when I have some free time again and your example is definitely helpful! Feel free to report your further findings in the meantime ;-)

@nadako
Copy link
Owner

nadako commented Sep 10, 2024

Logging some experiments for the sake of openness, here's a custom property drawer without Odin (i'm using serializedValue field name instead of _v in my current experimentation code):

[CustomPropertyDrawer(typeof(SerializableState<>))]
public class SerializableStateDrawer : PropertyDrawer
{
	public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
	{
		EditorGUI.BeginProperty(position, label, property);
		position = EditorGUI.PrefixLabel(position, GUIUtility.GetControlID(FocusType.Passive), label);
		EditorGUI.PropertyField(position, property.FindPropertyRelative("serializedValue"), GUIContent.none);
		EditorGUI.EndProperty();
	}
}

This doesn't quite to work with [SerializeReference] though and needs more investigation. (I have a SerializableReferenceState which is exactly like SerializableState, but with the SerializeReference attribute on the value field).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants
@nadako @Lythom and others