1
\$\begingroup\$

I am making a customizable save system for Unity. In my previous save systems, since they aren't designed to be modifiable, I already know what each save is going to contain.

However, when making this new system, I want to create some kind of attribute like [Savable] that allows that class to be serialized into the save file.

I figured the attribute would just store a list of classes, structs, etc, and then they would all be stored in some file, along with some other data. But how would I contain a list of class instances from these different classes?

I'm not talking about a list of instances of a specific class like:

public List<MyClass>;

I need some way to store a list of different class instances. Or perhaps a way to store a list for each class instance. Something like this:

public List<SavableClasses>;

Is there any way I can do this?

\$\endgroup\$
9
  • \$\begingroup\$ Have you taken a look at interfaces? \$\endgroup\$
    – Zibelas
    Commented Feb 10 at 12:39
  • \$\begingroup\$ What are you using to actually write the data for the save file currently? Unity's default serializer and JSON serialization tools have very limited polymorphic serialization capabilities without adding extra callbacks to handle things manually, but other serializers are structured better for this. \$\endgroup\$
    – DMGregory
    Commented Feb 10 at 12:40
  • \$\begingroup\$ @Zibelas I'm unsure what interfaces would do to help solve this problem. \$\endgroup\$
    – Pow
    Commented Feb 10 at 12:58
  • \$\begingroup\$ @DMGregory "but other serializers are better structured for this" if so, what do you suggest. Before I was using the BinaryFormatter (before I found out it was obselete), but its hard to find good serialization methods online, I was thinking perhaps a CryptoStream but I'm not 100% sure that would work. \$\endgroup\$
    – Pow
    Commented Feb 10 at 13:00
  • \$\begingroup\$ @Pow If you have a generic ISave interface and two classes TypeA and TypeB who are both of type ISave, you can have a List<ISave> and add both TypeA and TypeB to the same list. \$\endgroup\$
    – Zibelas
    Commented Feb 10 at 13:07

1 Answer 1

2
\$\begingroup\$

It turns out, you can do this half-decently with Unity's [SerializeReference] attribute. Here's a quick demo:

Inspector Preview

You can see I have a list containing more than one different type, but they're all directly inspectable in the Unity interface.

Click the three-dot ⋮ context menu in the top-right of the "Mixed Serialized (Script) component inspector to test out the following actions:

  • Add Mixed Types: Adds one instance of each class ObjectA, B, and C to the data list.

  • Serialize to JSON: Updates the json field to contain a string representation of the data object.

  • Deserialize from JSON: Uses the text in the json field to replace the data object with the saved version.

You can write that JSON to a file directly as a somewhat human-readable save file (good for debugging and modding), or apply compression or encoding to it if you want it obfuscated.

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class MixedSerializer : MonoBehaviour
{
    // Unrelated types with different data storage.
    [System.Serializable]
    public class ObjectA {
        public string a = "apple";
    }

    [System.Serializable]
    public class ObjectB {
        public int b = 42;
    }

    [System.Serializable]
    public class ObjectC {
        public float c = 3.14159f;
        public bool toggle;
    }

    // Containter type.
    [System.Serializable]
    public class SaveData {

        // SerializeReference allows the contents to be serialized
        // separate from the list, with their own type information.
        [SerializeReference]
        // Every reference type can be cast to/from System.Object.
        public List<System.Object> list;
    }

    // Structured data to save.
    public SaveData data;

    // Serialized version as text.
    [TextArea(5, 20)]
    public string json;

    // API method for other code to add stuff to be saved.
    public void AddDataToSave(System.Object serializableData) {
        if (data.list == null) data.list = new();

        data.list.Add(serializableData);
    }

    // API method to extract loaded data of a given type.
    public int GetSavedData<T>(List<T> output) where T: class {
        if (data.list == null) return 0;

        int count = 0;
        foreach (var item in data.list) {
            var cast = item as T;
            if (cast == null) continue;

            output.Add(cast);
            count++;
        }

        return count;
    }

    [ContextMenu("Add Mixed Types")]
    void AddMixedTypes() {
        if (data.list == null) data.list = new();

        data.list.Add(new ObjectA());
        data.list.Add(new ObjectB());
        data.list.Add(new ObjectC());
    }

    [ContextMenu("Serialize to JSON")]
    void ToJson() {
        json = JsonUtility.ToJson(data, true);
    }

    [ContextMenu("Deserialize from JSON")]
    void FromJson() {
        data = JsonUtility.FromJson<SaveData>(json);
    }
}

Note how the information about individual instances saved with [SerializeReference] get stored in a new field called "references" in the JSON version. That means the type you're saving cannot have its own field named "references" or you'll get errors when trying to deserialize.

When reading this data back, you'll have to inspect each item's type to figure out what to do with it. You can do so something like this:

    void ReportContents() {
        if (data.list == null || data.list.Count == 0) {
            Debug.Log("Save data list is empty.");
            return;
        }

        Debug.Log("Save data list contains...");
        foreach (var item in data.list) {
            switch(item) {
                case ObjectA objA:
                    Debug.Log($"- Object A with a = {objA.a}");
                    break;
                case ObjectB objB:
                    Debug.Log($"- Object B with b = {objB.b}");
                    break;
                case ObjectC objC:
                    Debug.Log($"- Object C with c = {objC.c} and toggle = {objC.toggle}");
                    break;
                default:
                    Debug.Log($"- Unknown type {item.GetType().Name}: {item.ToString()}");
                    break;
            }
        }
    }
}

But note that the method iterating over the contents needs to know about each type to be able to decide what to do with it. If you want this to work with arbitrary types without modifying this part, then this is where Zibelas's suggestion to look at interfaces may help:

You can make an ISavable interface that implements an OnLoad() method, then make your list of type List<ISavable>. On load, you can iterate over the list's contents and call OnLoad() on them, without knowing their concrete types. Each newly-added ISavable type can then implement its own logic for what should happen to it after loading, and have that logic fired from the save system without modifying the core save system code for each new type.

Or you can use the methods I labelled // API method above for external code to add and read back data to be saved / data that was loaded of a particular type, without this class knowing all those types.

\$\endgroup\$
6
  • \$\begingroup\$ Whoops, I just made a new question about that, but thanks for the info that will definitely be useful. Hopefully someone can help me out with encrypted and serializing the data. I also notice you've used System.Object which makes sense, I noticed after that all types derive from it. \$\endgroup\$
    – Pow
    Commented Feb 10 at 14:10
  • \$\begingroup\$ Ah, ive noticed one issue with this solution, when you use the "add mixed types" function, you know what classes to add, in my case, since I'm releasing this for the Unity asset store, I need any class, struct, etc, thats derives from "Savable" to be able to be added to this list when saved and then serialized. \$\endgroup\$
    – Pow
    Commented Feb 10 at 14:34
  • \$\begingroup\$ Right, but that's the responsibility of the folks using your asset. You just give a public void AddDataToSave(ISavable data) method, and they call that and pass in the things they want saved. I've updated the demo above with a pair of such API methods. \$\endgroup\$
    – DMGregory
    Commented Feb 10 at 14:37
  • \$\begingroup\$ Yeah i'll do that, but just incase, do you know if I could do that with some kind of attribute maybe? I just want to make it as easy to understand as possible. \$\endgroup\$
    – Pow
    Commented Feb 10 at 14:56
  • \$\begingroup\$ That's a different question, unrelated to how to serialize a list of different types. I don't recommend auto-registering every instance for saving though, as your users will likely want to have control over which things get saved (e.g. if they construct temporary instances, or create and destroy instances frequently, they don't want all of them automatically adding themselves to save files and bloating the storage) \$\endgroup\$
    – DMGregory
    Commented Feb 10 at 14:58

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .