欢迎您光临本站,秉承服务宗旨,履行"站长"责任,销售只是起点,服务永无止境!
也想出现在这里? 联系我们

虚幻引擎C++保存系统(保存游戏)

作者 : qwe789 本文共11203个字,预计阅读时间需要29分钟 发布时间: 2023-01-31 共988人阅读

介绍

对于您的游戏,您最终需要编写某种保存系统。存储玩家信息、解锁、成就等。在某些情况下,您需要保存世界状态,例如掠夺的宝箱、未上锁的门、掉落的玩家物品等。

在本教程(UE4和UE5兼容)中,我们将介绍您自己的C++SaveGame系统的设置。不同类型的游戏将有自己的保存系统需求。使用本文和代码作为要构建的任何游戏的起点。你需要相当熟悉虚幻引擎C++才能构建这个系统。

这不是分步教程。相反,它更像是带有解释的系统故障。 完整的源代码可用于整个项目。如果你确实希望采用更具指导性的方法,我会在我的虚幻引擎C++课程中教授这个概念和许多其他概念。

我们将创建一个类似于《黑暗之魂》的保存系统,通过篝火互动来拯救世界状态。我们将保存一些演员和一些玩家信息。篝火本身是一种主题互动,真正有趣的部分是我们保存/加载的实际世界状态。例如移动的物品位置,以前打开的宝箱和获得的积分(又名“灵魂”)。

虚幻引擎C++保存系统(保存游戏)
来自黑暗之魂的篝火(保存点)。

类盗贼动作(参考项目)

整个项目可通过GitHub获得!我建议您下载并浏览它。它包括其他详细信息,例如使用的每个类所需的#includes。

这个项目是为我在 193 年底教授的斯坦福大学计算机科学课程 (CS2020U) 创建的。这是我的虚幻引擎C++在线课程中使用的参考项目!

保存游戏系统设计

首先,让我们简要讨论一下系统设计,以便在我们进入代码后更好地理解意图。

虚幻引擎有一个内置的SaveGame UObject,我们从中继承并添加要写入磁盘的变量。另一个强大的功能是每个UObject/Actor中可用的Serialize()函数,可以将我们的变量转换为二进制数组并再次转换为变量。为了决定存储哪些变量,虚幻引擎使用了一个“保存游戏”的UPROPERTY说明符。每个Actor生成的二进制数组可以在写入磁盘之前添加到SaveGame对象中。

加载游戏基本上会做反向操作。我们从磁盘加载 SaveGame UObject,所有变量都在这个 SaveGame 对象中恢复。然后,我们将所有这些变量传递回它们源自的对象/Actor,例如玩家位置、获得的银币和单个Actor的状态(在我们的示例中与Actor的姓名匹配),例如宝箱是否在上一个会话中被洗劫一空。

为了确定我们希望为哪些Actor保存状态,我们使用接口。我们还使用此接口来允许Actor响应游戏负载(OnActorLoaded),以便他可以运行一些特定于Actor的代码来正确恢复动画状态等。在Action Roguelike项目中,我重用了我的GameplayInterface,但我建议你制作一个新的界面,专门用于将对象/actor标记为可保存(例如。可保存对象接口)

保存游戏文件将被放置在 ../我的项目/保存/保存游戏./

拯救世界状态

为了保存世界状态,我们必须决定为每个Actor存储哪些变量,以及我们需要将哪些杂项信息保存到磁盘,例如每个玩家获得的银币。积分并不是世界状态的一部分,而是属于玩家状态类。即使 PlayerState 存在于世界中并且实际上是一个 Actor,我们也会单独处理它们,以便我们可以根据它之前所属的 Player 正确恢复它。手动处理此问题的一个原因是,我们可以为每个玩家存储一个唯一的ID,以便在玩家稍后重新加入服务器时知道统计数据属于谁。

演员数据

对于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 最有用)

由于内置系统,其余代码非常简单(并在注释中进行了解释)。

要了解在C++中使用哪些#include用于我们的 FMemoryWriter 和本博客中的所有其他类,请务必查看源 cpp 文件。

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);
}

百宝箱示例

现在是时候准备我们的演员进行序列化了......

虚幻引擎C++保存系统(保存游戏)

以下是直接取自该项目的宝箱代码。请注意 ISGameplayInterface 继承和 bLidOpen 变量上标记的 '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();
};

最后,我们有 OnActorLoaded_Implementation() 函数来实现。这对于处理特定于负载的逻辑非常有用。在下面的示例中,我们只需调用更新要打开/关闭的盖子状态的现有函数。

但请记住,通常你可以依靠BeginPlay()作为你的“OnActorLoaded”替代品。只要您在触发 BeginPlay() 之前将保存的数据加载到每个 Actor 中。这就是为什么我们在 GameMode 类中很早就处理加载逻辑的原因(更多内容见下面的“加载游戏状态”)

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));
}

这照顾了Actor状态,剩下的就是迭代PlayerState实例并让它们存储数据。虽然 PlayerState 派生自 Actor,理论上可以在所有世界 actor 的迭代中保存,但单独执行此操作很有用,这样我们就可以将它们与玩家 ID 匹配(例如。Steam 用户 ID),而不是我们没有为这种类型的运行时决定/控制的不断变化的 Actor 名称,而是生成了 Actor。

保存玩家数据

在我的示例中,我选择在保存游戏之前从 PlayerState 获取所有数据。我们通过调用 SavePlayerState(USSaveGame* SaveObject)来做到这一点;这允许我们将任何相关的数据传递到 SaveGame 对象中,例如 Pawn 的 PlayerId 和转换(如果玩家当前还活着)

您也可以选择在此处使用 SaveGame 属性,并通过将其转换为二进制数组来自动存储一些玩家数据,就像我们对 Actor 所做的那样,而不是手动将其写入 SaveGame,但您仍然需要手动处理 PlayerID 和 Pawn 转换。

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);
	}
}

请确保在保存到磁盘之前在所有玩家状态上调用它们。重要的是要注意,GetUniqueId 只有在您加载了在线子系统(如 Steam 或 EOS)时才相关/一致。

加载玩家数据

为了检索玩家数据,我们做相反的事情,一旦棋子生成并准备好这样做,就必须手动分配玩家的转换。您可以在游戏模式中更无缝地覆盖玩家生成逻辑,以改用保存的变换。例如,我在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're 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(好像默认的虚幻行为),我选择保持简单。

GetPlayerData()

下面的函数查找玩家 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; });
}

加载世界状态

理想情况下,您可以在加载持久关卡时加载一次世界状态。通过这种方式,您可以轻松地加载关卡数据,然后在调用任何内容之前从磁盘反序列化任何Actor数据。您的用例可能会更复杂,因为动态流入/流出包含可保存世界状态的其他关卡。这有点超出目前的范围,特别是因为我自己的游戏谢天谢地不需要这样的功能。我建议看看史蒂夫的图书馆,因为他确实处理了如此复杂的案件。

将二进制转换回变量

为了恢复我们的世界状态,我们做了一些与以前相反的事情。我们从磁盘加载,迭代所有Actor,最后使用FMemoryReader将每个Actor的二进制数据转换回“虚幻”变量。有点令人困惑的是,我们仍然在Actor上使用Serialize(),但是因为我们传入了FMemoryReader而不是FMemoryWriter,所以该函数可用于将保存的变量传递回Actor。

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."));
}

}

从磁盘中选择特定的保存游戏

要加载可能在上一关(例如主菜单)中选择的特定保存文件,您可以使用游戏模式 URL 轻松地在关卡之间传递数据。这些 URL 是“选项”参数,您可能已经在托管多人游戏会话时将它们用于“?listen”之类的内容。

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();
}

现在,在加载关卡时,您应该在选项中传入 ?savegame=MySaveFile。“保存游戏”作为一个选项是编造的,你可以输入任何作为你的选项,只要确保在C++中解析相同的“选项”。

虚幻引擎C++保存系统(保存游戏)

在开始游戏之前加载保存游戏

在前面的代码示例中,我展示了在 InitGame() 期间加载数据,这在加载阶段很早就发生了。这意味着我们已经有了可用的关卡数据,但还没有在任何内容上调用 BeginPlay()。这让我们可以反序列化变量并使用 BeginPlay() 作为反应的一种方式,就好像那些保存的变量是它们的蓝图原始一样。

这对于使用相关的保存数据进行初始化或在 BeginPlay 中跳过整个代码块可能很有用,方法是保存特定的布尔值,例如 bHasSpawnedLoot(确保使用 SaveGame) 标记它,以免意外地重新运行此逻辑,如果它在上一个会话中已经这样做了,并且应该只这样做一次。

篝火

虚幻引擎C++保存系统(保存游戏)

在前面的部分中,我们设置了整个保存/加载系统。现在为了结束它,我将分解如何进行简单的篝火式交互。我跳过了特定于与Actor本身交互的所有步骤,您可以查看源代码以获取更多详细信息。

现在要在蓝图中创建实际的篝火,它非常简单快捷,因为我们已经完成了大部分艰苦的工作。以下是所需的基本步骤,包括下面的蓝图节点。

  • 带有网格体和粒子系统的新Actor蓝图(火焰)
  • 在粒子系统上禁用“自动激活”,我们只会在与它交互一次后打开它(并将其作为布尔存储在Actor中以供以后加载)
  • 添加界面(在我们的例子中是游戏界面)以将其标记为保存系统。
  • 添加一个布尔值bFireActive并将其标记为SaveGame(在变量详细信息中找到它,您需要打开高级选项 - 见下面的图像)
  • 像下面这样设置图表 - 我们与火(事件互动)交互,它更新bFireActive,然后保存游戏。然后我们更新粒子状态。

一旦与一次交互,bFireActive现在就会保存到篝火中,在下一次游戏加载时,粒子系统将通过OnActorLoaded(我们自己的界面功能)激活,您可以通过BeginPlay()执行相同的操作,因为我们将在调用之前加载我们的Actor数据,如本文前面所述。

虚幻引擎C++保存系统(保存游戏)
篝火蓝图图
虚幻引擎C++保存系统(保存游戏)
“保存游戏”被选中。

如您所见,这个基本的 SaveGame 系统并不涉及太多复杂性。一旦样板文件实现,甚至在蓝图中设置可保存的变量也非常容易。对于您自己的完整系统,需要考虑和需要更多内容,该系统涵盖了所有情况,并将取决于您的游戏机制。也许您还需要保存Actor组件的状态,或者保存有关能力和/或属性信息的UObjects。我将在下一段中简要讨论这些内容,但所有这些都不在本教程的讨论范围之内。

限制和改进

当然,此系统只是您自己的功能齐全的保存系统的起点。到目前为止,在构建自己的系统时,我遇到了一些需要考虑的事情,包括在上一个会话中生成的重生Actor,而不是从Map/关卡文件加载的Actor。

您还应该跟踪哪些Actor在上一个会话中被摧毁。为此,您可以根据保存游戏数据做出假设。当你的SaveGame中没有SavedActorData,但你的Actor(从关卡加载)确实有一个可保存的界面时,你应该能够立即调用Destroy。

您可能需要考虑将所有这些逻辑放在游戏子系统中,该子系统更整齐地将保存/加载逻辑从 GameMode 类中分离出来。

对于演示项目,我们只假设一个持久级别,并且不会使用Actor保存任何LevelName,也不会为每个(流)级别提供特定的Actor数组。这可能是您需要的东西,具体取决于您的游戏设计。

最好在保存文件中包含一个版本号作为标头。通过这种方式,您可以找出不兼容的(旧的)SaveGames,甚至可以在可能的情况下处理从旧保存版本到新保存版本的转换(例如,当数据格式已更改但可以使用某些代码转换时)Epic的ActionRPG示例对此进行了实现。

版权声明:
本站所有文章、资源等一切内容,皆为在本站的注册网友所发布、上传、提供,如您发现任何内容侵犯了您的合法权益, 请与我们联系 ,我们将第一时间进行清理。iiiue.com 旨在为广大虚幻引擎爱好者提供技术交流学习、知识技术变现平台。
永久域名 iii ue .com 本站投稿能赚取收益变现提现,请一定要牢记账号密码!
Ue资源站 iiiue.com » 虚幻引擎C++保存系统(保存游戏)

常见问题FAQ

免费下载或者VIP会员专享资源能否直接商用?
本站所有内容皆为网友发布,资源版权均属于原作者所有,如需商用请联系原作者获取授权许可,若由于商用引起版权纠纷,一切责任均由使用者承担。如您发现某些内容侵犯了您的合法权益, 请与我们联系 ,我们将在第一时间核实并清理。
下载的资源解压密码是多少?
任何资源的解压密码均由发布者提供,一般情况下都在资源的发布页面或者资源文件夹,请仔细检查,如您发现发布页面没有提供解压密码,请您试着在评论处留言联系作者,或者复制以下两个密码进行解压,或许会有意想不到的效果。
iiiue.com
ue5club
Tips:除了密码说明内的解压密码外,通常情况下,电脑在安装好RAR或360解压缩软件,双击打开压缩包,包内注释的网址也可以试试是否为解压密码哦
资源能否免费获取
本站用户可参与站内的一系列活动获取积分,资源皆可免费获取,若想快速获取可加入本站永久VIP钻石会员,荣耀身份,全站资源免费获取, 点此查看详情

发表回复

也想出现在这里? 联系我们
开通终身VIP 永久VIP折扣活动火热进行中,享全站资源免费下载特权!