斯坦福课程 UE4 C++ ActionRoguelike游戏实例教程 01.基础AI与行为树
斯坦福课程 UE4 C++ ActionRoguelike游戏实例教程 0.绪论
前言&摘要
本篇文章是基于斯坦福UE4 C++课程的学习记录。因为B站用户surkea由于学业原因,暂停了课程笔记的更新,这里狗尾续貂,将续写surkea同学的课程学习记录,以记录自己学习UE4的过程,并给后来者一些参考。既然都学到基础AI了,那么默认读者已经具备UE引擎的初步使用经验,一些比较简单的步骤不会再赘述。
本篇文章对应Lecture 10 - Basic AI & Behavior Trees 的 38节-40节,讲解了UE中AI的基础使用。
成果如下图所示,本篇文章将会教你获得一个紧紧跟着你的AI小弟,可以在距离你一定距离的地方停下,并且可以自动绕过障碍物。
在课程的开头,作者简要阐述了一下行为树的概念,建议读者先去了解一下行为树的概念。关于UE中的行为树,我会在后面的内容加以补充。
目录
- 创建敌对小兵
- 创建行为树
- 为行为树添加更复杂的逻辑
- 更加聪明的AI
1.创建敌对小兵
首先是创建敌对AI小兵的Character, 我们将其命名为SurAiCharacter。由于我之前已经创建过了,这里会提示已被其他类使用,不碍事。
这里有一个值得说明的点。正常在UE编辑器里,我们是无法新建C++类的文件夹的。
这样是不利于我们对项目的文件夹进行规划的。如果要想要新建文件夹,在创建新C++类的时候,需要在路径后面手动添加 /AI
路径,这样在生成的时候会自动创建我们所需要的文件夹。
同样的道理,我们还需创建一个AIController,我们将其命名为SurAIController。在查找AIController时,需要勾选右上角的显示所有类,然后进行查找。
AIController类似于AI角色的大脑,可以允许我们在其中自定义要读取的行为树以及其他一系列控制的逻辑,最后将其添加到AI角色上即可发挥作用。
接着我们创建这两个C++类的蓝图子类。我将其命名为BP_SurAICharacter和BP_SurAIController。并为SurAICharacter的蓝图类设置Minion_Lane_Ranged_Dawn网格体和MinionRanged_AnimBP动画,最后将其拖入世界场景中。
由于Epic商店下载的小兵模型没有包含动画蓝图,因此可以从作者的Git上下载文件,并可参考原路径将其放本地路径的 内容/ParagonMinions/Characters/Minions/Down_Minions/Meshes 目录下。这个蓝图已经修改好了,不需要进行如课程中的debug操作。
然后我们将小兵的AI控制器设置为我们自己的AIController。具体位置在小兵蓝图的细节面板里。
随后,从“放置Actor”中向世界添加导航网格体边界体积(NavMeshBoundsVolume),绿色的区域就是导航网格的区域,将其放大到跟地板差不多的大小。
有时,将导航网格体边界体积拖出来的时候是没有显示绿色区域的,此时只需要按下快捷键P
,或者在显示栏里选择导航即可。如图所示:
在本节中,它的作用是计算碰撞体积,阻止特定Actor进入体积,即计算哪些地方是Actor能走的、哪些地方是Actor不能走的。
2.创建行为树
在UE编辑器里右键,选择人工智能(AI), 创建一个行为树和黑板。我们将行为树命名为BT_MinionRange,将黑板命名为BB_MinionRange.
行为树是多个逻辑节点所组成的集合,定义了一个AI应该如何进行决策;黑板则相当于行为树的存储器,可以保存行为树所需要用到的参数和变量,并且这些变量可以在运行的过程中动态的变化,为行为树所用。具体怎么用,我们边做边说。
在黑板里新建一个向量变量,起名为MoveToLocation,用于设置移动目的地的具体目标。
进入行为树,将行为树的黑板资产设定为刚才创建的BB_MinionRange。并为行为树添加节点,只需点击节点下方的黑色区域,下拉即可新建节点,如图所示:
图中根节点往下的是复合节点(Composite),这里所用到的是复合节点中的顺序(Sequence)节点。正如树的特性一样,每一个节点都可以向上层节点返回信息。他的作用是按顺序运行每个子节点,在任何一个子节点失败的点返回失败,如果每个子节点返回成功状态,则返回成功。
图中最下层为叶节点(Leaf),在UE中又称为任务节点,颜色为紫色。叶节点是最强大的节点类型,因为它用于行为的定义和实现,以进行特定的功能测试或者使行为树实际起到有用动作的作用。
按照上述说法,行为树从根节点进入,按照从左到右顺序(我理解为先序遍历)经过Sequence节点,先执行MoveTo任务,直到到达目的地,返回成功。紧接着执行Wait任务,等待五秒后返回成功,执行结束,返回到根节点重新执行。这就是一个行为树运行的过程。
如果目标点在导航网格之外,Move To任务将无法成功执行,返回失败,此时不再执行Wait任务,如果Sequence节点没有后续节点,返回到Root节点重新开始。
至此,一个简单的行为树就创建完成了,他能够移动到指定的坐标,并且到达坐标后会等待五秒钟。
既然创建好了行为树,我们就要去使用它。前面在创建AI小兵的时候提到了将AI控制器设定成我们自定义的BP_AIcontroller,而使用行为树就是在AIController里进行的。修改我们的SurAicontroller代码,添加一个UBehaviorTree类型的指针,并将其暴露在蓝图中。再Beginplay函数里调用RunBehaviorTree(),这样才能运行我们的行为树 :
//SurAicontroller.h
class FPSPROJECT_API ASurAIController : public AAIController
{
GENERATED_BODY()
protected:
UPROPERTY(EditDefaultsOnly)
UBehaviorTree* BehaviorTree;
virtual void BeginPlay() override;
};
//SurAiController.CPP
void ASurAIController::BeginPlay()
{
Super::BeginPlay();
if(ensure(BehaviorTree))
{
RunBehaviorTree(BehaviorTree);
}
}
别忘了在BP_SueAicontroller里设置行为树。
运行游戏,可以看到行为树的模拟,此时发现一直在闪动,说明行为树是一直在运行的。之所以一直闪动,是因为没有设置MoveToLocation变量,默认为世界原点坐标,而原点坐标不在我的导航网格内,因此他会不断的执行失败。
设置目标坐标的方法我就不赘述了,这里我想让AI小兵跟着主角走。具体的做法是在黑板里新建一个TargetActor关键帧,类型为Object。在细节面板里将基类设置为Actor,因为我想使其可以跟随所有Actor,而非只有玩家角色。
将行为树Move To的目标修改为TargetActor。
此时黑板的TargetActor还没有赋值,我们可以在C++代码里对黑板里的关键帧进行赋值。具体代码如下所示,这里我演示了两种赋值的类型,想了解更多赋值的函数可以翻看官方的API文档。
值得一提的是,这里使用了一种获取玩家Pawn的办法,就是调用UGameplayStatics::GetPlayerPawn
函数。查阅资料可知,第一个参数可以传入这个关卡的任意对象实例,这里直接传入this指针。第二个参数则是玩家控制器的序号,传入0即默认玩家的序号。
void ASurAIController::BeginPlay()
{
Super::BeginPlay();
if(ensure(BehaviorTree))
{
RunBehaviorTree(BehaviorTree);
}
//GetPlayerPawn可以是这个关卡的任意对象,这里传入this就行
APawn* MyPawn = UGameplayStatics::GetPlayerPawn(this, 0);
if(MyPawn)
{
GetBlackboardComponent()->SetValueAsVector("MoveToLocation", MyPawn->GetActorLocation());
GetBlackboardComponent()->SetValueAsObject("TargetActor", MyPawn);
}
}
至此,一个简单的跟随AI就做好了。保存并编译,然后启动游戏,即可看到效果。
最后,为了提高可读性,给行为树的每个节点改个名字:
3.为行为树添加更复杂的逻辑
创建BtService对象
显然,在BeginPlay函数里修改一些初始化参数是远远无法满足复杂逻辑的要求的。我们想要在行为树的运行过程中,实时的更新黑板里的数据。对此,在课程中使用了BtService组件。
BtService 是指行为树服务(Behavior Tree Service),是用于管理和控制行为树的一个重要组件。
BtService 可以看做是行为树的节点,它的主要作用是在行为树执行的过程中,提供一些常规的功能服务,如获取黑板数据、更新黑板数据、执行定时器等。BtService 通常会作为一个行为树节点的子节点存在,它会在每次行为树更新时被调用,执行相应的任务,然后将结果保存到黑板中供其他行为树节点使用。
在这里,我们要实现的逻辑是,当AI小兵进入人物的攻击范围后,就停止移动。因此我们需要定义攻击范围,以及实时获取小兵和玩家人物的距离。
让我们新建一个BtService类:
在创建完CPP文件并试图编译的时候,编译器会报错,提示unresolved external symbol
报错的原因是模块缺失。这不得不让我们开始正视UE中的模块系统。在 UE4 中,有许多内置的模块可以供开发者使用,例如:Core、Engine、InputCore、Networking 等。同时,开发者也可以自己创建和管理自定义的模块,以实现更加复杂的游戏逻辑或服务。
我们平时所用到的模块,平时都会放在Source/Runtime的文件夹下:
编译报错显示缺少IGamePlayTaskOwnerInterface,那我们搜索整个项目,注意把前缀I去掉:
注意到这个类是在Runtime\GameplayTasks文件夹下,那么缺失的模块名就叫GameplayTasks。
现在要把这个模块添加到我们的项目中,打开我们源代码中的项目名.build.cs文件,添加我们所需要的模块。出于项目的规范统一,这里额外添加了AIModule,实际上不添加也是可以的,因为UE在.uproject已经为我们自动添加了:
修改完后,重新编译项目,项目顺利运行。
编写相关逻辑
进入到SBTService_CheckAttackRange文件,重写TickNode函数,按照如下修改代码:
//SBTService_CheckAttackRange.h
class FPSPROJECT_API USBTService_CheckAttackRange : public UBTService
{
GENERATED_BODY()
protected:
//可以动态的修改绑定的黑板键
UPROPERTY(EditAnywhere, Category = "AI")
FBlackboardKeySelector AttackRangeKey;
virtual void TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;
};
//SBTService_CheckAttackRange.cpp
void USBTService_CheckAttackRange::TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
Super::TickNode(OwnerComp, NodeMemory, DeltaSeconds);
UBlackboardComponent* BlackBoardComp = OwnerComp.GetBlackboardComponent();
if(ensure(BlackBoardComp))
{
AActor* TargetActor = Cast<AActor>(BlackBoardComp->GetValueAsObject("TargetActor"));
if(TargetActor)
{
AAIController* MyController = OwnerComp.GetAIOwner();
if(ensure(MyController))
{
APawn* AIPawn = MyController->GetPawn();
if(ensure(AIPawn))
{
float DistanceTo = FVector::Distance(TargetActor->GetActorLocation(), AIPawn->GetActorLocation());
//1000是我们自己设置的攻击范围,可以随意修改
bool bWithinRange = DistanceTo < 1000.f;
BlackBoardComp->SetValueAsBool(AttackRangeKey.SelectedKeyName, bWithinRange);
}
}
}
}
}
代码的逻辑很简单,值得一提的是重写的TickNode函数:当BtService对象被挂载到行为树的节点上时,当节点被激活时,它也会被激活。根据成员变量Interval每隔一段时间就执行一次TickNode函数,而我们修改黑板的逻辑就写在TickNode函数里。因为AI并不需要每一游戏帧都更新一次数据,因此调节Interval时间可以有效地降低资源的占用。
另外,这里还定义了FBlackboardKeySelector,并不使用硬编码来修改黑板,是一个小细节。
修改行为树
为了应用刚在代码里定义的AttackRangeKey,这里再黑板添加一个Bool类型的WithinAttackRange:
然后仿照添加Sequence节点,在行为树里添加一个Selector节点。这个节点的作用是按顺序运行每个子节点,直到有一个节点成功,则返回成功。
右键新添加的Selector节点,将刚才写的BTService挂到Selector节点上。UE这里很智能地将名字识别成了Check Attack Range, 推测为根据类名下划线后的英文来命名。
当Selector节点被激活时,会根据间隔事件定时执行TickNode函数。
别忘了指定AttackRangeKey对应哪个黑板键:
在Sequence节点右键,添加一个BlackBoard装饰器。这个装饰器的作用是检查黑板里的键,根据键值的情况判断是否要执行所装饰的节点。
装饰器的细节面板如下,观察器中止设为self是为了在条件改变的时候可以马上不执行下面的节点;黑板键设置为WithInAttackRange,因为我们想要AI在攻击范围内将其设置为true。将键查询设置为未设置的意思是当WithInAttackRange为false的时候可以执行装饰器所装饰的节点。事实上,对于不同的黑板键,这里都有不一样的设置,如果是常规数据类型比如浮点数,他这里可以判断是否相等;如果是Object类型,键查询的意思就是判断是否为空指针NULL。这里是bool类型,简单的理解成true和false就可以。
最后将行为树排列成这个样子:
这里总的描述一下整个执行流程:
- 从Root开始,进入Selector,激活CheckAttackRange,每隔一段时间执行TickNode。紧接着尝试执行下面第一个Sequence节点。
- 若AI角色在攻击范围之外,WithinAttackRange是false,因此会执行Sequence节点下面的MoveToPlayer节点。
- 当AI角色进入范围,装饰器发现WithinAttackRange变为true(由TickNode修改),立刻停止执行Sequence相关的节点,返回false。
- 由于Selector的特性,他会继续执行后面的节点也就是Wait。
- Wait等待一秒,执行完毕后,返回Root节点,重复上述流程。
4.更加聪明的AI
现在这个AI还有一个小问题,就是他检测玩家的距离是直线距离,也就是说,即使隔着一堵墙壁,他也只会傻傻的站在墙对面,却看不见玩家,更别谈攻击了。现在我们在代码里添加一段检测玩家的逻辑:
if(ensure(AIPawn))
{
float DistanceTo = FVector::Distance(TargetActor->GetActorLocation(), AIPawn->GetActorLocation());
bool bWithinRange = DistanceTo < 1000.f;
bool bHasLineSite = false;
//不想在超过范围外的地方也进行射线检测
if(bWithinRange)
{
//要求阻挡的墙体是ECC_Visiable类型的,详情可以看源码
bHasLineSite = MyController->LineOfSightTo(TargetActor);
}
BlackBoardComp->SetValueAsBool(AttackRangeKey.SelectedKeyName, bWithinRange && bHasLineSite);;
}
值得一提的是,LineOfSightTo函数的原理是射线检测,他要求阻挡的对象必须是ECC_Visiable类型的。如果我们把墙壁设置成其他类型,射线检测会直接穿墙而过。
最后为了更好的效果,将装饰器的观察器中止改为Both。
参考链接
https://www.bilibili.com/read/cv20073924?spm_id_from=333.999.0.0
https://blog.csdn.net/weixin_42313598/article/details/114541464