Skip to content

Sharing some solution to handle Unity serialization #33

@Lythom

Description

@Lythom

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions