2 - 游戏模式作业
本文将记录我写第二节课《游戏模式》课后作业的过程,如有不完美的地方还请提出!
目标
实现以下功能:
物件规则:
- 射击命中方块,获得积分X分;
- 方块被子弹命中后,缩放为Y倍,再次被命中后销毁。
游戏流程:
- 游戏开始时随机N个方块成为重要目标,射击命中后获得双倍积分。
- 游戏开始后限时T秒,时间到后游戏结算,打印日志输出每个玩家获得的积分和所有玩家获得的总积分。
附加题:
- 利用UMG制作结算UI替代日志打印。
- 支持多人联机。
实现
目标方块类及其蓝图子类
首先创建一个继承于Actor类的目标方块类TargetBlock
,它应该有如下属性:
// TargetBlock.h
{
// ...
protected:
// 被打到给玩家加的积分
UPROPERTY(EditDefaultsOnly, Category = TargetBlock)
float Score = 5.f;
// 被打到后自身的缩放倍数
UPROPERTY(EditDefaultsOnly, Category = TargetBlock)
float ScaleScalar = 0.75f;
// 是否是重要目标
UPROPERTY(EditDefaultsOnly, Category = TargetBlock)
bool bIsImportantTarget = false;
// 是否被打到过一次
bool bIsBeenHit = false;
public:
// ...
// Getters
FORCEINLINE float GetScore() const { return bIsImportantTarget ? 2.f * Score : Score; }
}
其中,前面三个属性可以在之后创建的蓝图子类中调节。然后需要给目标方块声明它的碰撞检测组件和碰撞回调函数OnHit()
:
// TargetBlock.h
{
// ...
private:
// 目标方块的碰撞体组件
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = TargetBlock, meta = (AllowPrivateAccess = "true"))
class UBoxComponent* BoxComponent;
protected:
// ...
// 碰撞回调
UFUNCTION()
void OnHit(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse,
const FHitResult& Hit);
}
接下来看看它的构造函数是怎么写的:
// TargetBlock.cpp
ATargetBlock::ATargetBlock()
{
// Set this actor to call Tick() every frame. You can turn this off to improve performance if you don't need it.
PrimaryActorTick.bCanEverTick = true;
// 初始化碰撞检测用的网格体组件
BoxComponent = CreateDefaultSubobject<UBoxComponent>(TEXT("Box Collision"));
BoxComponent->SetBoxExtent(FVector(55.0));
BoxComponent->SetCollisionProfileName(FName("TargetBlock"));
BoxComponent->SetSimulatePhysics(true);
BoxComponent->SetNotifyRigidBodyCollision(true);
BoxComponent->OnComponentHit.AddDynamic(this, &ATargetBlock::OnHit);
RootComponent = BoxComponent;
}
其中,创建了用于碰撞检测用的网格体组件BoxComponent
,并初始化了它的大小、碰撞预设、是否模拟物理和碰撞回调函数OnHit()
。
这里新建了一个碰撞预设TargetBlock
,在项目设置
->引擎
->碰撞
中可以查看:
然后是碰撞回调OnHit()
的实现,第一次碰撞时让自己缩放,第二次则销毁自身:
// TargetBlock.cpp
void ATargetBlock::OnHit(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp,
FVector NormalImpulse, const FHitResult& Hit)
{
// 只接受来自投射物的碰撞
ATxFPSDemoProjectile* Projectile = Cast<ATxFPSDemoProjectile>(OtherActor);
if (Projectile == nullptr)
{
return;
}
if (bIsBeenHit)
{
Destroy();
}
else
{
bIsBeenHit = true;
SetActorRelativeScale3D(FVector(ScaleScalar));
}
}
编译完代码后,基于TargetBlock
类创建两个蓝图子类,一个是普通目标BP_TargetBlock
,另一个是重要目标BP_ImportantTargetBlock
:
先编辑BP_TargetBlock
,添加一个Cube子组件,用于在游戏中显示目标方块:
BP_ImportantTargetBlock
同理,别忘了设置IsImportantTarget
:
可以试着拖到场景里看看效果,可以发现现在有了两个有物理属性的目标方块:
编写积分组件
为了实现让玩家拥有自己的积分,并能对其进行管理,可以新建一个名字为ScoreComponent
的C++类,它继承于ActorComponent
类:
// ScoreComponent.h
{
// ...
protected:
// 该玩家的积分
UPROPERTY(EditDefaultsOnly, Category = Score)
float Score = 0.f;
public:
// ...
// 给该玩家加分
void AddScore(float Amount);
}
其中,AddScore()
的实现如下:
void UScoreComponent::AddScore(float Amount)
{
Score += Amount;
}
使用积分组件
编写完后就要使用它了,先修改玩家Character类:
// TxFPSDemoCharacter.h
{
// ...
// 玩家积分组件
UPROPERTY()
class UScoreComponent* ScoreComponent;
// ...
public:
// ...
// 返回ScoreComponent subobject
class UScoreComponent* GetScoreComponent() const { return ScoreComponent; }
}
然后再修改投射物Projectile类的碰撞逻辑,如果打到TargetBlock
,就给Owner
加分:
// TxFPSDemoProjectile.cpp
void ATxFPSDemoProjectile::OnHit(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit)
{
// Only add impulse and destroy projectile if we hit a physics
if ((OtherActor != nullptr) && (OtherActor != this) && (OtherComp != nullptr) && OtherComp->IsSimulatingPhysics())
{
OtherComp->AddImpulseAtLocation(GetVelocity() * 100.0f, GetActorLocation());
// 先看看OwnerActor是不是玩家, OtherActor是不是TargetBlock
const ATxFPSDemoCharacter* Player = Cast<ATxFPSDemoCharacter>(GetOwner());
const ATargetBlock* TargetBlock = Cast<ATargetBlock>(OtherActor);
if (Player != nullptr && TargetBlock != nullptr)
{
// 给Player加分
float Score = TargetBlock->GetScore();
Player->GetScoreComponent()->AddScore(Score);
// 在屏幕上输出玩家分数
if (GEngine)
{
FString DebugMsg = FString::Printf(TEXT("Current player's score: %f"), Player->GetScoreComponent()->GetScore());
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::White, DebugMsg);
}
}
Destroy();
}
}
最后需要修改武器组件类WeaponComponent
,补充生成投射物的Owner为开枪的玩家:
// TP_WeaponComponent.cpp
void UTP_WeaponComponent::Fire()
{
// ...
// Set Spawn Collision Handling Override
FActorSpawnParameters ActorSpawnParams;
ActorSpawnParams.SpawnCollisionHandlingOverride =
ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButDontSpawnIfColliding;
ActorSpawnParams.Owner = Character;
// ...
}
这时候应该能满足 物件规则 这一作业要求了:
方块的随机生成
随机生成方块需要修改项目的游戏模式类TxFPSDemoGameMode
。首先声明给蓝图编辑的属性:
// TxFPSDemoGameMode.h
{
public:
// 普通方块类
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = TargetBlock)
TSubclassOf<class ATargetBlock> BP_NormalTgtBlk;
// 重要方块类
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = TargetBlock)
TSubclassOf<class ATargetBlock> BP_ImportantTgtBlk;
// 生成方块的数量
UPROPERTY(EditDefaultsOnly, Category = TargetBlock)
int32 TotalTargetBlockCnt = 10;
// 生成重要方块的数量
UPROPERTY(EditDefaultsOnly, Category = TargetBlock)
int32 ImportantTargetBlockCnt = 4;
}
接下来,在BeginPlay()
中实现随机生成方块的逻辑:
// TxFPSDemoGameMode.cpp
void ATxFPSDemoGameMode::BeginPlay()
{
Super::BeginPlay();
// 随机生成N个方块
ImportantTargetBlockCnt = (ImportantTargetBlockCnt > TotalTargetBlockCnt)
? TotalTargetBlockCnt
: ImportantTargetBlockCnt;
for (int32 i = 0; i < TotalTargetBlockCnt; ++i)
{
// 随机生成方块的位置
FVector Origin(1500.f, 1500.f, 400.f);
FVector Extents(1250.f, 1250.f, 0.f);
float RandomX = FMath::RandRange(Origin.X - Extents.X, Origin.X + Extents.X);
float RandomY = FMath::RandRange(Origin.Y - Extents.Y, Origin.Y + Extents.Y);
FVector SpawnLocation(RandomX, RandomY, Origin.Z);
FRotator SpawnRotation = FRotator::ZeroRotator;
if (i <= ImportantTargetBlockCnt)
{
GetWorld()->SpawnActor<ATargetBlock>(BP_ImportantTgtBlk, SpawnLocation, SpawnRotation);
}
else
{
GetWorld()->SpawnActor<ATargetBlock>(BP_NormalTgtBlk, SpawnLocation, SpawnRotation);
}
}
}
编译代码后,需要在对应蓝图类BP_FirstPersonGameMode
中应用相关设置:
然后就能随机生成了:
使用计时器
接下来给TxFPSDemoGameMode
类添加计时器,等到计时结束后就进入游戏结算阶段。要使用计时器,得添加如下属性,还有计时器使用的回调:
// TxFPSDemoGameMode.h
{
// ...
protected:
// 计时器
FTimerHandle GameOverTimerHandle;
// GameOver计时器回调
void OnGameOver();
}
然后在.cpp
中实现计时器逻辑:
// TxFPSDemoGameMode.cpp
void ATxFPSDemoGameMode::OnGameOver()
{
if (GEngine)
{
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::White, "====GAME OVER====");
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::White, " NAME SCORE ");
// 统计每个玩家的分数和总分数
float TotalScore = 0.f;
for (APlayerState* PlayerState : GetGameState<AGameStateBase>()->PlayerArray)
{
ATxFPSDemoCharacter* Player = Cast<ATxFPSDemoCharacter>(PlayerState->GetPlayerController()->GetCharacter());
if (Player != nullptr)
{
float Score = Player->GetScoreComponent()->GetScore();
FString TmpMsg = PlayerState->GetName() + FString::Printf(TEXT(" %f"), Score);
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::White, TmpMsg);
TotalScore += Score;
}
}
FString TmpMsg = FString::Printf(TEXT("Total Score: %f"), TotalScore);
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::White, TmpMsg);
}
}
最终结果如下(从下往上阅读):
这样就大概实现 游戏流程 这一模块了。
打包到手机上的效果如下:
附加题
添加UMG
接下来尝试给项目添加UMG制作的UI,而不是简单日志输出。UI需要制作两个,一个是玩家的HUD界面,用于显示玩家当前的状态(目前只有当前得分);另一个是结算时的菜单页面,需要有GAME OVER标题,玩家得分排名(降序),以及重启游戏和退出游戏按钮。
HUDWidget
首先看看HUD界面,先创建一个继承UserWidget
的蓝图类BP_HUDWidget
,并做如下布局:
然后创建一个继承UserWidget
的C++类HUDWidget
:
// HUDWidget.h
{
public:
UPROPERTY(meta = (BindWidget))
class UTextBlock* CurScoreText;
public:
void UpdatePlayerScore(float NewScore);
}
这里创建了和蓝图CurScoreText
(上图懒得改正了)同名控件绑定的UTextBlock*
属性,用于在C++侧更新当前积分文本。接下来看看UpdatePlayerScore()
是如何实现的:
// HUDWidget.cpp
void UHUDWidget::UpdatePlayerScore(float NewScore)
{
if (CurScoreText != nullptr)
{
CurScoreText->SetText(FText::AsNumber(NewScore));
}
}
别忘了在创建好HUDWidget的C++类后,让对应蓝图BP_HUDWidget
重设父类。
接下来修改PlayerController类,让它来更新UMG逻辑。在头文件中,新增如下声明:
// TxFPSDemoPlayerController.h
{
public:
UPROPERTY(EditDefaultsOnly)
TSubclassOf<class UHUDWidget> BP_HUDWidget;
private:
UPROPERTY()
class UHUDWidget* HUDWidget;
public:
void UpdateCurrentScore(float NewScore);
}
接着要在BeginPlay()
处初始化HUD,然后实现UpdateCurrentScore()
:
// TxFPSDemoPlayerController.cpp
void ATxFPSDemoPlayerController::BeginPlay()
{
// ...
// 初始化HUD
if (BP_HUDWidget != nullptr)
{
HUDWidget = CreateWidget<UHUDWidget>(this, BP_HUDWidget);
HUDWidget->AddToViewport();
}
}
void ATxFPSDemoPlayerController::UpdateCurrentScore(float NewScore)
{
if (HUDWidget != nullptr)
{
HUDWidget->UpdatePlayerScore(NewScore);
}
}
接下来就要找地方让PlayerController更新HUD了,这里用到接口。使用接口的好处是我们不需要知道它的具体实现,在该用的地方使用就行。新建一个Unreal Interface
C++类,命名为GameplayInterface
:
// GameplayInterface.h
class TXFPSDEMO_API IGameplayInterface
{
GENERATED_BODY()
// Add interface functions to this class. This is the class that will be inherited to implement this interface.
public:
// 玩家获得积分后的回调
UFUNCTION(BlueprintNativeEvent, Category = Score)
void OnGetScore();
virtual void OnGetScore_Implementation() = 0;
};
然后让玩家Character类实现这个接口:
// TxFPSDemoCharacter.h
class ATxFPSDemoCharacter : public ACharacter, public IGameplayInterface
{
// ...
protected:
// ...
// Gameplay 接口 - 开始
virtual void OnGetScore_Implementation() override;
// Gameplay 接口 - 结束
}
// TxFPSDemoCharacter.cpp
void ATxFPSDemoCharacter::OnGetScore_Implementation()
{
// 更新HUD
ATxFPSDemoPlayerController* PlayerController = Cast<ATxFPSDemoPlayerController>(GetController());
if (PlayerController != nullptr)
{
PlayerController->UpdateCurrentScore(ScoreComponent->GetScore());
}
}
然后需要在分数组件中调用这个接口:
// ScoreComponent.cpp
void UScoreComponent::AddScore(float Amount)
{
Score += Amount;
// 执行OnGetScore()接口
if (GetOwner()->Implements<UGameplayInterface>())
{
IGameplayInterface::Execute_OnGetScore(GetOwner());
}
}
最后删掉投射物的游戏画面LOG实现,编译代码并配置好相关蓝图,来看看效果:
然后是游戏倒计时的显示,需要改一下GameMode类中的相关逻辑,新增OnCountDown()
函数,让它在倒计时变化的时候更新每个玩家的HUD,等倒计时结束后才调用OnGameOver()
:
// TxFPSDemoGameMode.h
{
protected:
void OnCountDown();
}
// TxFPSDemoGameMode.cpp
void ATxFPSDemoGameMode::BeginPlay()
{
// ...
// 初始化游戏结算计时器
GetWorldTimerManager().SetTimer(GameOverTimerHandle, this, &ATxFPSDemoGameMode::OnCountDown, 1.0f, true);
}
void ATxFPSDemoGameMode::OnCountDown()
{
--GameOverInterval;
// 更新所有玩家的HUD
for (APlayerState* PlayerState : GetGameState<AGameStateBase>()->PlayerArray)
{
ATxFPSDemoPlayerController* PlayerController = Cast<ATxFPSDemoPlayerController>(
PlayerState->GetPlayerController());
if (PlayerController != nullptr)
{
PlayerController->UpdateCountdown(GameOverInterval);
}
}
if (GameOverInterval <= 0)
{
GetWorldTimerManager().ClearTimer(GameOverTimerHandle);
OnGameOver();
}
}
其余步骤和上面更新积分的相同,需要在BP_HUDWidget
上放两个文本块,一个是”倒计时:“,另一个用于显示具体时间。然后在HUDWidget.h
等类上做相应修改即可。
GameOverWidget
还有游戏结束的界面,新建一个基于UserWidget
的蓝图类BP_GameOverWidget
,布局如下:
主要控件有4个:用于重启游戏的RestartBtn
,用于退出游戏的ExitBtn
,显示总分数的TotalScoreText
和用于展示玩家名字及分数的PlayerListBox
。创建对应C++类GameOverWidget
如下:
// GameOverWidget.h
{
public:
UPROPERTY(meta = (BindWidget))
class UButton* RestartBtn;
UPROPERTY(meta = (BindWidget))
class UButton* ExitBtn;
UPROPERTY(meta = (BindWidget))
class UVerticalBox* PlayerListBox;
UPROPERTY(meta = (BindWidget))
class UTextBlock* TotalScoreText;
public:
virtual void NativeOnInitialized() override;
void UpdatePlayerListBox(const TArray<TPair<FString, float>>& PlayerScoreList);
protected:
UFUNCTION()
void OnRestartClicked();
UFUNCTION()
void OnExitClicked();
}
其中,我们声明了这4个控件的引用,还声明了对应的回调。接下来看看这些回调的实现:
// GameOverWidget.cpp
void UGameOverWidget::NativeOnInitialized()
{
Super::NativeOnInitialized();
// 绑定按钮回调
if (RestartBtn != nullptr)
{
RestartBtn->OnClicked.AddDynamic(this, &UGameOverWidget::OnRestartClicked);
}
if (ExitBtn != nullptr)
{
ExitBtn->OnClicked.AddDynamic(this, &UGameOverWidget::OnExitClicked);
}
}
void UGameOverWidget::UpdatePlayerListBox(const TArray<TPair<FString, float>>& PlayerScoreList)
{
float TotalScore = 0.f;
if (PlayerListBox != nullptr)
{
PlayerListBox->ClearChildren();
for (const auto& PlayerInfo : PlayerScoreList)
{
UTextBlock* TextBlock = NewObject<UTextBlock>(PlayerListBox);
if (TextBlock != nullptr)
{
TotalScore += PlayerInfo.Value;
FString Data = FString::Printf(TEXT("%s\t\t\t%f"), *PlayerInfo.Key, PlayerInfo.Value);
TextBlock->SetText(FText::FromString(Data));
PlayerListBox->AddChildToVerticalBox(TextBlock);
}
}
}
if (TotalScoreText != nullptr)
{
TotalScoreText->SetText(FText::AsNumber(TotalScore));
}
}
void UGameOverWidget::OnRestartClicked()
{
ATxFPSDemoPlayerController* PlayerController = Cast<ATxFPSDemoPlayerController>(GetOwningPlayer());
if (PlayerController != nullptr)
{
PlayerController->HideGameOverWidget();
}
UGameplayStatics::OpenLevel(this, FName(*UGameplayStatics::GetCurrentLevelName(this)));
}
void UGameOverWidget::OnExitClicked()
{
UKismetSystemLibrary::QuitGame(GetWorld(), nullptr, EQuitPreference::Quit, true);
}
其中:
NativeOnInitialized()
函数会在控件初始化后绑定两个按钮的OnClicked
事件回调;UpdatePlayerListBox()
函数会将传入的按分数降序排序的PlayerScoreList
添加到PlayerListBox
中,顺便统计并更新TotalScoreList
;OnRestartClicked()
函数是RestartBtn
按钮的回调,先让对应的PlayerController隐藏该窗口,然后重写打开该关卡;OnExitClicked()
函数是ExitBtn
按钮的回调,直接退出游戏。
接下来补充实现PlayerController类中的逻辑:
// TxFPSDemoPlayerController.h
{
public:
UPROPERTY(EditDefaultsOnly)
TSubclassOf<class UGameOverWidget> BP_GameOverWidget;
private:
UPROPERTY()
class UGameOverWidget* GameOverWidget;
public:
// GameOver Widget
void ShowGameOverWidget(const TArray<TPair<FString, float>>& PlayerScoreList);
void HideGameOverWidget();
}
我们声明了需要绑定的蓝图子类BP_GameOverWidget
,实际使用的实例GameOverWidget
,以及该控件的显示和隐藏函数。接下来看看这两个函数是怎么实现的:
// TxFPSDemoPlayerController.cpp
void ATxFPSDemoPlayerController::ShowGameOverWidget(const TArray<TPair<FString, float>>& PlayerScoreList)
{
if (BP_GameOverWidget != nullptr)
{
SetPause(true);
SetInputMode(FInputModeUIOnly());
bShowMouseCursor = true;
GameOverWidget = CreateWidget<UGameOverWidget>(this, BP_GameOverWidget);
GameOverWidget->UpdatePlayerListBox(PlayerScoreList);
GameOverWidget->AddToViewport();
}
}
void ATxFPSDemoPlayerController::HideGameOverWidget()
{
if (GameOverWidget != nullptr)
{
GameOverWidget->RemoveFromParent();
GameOverWidget->Destruct();
bShowMouseCursor = false;
SetInputMode(FInputModeGameOnly());
SetPause(false);
}
}
这两个函数的过程是相反的,前者先让游戏暂停,设置玩家输入模式为仅UI并显示玩家鼠标,然后实例化GameOverWidget
,更新玩家分数列表,最后显示到视口中;后者则先将控件从视口中移除,再将其销毁,最后取消鼠标显示,设置玩家输入模式为仅游戏输入(接收Input Action),取消暂停。
然后是GameMode类的逻辑,需要修改OnGameOver()
的逻辑,让它组装PlayerScoreList
并排序,最后显示所有玩家的GameOverWidget
:
// TxFPSDemoGameMode.cpp
void ATxFPSDemoGameMode::OnGameOver()
{
TArray<TPair<FString, float>> PlayerScoreList;
for (APlayerState* PlayerState : GetGameState<AGameStateBase>()->PlayerArray)
{
ATxFPSDemoCharacter* Player = Cast<ATxFPSDemoCharacter>(PlayerState->GetPlayerController()->GetCharacter());
if (Player != nullptr)
{
FString Name = PlayerState->GetName();
float Score = Player->GetScoreComponent()->GetScore();
PlayerScoreList.Add({Name, Score});
}
}
PlayerScoreList.Sort([](const TPair<FString, float>& A, const TPair<FString, float>& B)
{
return A.Value > B.Value;
});
for (APlayerState* PlayerState : GetGameState<AGameStateBase>()->PlayerArray)
{
ATxFPSDemoPlayerController* PlayerController = Cast<ATxFPSDemoPlayerController>(
PlayerState->GetPlayerController());
if (PlayerController != nullptr)
{
PlayerController->ShowGameOverWidget(PlayerScoreList);
}
}
}
最终结果如下:
支持多人联机
TODO:待我学完网络部分再回来看看。