UE4开发C++沙盒游戏教程笔记(十七)(对应教程 52 ~ 54)
51. 敌人攻击 AI (下)
给敌人 AI 控制器添加一个方法,用于更改黑板值的 “动作是否完成”。它会在行为树里面用于中断一些循环 Loop 的节点。
SlAiEnemyController.h
public:
// 告诉控制器动作完成
void ResetProcess(bool IsFinish);
SlAiEnemyController.cpp
void ASlAiEnemyController::ResetProcess(bool IsFinish)
{
// 修改完成状态
BlackboardComp->SetValueAsBool("ProcessFinish", IsFinish);
}
追赶攻击 Task(攻击动画四),类似冲刺攻击,不过速度更快;并且它的导航逻辑写在了 SlAiEnemyTaskFollow 里面,相较简单。
SlAiEnemyTaskAttackPursuit.h
protected:
// 动作结束后事件
void OnAnimationTimerDone();
protected:
UPROPERTY(EditAnywhere, Category = "Blackboard")
struct FBlackboardKeySelector IsActionFinish;
// 攻击动作结束后的委托
FTimerHandle TimerHandle;
private:
// 重写执行函数
virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent & OwnerComp, uint8* NodeMemory) override;
// 重写任务终止函数
virtual EBTNodeResult::Type AbortTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
SlAiEnemyTaskAttackPursuit.cpp
// 引入头文件
#include "SlAiEnemyController.h"
#include "SlAiEnemyCharacter.h"
#include "NavigationSystem.h"
#include "BehaviorTree/BehaviorTreeComponent.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "TimerManager.h"
EBTNodeResult::Type USlAiEnemyTaskAttackPursuit::ExecuteTask(UBehaviorTreeComponent & OwnerComp, uint8* NodeMemory)
{
// 如果初始化敌人参数不成功,直接返回失败
if (!InitEnemyElement(OwnerComp)) return EBTNodeResult::Failed;
// 播放突击动画
float AttackDuration = SECharacter->PlayAttackAction(EEnemyAttackType::EA_Pursuit);
// 设置速度为 600,不小于玩家
SECharacter->SetMaxSpeed(600.f);
// 设置参数,对应黑板值的 ProcessFinish
OwnerComp.GetBlackboardComponent()->SetValueAsBool(IsActionFinish.SelectedKeyName, false);
// 添加事件委托
FTimerDelegate TimerDelegate = FTimerDelegate::CreateUObject(this, &USlAiEnemyTaskAttackPursuit::OnAnimationTimerDone);
// 注册到事件管理器
SEController->GetWorld()->GetTimerManager().SetTimer(TimerHandle, TimerDelegate, AttackDuration, false);
return EBTNodeResult::Succeeded;
}
EBTNodeResult::Type USlAiEnemyTaskAttackPursuit::AbortTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
// 如果初始化敌人参数不成功或者事件句柄没有激活,直接返回
if (!InitEnemyElement(OwnerComp) || !TimerHandle.IsValid()) return EBTNodeResult::Aborted;
// 卸载时间委托
SEController->GetWorld()->GetTimerManager().ClearTimer(TimerHandle);
// 返回
return EBTNodeResult::Aborted;
}
void USlAiEnemyTaskAttackPursuit::OnAnimationTimerDone()
{
// 设置动作完成
if (SEController) SEController->ResetProcess(true);
// 重新设置速度为 300
if (SECharacter) SECharacter->SetMaxSpeed(300.f);
}
追赶攻击导航 Task,与上面的 Task 呈前后关系,这个 Task 的父节点 Selector 会与循环 Loop 的装饰器配合使用,通过黑板值 ProcessFinish 中断循环。
SlAiEnemyTaskAttackFollow.h
protected:
UPROPERTY(EditAnywhere, Category = "Blackboard")
struct FBlackboardKeySelector Destination;
private:
// 重写执行函数
virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent & OwnerComp, uint8* NodeMemory) override;
SlAiEnemyTaskAttackFollow.cpp
// 引入头文件
#include "SlAiEnemyController.h"
#include "SlAiEnemyCharacter.h"
#include "NavigationSystem.h"
#include "BehaviorTree/BehaviorTreeComponent.h"
#include "BehaviorTree/BlackboardComponent.h"
EBTNodeResult::Type USlAiEnemyTaskAttackFollow::ExecuteTask(UBehaviorTreeComponent & OwnerComp, uint8* NodeMemory)
{
// 如果初始化敌人参数不成功,直接返回失败
if (!InitEnemyElement(OwnerComp)) return EBTNodeResult::Failed;
// 范围是 5
const float ChaseRadius = 5.f;
// 获取玩家到敌人之间的单位向量
FVector SPToSE = SEController->GetPlayerLocation() - SECharacter->GetActorLocation();
// 获取距离
float EPDistance = SPToSE.Size();
// 如果距离大于 100.f,获取玩家与敌人连线上距离玩家 100.f 的那个点作为原始点来寻找导航点
if (EPDistance > 100.f) {
// 归一化
SPToSE.Normalize();
// 探索起点是玩家位置减去与敌人之间的距离的一点点
const FVector ChaseOrigin = SEController->GetPlayerLocation() - 100.f * SPToSE;
// 保存随机的位置
FVector DesLoc(0.f);
// 使用导航系统获取随机点
UNavigationSystemV1::K2_GetRandomReachablePointInRadius(SEController, ChaseOrigin, DesLoc, ChaseRadius);
// 修改目的地
OwnerComp.GetBlackboardComponent()->SetValueAsVector(Destination.SelectedKeyName, DesLoc);
}
else {
// 如果距离小于 100.f,那么设置敌人当前的位置为目标位置
OwnerComp.GetBlackboardComponent()->SetValueAsVector(Destination.SelectedKeyName, SECharacter->GetActorLocation());
}
// 返回成功
return EBTNodeResult::Succeeded;
}
在编写下一个 Task 类前先在敌人类添加一个转向的方法,以及两个要用的参数。
SlAiEnemyCharacter.h
public:
// 更改朝向
void UpdateRotation(FRotator NewRotator);
private:
// 朝向设置
FRotator NextRotation;
bool NeedRotate;
SlAiEnemyCharacter.cpp
void ASlAiEnemyCharacter::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
// 插值更新朝向
if(NeedRotate) {
SetActorRotation(FMath::RInterpTo(GetActorRotation(), NextRotation, DeltaTime, 10.f));
// 如果已经接近目标朝向,不再旋转
if (FMath::Abs(GetActorRotation().Yaw - NextRotation.Yaw) < 5) NeedRotate = false;
}
}
void ASlAiEnemyCharacter::UpdateRotation(FRotator NewRotator)
{
NextRotation = NewRotator;
NeedRotate = true;
}
敌人锁定玩家时的旋转 Task。(下下集这个旋转节点和敌人类里面的旋转方法都用不上了,不过这里还是先跟着老师的教程来)
SlAiEnemyTaskRotate.h
private:
// 重写执行函数
virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent & OwnerComp, uint8* NodeMemory) override;
SlAiEnemyTaskRotate.cpp
// 引入头文件
#include "SlAiEnemyController.h"
#include "SlAiEnemyCharacter.h"
#include "NavigationSystem.h"
#include "BehaviorTree/BehaviorTreeComponent.h"
#include "BehaviorTree/BlackboardComponent.h"
EBTNodeResult::Type USlAiEnemyTaskRotate::ExecuteTask(UBehaviorTreeComponent & OwnerComp, uint8* NodeMemory)
{
// 如果初始化敌人参数不成功,直接返回失败
if (!InitEnemyElement(OwnerComp)) return EBTNodeResult::Failed;
// 获取玩家到敌人的向量,除去 Y 向量
FVector SEToSP = FVector(SEController->GetPlayerLocation().X, SEController->GetPlayerLocation().Y, 0.f) - (SECharacter->GetActorLocation().X, SECharacter->GetActorLocation().Y, 0.f);
SEToSP.Normalize();
// 告诉敌人新的朝向
SECharacter->UpdateRotation(FRotationMatrix::MakeFromX(SEToSP).Rotator());
return EBTNodeResult::Succeeded;
}
编译后打开项目,调整敌人行为树局部如下:(最右下角被水印挡住了,MoveTo 的目标是 Destination)
保存后运行游戏,可见敌人正常执行追逐,在玩家靠近敌人时,敌人会根据距离作不同的攻击动作。
52. 敌人受伤 AI
以 SlAiEnemyTaskBase 为基类,创建 C++ 子类 SlAiEnemyTaskHurt,作为敌人播放受伤动画的 Task 节点。
先做一下准备工作,来到敌人的 AI 控制器。
敌人受伤肯定是要有频率的,不可能让敌人一挨揍就播放受伤动画,所以声明一个 bool 值决定敌人能否播放受伤动画。在 UpdateStatePama() 里面写一个逻辑来给这个 bool 值赋值。
敌人在受伤到一定程度就进入逃跑状态,所以我们声明一个变量,作为敌人的当前生命值百分比;并且声明一个方法来更新这个变量、改变敌人状态。
此外,敌人在受击完毕后需要进入其他状态,比如逃跑、攻击或防御。我们使用一个计时器和方法来在受击动画播放完毕后切换敌人状态。
SlAiEnemyController.h
public:
// 接受伤害,传入剩余生命值
void UpdateDamageRatio(float HPRatioVal);
// 完成伤害状态
void FinishStateHurt();
private:
// 生命值百分比
float HPRatio;
// 是否允许播放受伤状态动画
bool IsAllowHurt;
// 受伤计时器
float HurtTimeCount;
SlAiEnemyController.cpp
void ASlAiEnemyController::BeginPlay()
{
// 生命值百分比初始化为 1
HPRatio = 1;
// 设置状态计时器
IsAllowHurt = false;
HurtTimeCount = 0.f;
}
void ASlAiEnemyController::UpdateDamageRatio(float HPRatioVal)
{
// 更新生命值百分比
HPRatio = HPRatioVal;
// 状态修改为受伤
if (IsAllowHurt) BlackboardComp->SetValueAsEnum("EnemyState", (uint8)EEnemyAIState::ES_Hurt);
// 设置允许受伤状态
IsAllowHurt = false;
}
void ASlAiEnemyController::FinishStateHurt()
{
// 如果没有锁定玩家,设置锁定
if (!IsLockPlayer) IsLockPlayer = true;
// 如果生命值在 0.2f 以下,直接逃跑
if (HPRatio < 0.2f) {
BlackboardComp->SetValueAsEnum("EnemyState", (uint8)EEnemyAIState::ES_Escape);
}
else {
// 创建随机流,敌人有概率在受击后进行攻击或防御
FRandomStream Stream;
// 产生新的随机种子
Stream.GenerateNewSeed();
// 先随机一个动作类别
int ActionRatio = Stream.RandRange(0, 10);
// 目前还没有写好防御逻辑,所以先默认进入攻击状态
// 进入攻击状态
BlackboardComp->SetValueAsEnum("EnemyState", (uint8)EEnemyAIState::ES_Attack);
}
}
void ASlAiEnemyController::UpdateStatePama()
{
// 更新受伤害序列,受伤一次后 6 秒内不允许进入受伤状态
if (HurtTimeCount < 6.f) {
HurtTimeCount += 0.3f;
}
else {
HurtTimeCount = 0.f;
IsAllowHurt = true;
}
}
给敌人动画类添加一个播放受伤蒙太奇动画的方法。
SlAiEnemyAnim.h
public:
// 播放受伤动画返回动画时长
float PlayHurtAction();
protected:
// 受伤动画指针
UAnimMontage* AnimHurt;
SlAiEnemyAnim.cpp
USlAiEnemyAnim::USlAiEnemyAnim()
{
// 获取受伤动画资源
static ConstructorHelpers::FObjectFinder<UAnimMontage> StaticAnimHurt(TEXT("AnimMontage'/Game/Res/PolygonAdventure/Mannequin/Enemy/Animation/FightGroup/MonEnemy_Hurt.MonEnemy_Hurt'"));
AnimHurt = StaticAnimHurt.Object;
}
float USlAiEnemyAnim::PlayHurtAction()
{
if (!Montage_IsPlaying(AnimHurt)) Montage_Play(AnimHurt);
return AnimHurt->GetPlayLength();
}
给敌人一个接受伤害的方法;并且添加一个播放受伤动画的方法。
SlAiEnemyCharacter.h
public:
// 接受攻击,也可以重写 APawn 的 TakeDamage 函数,不过老师嫌麻烦
void AcceptDamage(int DamageVal);
// 播放受伤动画
float PlayHurtAction();
SlAiEnemyCharacter.cpp
void ASlAiEnemyCharacter::AcceptDamage(int DamageVal)
{
// 进行生命值更新
HP = FMath::Clamp<float>(HP - DamageVal, 0.f, 500.f);
HPBarWidget->ChangeHP(HP / 200.f);
// 如果生命值小于 0
if (HP == 0.f) {
}
else {
// 告诉控制器收到伤害
if (SEController) SEController->UpdateDamageRatio(HP / 200.f);
}
}
float ASlAiEnemyCharacter::PlayHurtAction()
{
// 如果动作蓝图不存在直接返回 0 秒
if (!SEAnim) return 0.f;
// 返回攻击时长
return SEAnim->PlayHurtAction();
}
void ASlAiEnemyCharacter::OnSeePlayer(APawn* PlayerChar)
{
if (Cast<ASlAiPlayerCharacter>(PlayerChar)) {
// 去掉这个 Debug 语句,免得一直输出
//SlAiHelper::Debug(FString("I See Player !"), 3.f);
if (SEController) SEController->OnSeePlayer();
}
}
来到物品基类,在触发碰撞检测的方法里加入判断,如果在挥动手中物品时击中了敌人,则调用它的受伤方法。
SlAiHandObject.cpp
// 引入头文件
#include "SlAiEnemyCharacter.h"
#include "SlAiDataHandle.h"
void ASlAiHandObject::OnOverlayBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
// 去掉之前的 Debug 语句
// 获取物品属性
if (Cast<ASlAiEnemyCharacter>(OtherActor)) {
TSharedPtr<ObjectAttribute> ObjectAttr = *SlAiDataHandle::Get()->ObjectAttrMap.Find(ObjectIndex);
// 获取对动物的伤害值
Cast<ASlAiEnemyCharacter>(OtherActor)->AcceptDamage(ObjectAttr->AnimalAttack);
}
}
void ASlAiHandObject::OnOverlayEnd(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex)
{
// 去掉之前的 Debug 语句
}
准备好了以后,正式来写敌人受伤 Task。不过并不复杂,就是要用计时器和一个绑定的方法来在受伤动画结束后告诉控制器受伤动画播放完毕了。
SlAiEnemyTaskHurt.h
protected:
// 动作结束后事件
void OnAnimationTimerDone();
protected:
UPROPERTY(EditAnywhere, Category = "Blackboard")
struct FBlackboardKeySelector WaitTime;
// 受伤动作结束后的委托
FTimerHandle TimerHandle;
private:
// 重写执行函数
virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
// 重写任务终止函数
virtual EBTNodeResult::Type AbortTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
SlAiEnemyTaskHurt.cpp
// 引入头文件
#include "SlAiEnemyController.h"
#include "SlAiEnemyCharacter.h"
#include "NavigationSystem.h"
#include "BehaviorTree/BehaviorTreeComponent.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "TimerManager.h"
EBTNodeResult::Type USlAiEnemyTaskHurt::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
// 如果初始化敌人参数不成功,直接返回失败
if (!InitEnemyElement(OwnerComp)) return EBTNodeResult::Failed;
// 播放受伤动画
float ActionDuration = SECharacter->PlayHurtAction();
// 设置等待时间
OwnerComp.GetBlackboardComponent()->SetValueAsFloat(WaitTime.SelectedKeyName, ActionDuration);
// 添加事件委托
FTimerDelegate TimerDelegate = FTimerDelegate::CreateUObject(this, &USlAiEnemyTaskHurt::OnAnimationTimerDone);
// 注册到事件管理器
SEController->GetWorld()->GetTimerManager().SetTimer(TimerHandle, TimerDelegate, ActionDuration, false);
return EBTNodeResult::Succeeded;
}
EBTNodeResult::Type USlAiEnemyTaskHurt::AbortTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
// 如果初始化敌人参数不成功或者事件句柄没有激活,直接返回
if (!InitEnemyElement(OwnerComp) || !TimerHandle.IsValid()) return EBTNodeResult::Aborted;
// 卸载时间委托
SEController->GetWorld()->GetTimerManager().ClearTimer(TimerHandle);
// 返回
return EBTNodeResult::Aborted;
}
void USlAiEnemyTaskHurt::OnAnimationTimerDone()
{
// 告诉控制器受伤完成
SEController->FinishStateHurt();
}
编译后打开行为树,修改如下:
往场景放一些可拾取物,至少有两个石头和一个木头,并且最好在 Standalone 模式运行游戏,否则有可能出现捡了东西没显示在快捷栏的情况。
运行游戏,捡起可拾取物,合成九宫格中间那一列,从上往下放石头、石头、木头,合成剑,放到快捷栏选中后操控角色去攻击敌人(其实直接用拳头也行,不过就是攻击距离短,看得不明显)。可以看到敌人的受击动画,此后要 6 秒后敌人再次受到攻击时才会进入受伤状态。敌人的血量降到最大生命值的 20% 以下时会定住,因为此时他已经进入了逃跑状态,但是我们目前还没有编写敌人的逃跑逻辑。
53. 逃跑与防御 AI
防御动画就不是蒙太奇动画了,它会被放在动画状态机里面。我们先在动画类里添加一个暴露给蓝图的 bool 值。
SlAiEnemyAnim.h
public:
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "EnemyAnim")
bool IsDefence;
SlAiEnemyAnim.cpp
USlAiEnemyAnim::USlAiEnemyAnim()
{
// 初始化参数
IsDefence = false;
}
编译后修改敌人的动画蓝图状态机如下:(状态节点里面的动画基本与节点名同名,就不贴出来了)
基于 SlAiEnemyTaskBase 为基类,创建以下 C++ 子类:
- SlAiEnemyTaskDefence :防御 Task。
- SlAiEnemyTaskEscapeSwitch :切换到逃跑逻辑的 Task。
- SlAiEnemyTaskLocaESC:定位逃跑目的地的 Task。
依旧先做下准备,到敌人类这里先把转向相关的逻辑去掉,因为老师觉得还是使用引擎提供的节点好些。
添加两个方法用于开始和停止防御,直接改变动画类里的 bool 值。
SlAiEnemyCharacter.h
public:
// 删掉更改朝向的方法
//void UpdateRotation(FRotator NewRotator);
// 实际上开启和防御的方法可以写成一个方法,这里就根据老师的代码来了
// 开启防御
void StartDefence();
// 停止防御
void StopDefence();
private:
// 删掉之前写的朝向设置
/*
FRotator NextRotation;
bool NeedRotate;
*/
把生命值调回 200,方便测试。并且让敌人在防御状态时不受伤害。
SlAiEnemyCharacter.cpp
void ASlAiEnemyCharacter::BeginPlay()
{
// 设置初始生命值
HP = 200.f;
HPBarWidget->ChangeHP(HP / 200.f);
}
void ASlAiEnemyCharacter::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
// 删掉插值更新朝向的逻辑
/*
if (NeedRotate) {
SetActorRotation(FMath::RInterpTo(GetActorRotation(), NextRotation, DeltaTime, 10.f));
// 如果已经接近,不再旋转
if (FMath::Abs(GetActorRotation().Yaw - NextRotation.Yaw) < 5) NeedRotate = false;
}
*/
}
// 删掉
/*
void ASlAiEnemyCharacter::UpdateRotation(FRotator NewRotator)
{
NextRotation = NewRotator;
NeedRotate = true;
}
*/
void ASlAiEnemyCharacter::AcceptDamage(int DamageVal)
{
// 如果开启了防御,直接返回
if (SEAnim && SEAnim->IsDefence) return;
HP = FMath::Clamp<float>(HP - DamageVal, 0.f, 500.f);
HPBarWidget->ChangeHP(HP / 200.f);
if (HP == 0.f) {
}
else {
if (SEController) SEController->UpdateDamageRatio(HP / 200.f);
}
}
void ASlAiEnemyCharacter::StartDefence()
{
if (SEAnim) SEAnim->IsDefence = true;
}
void ASlAiEnemyCharacter::StopDefence()
{
if (SEAnim) SEAnim->IsDefence = false;
}
在玩家类里面添加一个玩家是否在攻击的 bool 值,用于给敌人判断是否要进入防御状态。
SlAiPlayerCharacter.h
public:
// 是否在攻击
bool IsAttack;
SlAiPlayerCharacter.cpp
ASlAiPlayerCharacter::ASlAiPlayerCharacter()
{
// 初始化是否攻击
IsAttack = false;
}
void ASlAiPlayerCharacter::ChangeHandObjectDetect(bool IsOpen)
{
ASlAiHandObject* HandObjectClass = Cast<ASlAiHandObject>(HandObject->GetChildActor());
if (HandObjectClass) HandObjectClass->ChangeOverlayDetect(IsOpen);
// 修改攻击状态
IsAttack = IsOpen;
}
给敌人的 AI 控制器添加一个完成防御需要运行的相应逻辑的方法。
SlAiEnemyController.h
public:
// 完成防御状态
void FinishStateDefence();
顺便在之前缺少进入防御状态逻辑的地方补充上逻辑。
SlAiEnemyController.cpp
void ASlAiEnemyController::FinishStateHurt()
{
if (!IsLockPlayer) IsLockPlayer = true;
if (HPRatio < 0.2f) {
// 创建随机流
FRandomStream Stream;
// 产生新的随机种子
Stream.GenerateNewSeed();
// 先随机一个动作类别
int ActionRatio = Stream.RandRange(0, 10);
// 30 的几率触发防御
if (ActionRatio < 4) {
// 进入防御状态
BlackboardComp->SetValueAsEnum("EnemyState", (uint8)EEnemyAIState::ES_Defence);
}
else {
// 进入逃跑状态
BlackboardComp->SetValueAsEnum("EnemyState", (uint8)EEnemyAIState::ES_Escape);
}
}
else {
FRandomStream Stream;
Stream.GenerateNewSeed();
int ActionRatio = Stream.RandRange(0, 10);
// 30 的几率触发防御
if (ActionRatio < 4) {
// 进入防御状态
BlackboardComp->SetValueAsEnum("EnemyState", (uint8)EEnemyAIState::ES_Defence);
}
else {
// 进入攻击状态
BlackboardComp->SetValueAsEnum("EnemyState", (uint8)EEnemyAIState::ES_Attack);
}
}
}
void ASlAiEnemyController::FinishStateDefence()
{
// 获取与玩家的距离
float SEToSP = FVector::Distance(SECharacter->GetActorLocation(), GetPlayerLocation());
// 如果玩家停止攻击或者距离大于等于 200,停止防御状态
if (SPCharacter->IsAttack && SEToSP < 200.f) {
}
else {
// 设置状态完成
ResetProcess(true);
// 停止防御动作
SECharacter->StopDefence();
// 如果生命值小于 0.2,逃跑
if (HPRatio < 0.2f) {
BlackboardComp->SetValueAsEnum("EnemyState", (uint8)EEnemyAIState::ES_Escape);
}
else {
// 跳到攻击状态
BlackboardComp->SetValueAsEnum("EnemyState", (uint8)EEnemyAIState::ES_Attack);
}
}
}
防御 Task,也是改变下黑板值和用计时器告知控制器动作完成了。
SlAiEnemyTaskDefence.h
protected:
// 动作结束后事件
void OnAnimationTimerDone();
protected:
UPROPERTY(EditAnywhere, Category = "Blackboard")
struct FBlackboardKeySelector IsDefenceFinish;
UPROPERTY(EditAnywhere, Category = "Blackboard")
struct FBlackboardKeySelector PlayerPawn;
// 受伤动作结束后的委托
FTimerHandle TimerHandle;
private:
// 重写执行函数
virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
// 重写任务终止函数
virtual EBTNodeResult::Type AbortTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
SlAiEnemyTaskDefence.cpp
// 引入头文件
#include "SlAiEnemyController.h"
#include "SlAiEnemyCharacter.h"
#include "NavigationSystem.h"
#include "BehaviorTree/BehaviorTreeComponent.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "TimerManager.h"
EBTNodeResult::Type USlAiEnemyTaskDefence::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
// 如果初始化敌人参数不成功,直接返回失败
if (!InitEnemyElement(OwnerComp)) return EBTNodeResult::Failed;
// 播放防御动画
SECharacter->StartDefence();
// 设置没有结束状态
OwnerComp.GetBlackboardComponent()->SetValueAsBool(IsDefenceFinish.SelectedKeyName, false);
OwnerComp.GetBlackboardComponent()->SetValueAsObject(PlayerPawn.SelectedKeyName, SEController->GetPlayerPawn());
// 添加事件委托
FTimerDelegate TimerDelegate = FTimerDelegate::CreateUObject(this, &USlAiEnemyTaskDefence::OnAnimationTimerDone);
// 注册到事件管理器,循环检测是否可以进入其他状态
SEController->GetWorld()->GetTimerManager().SetTimer(TimerHandle, TimerDelegate, 2.f, true);
return EBTNodeResult::Succeeded;
}
EBTNodeResult::Type USlAiEnemyTaskDefence::AbortTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
// 如果初始化敌人参数不成功或者事件句柄没有激活,直接返回
if (!InitEnemyElement(OwnerComp) || !TimerHandle.IsValid()) return EBTNodeResult::Aborted;
// 卸载时间委托
SEController->GetWorld()->GetTimerManager().ClearTimer(TimerHandle);
// 返回
return EBTNodeResult::Aborted;
}
void USlAiEnemyTaskDefence::OnAnimationTimerDone()
{
if (SEController) SEController->FinishStateDefence();
}
逃跑状态切换 Task。
SlAiEnemyTaskEscapeSwitch.h
protected:
UPROPERTY(EditAnywhere, Category = "Blackboard")
struct FBlackboardKeySelector EnemyState;
private:
// 重写执行函数
virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
SlAiEnemyTaskEscapeSwitch.cpp
// 引入头文件
#include "SlAiEnemyController.h"
#include "SlAiEnemyCharacter.h"
#include "BehaviorTree/BehaviorTreeComponent.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "SlAiTypes.h"
EBTNodeResult::Type USlAiEnemyTaskEscapeSwitch::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
// 如果初始化敌人参数不成功,直接返回失败
if (!InitEnemyElement(OwnerComp)) return EBTNodeResult::Failed;
// 获取与玩家的距离
float EPDistance = FVector::Distance(SECharacter->GetActorLocation(), SEController->GetPlayerLocation());
// 如果大于 1500 了,回到巡逻状态,并且设置没有锁定玩家
if (EPDistance > 1500.f) {
// 告诉控制器丢失了玩家
SEController->LoosePlayer();
// 修改状态为巡逻
OwnerComp.GetBlackboardComponent()->SetValueAsEnum(EnemyState.SelectedKeyName, (uint8)EEnemyAIState::ES_Patrol);
}
return EBTNodeResult::Succeeded;
}
定位逃跑目的地 Task,在 2000 单位的范围内随机一个可到达的地点,并且目的地、玩家、敌人之间两两相连的交叉线夹角不小于 90°。
SlAiEnemyTaskLocaESC.h
protected:
UPROPERTY(EditAnywhere, Category = "Blackboard")
struct FBlackboardKeySelector Destination;
private:
// 重写执行函数
virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
SlAiEnemyTaskLocaESC.cpp
// 引入头文件
#include "SlAiEnemyController.h"
#include "SlAiEnemyCharacter.h"
#include "NavigationSystem.h"
#include "BehaviorTree/BehaviorTreeComponent.h"
#include "BehaviorTree/BlackboardComponent.h"
EBTNodeResult::Type USlAiEnemyTaskLocaESC::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
// 如果初始化敌人参数不成功,直接返回失败
if (!InitEnemyElement(OwnerComp)) return EBTNodeResult::Failed;
// 范围是 2000 以内
const float ChaseRadius = 2000.f;
//获取敌人到玩家之间的向量
FVector SPToSE = SECharacter->GetActorLocation() - SEController->GetPlayerLocation();
// 获取起点敌人位置
const FVector ChaseOrigin = SECharacter->GetActorLocation();
// 保存随机的位置
FVector DesLoc(0.f);
// 使用导航系统获取随机点
UNavigationSystemV1::K2_GetRandomReachablePointInRadius(SEController, ChaseOrigin, DesLoc, ChaseRadius);
// 如果获得的位置到敌人的向量和玩家到敌人的向量的夹角大于 90 度,重新定义方向
while (FMath::RadiansToDegrees(FMath::Acos(FVector::DotProduct(SPToSE, DesLoc - SECharacter->GetActorLocation()))) > 90.f) {
// 使用导航系统重新获取随机点
UNavigationSystemV1::K2_GetRandomReachablePointInRadius(SEController, ChaseOrigin, DesLoc, ChaseRadius);
}
// 修改速度为 300
SECharacter->SetMaxSpeed(300.f);
// 修改目的地
OwnerComp.GetBlackboardComponent()->SetValueAsVector(Destination.SelectedKeyName, DesLoc);
// 返回成功
return EBTNodeResult::Succeeded;
}
删掉旋转任务的相关逻辑(其实整个类 .h 和 .cpp 都可以不要了)
SlAiEnemyTaskRotate.cpp
EBTNodeResult::Type USlAiEnemyTaskRotate::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
if (!InitEnemyElement(OwnerComp)) return EBTNodeResult::Failed;
// 删掉这段旋转相关的逻辑
/*
FVector SEToSP = FVector(SEController->GetPlayerLocation().X, SEController->GetPlayerLocation().Y, 0.f) - (SECharacter->GetActorLocation().X, SECharacter->GetActorLocation().Y, 0.f);
SEToSP.Normalize();
SECharacter->UpdateRotation(FRotationMatrix::MakeFromX(SEToSP).Rotator());
*/
return EBTNodeResult::Succeeded;
}
因为可以用朝向指定目标的 Service,普通攻击 Task 的玩家黑板值也不用声明了。
SlAiEnemyTaskAttackNormal.h
protected:
// 删掉 Task 中对玩家的黑板值
/*
UPROPERTY(EditAnywhere, Category = "Blackboard")
struct FBlackboardKeySelector PlayerPawn;
*/
SlAiEnemyTaskAttackNormal.cpp
EBTNodeResult::Type USlAiEnemyTaskAttackNormal::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
// 删掉
//OwnerComp.GetBlackboardComponent()->SetValueAsObject(PlayerPawn.SelectedKeyName, SEController->GetPlayerPawn());
}
给攻击切换 Task 添加一个 PlayerPawn 的黑板值,用于给朝向指定目标的 Service 指明对象为玩家。
SlAiEnemyTaskAttackSwitch.h
protected:
// 添加对玩家的黑板值
UPROPERTY(EditAnywhere, Category = "Blackboard")
struct FBlackboardKeySelector PlayerPawn;
SlAiEnemyTaskAttackSwitch.cpp
EBTNodeResult::Type USlAiEnemyTaskAttackSwitch::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
if (!InitEnemyElement(OwnerComp)) return EBTNodeResult::Failed;
// 给玩家指针赋值
OwnerComp.GetBlackboardComponent()->SetValueAsObject(PlayerPawn.SelectedKeyName, SEController->GetPlayerPawn());
}
编译代码,打开项目,修改敌人行为树如图:(右下角被水印挡住了,等待时间为 2 秒)
保存后运行游戏,攻击敌人,他有概率会防御;并且敌人向我们攻击的时候朝向也正常;当他生命值百分比降到 20% 以下时他会逃跑;敌人的死亡逻辑要到下节课再写。
并且偶尔情况下可以注意到一个问题:攻击动画四没有播放完就跳到其他动作去了。老师分析说可能游戏的帧率算出来的时间跟我们获取的动画时长是不一样的,所以解决的办法就是在攻击动画四的蒙太奇里面添加一个 Notify 来通知动画结束了,但是老师留给我们来实现。