In the good old days, games were proper gamers; to play a game, one had to first find the tape, then fiddle with the play and fast-forward buttons to. If you wanted to finish a game, you had better have a whole day, there were no saves. The times have changed; don’t even think about trying to make a game that cannot save its state or progress. There’s nothing stopping you from rolling your own saving mechanism; defining your own data serialization; but in Unreal Engine, it’d be foolish to ignore the already existing USaveGame
mechanism.
USaveGame
The usage of USaveGame
is really simple; if you want to use the save and load mechanism, you create a subclass of USaveGame
, and for all properties you wish to take part in the save mechanism, add UPROPERTY(SaveGame)
macro. Then use UGameplayStatics::LoadGameFromSlot
and UGameplayStatics::SaveGameToSlot
to perform the loading and saving mechanics. The entire load-save flow includes starting the game from empty state (i.e. no save game exists), and removing the old saves (i.e. new game from start). None of these things are complicated, but if you have multiple USaveGame
objects, this additional gruntwork can get very tedious and very error-prone.
UCLASS(NotBlueprintType)
class UKopiServedRecipeLog : public USaveGame
{
GENERATED_BODY()
UPROPERTY(SaveGame)
TMap<FGameplayTag, int> ServedRecipeTags;
};
UCLASS(NotBlueprintType)
class UKopiTutorialDisplayLog : public USaveGame
{
GENERATED_BODY()
UPROPERTY(SaveGame)
TSet<FString> NoShowTutorialIDs;
};
UCLASS(NotBlueprintType)
class UKopiGameLog : public USaveGame
{
GENERATED_BODY()
UPROPERTY(SaveGame)
TArray<FKopiChapterLog> ChapterLogs;
};
Even if you have just these three USaveGame
subtypes, loading each will need almost the same code:
UCLASS(NotBlueprintType)
class UKopiGameLog : public USaveGame
{
GENERATED_BODY()
UPROPERTY(SaveGame)
TArray<FKopiChapterLog> ChapterLogs;
static UKopiGameLog *LoadSaveGame();
};
UKopiGameLog *UKopiGameLog::LoadSaveGame() {
const FString SlotName = ...;
if (UGameplayStatics::DoesSaveGameExist(SlotName, 0))
if (const auto SG = Cast<UKopiGameLog>(UGameplayStatics::LoadGameFromSlot(SlotName, 0));
SG != nullptr)
return SG;
return Cast<UKopiGameLog>(UGameplayStatics::CreateSaveGameObject(UKopiGameLog::StaticClass()));
}
Developer quality of life
The LoadSaveGame
method would be almost the same for all our USaveGame
s, except of course for the type; such scenarios, we can easily replace with a template.
template <typename T>
T *UKopiGameplayStatics::LoadSaveGame()
{
const FString SlotName = GetSlotName<T>();
if (UGameplayStatics::DoesSaveGameExist(SlotName, 0))
if (const auto SG = Cast<T>(UGameplayStatics::LoadGameFromSlot(SlotName, 0)); SG != nullptr)
return SG;
return Cast<T>(UGameplayStatics::CreateSaveGameObject(T::StaticClass()));
}
This works quite neatly, except for the little slight of hand called GetSlotName
; at the point of instantiation of the template, the compiler will replace the T
with the actual type name; if we assume that the T
will in fact be a subtype of UObject
, we can maybe use its StaticClass()->GetName()
as the name for the slot; in case of UKopiGameLog
, we’d get “KopiGameLog
“; in case of UKopiTutorialDisplayLog
, we’d get “KopiTutorialDisplayLog
“.
template <typename T>
FString UKopiGameplayStatics::GetSlotName()
{
const FString Version = "_1";
FString SlotName = T::StaticClass()->GetName();
return SlotName + Version;
}
This is sensible–ish; for completeness, we can throw in the save and delete save operations.
template <typename T>
void UKopiGameplayStatics::DeleteSaveGame()
{
const FString SlotName = GetSlotName<T>();
UGameplayStatics::DeleteGameInSlot(SlotName, 0);
}
template <typename T>
void UKopiGameplayStatics::SaveGame(T *SaveGame)
{
const FString SlotName = GetSlotName<T>();
UGameplayStatics::SaveGameToSlot(SaveGame, SlotName, 0);
}
The usage is equally painless: at the point of instantiating the template, the compiler will generate specialized overloads of the methods, in each instance “replacing” the T
with the concrete type.
UKopiGameLog *GameLog = UKopiGameplayStatics::LoadSaveGame<UKopiGameLog>();
// operate on the GameLog
UKopiGameplayStatics::SaveGame(GameLog);
Now, what happens when you by accident let the compiler instantiate the UKopiGameplayStatics::LoadSaveGame
for a type that doesn’t have the StaticClass()
member, for example; or a type that is not a subtype of USaveGame
?
UObject *X = UKopiGameplayStatics::LoadSaveGame<UObject>();
UKopiGameplayStatics::SaveGame(X);
FString *Y = UKopiGameplayStatics::LoadSaveGame<FString>();
UKopiGameplayStatics::SaveGame(Y);
No complaints from the IDE; it all looks good. Let’s compile.
[4/42] Compile KopiGameSubsystem.cpp
In file included from /home/janmachacek/Games/Kopi/Source/Kopi/KopiGameSubsystem.cpp:6:
/home/janmachacek/Games/Kopi/Source/Kopi/KopiGameplayStatics.h:70:35: error: cannot initialize a parameter of type 'USaveGame *' with an lvalue of type 'UObject *'
UGameplayStatics::SaveGameToSlot(SaveGame, SlotName, 0);
^~~~~~~~
/home/janmachacek/Games/Kopi/Source/Kopi/KopiGameSubsystem.cpp:88:24: note: in instantiation of function template specialization 'UKopiGameplayStatics::SaveGame<UObject>' requested here
UKopiGameplayStatics::SaveGame(X);
^
/opt/ue5_3/Engine/Source/Runtime/Engine/Classes/Kismet/GameplayStatics.h:1146:51: note: passing argument to parameter 'SaveGameObject' here
static ENGINE_API bool SaveGameToSlot(USaveGame* SaveGameObject, const FString& SlotName, const int32 UserIndex);
^
In file included from /home/janmachacek/Games/Kopi/Source/Kopi/KopiGameSubsystem.cpp:6:
/home/janmachacek/Games/Kopi/Source/Kopi/KopiGameplayStatics.h:58:59: error: no member named 'StaticClass' in 'FString'
return Cast<T>(UGameplayStatics::CreateSaveGameObject(T::StaticClass()));
~~~^
/home/janmachacek/Games/Kopi/Source/Kopi/KopiGameSubsystem.cpp:90:37: note: in instantiation of function template specialization 'UKopiGameplayStatics::LoadSaveGame<FString>' requested here
FString *Y = UKopiGameplayStatics::LoadSaveGame<FString>();
^
In file included from /home/janmachacek/Games/Kopi/Source/Kopi/KopiGameSubsystem.cpp:6:
/home/janmachacek/Games/Kopi/Source/Kopi/KopiGameplayStatics.h:45:25: error: no member named 'StaticClass' in 'FString'
FString SlotName = T::StaticClass()->GetName();
~~~^
/home/janmachacek/Games/Kopi/Source/Kopi/KopiGameplayStatics.h:53:27: note: in instantiation of function template specialization 'UKopiGameplayStatics::GetSlotName<FString>' requested here
const FString SlotName = GetSlotName<T>();
^
/home/janmachacek/Games/Kopi/Source/Kopi/KopiGameSubsystem.cpp:90:37: note: in instantiation of function template specialization 'UKopiGameplayStatics::LoadSaveGame<FString>' requested here
FString *Y = UKopiGameplayStatics::LoadSaveGame<FString>();
^
In file included from /home/janmachacek/Games/Kopi/Source/Kopi/KopiGameSubsystem.cpp:1:
In file included from /home/janmachacek/Games/Kopi/Intermediate/Build/Linux/x64/Kopi/Shipping/Engine/SharedPCH.Engine.CppLatest.h:3:
In file included from /opt/ue5_3/Engine/Source/Runtime/Engine/Public/EngineSharedPCH.h:5:
In file included from /opt/ue5_3/Engine/Source/Runtime/Slate/Public/SlateSharedPCH.h:5:
In file included from /opt/ue5_3/Engine/Source/Runtime/CoreUObject/Public/CoreUObjectSharedPCH.h:12:
In file included from /opt/ue5_3/Engine/Source/Runtime/CoreUObject/Public/Serialization/ArchiveUObjectFromStructuredArchive.h:13:
In file included from /opt/ue5_3/Engine/Source/Runtime/CoreUObject/Public/UObject/LazyObjectPtr.h:16:
In file included from /opt/ue5_3/Engine/Source/Runtime/CoreUObject/Public/Templates/Casts.h:9:
In file included from /opt/ue5_3/Engine/Source/Runtime/CoreUObject/Public/UObject/Class.h:61:
In file included from /opt/ue5_3/Engine/Source/Runtime/CoreUObject/Public/UObject/CoreNative.h:10:
In file included from /opt/ue5_3/Engine/Source/Runtime/CoreUObject/Public/UObject/Object.h:11:
/opt/ue5_3/Engine/Source/Runtime/CoreUObject/Public/UObject/UObjectBaseUtility.h:743:17: error: no member named 'StaticClass' in 'FString'
return IsA(T::StaticClass());
^
/opt/ue5_3/Engine/Source/Runtime/CoreUObject/Public/Templates/Casts.h:123:32: note: in instantiation of function template specialization 'UObjectBaseUtility::IsA<FString>' requested here
if (((const UObject*)Src)->IsA<To>())
^
/home/janmachacek/Games/Kopi/Source/Kopi/KopiGameplayStatics.h:55:25: note: in instantiation of function template specialization 'Cast<FString, USaveGame>' requested here
if (const auto This = Cast<T>(UGameplayStatics::LoadGameFromSlot(SlotName, 0)); This != nullptr)
^
/home/janmachacek/Games/Kopi/Source/Kopi/KopiGameSubsystem.cpp:90:37: note: in instantiation of function template specialization 'UKopiGameplayStatics::LoadSaveGame<FString>' requested here
FString *Y = UKopiGameplayStatics::LoadSaveGame<FString>();
^
In file included from /home/janmachacek/Games/Kopi/Source/Kopi/KopiGameSubsystem.cpp:6:
/home/janmachacek/Games/Kopi/Source/Kopi/KopiGameplayStatics.h:70:35: error: cannot initialize a parameter of type 'USaveGame *' with an lvalue of type 'FString *'
UGameplayStatics::SaveGameToSlot(SaveGame, SlotName, 0);
^~~~~~~~
/home/janmachacek/Games/Kopi/Source/Kopi/KopiGameSubsystem.cpp:91:24: note: in instantiation of function template specialization 'UKopiGameplayStatics::SaveGame<FString>' requested here
UKopiGameplayStatics::SaveGame(Y);
^
/opt/ue5_3/Engine/Source/Runtime/Engine/Classes/Kismet/GameplayStatics.h:1146:51: note: passing argument to parameter 'SaveGameObject' here
static ENGINE_API bool SaveGameToSlot(USaveGame* SaveGameObject, const FString& SlotName, const int32 UserIndex);
^
The trouble with templates is that when the compiler instantiates the template, it does not have any further context other than that T
represents a type name. It is only after it generates the instance of the template that it can actually do the compilation. The IDE is equally in the blind; it cannot provide any meaningful assistance. It knows that there is a function called LoadGameFromSlot
, that it needs a template parameter; and that the function returns the pointer to that type.
It would be very useful if we could give the compiler that kind of context. Enter concepts. We can understand concept as providing context for template instantiation. We do not accept any old typename T
, but only those typename T
s that satisfy some additional conditions. Note that templates are not generics–we are not introducing any class hierarchy, templates still generate specific instances at compile-time. We replace the “any old typename T
” with “satisfies CIsSaveGame T
“.
class KOPI_API UKopiGameplayStatics
{
template <typename T>
static FString GetSlotName();
public:
UKopiGameplayStatics() = delete;
template <CIsSaveGame T>
static T *LoadSaveGame();
template <CIsSaveGame T>
static void DeleteSaveGame();
template <CIsSaveGame T>
static void SaveGame(T *SaveGame);
};
Suppose CIsSaveGame
here is a concept that for now in human-speak says “is subtype of USaveGame”. When we now try to compile our code, we get much nicer error messages, and even the IDE can help out.
It’s now time to define the CIsSaveGame
concept. Because we’re using Unreal Engine, we usually try to favour the Unreal Engine-specific constructs over the C++ standard library ones. So, the CIsSaveGame
concept in its full glory is just
template <typename T>
concept CIsSaveGame = TIsDerivedFrom<T, USaveGame>::IsDerived;
The TIsDerivedFrom
is part of the Unreal Engine “standard library”, its constexpr
(i.e. computed at compile time) IsDerived
value is true
if and only if T
is a subclass of USaveGame
.
Slot names
The code above works wonderfully–until it is time to make significant code changes, change type names; maybe add a “version 2” of the save game. Now because of our choice to base the save slot name on the class name, we have a bit of a bother on our hands. It’d be nice if we could have an opt-in mechanism where the typename <typename T> FString GetSlotName<T>()
function could check if the type T
explicitly defines slot name and if so, use that; if no explicit slot name is defined, use the class name. Again, no class hierarchy, no virtual functions–that’s not to say that class hierarchies and virtual functions are bad per se, but if we can avoid creating hierarchies, we really should take that option.
With concepts, we can do that. Remember that concept is computed at compile-time; and we can use if constexpr
in template instantiation. With concept, this drives the template code generation, it is not a runtime check.
template <typename T>
FString UKopiGameplayStatics::GetSlotName()
{
const FString Version = "_1";
if constexpr (CHasExplicitSlotName<T>) {
return FString(T::SlotName) + Version;
} else {
FString SlotName = T::StaticClass()->GetName();
SlotName.ReplaceInline(TEXT("Kopi"), TEXT(""));
return SlotName + Version;
}
}
The last piece of work is to define the CHasExplicitSlotName
concept; it should evaluate to true
if and only if the type T
has a member static const wchar_t* const& SlotName
–in other words a string literal.
template <typename T>
concept CHasExplicitSlotName = requires
{
{ T::SlotName } -> std::same_as<const wchar_t * const&>;
};
The static const wchar_t* const& SlotName
is a little wild, but it is the actual type of a wide character string literal; this allows us to write
class UKopiServedRecipeLog : public USaveGame
{
GENERATED_BODY()
inline static const auto SlotName = "Foo";
UPROPERTY(SaveGame)
TMap<FGameplayTag, int> ServedRecipeTags;
};
Really so manual?
Suppose we want to save other things, for example (stable) actors that exist in the world that each have their own state that they wish to persist? Does that mean that each such actor needs its USaveGame
object, that each such actor needs to do the gymnastics of shuffling its state from the USaveGame
and back? So much typing!? Luckily, there is a solution, but that will have to wait for the next blog post.
Leave a Reply