斯坦福 UE4 C++ ActionRoguelike游戏实例教程 06.敲定AI——游戏框架拓展和细节优化

发布时间 2023-03-22 21:13:05作者: 仇白

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

概述

这篇文章对应课程13课, 50~54节。虽然标题是敲定AI,实际内容和AI关联并不大,主要工作是对游戏内各种细节做优化,涉及到的新知识并不多。本篇文章便出于记录的目的,对课程里进行的各种优化做下简单讲解。具体进行了哪些优化,让我们边做边说。

目录

  1. 设置死亡布娃娃效果
  2. AI被攻击时切换目标
  3. 使用静态成员函数优化代码架构
  4. 优化开火逻辑(随机散射、碰撞检测)

死亡布娃娃效果

布娃娃效果是什么?不知道读者们有没有见过断了线的木偶,四肢瘫软地趴在地上。顾名思义,就是使角色像布娃娃一样瘫软,常用在游戏角色死亡后的场景里,就像一个人被抽去了全部力气,只能受重力或者其他外力摆布。

在UE里,我们只需要对角色的骨架网格组件开启物理模拟,并取消控制器类对角色的控制权即可,让这个世界的物理法则作用于骨架,并抽去他的所有力量。

我们只需要在角色死亡的时候开启布娃娃效果,上篇文章对于角色死亡,我仅使其马上Destory。既然要开启布娃娃效果,那么需要延长它的生命好让我们观察它一会儿。具体的实现代码如下:

void ASurAiCharacter::OnHealthChanged(AActor* InstigatorActor, USurAttributeComponent* OwningComp, float NewHealth,
   float Delta)
{
   if(NewHealth <= 0.f && Delta < 0)
   {
      AAIController* AIC = Cast<AAIController>(GetController());
      if(AIC)
      {
         AIC->GetBrainComponent()->StopLogic("Killed");
      }
      
      //GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Yellow, TEXT("Killed an AI"));
      GetMesh()->SetAllBodiesSimulatePhysics(true);
      GetMesh()->SetCollisionEnabled(ECollisionEnabled::PhysicsOnly);

      SetLifeSpan(10.f);
   }
}

让我解释一下这段代码发生了什么:

  1. 当生命值变化时,如果当前生命值小于0,则执行后面的逻辑
  2. 获取AI控制器,停止他的行为树。没错,获取行为树的方法是调用GetBrainComponent()函数,通常Brain组件指的是AI的决策部分,这里当然就是行为树了。停止逻辑时可以传入一段字符串,作为调试的信息。
  3. 获取AI角色的骨骼网格体。由于骨骼网格体在Character类中是私有成员,因此这里只能使用函数的方式获取。然后设置骨骼网格体的全部部位都为物理模拟。
  4. 设置为物理模拟还不够,还需要设置骨骼网格体的碰撞类型为PhysicsOnly或者QueryAndPhysics,对应编辑器中的仅物理已启用碰撞,这里设置成了仅物理,因为我不希望后续让他参与到碰撞查询中。
  5. 最后使用SetLifeSpan设置AI角色剩下的生命长度。这个函数原理很简单,就是设置了一个Destory的定时器,没有什么特别的。

对于第三步和第四步,我和课程中的做法有一点点出入,因为在测试的时候发生了许多有趣的事情。我在这里详细的说一说我的发现:

教程里一开始使用了这条语句试图做到布娃娃系统

GetMesh()->SetAllBodiesSimulatePhysics(true);

代码执行表现为人物确实像布娃娃一样瘫软了,但是却穿过脚下的地板直直坠落了下去。暂停查看发现,人物的胶囊体还在地上,网格体却掉了下去(直接从地板穿过去了,然后无限地坠落)。

小兵死后,尸体滑了下去

查询官方文档发现,这个函数的作用是为将骨骼网格体的所有部位都设置成了物理模拟,却不修改网格体组件的物理模拟标识,这是什么意思?意思是网格体仍没有启用物理模拟,但是物理模拟却实实在在的作用在了网格体的每一个部位上。

为什么骨骼体会掉下去呢?

这里必须提一点,网格体的默认碰撞设置是“仅查询”,意思是网格体组件不会与其他物体产生物理效应(碰撞阻挡之类的),在这种状态下是不允许物理模拟的(可能是编辑器害怕出现什么BUG,或者是这种设置没有任何意义),而这个函数绕开了这一点(因为它不会修改物理模拟的标识),让部位能够进行物理模拟。又由于网格体是碰撞设置是“仅查询”,它不会碰到这世上的所有东西,所以会直接掉下去。只要将网格体的碰撞设置设置为“仅物理”或者“启用碰撞”,就不会穿墙了。因此课程在这里,新加了一条代码:

GetMesh()->SetCollisionProfileName("Ragdoll");

他直接修改了骨骼网格体的碰撞预设为Ragdoll,在这个碰撞预设中,碰撞设置是已启用碰撞,即允许物理和查询,因此骨骼体可以与场景中的物体产生碰撞和一些物理效应,就不会穿过去了。

新的问题出现了,如果在编辑器中直接将网格体预设设置为Ragdoll, AI小兵在刚开始运行的时候就会直接变成布娃娃。按逻辑来说,要让小兵死后变成布娃娃只需要SetCollisionProfileName即可,不需要开启所有部位的物理模拟,然而在实测中,我执行了>SetCollisionProfileName("Ragdoll"),但是角色依然屹立不倒。这点让我百思不得其解,也许是UE编辑器中的设置与代码里的设置有些出入吧,最后我作出了妥协。

我在查询了SetCollisionProfileName函数发现,函数本身是建议在构造函数中使用的,在其他地方使用不保证能产生你想要的效果。好吧,既然我们想要的只是Ragdoll预设中的已启用碰撞,那我改碰撞设置就是了。最后我改成了SetCollisionEnabled(ECollisionEnabled::PhysicsOnly);同样可以达成效果,并且也不需要大刀阔斧地修改碰撞预设。


以上内容只是笔者实验过程中产生的疑惑,至今仍有一些问题没有解决。没有关系,跟着代码的步骤走,你仍然可以获得想要的结果。现在产生的问题,等以后有了一定的知识储备,那就不再是问题了。

看看最后达成的效果:

小兵死后,尸体像烂泥一样倒在了地上

AI被攻击时切换目标

添加切换目标的代码很简单,之前已经做过,就是获取黑板组件设置黑板键即可。注意到OnPawnSeen中的部分代码也有相同的逻辑,出于不复制粘贴代码的原则,将设置目标抽象为一个函数,定义SetTargetActor

void ASurAiCharacter::SetTargetActor(AActor* TargetActor)
{
   AAIController* AIC = Cast<AAIController>(GetController());
   if(AIC)
   {
      AIC->GetBlackboardComponent()->SetValueAsObject(TargetActorKey, TargetActor);
   }
}

对应的OnPawnSeen也作简单修改:

void ASurAiCharacter::OnPawnSeen(APawn* Pawn)
{
   SetTargetActor(Pawn);
   DrawDebugString(GetWorld(), GetActorLocation(), "PLAYER SPOTTED", nullptr, FColor::White, 5);
}

实现切换目标的逻辑主要体现在OnHealthChanged函数里,我们只需要在原来的逻辑前加上设置目标的函数即可,这里还添加了不考虑自己攻击自己的情况:

void ASurAiCharacter::OnHealthChanged(AActor* InstigatorActor, USurAttributeComponent* OwningComp, float NewHealth,
   float Delta)
{
   if(InstigatorActor != this)
   {
      SetTargetActor(InstigatorActor);
   }
   //………后面是原本的逻辑
}

原先的属性组件中,广播的Instigator是nullptr。这里我们修改了ApplyHealthChanges的参数列表,添加了AActor* InstigatorActor,允许调用者传入自己的指针。然后将广播的Instigator修改为InstigatorActor。注意,其他用到ApplyHealthChanges的地方也需要修改。

当然,在实际开发并不建议这样改函数定义,因为如果很多地方用到了这个函数,多处修改会很让开发人员头疼的。

bool USurAttributeComponent::ApplyHealthChanges(AActor* InstigatorActor, float Delta)
{
   health += Delta;
   if(health > MaxHealth)
   {
      Delta = MaxHealth - health;
      health = MaxHealth;
   }
   OnHealthChanged.Broadcast(InstigatorActor, this, health, Delta);
   return true;
}

一点细节需要注意一下,,子弹打中人后传入的是自己的Instigator,也就是射出子弹的Character。因此在生成子弹的时候,注意在ActorSpawnParameters.Instigator中传入自己的指针,这样才能被子弹获取。

//在生成子弹的时候设置ActorSpawnParameters
FActorSpawnParameters params;
params.Instigator = MyPawn;

最终效果:

击中小兵后,小兵立刻锁定了玩家

如果AI小兵比较拥挤的话,你甚至可以看到他们互相攻击,真是一场好戏呀。

添加静态函数

如果要获取属性组件,我们需要写这么一行很长很长的代码来实现我们的想法。

USurAttributeComponent* AttributeComp = Cast<USurAttributeComponent>(Bot->GetComponentByClass(USurAttributeComponent::StaticClass()));

这里课程教了一个优化代码架构的小办法,就是使用静态函数的方式来优化代码的可读性。读者也可以学习和模仿UE中的GameplayStatics库中的写法,里面定义了很多获取游戏内常用资源的方法,比如获取控制器,获取所有Actor等。同样的,你也可以继承FunctionLibrary类来拓展UE的函数库,这里我就不展开叙述了。

现在我们想轻松地获得一个属性组件,不想再写这么长一串代码怎么办?我们可以定义一个获取属性组件的静态函数,将这个函数定义在SurAttributeComponent类中,因为功能和类是紧密相关的,所以在使用的时候可以减少部分记忆负担。

为了方便使用,在这里我定义了两个静态函数,实现也很简单。注意到我对IsActorAlive使用了meta关键字,meta为元数据的意思,这里使用了其中的DisplayName,即为该函数在蓝图中起了个别名,因此在蓝图搜素IsAlive也能搜到该静态函数。

//SurAttributeComponent.h
//获取Actor的属性组件
	UFUNCTION(BlueprintCallable, Category = "Attributes")
	static USurAttributeComponent* GetAttributes(AActor* TargetActor);

	//判断Actor是否还活着
	UFUNCTION(BlueprintCallable, Category = "Attributes", meta = (DisplayName = "IsAlive"))
	static bool IsActorAlive(AActor* TargetActor);


//SurAttributeComponent.cpp
USurAttributeComponent* USurAttributeComponent::GetAttributes(AActor* TargetActor)
{
	if(TargetActor)
	{
		return Cast<USurAttributeComponent>(TargetActor->GetComponentByClass(USurAttributeComponent::StaticClass()));
	}
	return nullptr;
}

bool USurAttributeComponent::IsActorAlive(AActor* TargetActor)
{
	USurAttributeComponent* AttrComp =  GetAttributes(TargetActor);
	if(AttrComp)
	{
		return AttrComp->IsAlive();
	}
	return false;
}

修改完后,现在只需要使用USAttributeComponent* AttributeComp = USAttributeComponent::GetAttributes(MyActor); 就可以获取属性了。

优化开火逻辑(随机散射、不打尸体)

目前AI小兵只要获取到玩家的角色对象,就会不断开火,即使玩家已经死亡倒地;另外的,AI小兵的准头似乎太好了,从来不马枪,对玩家躲闪子弹来说非常不友好。本节内容来优化这些细节。

我们主要修改的地方是SBTTask_RangeAttack类,我为这个类新加了一个float类型的MaxBulletSpread,并将其暴露在蓝图中,为的就是设定子弹偏移的范围。

在ExecuteTask函数的修改中,我新增了判断玩家死亡的逻辑,和使用FMath::RandRange生成随机偏移量,读者可以与之前的代码进行对比,自行测试。

有一个小细节需要注意一下,部分同学可能想在检测到玩家死亡的时候,就让他立刻更换目标,或者将目标对象设为nullptr。想法是可以,但是尽量别在ExecuteTask里实现。出于职责单一原则,行为树的Task尽量只做一件事情,更换目标不是当前这个发射子弹的Task所需要决定的。

EBTNodeResult::Type USBTTask_RangeAttack::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
   AAIController* MyController = OwnerComp.GetAIOwner();
   if(ensure(MyController))
   {
      ACharacter* MyPawn = Cast<ACharacter>(MyController->GetPawn());
      if(MyPawn == nullptr)
      {
         return EBTNodeResult::Failed;
      }

      AActor* TargetActor = Cast<AActor>(OwnerComp.GetBlackboardComponent()->GetValueAsObject("TargetActor"));
      if(TargetActor == nullptr)
      {
         return EBTNodeResult::Failed;
      }

      //如果目标死了,就不进行攻击。这里并不需要更换目标或者其他操作,因为这不是这个Task应该做的事情
      if(!USurAttributeComponent::IsActorAlive(TargetActor))
      {
         return EBTNodeResult::Failed;
      }
      
      FVector MuzzleLocation = MyPawn->GetMesh()->GetSocketLocation("Muzzle_01");
      //方向向量=目标位置-当前位置
      FVector Direction = TargetActor->GetActorLocation() - MuzzleLocation;
      FRotator MuzzleRotation = Direction.Rotation();

       //添加随机偏移
      MuzzleRotation.Pitch += FMath::RandRange(0.f, MaxBulletSpread);
      MuzzleRotation.Yaw += FMath::RandRange(-MaxBulletSpread, MaxBulletSpread);
      
      FActorSpawnParameters params;
      params.Instigator = MyPawn;
      params.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;

      ensure(ProjectileClass);
      AActor* NewProj = GetWorld()->SpawnActor<AActor>(ProjectileClass, MuzzleLocation, MuzzleRotation, params);
      return NewProj ? EBTNodeResult::Succeeded : EBTNodeResult::Failed;
   }

   return EBTNodeResult::Failed;
}

修改后,小兵出现了明显的马枪,而且不再攻击死人

总结

本文根据课程实现了一系列效果,为我们的游戏添加了更多有趣的功能。本来还想把54节的受击闪光功能做出来的,由于我使用的是官方的《虚幻争霸:小兵》资源包,里面的材质和课程的大相径庭,功能也复杂的多。笔者经过一番尝试,由于材质相关知识的欠缺,闪光功能没有成功实现,留给读者自己研究,或者让我日后补充上去吧。

参考链接

UE4 C++:UPROPERTY宏、属性说明符、元数据说明符https://blog.csdn.net/Jason6620/article/details/126502800