斯坦福 UE4 C++ ActionRoguelike游戏实例教程 10.5.作业五 为游戏添加一个积分系统,随机生成增益道具

发布时间 2023-04-24 20:20:20作者: 仇白

斯坦福课程 UE4 C++ ActionRoguelike游戏实例教程 0.绪论

概述

本篇文章将解决作业五提出的问题,使用PlayerState,在原本游戏的基础上引入积分系统,实现击杀敌人得分,拾取道具扣分,以及创建一个积分道具(金币),使用EQS在游戏开始时随机生成数个回血和积分道具。

image-20230421162300338

作业说明

目录

  1. 前情提要
  2. 自定义PlayerState
  3. 修改击杀函数
  4. 血瓶添加积分花费
  5. 添加积分道具
  6. 绑定UI事件
  7. 设置环境查询(EQS)
  8. 游戏开始自动生成道具

前情提要

在跟随这篇文章实现作业要求前,要求你的游戏项目中拥有以下要素:

  1. 实现了交互接口(Interact GameInterface),允许角色与实现了交互接口的Actor进行交互。
  2. 能够实现场景增益道具(PowerUpBase),笔者已经实现了基类的定义和回血道具的功能。这部分内容可以参考作业三的实现。
  3. 简单实现了积分(Credit)的UMG控件,这将有助于观察实现的结果。

在作业五的要求中,我们要实现以下内容:

  1. 在玩家杀死敌对AI时获取一定积分
  2. 使用回血道具时扣除一定积分
  3. 实现“Coin”道具,在拾取时给予玩家一定积分
  4. 在UI中实时展现玩家目前拥有的积分
  5. 使用EQS在游戏开始时随机生成一定数量的增益道具(包括回血瓶和金币等)

本文仅对笔者认为值得的地方进行详细说明。一些略过的地方,比如一眼可以看明白的代码或者蓝图逻辑,笔者就不进行赘述了。

自定义PlayerState

我们将使用PlayerState来定义积分系统,包括玩家拥有的积分,获得、失去积分等功能。

PlayerState通常用于保存各个玩家的各种数据。如果查看其源码的话,可以发现里面很多功能都是为网络同步服务的。目前我们还处于单机阶段,只需要将其看做是一个专门记录玩家数据的类即可。

PlayerState拥有以下值得注意的特点:

  1. Pawn和Controller都有一个PlayerState成员
  2. PlayerState由Controller在InitPlayerState时创建,而Pawn中的PlayerState一般都是Controller创建Pawn的时候赋值给Pawn的。也就是说,PlayerState不归Pawn管,而是归Controller管。
  3. PlayerState 的getowner函数返回的是 controller
  4. 可以调用APawn或者AController的GetPlayerState获得PlayerState。
//.h
//定义一个三个参数的多播委托,将其命名为FOnCreditsChanged类型。
//当调用这个委托时,执行注册在委托里的所有函数
DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(FOnCreditsChanged, ASurPlayerState*, PlayerState, int32, NewCredits, int32, Delta);

UCLASS()
class FPSPROJECT_API ASurPlayerState : public APlayerState
{
   GENERATED_BODY()
protected:
   UPROPERTY(EditDefaultsOnly, Category = "Credits")
   int32 Credits;

public:
   UFUNCTION(BlueprintCallable, Category = "Credits")
   int32 GetCredits() const;

   UFUNCTION(BlueprintCallable, Category = "Credits")
   bool AddCredits(int32 Delta);

   UFUNCTION(BlueprintCallable, Category = "Credits")
   bool RemoveCredits(int32 Delta);

   //BlueprintAssignable 只能与多播委托共用。可以在蓝图中指定函数
   UPROPERTY(BlueprintAssignable, Category = "Events")
   FOnCreditsChanged OnCreditsChanged;
};

//.cpp
bool ASurPlayerState::AddCredits(int32 Delta)
{
	if(!ensure(Delta >= 0))
	{
		return false;
	}
	Credits += Delta;
	OnCreditsChanged.Broadcast(this, Credits, Delta);
	return true;
}

bool ASurPlayerState::RemoveCredits(int32 Delta)
{
	if(!ensure(Delta >= 0))
	{
		return false;
	}
	//如果积分少于要扣除的量,那么是不被允许的
	if(Credits < Delta)
	{
		return false;
	}
	Credits -= Delta;
	OnCreditsChanged.Broadcast(this, Credits, Delta);
	return true;
}

修改击杀函数

之前在SurGameModeBase中我们定义了一个静态函数OnActorKilled,它将会在有角色被杀死时调用。

这里添加杀死其他角色时尝试增加杀手的积分。当受害者被杀手杀死时,会试图获取杀手的SurPlayerState,并增加其拥有的积分。

void ASurGameModeBase::OnActorKilled(AActor* VictimActor, AActor* Killer)
{
   AMyCharacter* Player = Cast<AMyCharacter>(VictimActor);
   if(Player)
   {
      //...重生相关逻辑
   }
   
   //尝试获取Killer的PlayerState,只有成功获取了才能添加积分
   APawn* KillerPawn = Cast<APawn>(Killer);
   if(KillerPawn)
   {
      ASurPlayerState* PS = Cast<ASurPlayerState>(KillerPawn->GetPlayerState());
      if(PS)
      {
         PS->AddCredits(CreditsPerKill);
      }
   }
   UE_LOG(LogTemp, Log, TEXT("OnActorKilled:Victim:%s, Killer: %s"), *GetNameSafe(VictimActor), *GetNameSafe(Killer));
}

血瓶添加积分花费

这部分修改很简单,在玩家与血瓶互动时会尝试扣除玩家的积分,只有扣除成功了才会将血瓶隐藏起来,表示血瓶已经使用了。

UCLASS()
class FPSPROJECT_API ASurPowerUp_HealthPotion : public ASurPowerUpBase
{
   GENERATED_BODY()
public:
   ASurPowerUp_HealthPotion();
   
protected:
   UPROPERTY(EditAnywhere)
   int32 CreditCost;
   

   virtual void Interact_Implementation(APawn* InstigatorPawn) override;
};

void ASurPowerUp_HealthPotion::Interact_Implementation(APawn* InstigatorPawn)
{
	AMyCharacter* MyCharacter = Cast<AMyCharacter>(InstigatorPawn);
	if(!ensure(MyCharacter)) return;

	//获得Attribute
	USurAttributeComponent* AttributeComponent = Cast<USurAttributeComponent>(MyCharacter->GetComponentByClass(USurAttributeComponent::StaticClass()));
	if(ensure(AttributeComponent))
	{
		if(ASurPlayerState* PS = Cast<ASurPlayerState>(MyCharacter->GetPlayerState()))
		{
			if(AttributeComponent->ApplyHealthChanges(this, HealingValue) && PS->RemoveCredits(CreditCost))
			{
				//只有使用成功了才暂时将血瓶隐藏起来
				HideAndCooldownPowerup();
			}
		}
	}
}

添加积分道具

积分道具的定义比血瓶的还简单,我将其命名为SurPowerUp_CreditsUp

//SurPowerUp_CreditsUp.h
class FPSPROJECT_API ASurPowerUp_CreditsUp : public ASurPowerUpBase
{
   GENERATED_BODY()
public:
   ASurPowerUp_CreditsUp();
   
   virtual void Interact_Implementation(APawn* InstigatorPawn) override;

protected:
   UPROPERTY(EditAnywhere, Category = "Credits")
   int32 CreditsValue;
};

//.cpp
ASurPowerUp_CreditsUp::ASurPowerUp_CreditsUp()
{
	CreditsValue = 100;
	bOnlyOnce = false;
}

void ASurPowerUp_CreditsUp::Interact_Implementation(APawn* InstigatorPawn)
{
	if(!ensure(InstigatorPawn))
	{
		return;
	}
	
	if(ASurPlayerState* PS = InstigatorPawn->GetPlayerState<ASurPlayerState>())
	{
		if(PS->AddCredits(CreditsValue))
		{
			HideAndCooldownPowerup();
		}
	}
}

image-20230424164518603

金币的蓝图

绑定UI事件

接下来进入编辑器。为了检验刚才的成果,我们要实现UI中Credits控件的绑定,这样才能实时查看我们当前拥有的积分。

首先需要将我们的PlayerState添加到GameMode中,这样游戏自动创建的玩家控制器才会带有指定的玩家状态类:

千万别忘了修改PlayerState

进入Credit_Widget的事件图表中,将其绑定到SurPlayerState中的OnCreditsChanged委托上。蓝图如下

image-20230421174518015

蓝图节点

如果找不到如何绑定事件,可以在获取PlayetState后,从后面拉出一条线,找到分配事件即可绑定自定义事件:

image-20230421174000036

从PlayerState拉出一条线,这样才能找到分配事件

调整好各个属性值,进入游戏查看效果。

注意看左下角

设置环境查询(EQS)

接下来实现在游戏开始时随机生成数个道具。首先创建EQS,起名为Query_FindPowerUpSpawn。在测试内容中,选择PathExist,只有在导航网格中能够到达的区域才会被选为查询结果。

image-20230421192259852

Query_FindPowerUpSpawn 相关

其中查询内容是我自定义的,这里根据玩家出生点来选择EQS查询的范围。

image-20230421192141521

QueryContext_PlayerStart 相关

在编辑器中使用EQSTestPawn确认一下查询范围。

image-20230421192535446

预览环境查询的结果

游戏开始自动生成道具

在之前的AI生成部分,我们已经做过类似的东西。最大的区别,不过是在游戏开始时一口气生成多个道具罢了。

简单讲讲OnSpawnPowerUpQueryCompleted函数。该函数的作用是在执行一个环境查询后,根据查询返回的位置结果生成一定数量的道具(PowerUp)物体。

具体来说,该函数首先检查查询的状态是否成功,如果不是,则会输出一个警告信息。然后,从查询实例中获取位置结果,并将其存储在Locations数组中。

接下来,使用while循环来生成道具。循环条件是需要生成的道具数小于指定的数量DesiredPowerupCount且位置数组Locations不为空。在每次迭代中,从Locations数组中随机选择一个位置,并将该位置存储在PickedLocation变量中。然后,从PowerupClasses数组中随机选择一个类别,并将其存储在RandomPowerupClass变量中。接着,创建一个SpawnParams对象并设置SpawnCollisionHandlingOverride参数以处理生成的物体的碰撞行为。最后调用GetWorld()方法生成一个ASurPowerUpBase对象,并将它放置在选定位置的稍微上方50个单位处。生成的道具计数器SpawnCounter加1,这样循环就可以继续进行,直到达到指定的生成数量为止。

当然,如果你有闲情的话,可以为道具生成添加更多个性化的设置,比如限制道具之间的生成距离之类的。

void ASurGameModeBase::StartPlay()
{
   Super::StartPlay();
   //循环调用定时器,生成AI小兵
   GetWorldTimerManager().SetTimer(TimerHandle_SpawnBots, this, &ASurGameModeBase::SpawnBotTimerElapsed, SpawnTimerInterval, true);

   //生成PowerUp对象
   if(ensure(PowerupClasses.Num() > 0))
   {
      UEnvQueryInstanceBlueprintWrapper* QueryInstance = UEnvQueryManager::RunEQSQuery(this, SpawnPowerUpQuery, this, EEnvQueryRunMode::AllMatching, nullptr);
      if(ensure(QueryInstance))
      {
         QueryInstance->GetOnQueryFinishedEvent().AddDynamic(this, &ASurGameModeBase::OnSpawnPowerUpQueryCompleted);
      }
   }
}

void ASurGameModeBase::OnSpawnPowerUpQueryCompleted(UEnvQueryInstanceBlueprintWrapper* QueryInstance,
	EEnvQueryStatus::Type QueryStatus)
{
	if(QueryStatus != EEnvQueryStatus::Success)
	{
		UE_LOG(LogTemp, Warning, TEXT("Spawn Powerup EQS Query Failed!"));
	}
	//环境查询得到的所有坐标
	TArray<FVector> Locations = QueryInstance->GetResultsAsLocations();
	//要生成增益道具的所有坐标
	TArray<FVector> UseLocation;

	int32 SpawnCounter = 0;

	while(SpawnCounter < DesiredPowerupCount && Locations.Num() > 0)
	{
		//随机选取环境查询结果的一个坐标
		int32 RandomLocationIndex = FMath::RandRange(0, Locations.Num() - 1);
		FVector PickedLocation = Locations[RandomLocationIndex];
		//为了防止重复选取,将选取的坐标从数组中删掉
		Locations.RemoveAt(RandomLocationIndex);

		//随机选择一个Powerup进行生成
		int32 RandomClassIndex = FMath::RandRange(0, PowerupClasses.Num() - 1);
		TSubclassOf<ASurPowerUpBase> RandomPowerupClass = PowerupClasses[RandomClassIndex];
		FActorSpawnParameters SpawnParams;
		SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn;
		GetWorld()->SpawnActor<ASurPowerUpBase>(RandomPowerupClass, PickedLocation + FVector(0, 0, 50.f), FRotator::ZeroRotator, SpawnParams);
		SpawnCounter++;
	}
}

最后是设置GameMode蓝图,这些成员都是在C++代码中添加的。

image-20230421210526844

设置GameMode

进入游戏,可以看到在游戏一开始的时候就生成了多个增益道具。

image-20230421210502285

最终效果

碎碎念

笔者在写下本系列第一篇文章的时候,想的是将自己在UE4的学习过程中所学的知识梳理出来,并将学到的知识稍作拓展,希望能拓展成更加”通用“的东西,最后形成一套自己的体系,并将其输出为其他人所用。因此,我希望在每一篇文章中,能够尽可能的插入更多UE中的知识点,随着课程老师的进度逐步点亮沿途的”科技树“,并让每一篇文章都拥有自己的主题,即使抛开课程项目的背景,也能让读者从文章中尽可能的学到一点东西。

遗憾的是,越写到后面,越发觉得力不从心。一方面想事无巨细地展现当前课程所涉及到的知识点,一方面又想跟随课程的进度,完成当前应该掌握的内容。最近发现文章里复制粘贴代码的占比越来越大,体现UE理解的相关段落越来越少,应该说是部分课程内容越来越倾向于同质化呢,还是说笔者个人越来越松懈呢?目前还是希望能从大段代码和知识点之间找到一个平衡点,希望能够早日写出优质的文章。

有时候都想用AI来帮我写代码的逻辑了,感觉很多一眼能看明白的代码,我都不确定要不要对其详细展开,这可能就是知识的诅咒吧。