312 lines
11 KiB
Markdown
312 lines
11 KiB
Markdown
|
## tomlooman写的教程
|
|||
|
https://www.tomlooman.com/save-system-unreal-engine-tutorial/
|
|||
|
|
|||
|
## 保存关卡世界数据与状态
|
|||
|
要保存世界状态,我们必须决定为每个演员存储哪些变量,以及我们需要保存在磁盘上的哪些杂项信息。例如每个玩家获得的金钱数。金钱数并不是世界状态的真正组成部分,而是属于PlayerState。尽管PlayerState存在于世界中,并且事实上是一个角色,但我们还是将它们分开处理,这样我们就可以根据它以前属于哪个玩家来正确地恢复它。
|
|||
|
|
|||
|
## Actor数据
|
|||
|
对于 Actor 变量,我们存储其名称、变换(位置、旋转、缩放)和一个字节数据数组,其中包含在其 UPROPERTY 中标有“SaveGame”的所有变量。
|
|||
|
```
|
|||
|
USTRUCT()
|
|||
|
struct FActorSaveData
|
|||
|
{
|
|||
|
GENERATED_BODY()
|
|||
|
|
|||
|
public:
|
|||
|
/* Identifier for which Actor this belongs to */
|
|||
|
UPROPERTY()
|
|||
|
FName ActorName;
|
|||
|
|
|||
|
/* For movable Actors, keep location,rotation,scale. */
|
|||
|
UPROPERTY()
|
|||
|
FTransform Transform;
|
|||
|
|
|||
|
/* Contains all 'SaveGame' marked variables of the Actor */
|
|||
|
UPROPERTY()
|
|||
|
TArray<uint8> ByteData;
|
|||
|
};
|
|||
|
```
|
|||
|
|
|||
|
## 将变量转换为二进制
|
|||
|
要将变量转换为二进制数组,我们需要一个FMemoryWriter和FObjectAndNameAsStringProxyArchive,它们派生自 FArchive(虚幻的数据容器,用于各种序列化数据,包括您的游戏内容)。
|
|||
|
|
|||
|
我们按接口过滤,以避免在我们不想保存的世界中潜在的数千个静态 Actor 上调用 Serialize。存储 Actor 的名称将在稍后用于识别要反序列化(加载)数据的 Actor。您可以想出自己的解决方案,例如FGuid(主要用于可能没有一致名称的运行时生成的 Actor)由于内置系统,其余的代码非常简单(并在注释中进行了解释)。
|
|||
|
```
|
|||
|
void ASGameModeBase::WriteSaveGame()
|
|||
|
{
|
|||
|
// ... < playerstate saving code ommitted >
|
|||
|
|
|||
|
// Clear all actors from any previously loaded save to avoid duplicates
|
|||
|
CurrentSaveGame->SavedActors.Empty();
|
|||
|
|
|||
|
// Iterate the entire world of actors
|
|||
|
for (FActorIterator It(GetWorld()); It; ++It)
|
|||
|
{
|
|||
|
AActor* Actor = *It;
|
|||
|
// Only interested in our 'gameplay actors', skip actors that are being destroyed
|
|||
|
// Note: You might instead use a dedicated SavableObject interface for Actors you want to save instead of re-using GameplayInterface
|
|||
|
if (Actor->IsPendingKill() || !Actor->Implements<USGameplayInterface>())
|
|||
|
{
|
|||
|
continue;
|
|||
|
}
|
|||
|
|
|||
|
FActorSaveData ActorData;
|
|||
|
ActorData.ActorName = Actor->GetFName();
|
|||
|
ActorData.Transform = Actor->GetActorTransform();
|
|||
|
|
|||
|
// Pass the array to fill with data from Actor
|
|||
|
FMemoryWriter MemWriter(ActorData.ByteData);
|
|||
|
|
|||
|
FObjectAndNameAsStringProxyArchive Ar(MemWriter, true);
|
|||
|
// Find only variables with UPROPERTY(SaveGame)
|
|||
|
Ar.ArIsSaveGame = true;
|
|||
|
// Converts Actor's SaveGame UPROPERTIES into binary array
|
|||
|
Actor->Serialize(Ar);
|
|||
|
|
|||
|
CurrentSaveGame->SavedActors.Add(ActorData);
|
|||
|
}
|
|||
|
|
|||
|
UGameplayStatics::SaveGameToSlot(CurrentSaveGame, SlotName, 0);
|
|||
|
}
|
|||
|
```
|
|||
|
|
|||
|
PS.tomlooman的意思是通过判断Actor是否继承对应接口来判断这个Actor是否需要将数据进行存档。
|
|||
|
|
|||
|
## 宝箱Actor案例
|
|||
|

|
|||
|
下面是直接从项目中取出的宝箱。请注意在bLidOpened 变量上标记的ISGameplayInterface继承和“ SaveGame ”。这将是唯一保存到磁盘的变量。默认情况下,我们也存储 Actor 的 FTransform。所以我们可以在地图上推动宝箱(启用模拟物理),在下一次播放时,位置和旋转将与盖子状态一起恢复。
|
|||
|
```
|
|||
|
UCLASS()
|
|||
|
class ACTIONROGUELIKE_API ASItemChest : public AActor, public ISGameplayInterface
|
|||
|
{
|
|||
|
GENERATED_BODY()
|
|||
|
public:
|
|||
|
UPROPERTY(EditAnywhere)
|
|||
|
float TargetPitch;
|
|||
|
|
|||
|
void Interact_Implementation(APawn* InstigatorPawn);
|
|||
|
|
|||
|
void OnActorLoaded_Implementation();
|
|||
|
|
|||
|
protected:
|
|||
|
UPROPERTY(ReplicatedUsing="OnRep_LidOpened", BlueprintReadOnly, SaveGame) // RepNotify
|
|||
|
bool bLidOpened;
|
|||
|
|
|||
|
UFUNCTION()
|
|||
|
void OnRep_LidOpened();
|
|||
|
|
|||
|
UPROPERTY(VisibleAnywhere)
|
|||
|
UStaticMeshComponent* BaseMesh;
|
|||
|
|
|||
|
UPROPERTY(VisibleAnywhere, BlueprintReadOnly)
|
|||
|
UStaticMeshComponent* LidMesh;
|
|||
|
|
|||
|
public:
|
|||
|
// Sets default values for this actor's properties
|
|||
|
ASItemChest();
|
|||
|
};
|
|||
|
```
|
|||
|
```
|
|||
|
void ASItemChest::Interact_Implementation(APawn* InstigatorPawn)
|
|||
|
{
|
|||
|
bLidOpened = !bLidOpened;
|
|||
|
OnRep_LidOpened();
|
|||
|
}
|
|||
|
|
|||
|
void ASItemChest::OnActorLoaded_Implementation()
|
|||
|
{
|
|||
|
OnRep_LidOpened();
|
|||
|
}
|
|||
|
|
|||
|
void ASItemChest::OnRep_LidOpened()
|
|||
|
{
|
|||
|
float CurrPitch = bLidOpened ? TargetPitch : 0.0f;
|
|||
|
LidMesh->SetRelativeRotation(FRotator(CurrPitch, 0, 0));
|
|||
|
}
|
|||
|
```
|
|||
|
## 玩家数据
|
|||
|
剩下的就是迭代 PlayerState 实例并让它们也存储数据。虽然 PlayerState 派生自 Actor 并且理论上可以在所有世界 Actor 的迭代过程中保存,但单独执行它很有用,因此我们可以将它们与玩家 ID(例如 Steam 用户 ID)匹配,而不是我们所做的不断变化的 Actor 名称不决定/控制此类运行时生成的 Actor。
|
|||
|
|
|||
|
### 保存数据
|
|||
|
在我的示例中,我选择在保存游戏之前从 PlayerState 获取所有数据。我们通过调用SavePlayerState(USSaveGame* SaveObject); 这让 use 将任何与 SaveGame 对象相关的数据传入,例如 Pawn 的 PlayerId 和 Transform(如果玩家当前还活着)
|
|||
|
|
|||
|
>您**可以**选择在这里也使用 SaveGame 属性并通过将其转换为二进制数组来自动存储一些玩家数据,就像我们对 Actors 所做的一样,而不是手动将其写入 SaveGame,但您仍然需要手动处理 PlayerID和典当变换。
|
|||
|
```
|
|||
|
void ASPlayerState::SavePlayerState_Implementation(USSaveGame* SaveObject)
|
|||
|
{
|
|||
|
if (SaveObject)
|
|||
|
{
|
|||
|
// Gather all relevant data for player
|
|||
|
FPlayerSaveData SaveData;
|
|||
|
SaveData.Credits = Credits;
|
|||
|
SaveData.PersonalRecordTime = PersonalRecordTime;
|
|||
|
// Stored as FString for simplicity (original Steam ID is uint64)
|
|||
|
SaveData.PlayerID = GetUniqueId().ToString();
|
|||
|
|
|||
|
// May not be alive while we save
|
|||
|
if (APawn* MyPawn = GetPawn())
|
|||
|
{
|
|||
|
SaveData.Location = MyPawn->GetActorLocation();
|
|||
|
SaveData.Rotation = MyPawn->GetActorRotation();
|
|||
|
SaveData.bResumeAtTransform = true;
|
|||
|
}
|
|||
|
|
|||
|
SaveObject->SavedPlayers.Add(SaveData);
|
|||
|
}
|
|||
|
```
|
|||
|
确保在保存到磁盘之前在所有 PlayerState 上调用这些。请务必注意,GetUniqueId 仅在您加载了在线子系统(例如 Steam 或 EOS)时才相关/一致。
|
|||
|
|
|||
|
### 加载数据
|
|||
|
为了检索玩家数据,我们进行了相反的操作,并且必须在 pawn 生成并准备好之后手动分配玩家的变换。您可以更无缝地覆盖游戏模式中的玩家生成逻辑以使用保存的转换。例如,我在HandleStartingNewPlayer期间坚持使用更简单的方法来处理这个问题。
|
|||
|
```
|
|||
|
void ASPlayerState::LoadPlayerState_Implementation(USSaveGame* SaveObject)
|
|||
|
{
|
|||
|
if (SaveObject)
|
|||
|
{
|
|||
|
FPlayerSaveData* FoundData = SaveObject->GetPlayerData(this);
|
|||
|
if (FoundData)
|
|||
|
{
|
|||
|
//Credits = SaveObject->Credits;
|
|||
|
// Makes sure we trigger credits changed event
|
|||
|
AddCredits(FoundData->Credits);
|
|||
|
|
|||
|
PersonalRecordTime = FoundData->PersonalRecordTime;
|
|||
|
}
|
|||
|
else
|
|||
|
{
|
|||
|
UE_LOG(LogTemp, Warning, TEXT("Could not find SaveGame data for player id '%i'."), GetPlayerId());
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
```
|
|||
|
与在初始关卡加载时处理的加载 Actor 数据不同,对于玩家状态,我们希望在玩家加入之前可能与我们一起玩过的服务器时一一加载它们。我们可以在 GameMode 类中的 HandleStartingNewPlayer 期间这样做。
|
|||
|
```
|
|||
|
void ASGameModeBase::HandleStartingNewPlayer_Implementation(APlayerController* NewPlayer)
|
|||
|
{
|
|||
|
// Calling Before Super:: so we set variables before 'beginplayingstate' is called in PlayerController (which is where we instantiate UI)
|
|||
|
ASPlayerState* PS = NewPlayer->GetPlayerState<ASPlayerState>();
|
|||
|
if (ensure(PS))
|
|||
|
{
|
|||
|
PS->LoadPlayerState(CurrentSaveGame);
|
|||
|
}
|
|||
|
|
|||
|
Super::HandleStartingNewPlayer_Implementation(NewPlayer);
|
|||
|
|
|||
|
// Now we are ready to override spawn location
|
|||
|
// Alternatively we could override core spawn location to use store locations immediately (skipping the whole 'find player start' logic)
|
|||
|
if (PS)
|
|||
|
{
|
|||
|
PS->OverrideSpawnTransform(CurrentSaveGame);
|
|||
|
}
|
|||
|
}
|
|||
|
```
|
|||
|
正如你所看到的,它甚至被分成了两部分。主要数据会尽快加载和分配,以确保它为我们的 UI 做好准备(这是在 PlayerController 内部特定实现中的“BeginPlayingState”期间创建的),并在我们处理位置/旋转之前等待 Pawn 生成.
|
|||
|
|
|||
|
这是您可以实现它的地方,以便在创建 Pawn 期间您使用加载的数据而不是寻找 PlayerStart(就像默认的 Unreal 行为)我选择保持简单。
|
|||
|
|
|||
|
### 获取玩家数据
|
|||
|
下面的函数查找 Player id 并在 PIE 中使用回退,假设我们当时没有加载在线子系统。上面的播放器状态的加载使用此函数。
|
|||
|
|
|||
|
```
|
|||
|
FPlayerSaveData* USSaveGame::GetPlayerData(APlayerState* PlayerState)
|
|||
|
{
|
|||
|
if (PlayerState == nullptr)
|
|||
|
{
|
|||
|
return nullptr;
|
|||
|
}
|
|||
|
|
|||
|
// Will not give unique ID while PIE so we skip that step while testing in editor.
|
|||
|
// UObjects don't have access to UWorld, so we grab it via PlayerState instead
|
|||
|
if (PlayerState->GetWorld()->IsPlayInEditor())
|
|||
|
{
|
|||
|
UE_LOG(LogTemp, Log, TEXT("During PIE we cannot use PlayerID to retrieve Saved Player data. Using first entry in array if available."));
|
|||
|
|
|||
|
if (SavedPlayers.IsValidIndex(0))
|
|||
|
{
|
|||
|
return &SavedPlayers[0];
|
|||
|
}
|
|||
|
|
|||
|
// No saved player data available
|
|||
|
return nullptr;
|
|||
|
}
|
|||
|
|
|||
|
// Easiest way to deal with the different IDs is as FString (original Steam id is uint64)
|
|||
|
// Keep in mind that GetUniqueId() returns the online id, where GetUniqueID() is a function from UObject (very confusing...)
|
|||
|
FString PlayerID = PlayerState->GetUniqueId().ToString();
|
|||
|
// Iterate the array and match by PlayerID (eg. unique ID provided by Steam)
|
|||
|
return SavedPlayers.FindByPredicate([&](const FPlayerSaveData& Data) { return Data.PlayerID == PlayerID; });
|
|||
|
}
|
|||
|
```
|
|||
|
|
|||
|
## 加载世界数据
|
|||
|
```
|
|||
|
void ASGameModeBase::LoadSaveGame()
|
|||
|
{
|
|||
|
|
|||
|
if (UGameplayStatics::DoesSaveGameExist(SlotName, 0))
|
|||
|
{
|
|||
|
CurrentSaveGame = Cast<USSaveGame>(UGameplayStatics::LoadGameFromSlot(SlotName, 0));
|
|||
|
if (CurrentSaveGame == nullptr)
|
|||
|
{
|
|||
|
UE_LOG(LogTemp, Warning, TEXT("Failed to load SaveGame Data."));
|
|||
|
return;
|
|||
|
}
|
|||
|
|
|||
|
UE_LOG(LogTemp, Log, TEXT("Loaded SaveGame Data."));
|
|||
|
|
|||
|
// Iterate the entire world of actors
|
|||
|
for (FActorIterator It(GetWorld()); It; ++It)
|
|||
|
{
|
|||
|
AActor* Actor = *It;
|
|||
|
// Only interested in our 'gameplay actors'
|
|||
|
if (!Actor->Implements<USGameplayInterface>())
|
|||
|
{
|
|||
|
continue;
|
|||
|
}
|
|||
|
|
|||
|
for (FActorSaveData ActorData : CurrentSaveGame->SavedActors)
|
|||
|
{
|
|||
|
if (ActorData.ActorName == Actor->GetFName())
|
|||
|
{
|
|||
|
Actor->SetActorTransform(ActorData.Transform);
|
|||
|
|
|||
|
FMemoryReader MemReader(ActorData.ByteData);
|
|||
|
|
|||
|
FObjectAndNameAsStringProxyArchive Ar(MemReader, true);
|
|||
|
Ar.ArIsSaveGame = true;
|
|||
|
// Convert binary array back into actor's variables
|
|||
|
Actor->Serialize(Ar);
|
|||
|
|
|||
|
ISGameplayInterface::Execute_OnActorLoaded(Actor);
|
|||
|
|
|||
|
break;
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
OnSaveGameLoaded.Broadcast(CurrentSaveGame);
|
|||
|
}
|
|||
|
else
|
|||
|
{
|
|||
|
CurrentSaveGame = Cast<USSaveGame>(UGameplayStatics::CreateSaveGameObject(USSaveGame::StaticClass()));
|
|||
|
|
|||
|
UE_LOG(LogTemp, Log, TEXT("Created New SaveGame Data."));
|
|||
|
}
|
|||
|
|
|||
|
}
|
|||
|
```
|
|||
|
|
|||
|
## 从磁盘选择特定的存档
|
|||
|
```
|
|||
|
void ASGameModeBase::InitGame(const FString& MapName, const FString& Options, FString& ErrorMessage)
|
|||
|
{
|
|||
|
Super::InitGame(MapName, Options, ErrorMessage);
|
|||
|
|
|||
|
FString SelectedSaveSlot = UGameplayStatics::ParseOption(Options, "SaveGame");
|
|||
|
if (SelectedSaveSlot.Len() > 0)
|
|||
|
{
|
|||
|
SlotName = SelectedSaveSlot;
|
|||
|
}
|
|||
|
LoadSaveGame();
|
|||
|
}
|
|||
|
```
|