斯坦福 UE4 C++ ActionRoguelike游戏实例教程 05.认识GameMode&自动生成AI角色
斯坦福课程 UE4 C++ ActionRoguelike游戏实例教程 0.绪论
概述
本篇文章将会讲述UE中Gamemode的基本概念,并在C++中开发GameMode,为游戏设置一个简单的玩法:使用环境查询自动生成AI角色,并自定义一条难度曲线,随着时间增大游戏的难度。
最终实现效果,为AI小兵添加了属性组件,可以被我们打爆;编辑GameMode,每隔两秒钟在玩家生成一个AI小兵,有生成上限,上限随着游戏时间增大而增大:
目录
- 认识GameMode
- 创建GameMode
- 创建环境查询、难度曲线
- 使自定义GameMode生效
认识GameMode
在之前的所有课程中,我们制作了自己的角色,制作了敌对AI角色,制作了一系列场景物品,这些形形色色的Actor被我们拖动到场景中,组成了一个美好的展览馆。但是我们可以问一下自己,我们做的这些真的可以组成一个游戏吗?到目前为止,我们只是不停地制作一个物品,一个功能,一段交互逻辑,并将他们放置在我们的虚拟世界中,但是他们只是静静地站立在那里,不知道自己存在的意义,不知道自己要到哪里去。
作为这个世界的创世神,是时候为这个世界创造一个规则了,有了规则,才称得上是游戏。本篇文章将会介绍构成整个游戏逻辑的一个重要组件:GameMode。
GameMode类继承自AInfo,作为Actor大家族的一个成员,它就像Actor家族的领袖,指引Actor们如何出生和灭亡。
GameMode定义了一个游戏的玩法,游戏的规则由他指定,正如它的标识为一个旗子一样,你可以用它来规定游戏的玩法是抢夺一个旗子,又或是一个5v5的团队竞技,或者是一个开放世界抽卡游戏。只要它一声令下,就可以宣布游戏开始,如果它愿意,它也可以随时暂停和终止游戏。每一个游戏世界都需要一个GameMode类来管理游戏逻辑。
同样的,它可以指定玩家进入关卡时,默认使用的是哪一个Controller,控制的是哪一个Pawn,加载的是哪一个UI界面。总之,它贯彻了一个关卡的始终。它不依附于场景里的任一个Actor,只要游戏启动了,它就会一直履行它的职责。具体到代码里如何实现,让我们边做边说。
创建GameMode
还是老规矩,右键内容浏览器,创建一个GameModeBase的子类,这里我将它命名为SurGameModeBase
。就这样,创世神的第一个得力助手诞生了。
进入代码编辑器,让我们看看GameModeBase支持的操作有哪些。比较常用的方法有InitGame
、InitGameState
、StartPlay
等函数,这篇文章并不是API文档,先短暂看一下今天我们要实现什么目标:实现AI角色每隔一段时间在玩家角色周围自动生成,并实现一个难度曲线,使得AI的个数存在一个动态的上限。因此,今天的重点是重写GameModeBase::StartPlay
函数,为这个游戏时间建立一个简单的初始法则。
在父类中,StartPlay
负责通知所有Actor调用BeginPlay函数,也就是说,只有GameModeBase类一声令下,调用StartPlayer,场景里的Actor才能开始工作,才能拥有自己的心跳(Tick)。而作为子类,我们重写时需要记得调用Super::StartPlay
,然后才在后面添加逻辑。
要想实现功能,我们需要为SurGameModeBase添加一系列成员:
- 指定生成的AI类型
- 生成AI所需要的环境查询
- 定义AI小兵生成数量难度曲线
- 生成AI的间隔时间
- 因为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
开始:
- 在游戏开始后,开启一个循环执行的定时器,每隔
SpawnTimerInterval
时间执行一次SpawnBotTimerElapsed
。注意SetTimer
的最后一个参数为True。 - 在
SpawnBotTimerElapsed
函数中, 调用UEnvQueryManager::RunEQSQuery。该函数可以执行一次环境查询,并返回一个环境查询的实例即UEnvQueryInstanceBlueprintWrapper
对象。由于环境查询可能需要花上好几帧的时间才能结束,因此需要自定义一个回调函数,即OnQueryCompleted
,作为参数传进去,在查询结束后调用。 - 当环境查询执行完毕后,调用
OnQueryCompleted
。在该函数里,我们需要判断环境查询的结果,如果为Success则继续。 - 使用UE提供的Actor迭代器,遍历所有AICharacter,如果AICharacter拥有属性组件且存活,则计入计数中。
- 获取在UE编辑器里定义的难度曲线,以时间为X轴获取数据,即当前允许存在的Bot的最大值。
- 如果当前存活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里的成员赋值:
其中的FindBotSpawn环境查询和难度曲线会在下面简单讲解。
设置AI出生点
这次将AI的出生点简单设置为取所有玩家附近圆环的一点。对于创建环境查询我们已经轻车熟路了,这里就不展开叙述了。
设置难度曲线
右键内容浏览器,选择其他->曲线
即可找到曲线。
进入曲线编辑器,使用alt+enter
组合键可以快速创建关键帧,这里将难度曲线设置为如图所示,读者可以自行试验各种曲线的插值方式。其中,曲线的X轴就是我们传入的游戏时间,在Y轴就是Bot的最大数量。
为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++代码里使用这些对象。这里可以根据自己写过的类随意设置一下,一般来说默认也行,不在本次课程的讨论范围里。
运行游戏,会发现自动生成的AI傻站着一动不动,原来是AI控制器没有运行。在默认的设置中,只有提前放置在场景中的AI角色才会被AI控制器控制,修改这个设置有两种办法,一种是在AICharacter里修改:
还有一种是在代码里进行修改,这样生成的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