在这一篇里,我们要实现一下敌人的AI,敌人也需要一系列的行为,比如朝向英雄攻击,移动,在满足条件时施放技能。这些敌人的行为可以通过使用UE的内置的AI系统去实现。
在UE里,只要是基于Character类创建的蓝图,都能够找到相应的AI配置项
可以自动控制角色的基类为AIController,配合AIController的还有两个类,分别是行为树(Behavior Tree)用于存储角色的行为逻辑以及用于存储AI使用的数据的黑板(Blackboard Data)。
接下来,我们将一步步创建适合RPG项目的AI系统。
创建AIController
首先基于AIController父类创建一个子类
命名为RPGAIController
在代码里使用AI系统,我们还需要在Build.cs里面添加AI模块
在RPGAIController类里面,我们需要初始化黑板和行为树控件,用于后续控制它们的实例
在AIController类里面,已经定义了黑板的控件
我们在RPGAIController里面再申明行为树控件
protected:
UPROPERTY()
TObjectPtr<UBehaviorTreeComponent> BehaviorTreeComponent;
记得设置它的构造函数
public:
ARPGAIController();
我们在构造函数里进行初始化这两个控件,为了保证不出错,我们需要进行检查,如果出错会抛出错误。
ARPGAIController::ARPGAIController()
{
Blackboard = CreateDefaultSubobject<UBlackboardComponent>("BlackboardComponent");
check(Blackboard);
BehaviorTreeComponent = CreateDefaultSubobject<UBehaviorTreeComponent>("BehaviorTreeComponent");
check(BehaviorTreeComponent);
}
在敌人基类实现应用AIController
我们创建了一个自定义的AIController,我们需要应用到角色身上,才能够起作用,首先,我们在代码里增加角色对AIController的处理逻辑。
在RPGAIController里面,我们创建了黑板和行为树的控件,但是没有数据,我们在角色类上面增加一个参数,用于在蓝图中设置使用的行为树,并增加一个参数用于保存转换为RPGAIController实例后的参数。
所以在EnemyBase类里,增加以下参数
UPROPERTY(EditAnywhere, Category="AI")
TObjectPtr<UBehaviorTree> BehaviorTree;
UPROPERTY()
TObjectPtr<ARPGAIController> RPGAIController;
然后我们要覆写PossessedBy函数,这个函数之前我们在服务器初始化ASC时用过,它将在Pawn被控制器(PlayerController和AIController都行)控制时触发回调
virtual void PossessedBy(AController* NewController) override;
我们在回调里面,首先将控制器转换为RPGAIController并存储起来,然后从我们设置的角色使用的行为树里获取行为树使用的黑板数据初始化,然后调用运行行为树。
这些操作都需要在服务器上面运行,所有的客户端都复制服务器的结果来保证所有的行为统一。
void AEnemyBase::PossessedBy(AController* NewController)
{
Super::PossessedBy(NewController);
//当前设置只在服务器端运行
if(!HasAuthority()) return;
//AIController是在服务器端执行的,所以需要在PossessedBy函数回调中获取服务器返回
RPGAIController = Cast<ARPGAIController>(NewController);
//初始化行为树上设置的黑板
RPGAIController->GetBlackboardComponent()->InitializeBlackboard(*BehaviorTree->BlackboardAsset);
//运行行为树
RPGAIController->RunBehaviorTree(BehaviorTree);
}
接下来我们编译代码打开UE,创建RPGAIController的蓝图类
顺便也将后面使用黑板和行为树创建
黑板推荐以BB_为前缀,行为树推荐以BT_为前缀
打开敌人的蓝图基类,将我们创建的BP_RPGAIController设置上去
设置行为树
打开行为树,在细节这里设置使用的黑板
在行为树上设置简单播放一个哥布林的动画,查看应用效果是否实现。
如果能够正常播放动画,证明实现了对应的效果。
实现行为树服务
对AI行为树不大了解的小伙伴可以查看一下之前我写Unreal Engine 5.1 AI行为树基础入门
我们打开行为树,首先在下面增加一个Selector节点。
elector节点主要用于在多个子树之间进行选择。它从左到右执行其下的子项(即子节点),当其中一个子项执行成功时,整个Selector节点即认为成功,并终止后续的子项执行。如果所有的子项都执行失败,那么Selector节点也失败。
我们可以在运行状态下查看,它在子项运行成功时直接返回,不再判断后续的子项,而是直接返回到Selector,Selector也作为成功返回。由于我们没有设置其它节点,行为树将重新运行,接着再运行第一项子项
我们右键Selector还可以看到给节点附加装饰器和服务的内容,它们的含义分别是:
- 装饰器节点用于控制是否应该执行一个节点。它们可以用来检查某些条件,如是否看到了玩家,或者是否在特定距离内。这些节点可以修改节点的行为,比如限制运行次数或添加延迟。在行为树中,它们通常被连接到任务节点或服务节点,用于在这些节点执行之前或之后添加额外的逻辑或条件判断。
- 服务节点不执行实际的行为,而是定期运行,用于设置和维护黑板(Blackboard)上的数据。黑板是一个用于存储AI状态信息的区域,可以被行为树中的其他节点访问和修改。服务节点通常连接到合成节点或任务节点,只要连接的节点被执行,它们就会以定义的频率执行。
为了能够实现对敌人目标的获取,我们下面实现一个自定义的服务类。
这里我们创建一个新的类,基于BTService_BluePrintBase,这样,我们创建的类,也可以通过蓝图创建使用
我们将其命名为BTService_FindNearestPlayer,用于查找最近的玩家,在后续实现对其的攻击。
打开代码,我们先看一下父类上能够获取的内容,我们发现可以直接从父类获取AIController和AIController控制的Actor
在代码里,我们首先覆写父类的函数,TickNode,这个函数会在一定的时间后重复执行,用于更新数据使用。
protected:
virtual void TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;
首先在代码里测试是能够获取到那两个参数的值
void UBTService_FindNearestPlayer::TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
Super::TickNode(OwnerComp, NodeMemory, DeltaSeconds);
GEngine->AddOnScreenDebugMessage(1, 1.f, FColor::Red, *AIOwner->GetName());
GEngine->AddOnScreenDebugMessage(2, 1.f, FColor::Green, *ActorOwner->GetName());
}
接着编译打开UE,创建一个基于自定义服务类的蓝图
打开蓝图,左侧我们可以覆写函数,不覆写无法触发对应的函数
类默认值这里,我们可以设置节点名称,方便在行为树里使用,服务这里为触发Tick事件的时间间隔,可以作为优化的性能点。
打开行为树,右键可以发现我们创建的两个类,一个是c++类另一个是蓝图类
选择蓝图类,会发现它会附着到节点上,并显示我们设置的节点名称
你现在直接运行会发现没有对应的打印,所以我们需要在服务里覆写Tick,来实现行为树对Tick的调用,你会发现接收Tick有两种,一个是普通版,还有一个AI专用的,这里我们覆写AI的
不需要执行任何操作,直接编译
运行这样会发现左上角的打印,我们发现两个打印的都是AIController,这代表它的AIController所有者和Actor所有者都是AIController
接着我们在蓝图中打印可以获取的数据
可以获取对应的AIController 控制的Pawn 以及更新时间间隔
在黑板添加属性
到现在了,我们还没使用黑板。黑板是一个比喻,相当于以前上学时候的黑板,而每个同学相当于一个行为树的节点,它们可以实现从黑板获取数据,也可以修改数据,黑板是节点和节点之间交互数据的一个渠道。
设置黑板属性你需要先设置它的类型
然后再选择它的基类
如果将实例已同步选中,那么,所有角色从黑板当前值获取的结果都是一样的。
我们在原有的基础上增加两个属性一个是需要攻击的目标,另一个是和目标的距离
我们在黑板上设置了属性以后,如何对其填充数据呢,在服务蓝图中,我们创建变量类型需要设置为黑板键选择器
并且要将右边的眼睛打开
回到行为树中,你可以选择当前属性需要绑定的黑板属性
在服务蓝图中,获取属性的值,你需要通过函数Get来获取对应的类型的值
如果你需要设置,你需要调用对应的Set方法
比如下面就是将控制的Pawn作为SelfActor设置
实现获取需要跟随的玩家
上面我们在蓝图中实现了对黑板属性的获取以及设置,这个我们也可以在C++中设置。但是获取到需要跟随的角色如果在蓝图中实现性能消耗比在c++中实现消耗大,所以,我们将在c++中实现对角色的获取。
打开服务类,我们创建两个参数用于记录需要跟随的玩家以及距离
UPROPERTY(BlueprintReadOnly, EditAnywhere)
FBlackboardKeySelector TargetToFollowSelector;
UPROPERTY(BlueprintReadOnly, EditAnywhere)
FBlackboardKeySelector DistanceToTargetSelector;
在Tick中,我们首先获取到AIController控制的Pawn
APawn* OwningPawn = AIOwner->GetPawn();
然后根据角色设置设置的标签来获取角色敌人是谁,这个标签不是GameplayTag而是FName类型的
const FName TargetTag = OwningPawn->ActorHasTag(FName("Player")) ? FName("Enemy") : FName("Player");
这个标签的设置你需要在蓝图中,找到Actor的配置项,填写即可,可以填写多个进行分类,这有助于提升性能。分别设置好英雄和敌人的标签。
这样,我们就可以去获取目标标签在场景的所有实例
TArray<AActor*> ActorsWithTag;
UGameplayStatics::GetAllActorsWithTag(OwningPawn, TargetTag, ActorsWithTag);
接下来我们就要遍历获取的实力,比对它们和OwningPawn之间的距离,来获取最短距离的目标。这里我们首先将默认距离设置为浮点数最大值,然后遍历获取每个和OwningPawn之间的距离和距离变量比大小,如果两个角色之间的距离比变量的距离小,证明又发现了一个距离更近的角色,将距离和角色都存储下来。遍历完成后,我们获取到的距离和角色将是当前最小的那个
//遍历所有的角色获取到最近的角色和距离
float ClosestDistance = TNumericLimits<float>::Max(); //默认设置float最大值
AActor* ClosestActor = nullptr;
for(AActor* Actor : ActorsWithTag)
{
const float Distance = OwningPawn->GetDistanceTo(Actor);
if(Distance < ClosestDistance)
{
ClosestDistance = Distance;
ClosestActor = Actor;
}
}
然后我们调用内置方法去设置黑板属性,它们封装在UBTFunctionLibrary内
//设置黑板数据
UBTFunctionLibrary::SetBlackboardValueAsObject(this, TargetToFollowSelector, ClosestActor);
UBTFunctionLibrary::SetBlackboardValueAsFloat(this, DistanceToTargetSelector, ClosestDistance);
实现跟随角色
我们编译代码打开UE,在行为树中,绑定对应的属性
运行你会发现它们被正确赋值
然后在下面增加一个MoveTo节点
右侧修改黑板键位TargetToFollow
运行起来你会发现都会追你