Compose chunks of save data into a single data type by creating loosely coupled save chunks at various points in your application.
Find the latest version of Chickensoft.SaveFileBuilder on nuget.
dotnet add package Chickensoft.SaveFileBuilder// Define your (serializable!) save data
public class UserData
{
public string Name { get; set; }
public DateTime Birthday { get; set; }
}
// Define your class responsible for saving and loading.
public class User
{
public string Name { get; set; }
public string Birthday { get; set; }
public SaveFile<UserData> SaveFile { get; }
public ISaveChunk<UserData> SaveChunk { get; }
public User()
{
// Define your saving and loading behavior at the start, and never again!
SaveChunk = new SaveChunk<UserData>(
onSave: (chunk) => new UserData()
{
Name = Name,
Birthday = Birthday
},
onLoad: (chunk, data) =>
{
Name = data.Name;
Birthday = data.Birthday;
}
);
// Let SaveFile take care of the rest.
SaveFile = SaveFile.CreateGZipJsonFile(SaveChunk, "savefile.json.gz");
}
public Task OnSave() => SaveFile.SaveAsync();
public Task OnLoad() => SaveFile.LoadAsync();
}Tip
You can define easily serializable types with Chickensoft.Serialization.
SaveChunks are smaller pieces of save data that are composed together into the overall save file.
// User data contains preferences data separately.
public class UserData
{
public string Name { get; set; }
public DateTime Birthday { get; set; }
public PreferencesData Preferences { get; set; }
}
// This allows us to keep our save data and -logic modular.
public class PreferencesData
{
public bool IsDarkMode { get; set; }
public string Language { get; set; }
}This modularity allows us to separate concerns when saving and loading data. The User class is only concerned with user data, while the UserPreferences class is only concerned with preferences data.
We can link our save chunks together using:
GetChunkSaveDatato retrieve child chunk data during save.LoadChunkSaveDatato load child chunk data during load.AddChunkto compose our save data.
// Handle user logic.
public class User
{
public string Name { get; set; }
public DateTime Birthday { get; set; }
public ISaveChunk<UserData> SaveChunk { get; }
public User()
{
// Define our user chunk with a nested preferences chunk.
SaveChunk = new SaveChunk<UserData>(
onSave: (chunk) => new UserData()
{
Name = Name,
Birthday = Birthday,
Preferences = chunk.GetChunkSaveData<PreferencesData>()
},
onLoad: (chunk, data) =>
{
Name = data.Name;
Birthday = data.Birthday;
chunk.LoadChunkSaveData(data.Preferences);
}
);
}
}
// Handle preferences logic.
public class UserPreferences
{
public bool IsDarkMode { get; set; }
public string Language { get; set; }
public ISaveChunk<PreferencesData> SaveChunk { get; }
public UserPreferences(User user)
{
// Define our preferences chunk.
SaveChunk = new SaveChunk<PreferencesData>(
onSave: (chunk) => new PreferencesData()
{
IsDarkMode = IsDarkMode,
Language = Language
},
onLoad: (chunk, data) =>
{
IsDarkMode = data.IsDarkMode;
Language = data.Language;
}
);
// Add our preferences chunk as a child of the user chunk.
user.SaveChunk.AddChunk(SaveChunk);
}
}Tip
If you just want to save some data to a file, call the following: SaveFile.CreateGZipJsonFile(Root, "savefile.json.gz");
Saving a file involves 2 to 3 steps:
- input / output (io)
- serialization
- (preferably) compression
SaveFile handles these steps for you, and optimally at that! By using Streams under the hood, SaveFile can efficiently save and load data without unnecessary memory allocations.
But the β‘ REAL POWER β‘ of SaveFile comes from its flexibility. You can define your own IO providers, compression algorithms, and serialization formats by implementing the relevant interfaces:
- IStreamIO / IAsyncStreamIO for io
- IStreamSerializer / IAsyncStreamSerializer for serialization
- IStreamCompressor for compression
public class AzureStreamIO : IAsyncStreamIO
{
public Stream ReadAsync() => //...
public void WriteAsync(Stream stream) => //...
public bool ExistsAsync() => //...
public bool DeleteAsync() => //...
}
public class YamlStreamSerializer : IStreamSerializer
{
public void Serialize(Stream stream, object? value, Type inputType) => //...
public object? Deserialize(Stream stream, Type returnType) => //...
}
public class SnappyStreamCompressor : IStreamCompressor
{
public Stream Compress(Stream stream, CompressionLevel compressionLevel, bool leaveOpen) => //...
public Stream Decompress(Stream stream, bool leaveOpen) => //...
}You can then provide them to your SaveFile and mix- and match them with existing types.
public class App
{
SaveFile<AzureData> AzureSaveFile { get; set; }
SaveFile<LocalData> LocalSaveFile { get; set; }
public void Save()
{
// Define a SaveChunk<AzureData> AzureChunk
// Define a SaveChunk<LocalData> LocalChunk
AzureSaveFile = new
(
root: AzureChunk,
asyncIO: new AzureStreamIO(),
serializer: new JsonStreamSerializer(),
compressor: new SnappyStreamCompressor()
);
LocalSaveFile = new
(
root: LocalChunk,
io: new FileStreamIO(),
serializer: new YamlStreamSerializer(),
compressor: new BrotliStreamCompressor()
);
}
}Note
If you write your own implementations of these interfaces, consider contributing them back to the Chickensoft community by opening a PR!
Using Introspection and AutoInject, you can link chunks together in Godot by providing- and accessing dependencies in your scene tree. Mark the relevant nodes as IAutoNode's, provide dependencies from parent nodes, and access them in child nodes.
using Chickensoft.Introspection;
using Chickensoft.AutoInject;
using Chickensoft.SaveFileBuilder;
using Godot;
// Game is the root node in the scene. It provides the dependency to descendant nodes.
[Meta(typeof(IAutoNode))]
public partial class Game : Node3D
{
public SaveFile<GameData> SaveFile { get; set; } = default!;
// Provide the root save chunk to all descendant nodes.
ISaveChunk<GameData> IProvide<ISaveChunk<GameData>>.Value() => SaveFile.Root;
public void Setup()
{
var root = new SaveChunk<GameData>(onSave: ..., onLoad: ...);
SaveFile = SaveFile.CreateGZipJsonFile(root, SaveFilePath, JsonOptions);
}
}
// Player is a child node of the Game node. It accesses the dependency provided by the Game class.
[Meta(typeof(IAutoNode))]
public partial class Player : CharacterBody3D
{
[Dependency]
public ISaveChunk<GameData> GameChunk => this.DependOn<ISaveChunk<GameData>>();
public ISaveChunk<PlayerData> PlayerChunk { get; set; } = default!;
// Player uses a StateMachine, or LogicBlock, to handle its state.
public IPlayerLogic PlayerLogic { get; set; } = default!;
public void Setup()
{
PlayerLogic = new PlayerLogic();
PlayerChunk = new SaveChunk<PlayerData>(
onSave: (chunk) => new PlayerData()
{
GlobalTransform = GlobalTransform,
StateMachine = PlayerLogic,
Velocity = Velocity
},
onLoad: (chunk, data) =>
{
GlobalTransform = data.GlobalTransform;
Velocity = data.Velocity;
PlayerLogic.RestoreFrom(data.StateMachine);
PlayerLogic.Start();
}
);
}
public void OnResolved()
{
// Add a child to our parent save chunk (the game chunk) so that it can
// look up the player chunk when loading and saving the game.
GameChunk.AddChunk(PlayerChunk);
}
}Tip
You can easily serialize entire LogicBlocks with Chickensoft.Serialization.
Tip
Check out the Chickensoft Game Demo for a complete, working example of using SaveFileBuilder to save composed states of everything that needs to be persisted in a game.
π£ Package generated from a π€ Chickensoft Template β https://chickensoft.games