In the last post, we explored the low-level USaveGame
objects, including a developer quality of life mechanism to support saving and loading of these objects. But this all feels like too much manual work. You have seen these UPROPERTY(SaveGame)
specifiers, and the Unreal build tool does not complain if you use this macro on a property outside of a USaveGame
object. So… logically, if she’s a duck; no wait, so… logically, there must be a way to save and load these values, too.
Suppose we have a map with several actors that can represent open doors, inventory boxes, …; in other words, these actors have some kind of stable identifiers, and we’d like to automagically persist their state. To do that, we will need to be able to identify the actors that should take part in the save game operation. We’d like to avoid getting all actors; instead, we will only get actors that implement a specified interface.
UINTERFACE()
class UKopiSaveLoadAble : public UInterface
{
GENERATED_BODY()
};
/**
* Implement this interface such that the KopiSaveSubsystem is able to identify and persist
* any PROPERTY that is flagged SAVE_GAME within the superclass.
* This will provide EVENTS on Save or Load activities such that the superclass can react.
*/
class KOPI_API IKopiSaveLoadAble
{
GENERATED_BODY()
public:
/* Custom logic can be implemented to create a CustomSaveGame that will be persisted. */
UFUNCTION(BlueprintNativeEvent)
USaveGame* ToCustomSave();
/* Custom logic can be implemented when loading from a CustomSaveGame */
UFUNCTION(BlueprintNativeEvent)
void FromCustomSave(USaveGame* InSaveGame);
/* Called when a new game is intended. */
UFUNCTION(BlueprintNativeEvent)
void OnNewGame();
/* Called right before the Actor or ActorComponent state is going to be saved. */
UFUNCTION(BlueprintNativeEvent)
void OnActorSaved(const FString& SaveName);
/* Called after the Actor or ActorComponent state was restored from a SaveGame file. */
UFUNCTION(BlueprintNativeEvent)
void OnActorLoaded();
};
In this interface, we provided varying degrees of control, together with various levels of work needed to achieve that control. We can go from
- Inherit this interface, implementing no methods; all
UPROPERTY(SaveGame)
properties will be saved and loaded, - Inherit this interface, implementing
OnNewGame
,OnActorSaved
,OnActorLoaded
events; allUPROPERTY(SaveGame)
properties will be saved and loaded, and the appropriate “on*” events will be called to perform additional logic, - Inherit this interface, additionally implement
ToCustomSave
andFromCustomSave
; what to persist is now completely the responsibility of the two methods.
SaveGameSubsystem
In the next step, we need to wrap everything together; and an game-instance singleton [a UGameInstanceSubsystem subtype] is the perfect place to this logic.
UCLASS()
class KOPI_API UKopiSaveGameSubsystem : public UGameInstanceSubsystem
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintCallable, Category="SaveGame")
const UKopiSaveGameMeta* MetaNewGame();
UFUNCTION(BlueprintCallable, Category="SaveGame")
TArray<UKopiSaveGameMeta*> LoadSaveGamesMeta() const;
UFUNCTION(BlueprintCallable, Category="SaveGame")
UKopiSaveGameMeta* LoadLatestSaveGameMeta() const;
UFUNCTION(BlueprintCallable, Category="SaveGame")
void Delete(const UKopiSaveGameMeta* Meta);
UFUNCTION(BlueprintCallable, Category="SaveGame")
void SaveGame(const UKopiSaveGameMeta* Meta);
UFUNCTION(BlueprintCallable, Category="SaveGame")
void LoadGame(const UKopiSaveGameMeta* Meta);
}
The SaveGame
, and LoadGame
functions do the heavy-lifting; but generally, in addition to these, the players will expect to be able to use game slots, and for each slot to know how far they got, when was the last save time; in other words, some type of metadata; hence the various meta-functions. However, let’s take a look at the core of the heavy-lifting SaveGame
and LoadGame
methods.
First, we need to define the data structure that these methods will use
USTRUCT()
struct FKopiActorComponentSaveData
{
GENERATED_BODY()
public:
/* Identifier for which ActorComponent this data belongs to.
Doesn't support runtime spawned ActorComponents that might not have a consistent name */
UPROPERTY()
FString ActorComponentName;
/* Contains CustomSaveGame of the ActorComponent */
UPROPERTY()
TArray<uint8> CustomSaveByteData;
/* Contains all 'SaveGame' marked variables of the ActorComponent */
UPROPERTY()
TArray<uint8> ByteDta;
};
USTRUCT()
struct FKopiActorSaveData
{
GENERATED_BODY()
public:
/* Identifier for which Actor this data belongs to.
Doesn't support runtime spawned Actors that might not have a consistent name */
UPROPERTY()
FString ActorName;
/* For movable Actors, keep location,rotation,scale. */
UPROPERTY()
FTransform Transform;
/* Contains CustomSaveGame of the Actor */
UPROPERTY()
TArray<uint8> CustomSaveByteData;
/* Contains all 'SaveGame' marked variables of the Actor */
UPROPERTY()
TArray<uint8> ByteData;
/* Contains ActorComponentSaveData of the ActorComponent */
UPROPERTY()
TArray<FKopiActorComponentSaveData> ActorComponents;
};
UCLASS()
class KOPI_API UKopiSaveGame : public USaveGame
{
GENERATED_BODY()
public:
// Actors' data stored
UPROPERTY()
TArray<FKopiActorSaveData> SavedActors;
};
The structure follows a hierarchy: UKopiSaveGame
contains many FKopiActorSaveData
entries; each FKopiActorSaveData
contains many FKopiActorComponentSaveData
entries. The last thing that remains is to be able to transfer the data from the world on save and back to the world on load. The key concept is to iterate over all actors that implement the well-known interface, and then to use the FMemoryWriter
to do the serialization of the UPROPERTY(SaveGame)
-“annotated” properties into byte array; and we then add the byte array into the USaveGame
subtype. This USaveGame
subtype is what ends up being written on disk.
void UKopiSaveGameSubsystem::SaveGame(const UKopiSaveGameMeta* Meta) {
FString SaveName = ...;
UKopiSaveGame* SaveGame = ...;
// Iterate the entire world of actors that has interface UTaliskerSaveLoadAble
TArray<AActor*> SaveLoadAbleActors;
UGameplayStatics::GetAllActorsWithInterface(
GetWorld(),
UKopiSaveLoadAble::StaticClass(), SaveLoadAbleActors);
for (AActor* Actor : SaveLoadAbleActors)
{
IKopiSaveLoadAble::Execute_OnActorSave(Actor, SaveName);
FActorSaveData ActorData;
ActorData.ActorName = GetStableName(Actor);
ActorData.Transform = Actor->GetActorTransform();
if (USaveGame* CustomSave = IKopiSaveLoadAble::Execute_ToCustomSave(Actor)) {
UGameplayStatics::SaveGameToMemory(CustomSave, ActorData.CustomSaveByteData);
}
FMemoryWriter ActorMemWriter(ActorData.ByteData);
FObjectAndNameAsStringProxyArchive ActorArchive(ActorMemWriter, true);
ActorArchive.ArIsSaveGame = true; // Find only variables with UPROPERTY(SaveGame)
Actor->Serialize(ActorArchive); // Converts Actor's SaveGame UPROPERTIES into binary array
// Iterate all ActorComponents (within the Actor) that has interface UTaliskerSaveLoadAble
for (UActorComponent* ActorComponent :
Actor->GetComponentsByInterface(UKopiSaveLoadAble::StaticClass())) {
IKopiSaveLoadAble::Execute_OnActorSave(ActorComponent, SaveName);
FActorComponentSaveData ActorComponentData;
ActorComponentData.ActorComponentName = GetStableName(ActorComponent);
if (USaveGame* CustomSave = IKopiSaveLoadAble::Execute_ToCustomSave(ActorComponent)) {
UGameplayStatics::SaveGameToMemory(CustomSave, ActorComponentData.CustomSaveByteData);
}
FMemoryWriter ActorComponentMemWriter(ActorComponentData.ByteDta);
FObjectAndNameAsStringProxyArchive ActorComponentArchive(ActorComponentMemWriter, true);
ActorComponentArchive.ArIsSaveGame = true;
ActorComponent->Serialize(ActorComponentArchive);
ActorData.ActorComponents.Add(ActorComponentData);
}
CurrentSaveGame->SavedActors.Add(ActorData);
}
UGameplayStatics::SaveGameToSlot(CurrentSaveGame, SaveName, 0);
}
Loading is the inverse; we load the object, and then
void UKopiSaveGameSubsystem::LoadGame(const UKopiSaveGameMeta* Meta) {
UKopiSaveGame *CurrentSaveGame = ...;
TArray<AActor*> SaveLoadAbleActors;
UGameplayStatics::GetAllActorsWithInterface(
GetWorld(), UKopiSaveLoadAble::StaticClass(),
SaveLoadAbleActors);
for (AActor* Actor : SaveLoadAbleActors) {
const auto ActorName = GetStableName(Actor);
bool bFound = false;
for (FActorSaveData ActorData : CurrentSaveGame->SavedActors) {
if (ActorData.ActorName == ActorName) {
bFound = true;
if (USaveGame* CustomSave = UGameplayStatics::LoadGameFromMemory(
ActorData.CustomSaveByteData)) {
IKopiSaveLoadAble::Execute_FromCustomSave(Actor, CustomSave);
}
FMemoryReader ActorMemReader(ActorData.ByteData);
FObjectAndNameAsStringProxyArchive ActorArchive(ActorMemReader, true);
ActorArchive.ArIsSaveGame = true;
Actor->Serialize(ActorArchive); // Convert binary array back into actor's variables
// Iterate all ActorComponents (within the Actor) that has interface UKopiSaveLoadAble
for (UActorComponent* ActorComponent :
Actor->GetComponentsByInterface(UKopiSaveLoadAble::StaticClass())) {
const auto ActorComponentName = GetStableName(ActorComponent);
for (FActorComponentSaveData ActorComponentData : ActorData.ActorComponents) {
if (ActorComponentData.ActorComponentName == ActorComponentName) {
if (USaveGame* CustomSave = UGameplayStatics::LoadGameFromMemory(
ActorComponentData.CustomSaveByteData)) {
IKopiSaveLoadAble::Execute_FromCustomSave(ActorComponent, CustomSave);
}
FMemoryReader ActorComponentMemReader(ActorComponentData.ByteDta);
FObjectAndNameAsStringProxyArchive ActorComponentArchive(ActorComponentMemReader,
true);
ActorComponentArchive.ArIsSaveGame = true;
ActorComponent->Serialize(ActorComponentArchive);
IKopiSaveLoadAble::Execute_OnActorLoaded(ActorComponent);
}
}
}
IKopiSaveLoadAble::Execute_OnActorLoaded(Actor);
}
}
if (!bFound)
{
UE_LOG(LogKopi, Warning,
TEXT(
"Did not load actor %s. Is it missing from the save file?"
), *Actor->GetFName().ToString());
}
}
}
Summary
And this is all the magic; with this code, you can have the initial idea of how to conveniently save the state of the actors in your game. Notice that the saving and loading is synchronous, which means that it will block all other operations until it finishes. This is a major drawback, for production-level games that have a large number of actors, you should definitely switch to using the asynchronous methods. These allow the loading and saving to operate on its own thread. This of course brings its own challenges, and we will tackle those in the third instalment of this post.
Leave a Reply