Skip to content

Commit

Permalink
Changed the ISaveable interface from using Guids to string keys. Adde…
Browse files Browse the repository at this point in the history
…d error handling for bad keys on loading.
  • Loading branch information
nickpettit committed Jul 16, 2024
1 parent 765787e commit 0b7b663
Show file tree
Hide file tree
Showing 7 changed files with 70 additions and 33 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## [0.4.0] - 2024-07-16

- The ISaveable interface Guid property ISaveable.Guid has been replaced with a string property called ISaveable.Key. This is a breaking change, but it enables the option to use either a unique string key or store a Guid as a string, rather than using Guids and serialized byte arrays exclusively.
- Added some much needed error checking to validate ISaveable keys when loading data. Previously, if a key was not found in the registered saveables dictionary, it would throw an unhandled exception.

## [0.3.2] - 2024-07-09

- The default FileHandler now inherits from ScriptableObject and can be overridden. This can be useful in scenarios where files should not be saved using local file IO (such as cloud saves) or when a platform-specific save API must be used.
Expand Down
18 changes: 11 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ This package includes a sample project which you can install from the Unity Pack

Any class that should save or load data needs to implement the [`ISaveable`](Runtime/ISaveable.cs) interface.

- **Guid Property**: Each `ISaveable` must have a globally unique identifier (Guid) for distinguishing it when saving and loading data.
- **Key Property**: Each `ISaveable` must have a globally unique string for distinguishing it when saving and loading data.
- **Filename Property**: Each `ISaveable` must have a filename string that identifies which file it should be saved in.
- **CaptureState Method**: This method captures and returns the current state of the object in a serializable format.
- **RestoreState Method**: This method restores the object's state from the provided data.
Expand Down Expand Up @@ -113,12 +113,16 @@ Any class that should save or load data needs to implement the [`ISaveable`](Run
}
```

3. **Generate and Store a Unique Serializable Guid**
<br>Ensure that your class has a globally unique identifier (a GUID for short). Use `SaveManager.GetSerializableGuid()` to make sure that your MonoBehaviours and other classes can be identified when being saved and loaded.
3. **Generate and Store a Unique Serializable Key**
<br>Ensure that your class has a globally unique string key, such as "GameDataExample".
```csharp
[SerializeField, HideInInspector] byte[] m_guidBytes;
public Guid Guid => new(m_guidBytes);
void OnValidate() => SaveManager.GetSerializableGuid(ref m_guidBytes);
public string Key => "GameDataExample";
```
Optionally, you can generate and use a serializable Guid to uniquely identify your objects. Use `SaveManager.GetSerializableGuid()` in MonoBehaviour.OnValidate() to get the Guid and then store it as a serialized byte array (since the System.Guid type itself cannot be serialized).
```csharp
[SerializeField, HideInInspector] byte[] m_guidBytes;
public string Key => new Guid(m_guidBytes).ToString();
void OnValidate() => SaveManager.GetSerializableGuid(ref m_guidBytes);
```

4. **Register Your Object with `SaveManager`**
Expand Down Expand Up @@ -249,7 +253,7 @@ Sets the given Guid byte array to a new Guid byte array if it is null, empty, or
**Usage Example**:
```csharp
[SerializeField, HideInInspector] byte[] m_guidBytes;
public Guid Guid => new(m_guidBytes);
public string Key => new Guid(m_guidBytes).ToString();
void OnValidate() => SaveManager.GetSerializableGuid(ref m_guidBytes);
```
<br>
Expand Down
7 changes: 4 additions & 3 deletions Runtime/ISaveable.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ namespace Buck.SaveAsync
public interface ISaveable
{
/// <summary>
/// This property should be backed by a serialized byte array that does not change.
/// This is used to identify the object when saving and loading.
/// This is a unique string used to identify the object when saving and loading.
/// If you choose to use a Guid, it is recommended that it is backed by a
/// serialized byte array that does not change.
/// </summary>
public System.Guid Guid { get; }
public string Key { get; }

/// <summary>
/// This is the file name where this object's data will be saved.
Expand Down
55 changes: 42 additions & 13 deletions Runtime/SaveManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Collections.Generic;
using UnityEngine;
using Newtonsoft.Json;
using UnityEngine.Serialization;

namespace Buck.SaveAsync
{
Expand Down Expand Up @@ -46,7 +47,7 @@ public FileOperation(FileOperationType operationType, string[] filenames)
}

static FileHandler m_fileHandler;
static Dictionary<Guid, ISaveable> m_saveables = new();
static Dictionary<string, ISaveable> m_saveables = new();
static List<SaveableObject> m_loadedSaveables = new();
static Queue<FileOperation> m_fileOperationQueue = new();
static HashSet<string> m_files = new();
Expand All @@ -63,7 +64,7 @@ public FileOperation(FileOperationType operationType, string[] filenames)
[Serializable]
public class SaveableObject
{
public string Guid;
public string Key;
public object Data;
}

Expand Down Expand Up @@ -96,11 +97,22 @@ static void Initialize()
/// <param name="saveable">The ISaveable to register for saving and loading.</param>
public static void RegisterSaveable(ISaveable saveable)
{
if (m_saveables.TryAdd(saveable.Guid, saveable))
if (m_saveables.TryAdd(saveable.Key, saveable))
m_files.Add(saveable.Filename);
else
Debug.LogError($"Saveable with GUID {saveable.Guid} already exists!");
Debug.LogError($"Saveable with Key {saveable.Key} already exists!");
}

/// <summary>
/// Checks if a file exists at the given path or filename.
/// <code>
/// File example: "MyFile.dat"
/// Path example: "MyFolder/MyFile.dat"
/// </code>
/// </summary>
/// <param name="filename">The path or filename to check for existence.</param>
public static bool Exists(string filename)
=> m_fileHandler.Exists(filename);

/// <summary>
/// Saves the files at the given paths or filenames.
Expand Down Expand Up @@ -196,6 +208,7 @@ public static async Awaitable Erase(string filename)

/// <summary>
/// Sets the given Guid byte array to a new Guid byte array if it is null, empty, or an empty Guid.
/// This method can be useful for creating unique keys for ISaveables.
/// </summary>
/// <param name="guidBytes">The byte array (passed by reference) that you would like to fill with a serializable guid.</param>
/// <returns>The same byte array that contains the serializable guid, but returned from the method.</returns>
Expand All @@ -204,14 +217,14 @@ public static byte[] GetSerializableGuid(ref byte[] guidBytes)
// If the byte array is null, return a new Guid byte array.
if (guidBytes == null)
{
Debug.Log("Guid byte array is null. Generating a new Guid.");
Debug.LogWarning("Guid byte array is null. Generating a new Guid.");
guidBytes = Guid.NewGuid().ToByteArray();
}

// If the byte array is empty, return a new Guid byte array.
if (guidBytes.Length == 0)
{
Debug.Log("Guid byte array is empty. Generating a new Guid.");
Debug.LogWarning("Guid byte array is empty. Generating a new Guid.");
guidBytes = Guid.NewGuid().ToByteArray();
}

Expand All @@ -225,7 +238,7 @@ public static byte[] GetSerializableGuid(ref byte[] guidBytes)

if (guidObj == Guid.Empty)
{
Debug.Log("Guid is empty. Generating a new Guid.");
Debug.LogWarning("Guid is empty. Generating a new Guid.");
guidBytes = Guid.NewGuid().ToByteArray();
}

Expand Down Expand Up @@ -293,12 +306,28 @@ static async Awaitable DoFileOperation(FileOperationType operationType, string[]
// Restore state for each ISaveable
foreach (SaveableObject wrappedData in m_loadedSaveables)
{
var guid = new Guid(wrappedData.Guid);

var saveable = m_saveables[guid];
// Try to get the ISaveable from the dictionary
if (m_saveables.ContainsKey(wrappedData.Key) == false)
{
Debug.LogError("The ISaveable with the key " + wrappedData.Key + " was not found in the saveables dictionary. " +
"The data will not be restored. This could mean that the string Key for the matching object has " +
"changed since the save data was created.", Instance.gameObject);
continue;
}

// Get the ISaveable from the dictionary
var saveable = m_saveables[wrappedData.Key];

if (saveable != null)
saveable.RestoreState(wrappedData.Data);
// If the ISaveable is null, log an error and continue to the next iteration
if (saveable == null)
{
Debug.LogError("The ISaveable with the key " + wrappedData.Key + " is null. "
+ "The data will not be restored.", Instance.gameObject);
continue;
}

// Restore the state of the ISaveable
saveable.RestoreState(wrappedData.Data);
}
}

Expand Down Expand Up @@ -372,7 +401,7 @@ static string SaveablesToJson(List<ISaveable> saveables)

wrappedSaveables[i] = new SaveableObject
{
Guid = s.Guid.ToString(),
Key = s.Key.ToString(),
Data = data
};
}
Expand Down
7 changes: 4 additions & 3 deletions Samples~/SaveAsyncExample/CacheDataExample.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@ namespace Buck.SaveAsyncExample
{
public class CacheDataExample : MonoBehaviour, ISaveable
{
// ISaveable needs a Guid which is used to identify the object in the save data.
// This is typically a serialized byte array that does not change.
// ISaveable needs a unique string "Key" which is used to identify the object in the save data.
// This is can optionally be a serialized byte array that does not change.
// Use OnValidate to ensure that your ISaveable's Guid has a value when the MonoBehaviour is created.
// For an example that uses a string, see GameDataExample.cs.
[SerializeField, HideInInspector] byte[] m_guidBytes;
public Guid Guid => new(m_guidBytes);
public string Key => new Guid(m_guidBytes).ToString();
void OnValidate() => SaveManager.GetSerializableGuid(ref m_guidBytes);
public string Filename => Files.SomeFile;

Expand Down
9 changes: 3 additions & 6 deletions Samples~/SaveAsyncExample/GameDataExample.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,9 @@ namespace Buck.SaveAsyncExample
{
public class GameDataExample : MonoBehaviour, ISaveable
{
// ISaveable needs a Guid which is used to identify the object in the save data.
// This is typically a serialized byte array that does not change.
// Use OnValidate to ensure that your ISaveable's Guid has a value when the MonoBehaviour is created.
[SerializeField, HideInInspector] byte[] m_guidBytes;
public Guid Guid => new(m_guidBytes);
void OnValidate() => SaveManager.GetSerializableGuid(ref m_guidBytes);
// ISaveable needs a unique string "Key" which is used to identify the object in the save data.
// For an example that uses a Guid, see CacheDataExample.cs.
public string Key => "GameDataExample";
public string Filename => Files.GameData;

// Your game data should go in a serializable struct
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "co.buck.saveasync",
"version": "0.3.2",
"version": "0.4.0",
"displayName": "Save Async",
"description": "Save Async is BUCK's Unity package for asynchronously saving and loading data in the background using Unity's Awaitable class. Capture and restore state without interrupting Unity's main render thread.",
"unity": "2023.1",
Expand Down

0 comments on commit 0b7b663

Please sign in to comment.