ScriptableObjects in Unity are super useful for all kinds of things including creating variables as assets that make it really easy to test and debug your game.
If you don’t know what scriptable objects do, check out 3 Cool Ways to Architect Your Game With Scriptable Objects at Unity’s website.
In this post I want to expand on the information in that article and show you how to create an extensible system for variables of different types and how to easily save and load them from a file.
Extending Variable Types
First of all, if you don’t want to be stuck with just one type of variable (i.e FloatVariable), you need to figure out how to create new types without constantly copy/pasting code (BAD!).
C# generics to the rescue!
public abstract class Variable<T> : ScriptableObject {
public T DefaultValue;
public T RuntimeValue { get; set; }
protected void OnEnable()
{
RuntimeValue = DefaultValue;
}
}
Note that the DefaultValue is persisted (can be set from the inspector), while the RuntimeValue is only available during run-time.
To add a new type of variable, just declare a new class like this:
[CreateAssetMenu(fileName = "intVar",menuName ="Variables/Integer")]
public class IntVariable : Variable<int>
{
}
Now your Variables can hold any type! You can also use your own Enums or add other functionality like Min/Max values for the IntVariable.
Tip: Add a custom icon for any scriptable object by adding a square png to Assets/Gizmos with the name ScriptableType Icon.png, like so: IntVariable Icon.png (note the space).
Variable Persistence
The need to persist the runtime values of variables between game sessions quickly becomes apparent once you start using them. If you use a scriptable object as an inventory for your RPG game, how do you save its contents with your save file?
First of all, we need to find all variables of all the types in the project. For that we will need to introduce another abstract base class that does not use generics.
public abstract class BaseVariable : ScriptableObject
The whole inheritance chain should now look like this:
IntVariable --> Variable --> BaseVariable --> ScriptableObject
This way, we can actually have an array or a list of BaseVariables to hold variables of any type.
To hold the list of all the variables in the project, let’s create another ScriptableObject – Variable Manager.
[CreateAssetMenu(fileName = "varManager", menuName = "Variables/Variable Manager")]
public class VariableManager : ScriptableObject
{
public List<BaseVariable> Variables = new List<BaseVariable>();
private void OnEnable()
{
#if UNITY_EDITOR
Variables = new List(GetAllVariables());
#endif
}
// This method works only in Editor
private static BaseVariable[] GetAllVariables()
{
string[] guids = AssetDatabase.FindAssets("t:BaseVariable");
BaseVariable[] vars = new BaseVariable[guids.Length];
for (int i = 0; i < guids.Length; i++)
{
string path = AssetDatabase.GUIDToAssetPath(guids[i]);
vars[i] = AssetDatabase.LoadAssetAtPath(path);
}
return vars;
}
}
If you create a couple of variables of different types and run this, you should see a nice list with all your variables in the inspector of the Variable Manager. The list will stay populated even after you quit the play mode, because the data gets saved directly to the scriptable object.
You just have to remember to always run your game in the editor at least once before building your game whenever you add or remove any variables.
Now how to save and load the runtime values of all these variables? We need to somehow serialize and deserialize these different values. As it turns out, when working with a BinaryFormatter you can safely use the base class and it will correctly save all the data from the subclasses. We can’t throw the whole scriptable object into the formatter, so let’s create a data class:
[Serializable]
public abstract class VariableData
{
}
And extend our generic Variable class so that every Variable type has a corresponding inner Data class.
[Serializable]
private class VariableData<TV> : VariableData
{
public TV Value;
}
public override VariableData GetData()
{
return new VariableData
{
Value = RuntimeValue
};
}
public override void LoadFromData(VariableData data)
{
RuntimeValue = ((VariableData<T>) data).Value;
}
Note: You should reuse the data object instead of creating a new one every time.
Now we can iterate over all of the variables, get their data, cast them to VariableBase, and save them with BinaryFormatter.
Note: Using BinaryFormatter means we need to constrain our variable types. Vector3, Color, and other Unity specific types will not serialize this way.
The problem is how to connect the values to the scriptable objects. Any references will not be serialized. We need to assign every variable a unique ID and then store all data as an (ID, VariableData) pair. We could generate our own ID, but Unity already has a globally unique ID (guid) for every scriptable object, so we can just use that. Extending our GetAllVariables function like this:
private static BaseVariable[] GetAllVariables()
{
string[] guids = AssetDatabase.FindAssets("t:BaseVariable");
BaseVariable[] vars = new BaseVariable[guids.Length];
for (int i = 0; i < guids.Length; i++)
{
string path = AssetDatabase.GUIDToAssetPath(guids[i]);
vars[i] = AssetDatabase.LoadAssetAtPath(path);
if (string.IsNullOrEmpty(vars[i].Guid))
{
vars[i].Guid = guids[i];
}
}
return vars;
}
To wrap all this data and the basic methods, let’s create a new data class:
[Serializable]
public class VariableRevision {
[SerializeField] Dictionary<string, VariableData> Data = new Dictionary<string, VariableData>();
public void LoadFromList(List<BaseVariable> list)
{
Data.Clear();
foreach (var v in list)
{
Data.Add(v.Guid, v.GetData());
}
}
public void RestoreVariable(List<BaseVariable> list)
{
foreach (var v in list)
{
VariableData d;
if (Data.TryGetValue(v.Guid, out d))
{
v.LoadFromData(d);
}
}
}
}
Now we have all the pieces to write a Persistence Manager script that can save and load all variables.
public class VariablePersistence : MonoBehaviour
{
public VariableManager VariableManager;
private const string SavePath = "Temp/save.savefile";
private void Start()
{
if (VariableManager == null)
{
Debug.LogError("No variable manager assigned!");
return;
}
#if UNITY_EDITOR
VariableManager.RefreshList();
#endif
}
public void Save()
{
var revision = new VariableRevision();
revision.LoadFromList(VariableManager.Variables);
BinaryFormatter bf = new BinaryFormatter();
FileStream file = File.Create(SavePath);
bf.Serialize(file, revision);
file.Close();
}
public void Load()
{
if (File.Exists(SavePath))
{
BinaryFormatter bf = new BinaryFormatter();
FileStream file = File.Open(SavePath, FileMode.Open);
var revision = (VariableRevision) bf.Deserialize(file);
revision.RestoreVariable(VariableManager.Variables);
file.Close();
}
}
}
The neat thing here is that we give BinaryFormatter a dictionary declared with only the (empty) VariableData type and it will correctly (de)serialize all of our properties, no matter if it’s a single int or some complicated data structure.
Check out the full code with a working demo on my GitHub!
Let me know what you think about this solution in the comments, I’m always happy to learn more!