Did I miss the slide where we added the Delete functionality for the save file? I finished section 9, then went back to watch section 8, and 8 shows the delete function existing. It was easy and quick, so I added it in myself, but I don’t think I missed it being added in section 9. I opened the github commit for the last couple commits in 9, and don’t see it there either. Are there any other little things we should add to the saving system to have the same functionality as importing the premade one?
No, it gets added later in the course. I didn’t have the premade one when I built the course so you should be fine following through.
Does the save system work with unity 2019.1
Yes, I’ve imported it into 2019.1 with no issues.
In my scene I have a moment where the player can pick up a health potion and the object is deleted. When i leave the scene I am getting the save message in the console but when i re enter the scene the potion returns. How do i add the save system to a object I want to delete so it does not respawn when I go back to the scene. In other words delete it forever., and have the system remember it is gone.
Hello, i made a discussion thread about how I extended the saving system to also capture the state of objects that are spawned or get destroyed during runtime.
you can have a look at it here.
This will also be covered in detail in the Inventory course we are working on right now.
I think it was kinda hard since there was a lot to keep in mind, especially with the indentifiers and the saving multiple scenes part.
But I think you did a pretty good job with this tutorial. The challenges are escpecially great since I could easily learn from my mistakes. I feel like I have a good understanding with how a save system works. Thanks Sam!
Hello!
Short disclaimer first; I did not implement the save system but mostly listened through this section and kept up with it. This kind of programming is more of what I’m used to than game-logic, so I found this intuitive and well explained!
I did notice a strange “bug” with the save system in Unity 2020.3, that has to do with the call to LoadSceneAsync
. This “bug” was noticed when I changed the Load
function in my SavingWrapper class to reload the scene again, done by calling LoadLastScene from the SavingSystem class.
The problem I encountered was that enemies that had been killed in an earlier save would suddenly come back to life if I saved and then loaded the game again. This seems to come from a short delay between LoadSceneAsync
and when the function Start
is called. The result of this seems to be that the call-order is something akin to:
LoadSceneAsync
RestoreState
Start
So while all entities with the component SaveableEntity will be correctly updated during the call to LoadLastScene
, this information will quickly be overwritten by the Start
function inside each MonoBehaviour
. To fix this, I added a yield return new WaitForEndOfFrame()
to the LoadLastScene
function inside of SavingSystem, between the call to LoadSceneAsync
and RestoreState
. This seems to fix the issue at hand:
public IEnumerator LoadLastScene(string saveFile)
{
Dictionary<string, object> state = LoadFile(saveFile);
int buildIndex = SceneManager.GetActiveScene().buildIndex;
if (state.ContainsKey("lastSceneBuildIndex"))
{
buildIndex = (int)state["lastSceneBuildIndex"];
}
yield return SceneManager.LoadSceneAsync(buildIndex);
yield return new WaitForEndOfFrame();
RestoreState(state);
}
Worth noting! This problem could stem from the fact that I use the new InputSystem with events to call my Load
function inside the SaveWrapper
class. I am uncertain if this bug will show up when LoadLastScene
is called from inside the Update
function.
Ah, the issue here is that certain things are being done in Start() that really should be done in Awake()…
Some things are unsafe to set in Awake, however, and that’s why we’ve introduced LazyValues.
The order of events when Loading a scene (or portalling to a new scene are as follows:
- LoadSceneASync()
- Awake() (You can safely cache references to other components here, but don’t depend on anything in those components quite yet
- RestoreState()
- OnEnable() - You can safely subscribe to events here
- Start() - You can safely start accessing other classes, the issue being that if RestoreState has fired, then you might overwrite the data from RestoreState, hence the LazyValues (or uninit traps like starting with health=-1; as the declaration, and then in Start() {if health<0) health = …}
Just wanted to say I’m really enjoying this course, I’ve learned a lot and I really appreciate your approach to the design of the code. Taking this saving section (in addition to just learning more about saving in general) really helped me with learning how to better section off different types of tasks in a way that’s very clear to follow and avoids dependencies and spaghetti code (which I definitely used to have a problem with when I was first learning C#). Thank you so much for all of this information! I’m excited to keep learning!
This course is awesome. The clarity and the way sam present things is making it so fluid ! He is by far one of the best instructor i know regarding unity code programming
I’ve just completed the second saving section that goes through the full creation but the lazyvalue was never introduced in that section, but I remember using it years ago when I did the course for the first time when completing the first saving section. Is the use of the lazyvalues important?
I already saved all of my caches in awake the only thing I haven’t got in the order you’ve suggested is I still have LoadSceneASync being called in start.
I also recommend that everyone check out my message about the security risks with using Binary Formatter:
I’m super happy with it!
I added the ability to Save GameObjects that are not active, can change this in the inspector.
I also added Version Control to my saving system with backwards compatibility.
Here is the code that was added/changed to get these features
VersionControl.cs
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Runtime.Serialization.Formatters.Binary;
using UnityEngine;
using UnityEngine.SceneManagement;
using static RPG.Saving.VersionControl;
namespace RPG.Saving
{
public static class VersionControl
{
public static int currentFileVersion = 1;
public static int minFileVersion = 1;
}
}
SavingSystem.cs
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Runtime.Serialization.Formatters.Binary;
using UnityEngine;
using UnityEngine.SceneManagement;
using static RPG.Saving.VersionControl;
namespace RPG.Saving
{
public class SavingSystem : MonoBehaviour
{
#region Inspector Fields
[Header("Config Data")]
[Tooltip("Toggle this to true if you want the saving system to save state for inactive objects.")]
[SerializeField]
private bool includeInactive;
#endregion
#region Private Methods
private Dictionary<string, object> LoadFile(string saveFile)
{
string path = GetPath(saveFile);
if (!File.Exists(path)) return new Dictionary<string, object>();
BinaryFormatter formatter = new BinaryFormatter();
using FileStream stream = File.Open(path!, FileMode.Open);
// Version Control
Dictionary<string, object> state = (Dictionary<string, object>)formatter.Deserialize(stream);
int currentFileVersion = 0;
if (state.ContainsKey("CurrentFileVersion"))
{
currentFileVersion = (int)state["CurrentFileVersion"];
}
if (currentFileVersion >= VersionControl.currentFileVersion || currentFileVersion >= minFileVersion)
return state;
Debug.LogWarning($"Save file is from an older version of the game and is not supported. " +
$"Expected version: {VersionControl.currentFileVersion}, " +
$"Minimum Expected version: {minFileVersion}, " +
$"Current version: {currentFileVersion}");
return new Dictionary<string, object>();
}
private void CaptureState(Dictionary<string, object> state)
{
// Version Control
state["CurrentFileVersion"] = currentFileVersion;
// Ability to include inactive game objects
foreach (SavableEntity entity in FindObjectsOfType<SavableEntity>(includeInactive))
{
state[entity.GetUniqueIdentifier()!] = entity.CaptureState();
}
state["lastSceneBuildIndex"] = SceneManager.GetActiveScene().buildIndex;
}
private void RestoreState(Dictionary<string, object> state)
{
// Version Control
int currentFileVersion = 0;
if (state.ContainsKey("CurrentFileVersion"))
{
currentFileVersion = (int)state["CurrentFileVersion"];
}
// Ability to include inactive game objects
foreach (SavableEntity entity in FindObjectsOfType<SavableEntity>(includeInactive))
{
string id = entity.GetUniqueIdentifier();
if (!string.IsNullOrWhiteSpace(id) && state.ContainsKey(id))
{
// Version Control
entity.RestoreState(state[id], currentFileVersion);
}
}
}
#endregion
}
}
SavableEntity.cs
namespace RPG.Saving
{
public class SavableEntity : MonoBehaviour
{
#region Public Methods
// Version Control
public void RestoreState(object state, int currentFileVersion)
{
foreach (ISavable savable in GetComponents<ISavable>())
{
Dictionary<string, object> stateDict = (Dictionary<string, object>)state;
string typeString = savable.GetType().ToString()!;
if (stateDict.ContainsKey(typeString))
{
// Version Control
savable.RestoreState(stateDict[typeString], currentFileVersion);
}
}
}
#endregion
}
}
ISavable.cs
namespace RPG.Saving
{
public interface ISavable
{
// Version Control
void RestoreState(object state, int version);
}
}
Anything that implements ISavable use RestoreState(object state, int version)
instead of RestoreState(object state)
For the Editor Window in an Editor Folder
SaveEditorWindow.cs
using System.IO;
using UnityEditor;
using UnityEngine;
using static UnityEngine.Screen;
namespace RPG.Saving.Editor
{
/// <summary>
/// A custom editor window
/// <seealso href="https://docs.unity3d.com/ScriptReference/EditorWindow.html"/>
/// </summary>
public class SaveEditorWindow : EditorWindow
{
private const float WindowWidth = 400;
private const float WindowHeight = 200;
private const string HelpBoxText =
"Every Save File has a Version Number. When trying to load a save, only files with the current version (or the minimum legacy version) will be valid.";
private int m_cachedVersionNumber;
private int m_cachedMinVersionNumber;
private bool m_legacySupport;
private GUIStyle m_centeredLabel;
#region Window Managment
[MenuItem("Window/RPG Tool Kit/Save Settings")]
private static void ShowWindow()
{
SaveEditorWindow window = GetWindow<SaveEditorWindow>("Save Settings", true);
//Set default size & position
Rect windowRect = new Rect()
{
size = new Vector2(WindowWidth, WindowHeight),
x = (float)currentResolution.width / 2 - WindowWidth,
y = (float)currentResolution.height / 2 - WindowHeight
};
window.position = windowRect;
window.m_legacySupport = VersionControl.minFileVersion != VersionControl.currentFileVersion;
window.m_cachedVersionNumber = VersionControl.currentFileVersion;
window.m_cachedMinVersionNumber = VersionControl.minFileVersion;
window.m_centeredLabel = EditorStyles.boldLabel;
window.m_centeredLabel.alignment = TextAnchor.MiddleCenter;
window.Show();
}
/// <summary>
/// <seealso href="https://docs.unity3d.com/ScriptReference/EditorWindow.OnGUI.html"/>
/// </summary>
private void OnGUI()
{
// Help Box
EditorGUILayout.HelpBox(HelpBoxText, MessageType.Info);
EditorGUILayout.Space();
//Version Number Editing
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("Current Version NR.", GUILayout.MaxWidth(150));
if (m_cachedVersionNumber == 1) GUI.enabled = false;
if (GUILayout.Button("-", GUILayout.MaxWidth(25))) ShiftCurrentVersion(-1);
if (!GUI.enabled) GUI.enabled = true;
EditorGUILayout.LabelField($"{m_cachedVersionNumber}", m_centeredLabel, GUILayout.MaxWidth(35));
if (GUILayout.Button("+", GUILayout.MaxWidth(25))) ShiftCurrentVersion(1);
EditorGUILayout.EndHorizontal();
// Legacy Version Number Editing
m_legacySupport = EditorGUILayout.Toggle("Backwards Compatibility", m_legacySupport);
if (m_legacySupport)
{
if (m_cachedMinVersionNumber > m_cachedVersionNumber)
{
m_cachedMinVersionNumber = m_cachedVersionNumber;
}
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("Min. Version NR.", GUILayout.MaxWidth(150));
if (m_cachedMinVersionNumber <= 1) GUI.enabled = false;
if (GUILayout.Button("-", GUILayout.MaxWidth(25))) ShiftMinVersion(-1);
if (!GUI.enabled) GUI.enabled = true;
EditorGUILayout.LabelField($"{m_cachedMinVersionNumber}", m_centeredLabel, GUILayout.MaxWidth(35));
if (m_cachedMinVersionNumber >= m_cachedVersionNumber) GUI.enabled = false;
if (GUILayout.Button("+", GUILayout.MaxWidth(25))) ShiftMinVersion(1);
if (!GUI.enabled) GUI.enabled = true;
EditorGUILayout.EndHorizontal();
}
else
{
m_cachedMinVersionNumber = m_cachedVersionNumber;
}
// Apply Changes
if (m_cachedVersionNumber == VersionControl.currentFileVersion &&
m_cachedMinVersionNumber == VersionControl.minFileVersion)
{
GUI.enabled = false;
}
if (GUILayout.Button("Apply")) UpdateVersionNumber();
if (!GUI.enabled) GUI.enabled = true;
// Source Folder Access
if (GUILayout.Button("Open Source Folder"))
System.Diagnostics.Process.Start(GetSaveFolderPath()!);
}
#endregion
#region Version Number Management
private void ShiftCurrentVersion(int increment)
{
m_cachedVersionNumber += increment;
if (!m_legacySupport)
{
m_cachedMinVersionNumber = m_cachedVersionNumber;
}
}
private void ShiftMinVersion(int increment)
{
m_cachedMinVersionNumber += increment;
}
private void UpdateVersionNumber()
{
VersionControl.currentFileVersion = m_cachedVersionNumber;
VersionControl.minFileVersion = m_cachedMinVersionNumber;
}
#endregion
#region Path Management
private string GetSaveFolderPath()
{
string basePath = Path.Combine(Application.persistentDataPath!, "GameData");
if (!Directory.Exists(basePath)) Directory.CreateDirectory(basePath);
return basePath;
}
#endregion
}
}
And to implement backwards compatibility you check the version number
if (version < 2 && version >= 1)
{
Dictionary<string, object> stateDict = (Dictionary<string, object>)state;
if (state.ContainsKey("position"))
transform.position = ((SerializableVector3)state["position"]).ToVector();
if (state.ContainsKey("rotation"))
transform.eulerAngles = ((SerializableVector3)state["rotation"]).ToVector();
}
else if (version > 2)
{
MoverSaveData data = (MoverSaveData)state;
transform.position = data.position.ToVector();
transform.eulerAngles = data.rotation.ToVector();
}
Nicely done!
I had to make a slight change to this as after coming back to this in Unity 2021.3.9f1, when entering play mode it kept resting the static variables back to the original. I am not sure if maybe I just had or of the scripts open in Rider and every time I entered Play Mode it recompiled the Save System, or if something changed in between one of the Unity versions. To Fix this issue I just wrote the Version.cs script to have the new values when applying the changes.
private void UpdateVersionNumber()
{
VersionControl.CurrentFileVersion = _cachedVersionNumber;
VersionControl.MinFileVersion = _cachedMinVersionNumber;
var files = Directory.EnumerateFiles(Directory.GetCurrentDirectory(), "VersionControl.cs",
SearchOption.AllDirectories).Where(f => File.ReadAllText(f).Contains("namespace RPGEngine.Saving"));
foreach (var file in files)
{
if (!File.Exists(file)) continue;
//HARDCODED VERSION UPDATE (Ugly but saves playing around with reading textfiles)
string[] code =
{
"namespace RPGEngine.Saving",
"{",
"\tpublic static class VersionControl",
"\t{",
$"\t\tpublic static int CurrentFileVersion = {_cachedVersionNumber};",
$"\t\tpublic static int MinFileVersion = {_cachedMinVersionNumber};",
"\t}",
"}"
};
File.WriteAllLines(file, code);
}
}
I also updated the Save System to use the JToken and strategy from the Advanced Saving System topic.
May I ask when is the best time to use Application.persistentDataPath vs Application.dataPath for saving the file?
And of course, I’ve been really super happy with how the Saving System was implemented. Thank you, Sam! I’ve always been amazed at how you coded and designed this system!
For saving data between sessions, you always want to use Application.persistentDataPath. You are generally guaranteed read/write asset to this folder on all platforms. The same cannot be said for Application.dataPath, which is basically the location of the game itself. If you are updating the game with content, you may try to use dataPath, although without permissions, you’ll likely be denied write access (or you’ll wind up with the OS putting up a permissions dialogue).
Never use Application.dataPath for saving game state between sessions. This is what peristentDataPath is for (and in my opinion, it’s also where I would store updated content).
Thank you very much, @Brian_Trotter! Your info and insight on this is very helpful!
Does it also apply for a WebGL build? Say for example, uploading a new build to itch.io, would the existing save file still remain (using Application.persistentDataPath) even after uploading a new updated build? Although I think when clearing browser cache, it also removes the save file with it, but I kind of wanna know what the behavior is when uploading a new build. Would love to know your insights on this