It turns out, you can do this half-decently with Unity's [SerializeReference] attribute. Here's a quick demo:
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.
BinaryFormatter
(before I found out it was obselete), but its hard to find good serialization methods online, I was thinking perhaps aCryptoStream
but I'm not 100% sure that would work. \$\endgroup\$