斯坦福 UE4 C++ ActionRoguelike游戏实例教程 10.5.作业五 为游戏添加一个积分系统,随机生成增益道具
斯坦福课程 UE4 C++ ActionRoguelike游戏实例教程 0.绪论
概述
本篇文章将解决作业五提出的问题,使用PlayerState,在原本游戏的基础上引入积分系统,实现击杀敌人得分,拾取道具扣分,以及创建一个积分道具(金币),使用EQS在游戏开始时随机生成数个回血和积分道具。
目录
- 前情提要
- 自定义PlayerState
- 修改击杀函数
- 血瓶添加积分花费
- 添加积分道具
- 绑定UI事件
- 设置环境查询(EQS)
- 游戏开始自动生成道具
前情提要
在跟随这篇文章实现作业要求前,要求你的游戏项目中拥有以下要素:
- 实现了交互接口(Interact GameInterface),允许角色与实现了交互接口的Actor进行交互。
- 能够实现场景增益道具(PowerUpBase),笔者已经实现了基类的定义和回血道具的功能。这部分内容可以参考作业三的实现。
- 简单实现了积分(Credit)的UMG控件,这将有助于观察实现的结果。
在作业五的要求中,我们要实现以下内容:
- 在玩家杀死敌对AI时获取一定积分
- 使用回血道具时扣除一定积分
- 实现“Coin”道具,在拾取时给予玩家一定积分
- 在UI中实时展现玩家目前拥有的积分
- 使用EQS在游戏开始时随机生成一定数量的增益道具(包括回血瓶和金币等)
本文仅对笔者认为值得的地方进行详细说明。一些略过的地方,比如一眼可以看明白的代码或者蓝图逻辑,笔者就不进行赘述了。
自定义PlayerState
我们将使用PlayerState来定义积分系统,包括玩家拥有的积分,获得、失去积分等功能。
PlayerState通常用于保存各个玩家的各种数据。如果查看其源码的话,可以发现里面很多功能都是为网络同步服务的。目前我们还处于单机阶段,只需要将其看做是一个专门记录玩家数据的类即可。
PlayerState拥有以下值得注意的特点:
- Pawn和Controller都有一个PlayerState成员
- PlayerState由Controller在InitPlayerState时创建,而Pawn中的PlayerState一般都是Controller创建Pawn的时候赋值给Pawn的。也就是说,PlayerState不归Pawn管,而是归Controller管。
- PlayerState 的getowner函数返回的是 controller
- 可以调用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();
}
}
}
绑定UI事件
接下来进入编辑器。为了检验刚才的成果,我们要实现UI中Credits控件的绑定,这样才能实时查看我们当前拥有的积分。
首先需要将我们的PlayerState添加到GameMode中,这样游戏自动创建的玩家控制器才会带有指定的玩家状态类:
进入Credit_Widget的事件图表中,将其绑定到SurPlayerState中的OnCreditsChanged委托上。蓝图如下
如果找不到如何绑定事件,可以在获取PlayetState后,从后面拉出一条线,找到分配事件即可绑定自定义事件:
调整好各个属性值,进入游戏查看效果。
设置环境查询(EQS)
接下来实现在游戏开始时随机生成数个道具。首先创建EQS,起名为Query_FindPowerUpSpawn。在测试内容中,选择PathExist,只有在导航网格中能够到达的区域才会被选为查询结果。
其中查询内容是我自定义的,这里根据玩家出生点来选择EQS查询的范围。
在编辑器中使用EQSTestPawn
确认一下查询范围。
游戏开始自动生成道具
在之前的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++代码中添加的。
进入游戏,可以看到在游戏一开始的时候就生成了多个增益道具。
碎碎念
笔者在写下本系列第一篇文章的时候,想的是将自己在UE4的学习过程中所学的知识梳理出来,并将学到的知识稍作拓展,希望能拓展成更加”通用“的东西,最后形成一套自己的体系,并将其输出为其他人所用。因此,我希望在每一篇文章中,能够尽可能的插入更多UE中的知识点,随着课程老师的进度逐步点亮沿途的”科技树“,并让每一篇文章都拥有自己的主题,即使抛开课程项目的背景,也能让读者从文章中尽可能的学到一点东西。
遗憾的是,越写到后面,越发觉得力不从心。一方面想事无巨细地展现当前课程所涉及到的知识点,一方面又想跟随课程的进度,完成当前应该掌握的内容。最近发现文章里复制粘贴代码的占比越来越大,体现UE理解的相关段落越来越少,应该说是部分课程内容越来越倾向于同质化呢,还是说笔者个人越来越松懈呢?目前还是希望能从大段代码和知识点之间找到一个平衡点,希望能够早日写出优质的文章。
有时候都想用AI来帮我写代码的逻辑了,感觉很多一眼能看明白的代码,我都不确定要不要对其详细展开,这可能就是知识的诅咒吧。