2 - 游戏模式作业

本文将记录我写第二节课《游戏模式》课后作业的过程,如有不完美的地方还请提出!

目标

实现以下功能:

物件规则

  1. 射击命中方块,获得积分X分;
  2. 方块被子弹命中后,缩放为Y倍,再次被命中后销毁。

游戏流程

  1. 游戏开始时随机N个方块成为重要目标,射击命中后获得双倍积分。
  2. 游戏开始后限时T秒,时间到后游戏结算,打印日志输出每个玩家获得的积分和所有玩家获得的总积分。

附加题

  1. 利用UMG制作结算UI替代日志打印。
  2. 支持多人联机。

实现

目标方块类及其蓝图子类

首先创建一个继承于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 InterfaceC++类,命名为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:待我学完网络部分再回来看看。

参考资料