I’ve been working on a problem over the last several days to implement a non-obtrusive Save Game system.
I’ve seen many (many) other frustrated posters on the topic, and I’m satisfied enough with the (in process) results based on Jacob Dufault’s Full Serializer, that I thought I would share – particularly since Jacob was nice enough to offer the Full Serializer under the MIT License.
I’ll note up front the the solution shared here requires your project to follow a specific nomenclature for Unity.GameObjects and scripts to be serialized. However, it gives the correct hooks to use so you can adjust that requirement as needed.
Desired Solution and Context:
- The serialization could occur using either BinaryFormatter or JSON to then be encrypted (the latter makes it easier to inspect and validate)
- Need to be able to save an arbitrary
SaveDataobject, where each of the member fields of that object are serialized and saved - The fields on the SaveData object should be the same instances used in the game, not intermediary data object.
I.e., we want to serialize, save, and load this:
public class SaveData
{
public Player player; //A MonoBehaviour
public List<Region> regions; //A list of MonoBehaviours
}
Not this:
public class SaveData
{
public PlayerData player; //A data class as intermediary reconstructed during each save
public List<RegionData> regions; //A list of data classes as intermediary reconstructed during each save
}
Limitations of Built-In Unity Serialization
While I came in eyes-wide-open that the built-in deserialization functions do not handle inheritance, and would require a callback receiver, there also appear to be some undocumented limitations in the serialziation of classes that have MonoBehaviour objects as fields. I’ve put a question on Unity Answers that details the expected vs actual behavior, so I won’t belabor it here. Bottom line it was worth exploring other options.
Limitations of Unity Save Load Utility
Many threads reference the Unity Save Load Utility. It’s a great free asset from Cherno, who makes it clear that it’s not one-size fits all. In the end, it’s architecture had a few limitations in the context of my desired solution above:
- Serialization occurs on a GameObject, not a arbitrary object
- Serialization (specifically restore) requires a prefab
- All objects not in the serialized state must be marked as persisted or they will be destroyed when the save is reloaded
- It does not (appear) to handle more complex types like Dictionaries. With the save and load using the BinaryFormatter it’s difficult to inspect in too much detail.
Limitation #2 and #3 are easily solved with minor tweaking to the code, but based on my limited research #1 and #4 appear to be fundamental to the design.
Leveraging the Full Serializer
The Full Serializer is an extremely robust serialization solution to-and-from JSON (which will ultimately require encryption in this context). It accurately documents that it has very few limitation.
It serialized anything I threw at it perfectly, including fields of MonoBehavior objects, and Dictionaries, overcoming the limitations of the other options above. However, as the author appropriately notes, deserializing MonoBehaviours is tricky. If it was possible generically, I suspect he would have included a generic converter, so it’s time to write a specific one.
The Solution
This solution relies on:
-
The name of the MonoBehaviour class must match either a GameObject in scene or a Prefab
-
Limitation: Only a single script on a GameObject can have Serialized Data
-
This limitation is okay for my structure, as by convention there is a single script to control state and primary behavior, and then possibly a few small behavior scripts that don’t need to be serialized (e.g., “Bring to Font on Enable”).
-
Creating and registering a Converter and a Processor with the Full Serializer (fs). If you have not read about them, please check out the documentation.
The Converter will override the generic fsReflectedConverted (which works very well for serialization and deserialization) to control the instance creation. The Processor will tag the serialized state with the name of the game object to allow saving and restoring prefabs that are inserted into scene at design time.
Step 1: The Converter
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using FullSerializer;
using FullSerializer.Internal;
namespace Data.Serialization
{
public class MonoBehaviourConverter : fsReflectedConverter
{
public override bool CanProcess(Type type) {
return typeof(MonoBehaviour).IsAssignableFrom(type);
}
// Create or Invoke an instance of a MonoBehaviour.
// Requires:
// storageType.name (a.k.a. current type being deserialized) exists as a componenet on
// a game object or prefab with the same name
// Non-prefabs: The MonoBehaviour component has the same name as the GameObject
// Prefabs: The MonoBehaviour component has the same name as a prefab &&
// If a existing instance in scene should be updated:
// The game object name specified during serialization exists in the scene
public override object CreateInstance(fsData data, Type storageType){
//First assume the storage type is the game object
string gameObjectName = storageType.Name;
// Check to see if the serialized state contains an explicitly specified
// game object name that we should search for in the scene. This is useful
// for prefabs -- enables identifying existing objects rather than always creating new.
if (data.IsDictionary && data.AsDictionary.ContainsKey(MonoBehaviourProcessor.Key_GameObjectName)){
fsData gameObjectNameData = data.AsDictionary[MonoBehaviourProcessor.Key_GameObjectName];
if(gameObjectNameData.IsString) {
gameObjectName = gameObjectNameData.AsString;
}
}
// Search for the game object in the scene
GameObject go = GameObject.Find(gameObjectName);
// If the game object doesn't exist, it may have originally been
// created from a prefab that doesn't exist in the scene, and will
// need to be recreated. Search again from the prefab list:
if(go == null){
GameObject prefab = null;
// Note - This relies on a 'singleton' DataController that manages the save/load
// And includes an array of prefabs that are attached in the inspector. This could
// be managed other ways as well
GameObject[] gos = DataController.instance.serialiablePrefabs;
for (int i = 0; i < gos.Length; i++) {
if (gos[i].name == storageType.Name){
prefab = gos[i];
break;
}
}
if(prefab != null) {
go = GameObject.Instantiate(prefab, Vector3.zero, Quaternion.identity);
go.name = gameObjectName;
}
}
// Return the component from the game object
object instance = null;
if(go != null)
instance = go.GetComponent(storageType);
return instance;
}
}
}
Step 2: The Processor. Take note that it waits until OnBeforeDeserializeAfterInstanceCreation to remove the GameObjectName, which is part of the fsObjectProcessor API, but not listed in the GitHub examples.
using System;
using UnityEngine;
using FullSerializer;
namespace Data.Serialization
{
public class MonoBehaviourProcessor : fsObjectProcessor
{
public static readonly string Key_GameObjectName = string.Format("{0}goname", fsGlobalConfig.InternalFieldPrefix);
public override bool CanProcess (Type type) {
return typeof(MonoBehaviour).IsAssignableFrom(type);
}
public override void OnBeforeDeserializeAfterInstanceCreation (Type storageType, object instance, ref fsData data) {
// Remove Game Object name key, as it's already been applied to the game object directly during instance creation
if (data.IsDictionary) {
var dict = data.AsDictionary;
dict.Remove(Key_GameObjectName);
}
}
public override void OnAfterSerialize (Type storageType, object instance, ref fsData data) {
// Add game object name key to identify game objects or name prefabs created into scene
MonoBehaviour mb = (MonoBehaviour)instance;
data.AsDictionary[Key_GameObjectName] = new fsData(mb.gameObject.name);
}
}
}
Step 3: Registering the Converter and Processor on all MonoBehaviour classes that need to be serialized:
//[... other namespaces]
using UnityEngine;
using FullSerializer;
using Data.Serialization;
[fsObject(Converter = typeof(MonoBehaviourConverter), Processor = typeof(MonoBehaviourProcessor))]
public class Player : MonoBehaviour {
//Lots of attributes to control state
}
Step 4: Define a “SaveData” class with references to the classes to be serialized (simplified example above), and execute serialization / deserialization on that class per the Full Serialization usage documentation.
Conclusion
This has handled everything I need for my game structure; however, I can see how it may need to be quickly extended for several other scenarios, and the converter has opportunity for optimization dependent on how you construct your game objects and prefabs. Also keep in mind this is all invoked via a “DataController”, that manages other activities I consider outside the scope of serialization (e.g., removing prefabs from the scene that aren’t in the saved state, etc.).
This is my first post on the forum, and an attempt to give back to the community. I hope some other folks find it useful.