由于后续代码量逐渐增多,我打算只放出相应集数改动到的代码,部分位置会放出一些之前的代码用于定位代码位置。如果想看其他先前集数写的代码,读者可以翻阅之前的笔记进行查阅。此外,接下来的代码我会在自己的项目先验证,大概疏漏之处会少一些,不过如果读者发现错误恳请指出 : )
写好了各个界面的布局,接下来就是添加切换界面的逻辑,并给这个切换的过程添加动画。
先在数据结构类添加记录动画状态的枚举。
SlAiTypes.h
// ... 省略
// Menu 动画状态枚举
namespace EMenuAnim
{
enum Type
{
Stop, // 停止动画
Close, // 关闭 Menu
Open // 打开 Menu
};
}
然后是在菜单栏界面添加动画播放与关闭的逻辑。
SSlAiMenuWidget.h
class SLAICOURSE_API SSlAiMenuWidget : public SCompoundWidget
{
public:
// 重写 Tick 函数
virtual void Tick(const FGeometry& AllottedGeometry, const double InCurrentTime,
const float InDeltaTime) override;
private:
// 初始化动画组件(老师写的名字有个 d,笔者为了名字与初始化菜单列表的方法名字统一所以没有加,
// 看读者个人喜好)
void InitializeAnimation();
// 播放关闭动画
void PlayClose(EMenuType::Type NewMenu);
private:
// 动画播放器
FCurveSequence MenuAnimation;
// 曲线控制器
FCurveHandle MenuCurve;
// 用来保存新的长度
float CurrentHeight;
// 是否已经显示 Menu 组件
bool IsMenuShow;
// 是否锁住按钮
bool ControlLocked;
// 保存当前的动画状态
EMenuAnim::Type AnimState;
// 保存当前的菜单
EMenuType::Type CurrentMenu;
};
SSlAiMenuWidget.cpp
BEGIN_SLATE_FUNCTION_BUILD_OPTIMIZATION
void SSlAiMenuWidget::Construct(const FArguments& InArgs)
{
InitializeMenuList();
// 动画组件初始化
InitializeAnimation();
}
void SSlAiMenuWidget::Tick(const FGeometry& AllottedGeometry, const double InCurrentTime,
const float InDeltaTime)
{
switch (AnimState)
{
case EMenuAnim::Stop:
break;
case EMenuAnim::Close:
// 如果正在播放
if (MenuAnimation.IsPlaying()) {
// 实时修改 Menu 的大小
ResetWidgetSize(MenuCurve.GetLerp() * 600.f, -1.f);
// 在关闭了 40% 的时候设置不显示组件
if (MenuCurve.GetLerp() < 0.6f && IsMenuShow) ChooseWidget(EMenuType::None);
}
else {
// 关闭动画完了,设置状态为打开
AnimState = EMenuAnim::Open;
// 开始播放
MenuAnimation.Play(this->AsShared());
}
break;
case EMenuAnim::Open:
// 如果正在播放
if (MenuAnimation.IsPlaying())
{
// 实时修改 Menu 大小
ResetWidgetSize(MenuCurve.GetLerp() * 600.f, CurrentHeight);
// 打开 60% 之后显示组件
if (MenuCurve.GetLerp() > 0.6f && !IsMenuShow) ChooseWidget(CurrentMenu);
}
// 如果已经播放完毕
if (MenuAnimation.IsAtEnd())
{
// 修改状态为 Stop
AnimState = EMenuAnim::Stop;
// 解锁按钮
ControlLocked = false;
}
break;
}
}
END_SLATE_FUNCTION_BUILD_OPTIMIZATION
void SSlAiMenuWidget::MenuItemOnClicked(EMenuItem::Type ItemType)
{
// 原来的 Debug 语句如果不用了就删掉
// 如果锁住了,直接 return
if (ControlLocked) return;
// 设置锁住了按钮
ControlLocked = true;
// 添加按钮响应逻辑
switch (ItemType)
{
case EMenuItem::StartGame:
PlayClose(EMenuType::StartGame);
break;
case EMenuItem::GameOption:
PlayClose(EMenuType::GameOption);
break;
case EMenuItem::QuitGame:
ControlLocked = false;
break;
case EMenuItem::NewGame:
PlayClose(EMenuType::NewGame);
break;
case EMenuItem::LoadRecord:
PlayClose(EMenuType::ChooseRecord);
break;
case EMenuItem::StartGameGoBack:
PlayClose(EMenuType::MainMenu);
break;
case EMenuItem::GameOptionGoBack:
PlayClose(EMenuType::MainMenu);
break;
case EMenuItem::NewGameGoBack:
PlayClose(EMenuType::StartGame);
break;
case EMenuItem::ChooseRecordGoBack:
PlayClose(EMenuType::StartGame);
break;
case EMenuItem::EnterGame:
ControlLocked = false;
break;
case EMenuItem::EnterRecord:
ControlLocked = false;
break;
}
}
void SSlAiMenuWidget::InitializeMenuList()
{
// ... 省略一大段
MenuMap.Add(EMenuType::ChooseRecord, MakeShareable(new MenuGroup(NSLOCTEXT("SlAiMenu",
"LoadRecord", "LoadRecord"), 510.f, &ChooseRecordList)));
// 去掉上一集在此写的选择跳转主界面,由动画部分更改
}
void SSlAiMenuWidget::ChooseWidget(EMenuType::Type WidgetType)
{
// 定义是否已经显示菜单
IsMenuShow = WidgetType != EMenuType::None;
ContentBox->ClearChildren();
if (WidgetType == EMenuType::None) return;
for (TArray<TSharedPtr<SCompoundWidget>>::TIterator It((*MenuMap.Find(WidgetType))->ChildWidget); It; ++It) {
ContentBox->AddSlot().AutoHeight()[(*It)->AsShared()];
}
TitleText->SetText((*MenuMap.Find(WidgetType))->MenuName);
// 去掉这里的修改 Size,由动画部分更改
}
void SSlAiMenuWidget::InitializeAnimation()
{
// 开始延时
const float StartDelay = 0.3f;
// 持续时间
const float AnimDuration = 0.6f;
MenuAnimation = FCurveSequence();
MenuCurve = MenuAnimation.AddCurve(StartDelay, AnimDuration, ECurveEaseFunction::QuadInOut);
// 初始设置 Menu 大小
ResetWidgetSize(600.f, 510.f);
// 初始显示主界面
ChooseWidget(EMenuType::MainMenu);
// 允许点击按钮
ControlLocked = false;
// 设置动画状态为停止
AnimState = EMenuAnim::Stop;
// 设置动画播放器跳到结尾,也就是1
MenuAnimation.JumpToEnd();
}
void SSlAiMenuWidget::PlayClose(EMenuType::Type NewMenu)
{
// 设置新的界面
CurrentMenu = NewMenu;
// 设置新高度
CurrentHeight = (*MenuMap.Find(NewMenu))->MenuHeight;
// 设置播放状态是 Close
AnimState = EMenuAnim::Close;
// 播放反向动画
MenuAnimation.PlayReverse(this->AsShared());
}
到这里 UI 切换以及切换动画就完成了。如果动画播放部分不太明白的话,建议看下 UMG 动画部分以及蓝图里面一些动画相关的节点之类的相关讲解视频。
接下来要给 UI 切换的动画添加相关音效,以及添加菜单的背景音乐
先在样式类里面添加相关的变量。
SlAiMenuStyle.h
#include "SlateSound.h" // 添加音频头文件
#include "SlAiMenuWidgetStyle.generated.h"
USTRUCT()
struct SLAICOURSE_API FSlAiMenuStyle : public FSlateWidgetStyle
{
// 开始游戏声音
UPROPERTY(EditAnywhere, Category = "Sound")
FSlateSound StartGameSound;
// 结束游戏声音
UPROPERTY(EditAnywhere, Category = "Sound")
FSlateSound ExitGameSound;
// 转换按钮声音
UPROPERTY(EditAnywhere, Category = "Sound")
FSlateSound MenuItemChangeSound;
// Menu 背景声音
UPROPERTY(EditAnywhere, Category = "Sound")
FSlateSound MenuBackgroundMusic;
};
添加后在样式类蓝图配置相关音频文件。
退出游戏时需要先播放音效后再正式退出游戏,所以此处需要一个 Timer(定时器),这个定时器的逻辑写在之前用来 Debug 的 SlAiHelper 类里。
SlAiHelper.h
// 引入三个头文件
#include "SlateApplication.h"
#include "SlateSound.h"
#include "TimerManager.h"
namespace SlAiHelper{
FORCEINLINE void Debug(FString Message, float Duration = 3.f) {
if (GEngine) {
GEngine->AddOnScreenDebugMessage(-1, Duration, FColor::Yellow, Message);
}
}
// 添加定时器模板
template<class UserClass>
FORCEINLINE FTimerHandle PlayerSoundAndCall(UWorld* World, const FSlateSound Sound, UserClass* InUserObject,
typename FTimerDelegate::TRawMethodDelegate<UserClass>::FMethodPtr InMethod)
{
FSlateApplication::Get().PlaySound(Sound);
FTimerHandle Result;
const float SoundDuration = FMath::Max(FSlateApplication::Get().GetSoundDuration(Sound), 0.1f);
FTimerDelegate Callback;
Callback.BindRaw(InUserObject, InMethod);
World->GetTimerManager().SetTimer(Result, Callback, SoundDuration, false);
return Result;
}
}
关于定时器 Timer 的知识点,推荐这两篇文章:
《UE4随笔:Gameplay定时器》 基础理论讲解
《UE4 计时器的简单使用(FTimerManager)》 理论 + 使用
在菜单栏界面添加音乐播放逻辑。
SSlAiMenuWidget.h
class SLAICOURSE_API SSlAiMenuWidget : public SCompoundWidget
{
private:
// 退出游戏
void QuitGame();
// 进入游戏
void EnterGame();
};
SSlAiMenuWidget.cpp
// 新增两个头文件
#include "SlAiMenuController.h"
#include "Kismet/GameplayStatics.h"
BEGIN_SLATE_FUNCTION_BUILD_OPTIMIZATION
void SSlAiMenuWidget::Construct(const FArguments& InArgs)
{
MenuStyle = &SlAiStyle::Get().GetWidgetStyle<FSlAiMenuStyle>("BPSlAiMenuStyle");
// 播放背景音乐
FSlateApplication::Get().PlaySound(MenuStyle->MenuBackgroundMusic);
}
END_SLATE_FUNCTION_BUILD_OPTIMIZATION
void SSlAiMenuWidget::MenuItemOnClicked(EMenuItem::Type ItemType)
{
if (ControlLocked) return;
ControlLocked = true;
switch (ItemType)
{
case EMenuItem::StartGame:
PlayClose(EMenuType::StartGame);
break;
case EMenuItem::GameOption:
PlayClose(EMenuType::GameOption);
break;
case EMenuItem::QuitGame:
// 退出游戏,播放声音并且延时调用退出函数
SlAiHelper::PlayerSoundAndCall(UGameplayStatics::GetPlayerController(GWorld, 0)->GetWorld(),
MenuStyle->ExitGameSound, this, &SSlAiMenuWidget::QuitGame);
break;
case EMenuItem::NewGame:
PlayClose(EMenuType::NewGame);
break;
case EMenuItem::LoadRecord:
PlayClose(EMenuType::ChooseRecord);
break;
case EMenuItem::StartGameGoBack:
PlayClose(EMenuType::MainMenu);
break;
case EMenuItem::GameOptionGoBack:
PlayClose(EMenuType::MainMenu);
break;
case EMenuItem::NewGameGoBack:
PlayClose(EMenuType::StartGame);
break;
case EMenuItem::ChooseRecordGoBack:
PlayClose(EMenuType::StartGame);
break;
case EMenuItem::EnterGame:
// 进入游戏
SlAiHelper::PlayerSoundAndCall(UGameplayStatics::GetPlayerController(GWorld, 0)->GetWorld(),
MenuStyle->StartGameSound, this, &SSlAiMenuWidget::EnterGame);
break;
case EMenuItem::EnterRecord:
// 进入游戏
SlAiHelper::PlayerSoundAndCall(UGameplayStatics::GetPlayerController(GWorld, 0)->GetWorld(),
MenuStyle->StartGameSound, this, &SSlAiMenuWidget::EnterGame);
break;
}
}
void SSlAiMenuWidget::PlayClose(EMenuType::Type NewMenu)
{
MenuAnimation.PlayReverse(this->AsShared());
// 播放切换菜单音乐
FSlateApplication::Get().PlaySound(MenuStyle->MenuItemChangeSound);
}
// 退出游戏的方法
void SSlAiMenuWidget::QuitGame()
{
Cast<ASlAiMenuController>(UGameplayStatics::GetPlayerController(GWorld, 0))->ConsoleCommand("quit");
}
// 进入游戏的方法
void SSlAiMenuWidget::EnterGame()
{
// 临时替代
SlAiHelper::Debug(FString("EnterGame"), 10.f);
ControlLocked = false;
}
此时单击按钮就会播放音效了(不过会以音量最大的状态来播放,注意调节电脑音量)。但是笔者测试的时候,背景音乐只会在独立进程游戏(Standalone Game)模式或选中视口(Selected Viewport)模式下启动才会播放,否则在新开窗口模式启动的话,只有在按下 “退出游戏” 按钮才会播放背景音乐;此外在选中视口模式下启动,按 “退出游戏” 按钮会导致引擎崩溃,原因是访问冲突异常。所以只有在独立进程游戏的启动模式下启动,所有音频才会正确播放。以上原因暂时还不清楚 = =
为了让音量不要太大,要使设置界面的音量调节面板调节数值时,运行时的实际音量大小也同步变更。
SlAiDataHandle.h
// ... 头文件省略
class USoundCue; // 提前声明
class SLAICOURSE_API SlAiDataHandle
{
private:
// 初始化 Menu 声音数据(同样我也去掉了 d)
void InitializeMenuAudio();
private:
// 保存 Menu 的声音
TMap<FString, TArray<USoundCue*>> MenuAudioResource;
// 获取 MenuStyle,里面存放有声音文件
const struct FSlAiMenuStyle* MenuStyle;
};
SlAiDataHandle.cpp
// 引入两个获取样式用的头文件
#include "SlAiStyle.h"
#include "SlAiMenuWidgetStyle.h"
#include "Sound/SoundCue.h" // 引入声音头文件
SlAiDataHandle::SlAiDataHandle()
{
// 初始化音乐数据
InitializeMenuAudio();
}
// ... 省略
void SlAiDataHandle::ResetMenuVolume(float MusicVol, float SoundVol)
{
if (MusicVol > 0)
{
MusicVolume = MusicVol;
// 循环设置背景音量
for (TArray<USoundCue*>::TIterator It(MenuAudioResource.Find(FString("Music"))->CreateIterator()); It; ++It) {
// 设置音量
(*It)->VolumeMultiplier = MusicVolume;
}
}
if (SoundVol > 0)
{
SoundVolume = SoundVol;
// 循环设置背景音效
for (TArray<USoundCue*>::TIterator It(MenuAudioResource.Find(FString("Sound"))->CreateIterator()); It; ++It) {
// 设置音效
(*It)->VolumeMultiplier = SoundVolume;
}
}
}
void SlAiDataHandle::InitializeMenuAudio()
{
// 获取 MenuStyle
MenuStyle = &SlAiStyle::Get().GetWidgetStyle<FSlAiMenuStyle>("BPSlAiMenuStyle");
// 添加资源文件到资源列表
TArray<USoundCue*> MusicList;
MusicList.Add(Cast<USoundCue>(MenuStyle->MenuBackgroundMusic.GetResourceObject()));
TArray<USoundCue*> SoundList;
SoundList.Add(Cast<USoundCue>(MenuStyle->StartGameSound.GetResourceObject()));
SoundList.Add(Cast<USoundCue>(MenuStyle->ExitGameSound.GetResourceObject()));
SoundList.Add(Cast<USoundCue>(MenuStyle->MenuItemChangeSound.GetResourceObject()));
// 添加资源到 Map
MenuAudioResource.Add(FString("Music"), MusicList);
MenuAudioResource.Add(FString("Sound"), SoundList);
// 重置一下声音
ResetMenuVolume(MusicVolume, SoundVolume);
}
如果设置界面调节音量可以改变声音大小,则说明逻辑无误。
不过笔者在测试时发现如果拖动滑动条太快的话会使得数值写入错误,出现百分比数值为 0% 但是 Json 文件里的数值还是 0.1~0.5 这种情况。
将老师准备好的关卡文件
Content/Res/PolygonAdventure/Maps/Demonstration_Large
从复制一份放到 Map 文件夹中,并将其改名为 GameMap
随后在菜单栏界面添加一下进入游戏按钮的跳转逻辑
SSlAiMenuWidget.cpp
void SSlAiMenuWidget::MenuItemOnClicked(EMenuItem::Type ItemType)
{
if (ControlLocked) return;
ControlLocked = true;
switch (ItemType)
{
// ... 省略
case EMenuItem::EnterGame:
// 检测是否可以进入游戏
if (NewGameWidget->AllowEnterGame())
{
SlAiHelper::PlayerSoundAndCall(UGameplayStatics::GetPlayerController(GWorld, 0)->GetWorld(), MenuStyle->StartGameSound, this, &SSlAiMenuWidget::EnterGame);
}
else {
// 解锁按钮
ControlLocked = false;
}
break;
case EMenuItem::EnterRecord:
// 告诉选择存档更新存档名
ChooseRecordWidget->UpdateRecordName();
SlAiHelper::PlayerSoundAndCall(UGameplayStatics::GetPlayerController(GWorld, 0)->GetWorld(), MenuStyle->StartGameSound, this, &SSlAiMenuWidget::EnterGame);
break;
}
}
void SSlAiMenuWidget::EnterGame()
{
// 用打开关卡方法替换原来临时写的 Debug 输出
UGameplayStatics::OpenLevel(UGameplayStatics::GetPlayerController(GWorld, 0)->GetWorld(),
FName("GameMap"));
}
随后在 GameMap 关卡地图中加入一个 Player Start(玩家出生点)。运行后如果能看到场景,则说明写的逻辑没有错误。
为了方便开发 GameMap 里的内容,先到项目设置里选择 GameMap 为默认地图。
添加一个 C++ 类的 GameMode ,取名叫 SlAiGameMode,路径为 /Public/GamePlay/,作为游玩时的游戏模式。
再添加一个 C++ 类的 HUD,取名叫 SlAiGameHUD,路径为 /Public/UI/HUD/,作用是管理游玩时的 Widget 界面。
再添加一个 C++ 类的 GameInstance (有些不常用的类需要勾选 “Show All Classes” 后,在搜索框里输入搜索,以后不再赘述此操作),取名叫 SlAiGameInstance,路径为 /Public/GamePlay/,作用是在切换关卡时保存并传递数据。
随后在 GameMap 里的 World Settings 设置默认的游戏模式为刚刚新建的 SlAiGameMode;再到项目设置里选择游戏实例类为刚刚新建的 SlAiGameInstance。
接下来测试下我们自己写的数据处理类以及新建的 GameInstance 是否都可以跨场景传递数据。
首先给 SlAiGameInstance 添加一个变量用于测试。
SlAiGameInstance.h
#pragma once
#include "CoreMinimal.h"
#include "Engine/GameInstance.h"
#include "SlAiGameInstance.generated.h"
UCLASS()
class SLAICOURSE_API USlAiGameInstance : public UGameInstance
{
GENERATED_BODY()
public:
// 稍微提及,这个方法是父类的一个初始化方法,在世界开始前运行,可以放一些初始化的逻辑
// virtual void Init() override;
// 设定一个变量
UPROPERTY(VisibleAnywhere, Category = "SlAi")
FString GameName;
}
随后在菜单游戏模式里重写 BeginPlay() 函数,给刚刚设置的变量赋值。
SlAiMenuGameMode.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/GameModeBase.h"
#include "SlAiMenuGameMode.generated.h"
UCLASS()
class SLAICOURSE_API ASlAiMenuGameMode : public AGameModeBase
{
GENERATED_BODY()
public:
ASlAiMenuGameMode();
protected:
// 重写 BeginPlay 函数
virtual void BeginPlay() override;
}
SlAiMenuGameMode.cpp
#include "SlAiMenuGameMode.h"
#include "SlAiMenuHUD.h"
#include "SlAiMenuController.h"
// 引入两个头文件
#include "SlAiGameInstance.h"
#include "Kismet/GameplayStatics.h"
ASlAiMenuGameMode::ASlAiMenuGameMode()
{
PlayerControllerClass = ASlAiMenuController::StaticClass();
HUDClass = ASlAiMenuHUD::StaticClass();
}
void ASlAiMenuGameMode::BeginPlay()
{
// 在菜单游戏模式中给这个 GameInstance 内的变量赋值
Cast<USlAiGameInstance>(UGameplayStatics::GetGameInstance(GetWorld()))->GameName =
FString("SlAiCourse");
}
然后在游玩游戏模式里也重写 BeginPlay() 函数,分别通过 Debug 输出数据控制类与 GameInstance 里的数据。
SlAiGameMode.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/GameModeBase.h"
#include "SlAiGameMode.generated.h"
UCLASS()
class SLAICOURSE_API ASlAiGameMode : public AGameModeBase
{
GENERATED_BODY()
public:
ASlAiGameMode();
protected:
// 重写 BeginPlay 函数
virtual void BeginPlay() override;
}
SlAiGameMode.cpp
#include "SlAiGameMode.h"
// 引入以下头文件
#include "SlAiDataHandle.h"
#include "SlAiHelper.h"
#include "SlAiGameInstance.h"
#include "Kismet/GameplayStatics.h"
ASlAiGameMode::ASlAiGameMode()
{
}
void ASlAiGameMode::BeginPlay()
{
// 分别输出数据处理类和 GameInstance 里保存的数据
SlAiHelper::Debug(FString("DataHandle : ") + SlAiDataHandle::Get()->RecordName, 30.f);
SlAiHelper::Debug(FString("GameInstance : ") + Cast<USlAiGameInstance>
(UGameplayStatics::GetGameInstance(GetWorld()))->GameName, 30.f);
}
运行后选择开始新游戏或载入游戏,通过左上角 Debug 输出可见,两个类都是可以跨关卡(从菜单关卡跳转到游玩关卡)传递数据的。
后面如果不需要这里的 Debug 语句的话,可以注释或删掉。
随后在 /Public/Player/ 路径下创建这四个类:
创建一个 C++ 的 PlayerController 类,取名 SlAiPlayerController,作为游玩时的玩家控制器。
创建一个 C++ 的 Character 类,取名 SlAiPlayerCharacter,作为游玩时的玩家操控角色。
创建一个 C++ 的 PlayerState 类,取名 SlAiPlayerState,作为游玩时保存玩家数据的类。
创建一个 C++ 的 AnimInstance 类,取名 SlAiPlayerAnim,作为玩家角色的动画控制类。
然后在相同路径下以 SlAiPlayerAnim 为基类,再分别创建第三人称动画的 SlAiThirdPlayerAnim 和第一人称动画的 SlAiFirstPlayerAnim。
最后完善下游玩游戏模式的一些初始化配置工作。
SlAiGameMode.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/GameModeBase.h"
#include "SlAiGameMode.generated.h"
UCLASS()
class SLAICOURSE_API ASlAiGameMode : public AGameModeBase
{
GENERATED_BODY()
public:
ASlAiGameMode();
// 重写帧函数
virtual void Tick(float DeltaSeconds) override;
protected:
virtual void BeginPlay() override;
}
SlAiGameMode.cpp
#include "SlAiGameMode.h"
// 添加下面四个头文件
#include "SlAiPlayerController.h"
#include "SlAiPlayerCharacter.h"
#include "SlAiPlayerState.h"
#include "SlAiGameHUD.h"
// ... 省略四个头文件
ASlAiGameMode::ASlAiGameMode()
{
// 允许开启 Tick 函数
PrimaryActorTick.bCanEverTick = true;
// 添加组件
HUDClass = ASlAiGameHUD::StaticClass();
PlayerControllerClass = ASlAiPlayerController::StaticClass();
PlayerStateClass = ASlAiPlayerState::StaticClass();
DefaultPawnClass = ASlAiPlayerCharacter::StaticClass();
}
void ASlAiGameMode::Tick(float DeltaSeconds)
{
}
// ... 省略
对于 Gameplay 框架相关知识点,如果读者不太清楚的话,说明你应该回过头去补全这方面知识,就不作推荐了。
运行后如果 GameMap 的右侧 GameMode 面板内的数据都配置为上面写好的类,则说明添加成功。