目录
一、存档类
1.创建一个SaveGame类
2.存储关卡内数据
3.加载关卡数据
4.关于定时器
5.存储全局数据
6.加载全局数据
二、存档栏
1.存档栏的数据结构
2.创建新存档
3.覆盖已有存档
4.删除存档
三、游戏的基础设置
1.存储游戏设置的数据结构
2.初始化设置
3.修改设置
本篇日志将会介绍如何实现一个模拟经营游戏中的存档系统以及能够调整游戏画质分辨率等的游戏设置菜单,效果如下图:
UE中存档的原理是我们建一个SaveGame类,然后我们在其中声明要存储的变量类型,再实例化一个该类的对象,将要存储的值传给该对象中声明的变量,再调用保存函数就能将数据以.sav文件的方式保存到本地,读档时也是从 该文件中实例化一个SaveGame对象,将该对象中的值赋给当前场景以实现数据的加载
创建SaveGame类的子类,这里我们已经创建好了两个类,一个存储全局设置包括存档栏,另一个储存关卡数据:
每个关卡存档都包含关卡内必须要保存的数据,这里以玩家仓库为例,同时我们每个存档还有记录游玩总时长的功能,FTimeSpan是存储流逝的时间的结构,可以从秒数转化而来:
UCLASS() class ASTROMUTATE_2_API USaveGameData : public USaveGame { GENERATED_BODY() public: UPROPERTY(EditAnywhere,BlueprintReadWrite,Category="SaveGame") FInventoryInformation PlayerStorage;//玩家仓库 //游戏游玩的总秒数 UPROPERTY(EditAnywhere,BlueprintReadWrite,Category="Time") int PlayedSeconds; //游戏游玩的总时间 UPROPERTY(EditAnywhere,BlueprintReadWrite,Category="Time") FTimespan PlayedTime; };
接下来就可以实现保存游戏的函数了,首先传入存档的文件名,稍后在加载游戏是也是根据该存档的文件名来找到具体存档,SaveGameSlot中的第一个参数是要保存的SaveGame对象,第二个参数是本地存档文件的名字,第三个参数是玩家的索引,单机游戏使用0即可:
void ADebugActor::SaveGame(const FString& SaveFileName) { UE_LOG(LogTemp,Warning,TEXT("Saving")); //实例化我们之前创建的SaveGame类 USaveGameData* DataToSave{ Cast(UGameplayStatics::CreateSaveGameObject(USaveGameData::StaticClass())) }; //该存档游玩的总秒数=之前读档是继承的总秒数+当前时间-进入游戏时获取的时间 DataToSave->PlayedSeconds=PlayedSeconds+(FDateTime::Now()-StartTime).GetTotalSeconds(); //将秒数转换为小时分钟 DataToSave->PlayedTime=FTimespan::FromSeconds(DataToSave->PlayedSeconds); PlayedTime=DataToSave->PlayedTime; //从场景中获取玩家库存信息 SetPlayerStorageEvent(); //将玩家库存数据赋值给存档类 DataToSave->PlayerStorage = PlayerStorage; //将存档保存到本地 UGameplayStatics::SaveGameToSlot(DataToSave, SaveFileName, 0); UE_LOG(LogTemp,Warning,TEXT("game succesfully saved")); }
在加载存档前,无论是从主菜单加载还是从已经进入的关卡中加载,我们都需要重新打开这个关卡,在打开关卡之后,我们首先需要确保所有关键Actor初始化完成,如果是用c++定义的Actor,可以直接使用DispatchBeginPlay()来确保该actor执行完了BeginPlay中的所有步骤,没有用C++定义的actor比较麻烦,这里的实现方法时进入关卡后设置一个每0.5秒一检查的定时器,所有待加载的Actor都标记为已执行完BeginPlay后,再调用下面的LoadGame函数。
因为我们实现加载游戏函数的Actor是在关卡内的,所以要实现从主菜单加载,就在玩家点击存档栏中的存档时,将游戏实例中的存档文件名设置成要加载的存档的名字,然后在进入关卡时如果检查到这个文件名不为空,则执行加载。
void ADebugActor::BeginPlay() {//省略了其他与存档系统无关的代码 Instance = Cast(GetWorld()->GetGameInstance()); if (!Instance->IsValidLowLevel()) { UE_LOG(LogTemp, Error, TEXT("BeginPlay in DebugActor failed,invalid pointer:Instance")); return; } Super::BeginPlay(); if(Instance->SaveFileName!="Empty") { //每0.5秒检查一次读档条件是否满足 GetWorld()->GetTimerManager().SetTimer(LoadTimer,this,&ADebugActor::LoadGameFromMenuEvent,0.5,true,0.1); } }
bool ADebugActor::LoadGame(const FString& SaveFileName) { //确保必要组件被初始化完成 Prime->DispatchBeginPlay(); TradingSystem->DispatchBeginPlay(); USaveGameData* DataToLoad{ Cast(UGameplayStatics::LoadGameFromSlot(SaveFileName,0)) }; //没找到对应名字的存档 if (!DataToLoad->IsValidLowLevel()) { UE_LOG(LogTemp, Error, TEXT("LoadGame failed,save file: %s doesn't exist"),*SaveFileName); return false; } PlayedSeconds=DataToLoad->PlayedSeconds; //记录进入存档时的时间 StartTime=FDateTime::Now(); PlayerStorage = DataToLoad->PlayerStorage; //将加载的值赋给场景中 SetNewPlayerStorage(PlayerStorage); return true; }
其实在之前几篇博客介绍的系统中也用到了定时器,这里代码直接用到了,所以我们详细介绍一下UEC++中定时器的用法。
要使用定时器,首先需要声明一个定时器柄,用来绑定调用的事件,我们使用上面展示过的加载使用的定时器为例:
UPROPERTY(BlueprintReadWrite) FTimerHandle LoadTimer;
我们详细看一下上面是怎么开始一个定时器的,首先所有定时器相关的函数都在TimerManager类中,要开始一个定时器,首先传入要绑定的定时器柄,然后是调用该函数的对象,一般使用this,接着是定时器委托FTimerDelegate,它必须是无输入参数和返回值的函数,定义格式如下,接着是定时器触发的时间间隔,单位是秒,后面的bool值表示是否循环,如果为true,则每隔一个我们设定的间隔就会调用一次绑定的定时器委托函数,最后一个参数是从定时器启动到第一次执行委托函数的时间间隔,如果<0,则该时间等于前面定义的定时器的时间间隔
GetWorld()->GetTimerManager().SetTimer(LoadTimer,this,&ADebugActor::LoadGameFromMenuEvent,0.5,true,0.1);
还有一些常用的定时器相关的函数,只需要传入我们声明的定时器柄,这里一起来看一下:
//使定时器失效,解除器绑定的定时器委托 GetWorld()->GetTimerManager().ClearTimer(LoadTimer); //暂停定时器 GetWorld()->GetTimerManager().PauseTimer(LoadTimer); //取消暂停定时器 GetWorld()->GetTimerManager().UnPauseTimer(LoadTimer); //返回定时器是否暂停 GetWorld()->GetTimerManager().IsTimerPaused(LoadTimer); //返回定时器是否有效,失效的方式包括CLearTimer,和非循环定时器执行过一次委托,暂停时仍然有效 GetWorld()->GetTimerManager().IsTimerActive(LoadTimer); //返回定时器设定的执行间隔时间 GetWorld()->GetTimerManager().GetTimerRate(LoadTimer); //返回定时器距离上一次执行委托的时间 GetWorld()->GetTimerManager().GetTimerElapsed(LoadTimer); //返回定时器距离下一次执行委托的时间 GetWorld()->GetTimerManager().GetTimerRemaining(LoadTimer);
全局数据包括存档栏、游戏的设置,教程是否出现过等,这里我们只展示存档栏和显示设置,其数据结构如下,存档栏和显示设置的结构后面用到的时候再说:
UCLASS() class ASTROMUTATE_2_API UGameSettingSave : public USaveGame { GENERATED_BODY() public: //存档栏 UPROPERTY(EditAnywhere,BlueprintReadWrite,Category="Saving") TArray SaveSlots; //游戏设置参数 UPROPERTY(EditAnywhere,BlueprintReadWrite,Category="Saving") FGameSettingsData GameSettings; };
这里全局设置在存储时使用的对象直接为加载的全局数据存档,因为我们在进入游戏时已经确保了其一定存在
void UAstromutateGameInstance::SaveGameSetting() { auto DataToSave{ Cast(UGameplayStatics::LoadGameFromSlot("GameSettingsSaves",0)) }; DataToSave->SaveSlots=SaveSlots; DataToSave->GameSettings=GameSettingsData; UGameplayStatics::SaveGameToSlot(DataToSave, "GameSettingsSaves", 0); }
如果玩家是第一次打开游戏,就要创建一个全局数据的存档,同时初始化全局数据,如果已有存档就直接加载
void UAstromutateGameInstance::LoadGameSetting() { auto DataToSave{ Cast(UGameplayStatics::LoadGameFromSlot("GameSettingsSaves",0)) }; //第一次进入游戏 if (!DataToSave->IsValidLowLevel()) { UGameSettingSave* DataToSave2{ Cast(UGameplayStatics::CreateSaveGameObject(UGameSettingSave::StaticClass())) }; UGameplayStatics::SaveGameToSlot(DataToSave2, "GameSettingsSaves", 0); GameSettingsData=FGameSettingsData(); //这是用来还原设置更改的变量,后面会介绍 LastSavedGameSetting=GameSettingsData; SaveGameSetting(); } else { SaveSlots=DataToSave->SaveSlots; GameSettingsData=DataToSave->GameSettings; } }
我们需要用存档栏来展示玩家的存档,同时包括创建存档时的命名,如不命名自动命名为当前时间,以及修改命名和删除存档的功能
首先是存档栏数组中元素的数据结构,文件名用于保存和加载的调用,命名,游戏时间,保存时间用于展示:
USTRUCT(BlueprintType) struct FSaveSlot { friend bool operator==(const FSaveSlot& Lhs, const FSaveSlot& RHS) { return Lhs.FileName == RHS.FileName; } friend bool operator!=(const FSaveSlot& Lhs, const FSaveSlot& RHS) { return !(Lhs == RHS); } FSaveSlot() = default; GENERATED_BODY() //存档文件名 UPROPERTY(EditAnywhere,BlueprintReadWrite,Category="Save") FString FileName; //玩家命名的存档名 UPROPERTY(EditAnywhere,BlueprintReadWrite,Category="Save") FText SaveName; //该存档总游戏时间 UPROPERTY(VisibleAnywhere,BlueprintReadOnly,Category="Save") FTimespan PlayedTime; //保存时的时间 UPROPERTY(VisibleAnywhere,BlueprintReadOnly,Category="Save") FDateTime SavedTime; };
在玩家创建存档时,如果没有输入自定义的存档名,就使用当前时间作为存档名,文件名使用玩家定义的命名+当前时间,这样玩家可以创建多个重名存档而不会产生冲突
void UAstromutateGameInstance::AddSave(const FText SaveName) { FSaveSlot Temp{FSaveSlot()}; Temp.SaveName=SaveName; //玩家没有输入命名 if(SaveName.IsEmpty()) { Temp.SaveName=FText::FromString(FDateTime::Now().ToString()); } //获取当前时间 const auto Time {FDateTime::Now()}; Temp.SavedTime=Time; FString FileName{SaveName.ToString()+Time.ToString()}; //因为这个函数在游戏实例中,存档函数在关卡的中控Actor中,所以要找一下中控的Actor for (TActorIteratorit(GetWorld()); it; ++it) { if (IsValid(*it)) { it->SaveGame(FileName); Temp.PlayedTime=it->PlayedTime; break; } UE_LOG(LogTemp,Error,TEXT("AddSave failed,invalid pointer:debugactor")); } Temp.FileName=FileName; SaveSlots.Add(Temp); //更新全局数据存档中的存档栏信息 SaveGameSetting(); }
在覆盖已有存档时要注意更新游戏时间和保存的时间
void UAstromutateGameInstance::CoverSave(const int& Index) { //检查索引是否合法 if(Index<0||Index>=SaveSlots.Num()) { UE_LOG(LogTemp,Error,TEXT("CoverSave failed,invalid index:%d"),Index); } //和创建新存档一样的原因,要找一下负责中控的Actor for (TActorIteratorit(GetWorld()); it; ++it) { if (IsValid(*it)) { it->SaveGame(SaveSlots[Index].FileName); USaveGameData* DataToSave{ Cast(UGameplayStatics::LoadGameFromSlot(SaveSlots[Index].FileName,0)) }; SaveSlots[Index].SavedTime=FDateTime::Now(); SaveSlots[Index].PlayedTime=DataToSave->PlayedTime; break; } UE_LOG(LogTemp, Error, TEXT("CoverSave failed,invalid pointer:debugactor")); } }
删除存档用到的DeleteGameInSlot函数需要传入存档的文件名和玩家索引
void UAstromutateGameInstance::RemoveSave(const int& Index) { //检查索引是否合法 if(Index<0||Index>=SaveSlots.Num()) { UE_LOG(LogTemp,Error,TEXT("RemoveSave failed,invalid index:%d"),Index); } //删除本地文件 UGameplayStatics::DeleteGameInSlot(SaveSlots[Index].FileName, 0); //删除存档栏中的元素 SaveSlots.RemoveAt(Index); //更新全局数据中的存档栏信息 SaveGameSetting(); }
虚幻中提供了GameUserSetting这个类来设置游戏音量显示画质等,也提供了保存和读取的功能,但为了统一管理,这里我们都使用自定义的存档系统,这里以窗口模式和分辨率为例,窗口模式是一个赋值0-2的枚举,分别是全屏,窗口化全屏,窗口化,分辨率是FIntPoint结构,也就是两个整数
USTRUCT(BlueprintType) struct FGameSettingsData {//这里仅展示分辨率和窗口模式 FGameSettingsData() = default; GENERATED_BODY() //全屏模式 UPROPERTY(EditAnywhere,BlueprintReadWrite,Category="GameSetting") int WindowMode{0}; //分辨率 UPROPERTY(EditAnywhere,BlueprintReadWrite,Category="GameSetting") };
因为是在同事写的蓝图的基础上改进的,所以这里也使用蓝图展示,首先我们要获取适合本机的分辨率,窗口化和全屏(全屏包括窗口化全屏)所适用的分辨率是不同的:
然后从存档中获取之前保存的信息,
再根据当前的窗口模式到对应的数组中找到匹配的,给当前选项的索引赋值
UI是同事布置的,这里只展示我写的按下按钮后的事件,一个+按钮一个-按钮,按一下对应设置选项的索引就会+1或-1,也都可以循环,首先来看窗口模式的修改,如果全屏和窗口化之间有切换,那么当前的分辨率选项也要跟着改变,这里就使其变为对应分辨率数组的最后一个元素
分辨率的改变比较简单,因为不太会整理蓝图,所以只给大家看一个+按钮的
4.应用设置
分辨率和窗口模式设置的接口都在GameUserSetting中,这里要说的是在应用后生成一个UI,提示玩家确认否则在15秒后还原修改,因为如果分辨率设置错误的话,可能带来严重后果
序列中Then2后面的节点:
当玩家确认更改时,更新上一次应用的设置为当前设置:
玩家撤销时还原设置,调用的函数只是将上面应用设置的部分去掉了生成确认UI: