斯坦福 UE4 C++ ActionRoguelike游戏实例教程 05.认识GameMode&自动生成AI角色

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

概述

本篇文章将会讲述UE中Gamemode的基本概念,并在C++中开发GameMode,为游戏设置一个简单的玩法:使用环境查询自动生成AI角色,并自定义一条难度曲线,随着时间增大游戏的难度。

最终实现效果,为AI小兵添加了属性组件,可以被我们打爆;编辑GameMode,每隔两秒钟在玩家生成一个AI小兵,有生成上限,上限随着游戏时间增大而增大:

目录

  1. 认识GameMode
  2. 创建GameMode
  3. 创建环境查询、难度曲线
  4. 使自定义GameMode生效

认识GameMode

在之前的所有课程中,我们制作了自己的角色,制作了敌对AI角色,制作了一系列场景物品,这些形形色色的Actor被我们拖动到场景中,组成了一个美好的展览馆。但是我们可以问一下自己,我们做的这些真的可以组成一个游戏吗?到目前为止,我们只是不停地制作一个物品,一个功能,一段交互逻辑,并将他们放置在我们的虚拟世界中,但是他们只是静静地站立在那里,不知道自己存在的意义,不知道自己要到哪里去。

作为这个世界的创世神,是时候为这个世界创造一个规则了,有了规则,才称得上是游戏。本篇文章将会介绍构成整个游戏逻辑的一个重要组件:GameMode。

img

图片来自知乎《InsideUE4》

GameMode类继承自AInfo,作为Actor大家族的一个成员,它就像Actor家族的领袖,指引Actor们如何出生和灭亡。

GameMode定义了一个游戏的玩法,游戏的规则由他指定,正如它的标识为一个旗子一样,你可以用它来规定游戏的玩法是抢夺一个旗子,又或是一个5v5的团队竞技,或者是一个开放世界抽卡游戏。只要它一声令下,就可以宣布游戏开始,如果它愿意,它也可以随时暂停和终止游戏。每一个游戏世界都需要一个GameMode类来管理游戏逻辑。

同样的,它可以指定玩家进入关卡时,默认使用的是哪一个Controller,控制的是哪一个Pawn,加载的是哪一个UI界面。总之,它贯彻了一个关卡的始终。它不依附于场景里的任一个Actor,只要游戏启动了,它就会一直履行它的职责。具体到代码里如何实现,让我们边做边说。

创建GameMode

还是老规矩,右键内容浏览器,创建一个GameModeBase的子类,这里我将它命名为SurGameModeBase。就这样,创世神的第一个得力助手诞生了。

image-20230315113133485

创建GameModeBase的子类

进入代码编辑器,让我们看看GameModeBase支持的操作有哪些。比较常用的方法有InitGameInitGameStateStartPlay等函数,这篇文章并不是API文档,先短暂看一下今天我们要实现什么目标:实现AI角色每隔一段时间在玩家角色周围自动生成,并实现一个难度曲线,使得AI的个数存在一个动态的上限。因此,今天的重点是重写GameModeBase::StartPlay函数,为这个游戏时间建立一个简单的初始法则。

在父类中,StartPlay负责通知所有Actor调用BeginPlay函数,也就是说,只有GameModeBase类一声令下,调用StartPlayer,场景里的Actor才能开始工作,才能拥有自己的心跳(Tick)。而作为子类,我们重写时需要记得调用Super::StartPlay,然后才在后面添加逻辑。

要想实现功能,我们需要为SurGameModeBase添加一系列成员:

  1. 指定生成的AI类型
  2. 生成AI所需要的环境查询
  3. 定义AI小兵生成数量难度曲线
  4. 生成AI的间隔时间
  5. 因为AI是定时生成的,因此需要一个定时器

注意,UE的回调函数都需要使用UFUNCTION宏进行标识。

以下是为了实现功能,对.h文件所进行的修改:

// ASurGameModeBase.h
class FPSPROJECT_API ASurGameModeBase : public AGameModeBase
{
   GENERATED_BODY()
protected:
   UPROPERTY(EditDefaultsOnly, Category = "AI")
   TSubclassOf<AActor> MinionClass;

   //要调用的环境查询
   UPROPERTY(EditDefaultsOnly, Category = "AI")
   UEnvQuery* SpawnBotQuery;

    //难度曲线
   UPROPERTY(EditDefaultsOnly, Category = "AI")
   UCurveFloat* DifficultyCurve;
   
   FTimerHandle TimerHandle_SpawnBots;

   //生成AI的间隔
   UPROPERTY(EditDefaultsOnly, Category = "AI")
   float SpawnTimerInterval;

   //定时器的回调函数
   UFUNCTION()
   void SpawnBotTimerElapsed();

   //查询结束后的回调函数
   UFUNCTION()
   void OnQueryCompleted(UEnvQueryInstanceBlueprintWrapper*  QueryInstance, EEnvQueryStatus::Type QueryStatus);
public:
   ASurGameModeBase();

   virtual void StartPlay() override;
};

由于环境查询非常消耗时间,一帧的时间不足以让其执行完毕,所以UE使用异步的方式执行环境查询。提到异步,就不得不创建一个回调函数传递给环境查询,当环境查询结束后,调用回调函数。这里定义了一个查询结束后的回调函数OnQueryCompleted,回调函数的函数签名可以查看UEnvQueryInstanceBlueprintWrappe的源码或官方文档得知。

由于大部分逻辑都是在查询结束后进行的,因此代码的逻辑部分重点集中在OnQueryCompleted函数中。为了实现目标,列出了以下主要步骤,从StartPlay开始:

  1. 在游戏开始后,开启一个循环执行的定时器,每隔SpawnTimerInterval时间执行一次SpawnBotTimerElapsed。注意SetTimer的最后一个参数为True。
  2. SpawnBotTimerElapsed函数中, 调用UEnvQueryManager::RunEQSQuery。该函数可以执行一次环境查询,并返回一个环境查询的实例即UEnvQueryInstanceBlueprintWrapper对象。由于环境查询可能需要花上好几帧的时间才能结束,因此需要自定义一个回调函数,即OnQueryCompleted,作为参数传进去,在查询结束后调用。
  3. 当环境查询执行完毕后,调用OnQueryCompleted。在该函数里,我们需要判断环境查询的结果,如果为Success则继续。
  4. 使用UE提供的Actor迭代器,遍历所有AICharacter,如果AICharacter拥有属性组件且存活,则计入计数中。
  5. 获取在UE编辑器里定义的难度曲线,以时间为X轴获取数据,即当前允许存在的Bot的最大值。
  6. 如果当前存活AI数小于最大值,则从环境查询的结果中选取一个点,这里默认为第0个坐标,生成一个AI角色。
ASurGameModeBase::ASurGameModeBase()
{
   SpawnTimerInterval = 2.f;
}

void ASurGameModeBase::StartPlay()
{
   Super::StartPlay();
   //循环调用定时器
   GetWorldTimerManager().SetTimer(TimerHandle_SpawnBots, this, &ASurGameModeBase::SpawnBotTimerElapsed, SpawnTimerInterval, true);
}

void ASurGameModeBase::SpawnBotTimerElapsed()
{
   UEnvQueryInstanceBlueprintWrapper* QueryInstance = UEnvQueryManager::RunEQSQuery(this, SpawnBotQuery, this, EEnvQueryRunMode::RandomBest25Pct, nullptr);
   if(ensure(QueryInstance))
   {
      QueryInstance->GetOnQueryFinishedEvent().AddDynamic(this, &ASurGameModeBase::OnQueryCompleted);
   }
}

void ASurGameModeBase::OnQueryCompleted(UEnvQueryInstanceBlueprintWrapper* QueryInstance,
   EEnvQueryStatus::Type QueryStatus)
{
   if(QueryStatus != EEnvQueryStatus::Success)
   {
      UE_LOG(LogTemp, Warning, TEXT("Spawn bot EQS Query Failed!"));
   }
   //NrOf意思为Number Of 外文编程里奇妙的小缩写
   //当前存活的Bot数量
   int32 NrOfAliveBots = 0;

   //遍历所有AI角色,计算存活的Bot数量
   for(TActorIterator<ASurAiCharacter> It(GetWorld()); It; ++It)
   {
      ASurAiCharacter* Bot = *It;

      //判断Bot是否存活。要求Bot拥有属性组件
      USurAttributeComponent* AttributeComp = Cast<USurAttributeComponent>(Bot->GetComponentByClass(USurAttributeComponent::StaticClass()));
      if(AttributeComp && AttributeComp->IsAlive())
      {
         NrOfAliveBots++;
      }
   }

   float MaxBotCount = 10.f;
   if(DifficultyCurve)
   {
      MaxBotCount = DifficultyCurve->GetFloatValue(GetWorld()->TimeSeconds);
   }

   if(NrOfAliveBots >= FMath::RoundToInt(MaxBotCount))
   {
      return;
   }
   //从结果中获取一个坐标生成Bot
   TArray<FVector> Locations = QueryInstance->GetResultsAsLocations();
   if(Locations.IsValidIndex(0))
   {
      GetWorld()->SpawnActor<AActor>(MinionClass, Locations[0], FRotator::ZeroRotator);
   }
}

以上就是我们制定的第一个游戏规则,以C++代码的方式记录在我们自定义的Gamemode类中。要想使其生效,我们还需要做一些简单的工作。

创建环境查询、难度曲线

首先为我们刚才创建的C++Gamemode类创建一个蓝图子类,我将其命名为BP_SurGameModeBase。进入蓝图,为SurGameMode里的成员赋值:

image-20230315163943467

设置成员

其中的FindBotSpawn环境查询和难度曲线会在下面简单讲解。

设置AI出生点

这次将AI的出生点简单设置为取所有玩家附近圆环的一点。对于创建环境查询我们已经轻车熟路了,这里就不展开叙述了。

image-20230315122403021

设置环境查询(Query_FindBotSpawn)

image-20230315122429900

设置环境查询内容(QueryContext_AllPlayers)

设置难度曲线

右键内容浏览器,选择其他->曲线即可找到曲线。

image-20230315201503029

创建曲线

进入曲线编辑器,使用alt+enter组合键可以快速创建关键帧,这里将难度曲线设置为如图所示,读者可以自行试验各种曲线的插值方式。其中,曲线的X轴就是我们传入的游戏时间,在Y轴就是Bot的最大数量。

image-20230315162452618

编辑曲线

为AICharacter添加AttributeComponent

方法同添加其他寻常组件一样,注意这里的属性组件AttributeComponent是我们自定义的,要想知道如何创建和使用AttributeComponent,可以参考https://www.bilibili.com/read/cv19014581?spm_id_from=333.999.0.0这篇文章。AttributeComponent类定义了血量属性和血量变化的委托,组装了该组件的Character只需要定义好回调函数,绑定到委托里即可。

回调函数我设置成了当血量降低到0及以下,就销毁这个AI小兵。

//SurAiCharacter.cpp
void ASurAiCharacter::OnHealthChanged(AActor* InstigatorActor, USurAttributeComponent* OwningComp, float NewHealth,
   float Delta)
{
   if(NewHealth <= 0.f && Delta < 0)
   {
      GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Yellow, TEXT("Killed an AI"));
      Destroy();
   }
}

PS:在测试的时候遇到一个非常诡异的BUG,有些在C++里创建的组件在使用时会变成空指针,对应的就是蓝图类组件的细节面板为空,导致程序运行出现错误乃至崩溃.解决方法竟然只是给组件改个名。令人费解。

使自定义GameMode生效

在UE编辑器的主界面中,右侧的细节面板旁边一般是有一个世界场景设置的。如果没有这个设置,可以在上方的窗口->世界场景设置中打开。找到游戏模式,将游戏模式重载修改为刚刚创建的蓝图类。这样游戏开始时就默认会实例化一个BP_SurGameModeBase,并开始执行我们刚才定义的逻辑。

下方的选中的游戏模式是GameMode预先登记的一些默认类,当场景没有默认的相关类的话,就会自动帮我们实例化,我们同样可以在蓝图或者C++代码里使用这些对象。这里可以根据自己写过的类随意设置一下,一般来说默认也行,不在本次课程的讨论范围里。

image-20230315164031533

世界场景设置中更换游戏模式重载

运行游戏,会发现自动生成的AI傻站着一动不动,原来是AI控制器没有运行。在默认的设置中,只有提前放置在场景中的AI角色才会被AI控制器控制,修改这个设置有两种办法,一种是在AICharacter里修改:

image-20230315150911261

在蓝图里修改“自动控制AI”

还有一种是在代码里进行修改,这样生成的AI就可以默认被AI控制器控制了:

//SurAiCharacter.cpp
void ASurAiCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
   Super::SetupPlayerInputComponent(PlayerInputComponent);
   AutoPossessAI = EAutoPossessAI::PlacedInWorldOrSpawned;
}

最终效果&总结

最后运行游戏,可以看到AI小兵不断地在角色身边生成直到上限,这些AI看到主角后会发起攻击,同样的,玩家也可以攻击AI将其摧毁。随着游戏的进展,数量上限越来越高,AI角色也越来越多,现在终于有点游戏的样子了?可喜可贺。

做个总结吧,本节课我们创建了第一个GameModeBase类,为这个游戏添加了第一个规则,有点像丧尸围城,会有源源不断的AI敌人生成并试图攻击玩家。读者可以发挥想象力,活用GameModeBase类,以及他的好兄弟GameState类,为这游戏创建更加复杂的规则,包括胜利条件。学习到这个阶段,相信大家对UE C++已经具备了感性的认识,这时候应该试着更进一步,理解UE4的架构以及各个组件之间的关系。这里推荐知乎文章《InsideUE4》,以风趣幽默的口吻讲述了不少UE4架构的相关知识。笔者本人也在不断的学习,不论是UE4的知识还是写博客的风格,希望看到这里的读者能够积极发表评论,共同进步。

参考链接

细节面板空白相关BUG https://zhuanlan.zhihu.com/p/267986596

《InsideUE4》Gamemode和GameState https://zhuanlan.zhihu.com/p/23707588

创建属性组件(虽然文章的标题不是这个)https://www.bilibili.com/read/cv19014581?spm_id_from=333.999.0.0

posted @ 2023-03-15 20:43  仇白  阅读(613)  评论(0编辑  收藏  举报