Игровой ИИ. Utility Based AI
Продолжаю мой цикл про игровой ИИ. В предыдущей части мы рассматривали подход полезности для поиска позиции в пространстве и для поиска целей нашего поведения. В этой части мы попробуем реализовать полноценную систему UtilityBasedAI на UE 5.4. В этой части будет много кода, поэтому дублирую ссылку на git, в котором будет код из этой статьи.
Общая Архитектура
Во время разработки любой фичи стоит с самого начала понять, что мы хотим получить. Наш ИИ не исключение. Очевидно в рамках этой статьи у нас нет цели рассмотреть низкоуровневую логику ИИ. Поэтому одно из условий - это удержаться от реализации того, что уже умеет ИИ UE5. Наша цель - построить модель выбора высокоуровневого поведения, а не реализовывать логику навигации или логику поиска пути. Отсюда напрашивается желание использовать то, что уже реализовано в UE5. Первое, что мы можем попробовать использовать, это узлы (UBTNode) BehaviourTree, которые доступны по умолчанию. Но при детальном рассмотрении становится очевидно, что у в них огромное число логики, которая рассчитана на именно дерево поведения, и такая логика будет нам лишней. Поэтому стоит рассмотреть, что же используют узлы для внутренней реализации. Для этого рассмотрим узел UBTTask_MoveTo, используемый для перемещения к цели, переданной через Blackboard.
FAIMoveRequest MoveReq; MoveReq.SetNavigationFilter(*FilterClass ? FilterClass : MyController->GetDefaultNavigationFilterClass()); MoveReq.SetAllowPartialPath(bAllowPartialPath); MoveReq.SetAcceptanceRadius(AcceptableRadius); MoveReq.SetCanStrafe(bAllowStrafe); MoveReq.SetReachTestIncludesAgentRadius(bReachTestIncludesAgentRadius); MoveReq.SetReachTestIncludesGoalRadius(bReachTestIncludesGoalRadius); MoveReq.SetRequireNavigableEndLocation(bRequireNavigableEndLocation); MoveReq.SetProjectGoalLocation(bProjectGoalLocation); MoveReq.SetUsePathfinding(bUsePathfinding); . . . MoveTask = PrepareMoveTask(OwnerComp, MoveTask, MoveReq);
При быстром осмотре этого класса мы понимаем, что класс является сложной оберткой для UAITask_MoveTo, в котором и выполняется вся основная логика. Почитав документацию и исходный код, мы можем понять, что UAITask является реализацией асинхронных задач для ИИ или агента, которые запускаются и контролируются высокоуровневыми системами. Таски являются идеальными кандидатами для наших задач. Напомню, что в дереве поведения узел чаще всего является атомарным действием: передвинуться куда-то, найти цель, провести атаку. В ИИ, основанном на полезности, поведения скорее являются последовательностью простых действий, которые собираются в одно поведение. Например, поведение Атаки может включать в себя последовательность простых действий: передвинуться, проиграть атаку, отступить. Отсюда наш будущий ИИ будет запускать последовательно таски и тем самым реализовывать необходимое поведение. Поэтому сразу обозначим, что наше ИИ будет состоять из:
- Действий (Action) - обертка вокруг UAITask или простая атомарная логика.
- Полезности (Utility) - атомарного класса, который высчитывает одно из составляющих суммарной полезности.
- Поведения (Behavior) - набора действий и полезностей, который наш ИИ будет выбирать и выполнять
- Архетипа(Archetype) - набора поведений и правил выбора этих поведений (например то, как мы получаем итоговую полезность).
Очевидно у нас нет необходимости создавать свой контроллер, поэтому, обратившись к UBehaviorTreeComponent как к примеру, можно увидеть, что он наследует от общего класса UBrainComponent , c которым в свою очередь умеет работать AIController UE5. Поэтому весь наш ИИ будет существовать в отдельном компоненте UUtilityAIBehaviorComponent, с которым будут взаимодействовать остальные системы. Для первой итерации мы также воспользуемся UBlackboardComponent, чтобы у нашего ИИ была возможность использовать память. Теперь, когда у нас есть общее понимание того, что нам нужно, мы можем начать реализовывать нашу логику.
UUtilityAIBaseAction
Первым классом, который мы реализуем, будет класс наших действий.
UCLASS(EditInlineNew, Abstract) class UUtilityAIBaseAction : public UObject
Базовый класс, который можно создавать прямо в редакторе UE5, абстрактный, так как мы не хотим, чтобы кто-то использовал эту конкретную версию.
Поскольку в будущем мы будем использовать UAITask как кирпичики нашего поведения, то стоит сразу реализовать поддержку всего необходимого для работы с ними. Поэтому унаследуем наш класс от IGameplayTaskOwnerInterface и реализуем необходимые методы.
UCLASS(EditInlineNew, Abstract)
class UUtilityAIBaseAction : public UObject, public IGameplayTaskOwnerInterface
{
//IGameplayTaskOwnerInterface
virtual UGameplayTasksComponent* GetGameplayTasksComponent(const UGameplayTask& Task) const override;
virtual AActor* GetGameplayTaskOwner(const UGameplayTask* Task) const override;
//~IGameplayTaskOwnerInterface
protected:
//IGameplayTaskOwnerInterface
template <class T>
T* NewUtilityAITask(UAIUtilityBehaviorComponent& UtilityComponent)
{
check(UtilityComponent.GetAIOwner());
bOwnsGameplayTasks = true;
return UAITask::NewAITask<T>(*UtilityComponent.GetAIOwner(), *this, TEXT("Behavior"));
}
//~IGameplayTaskOwnerInterface
private:
bool bOwnsGameplayTasks = true;
};Теперь надо добавить возможность нашему классу что-то делать и сообщать о том, успешные эти действия или нет. Для начала заведем enum ,который будет показывать статус текущего действия
UENUM()
enum class EUtilityAIActionStatus : uint8
{
BadContext,// utility action failed to run cause of bad context (e.g. no target for attack action)
Failed, //utility action failed (e.g. no path to target)
Running, // utility action is running (e.g. agent are playing animation montage)
Success, //utility action has finished with success
};Кроме того, поскольку наши действия могут быть асинхронными, мы добавим делегат, вызвав который наш класс сможет сообщить о том, что действие завершено.
DECLARE_DELEGATE_OneParam(FUtilityAIActionFinished, const EUtilityAIActionStatus&);
Также нашим действиям потребуется контекст того, что происходит и кто выполняет это действие. Для первой итерации заведем простую структуру.
USTRUCT(BlueprintType)
struct FUtilityAIActionContext
{
GENERATED_BODY()
public:
UPROPERTY()
AActor* Agent;
UPROPERTY()
AAIController* Controller;
UPROPERTY()
class UUtilityAIBehaviorComponent* OwnerComponent;
};Хотя мы еще не создали класс для компонента UUtilityAIBehaviorComponent,мы уже обсудили его наличие в нашей логике, поэтому очевидно, что он понадобится в нашем контексте.
Теперь добавим функцию запуска нашего действия и виртуальную функцию, в которой наследники нашего класса будут реализовывать свою логику, и делегат, с помощью которого конкретное действие будет сообщать о своем завершении.
EUtilityAIActionStatus RunAction(const FUtilityAIActionContext& Context, const FUtilityAIActionFinished& OnActionFinished)
{
FinishedDelegate = OnActionFinished;
return RunAction_Internal(Context);
}
virtual EUtilityAIActionStatus RunAction_Internal(const FUtilityAIActionContext& Context){ return EUtilityAIActionStatus::Failed; };
//Delegate that action should fire when it finished if this async action
FUtilityAIActionFinished FinishedDelegate;Итоговый класс будет выглядеть следующим образом
/**
* One AI Action that should be keep simple and atomic.
* This is used by UUtilityBehaviorAsset to create needed behavior
*/
UCLASS(EditInlineNew, Abstract)
class UUtilityAIBaseAction : public UObject, public IGameplayTaskOwnerInterface
{
GENERATED_BODY()
public:
EUtilityAIActionStatus RunAction(const FUtilityAIActionContext& Context, const FUtilityAIActionFinished& OnActionFinished);
UUtilityAIBehaviorComponent* GetUtilityComponentForTask(UGameplayTask& Task) const;
//IGameplayTaskOwnerInterface
virtual UGameplayTasksComponent* GetGameplayTasksComponent(const UGameplayTask& Task) const override;
virtual AActor* GetGameplayTaskOwner(const UGameplayTask* Task) const override;
//~IGameplayTaskOwnerInterface
protected:
//Main function to run implement this action
virtual EUtilityAIActionStatus RunAction_Internal(const FUtilityAIActionContext& Context){ return EUtilityAIActionStatus::Failed; };
//Delegate that action should fire when it finished if this async action
FUtilityAIActionFinished FinishedDelegate;
//IGameplayTaskOwnerInterface
template <class T>
T* NewUtilityAITask(UUtilityAIBehaviorComponent& UtilityComponent)
{
check(UtilityComponent.GetAIOwner());
bOwnsGameplayTasks = true;
return UAITask::NewAITask<T>(*UtilityComponent.GetAIOwner(), *this, TEXT("Behavior"));
}
//~IGameplayTaskOwnerInterface
private:
bool bOwnsGameplayTasks = true;
};
EUtilityAIActionStatus UUtilityAIBaseAction::RunAction(const FUtilityAIActionContext& Context, const FUtilityAIActionFinished& OnActionFinished)
{
FinishedDelegate = OnActionFinished;
return RunAction_Internal(Context);
}
UUtilityAIBehaviorComponent* UUtilityAIBaseAction::GetUtilityComponentForTask(UGameplayTask& Task) const
{
UAITask* AITask = Cast<UAITask>(&Task);
return (AITask && AITask->GetAIController()) ? Cast<UUtilityAIBehaviorComponent>(AITask->GetAIController()->BrainComponent) : nullptr;
}
UGameplayTasksComponent* UUtilityAIBaseAction::GetGameplayTasksComponent(const UGameplayTask& Task) const
{
const UAITask* AITask = Cast<const UAITask>(&Task);
return (AITask && AITask->GetAIController()) ? AITask->GetAIController()->GetGameplayTasksComponent(Task) : Task.GetGameplayTasksComponent();
}
AActor* UUtilityAIBaseAction::GetGameplayTaskOwner(const UGameplayTask* Task) const
{
if (Task == nullptr)
{
const UUtilityAIBehaviorComponent* UtilityComponent = Cast<const UUtilityAIBehaviorComponent>(GetOuter());
//not having UAIUtilityBehaviorComponent component for an instanced UUtilityAIBaseAction is invalid!
check(UtilityComponent);
return UtilityComponent->GetAIOwner();
}
const UAITask* AITask = Cast<const UAITask>(Task);
if (AITask)
{
return AITask->GetAIController();
}
const UGameplayTasksComponent* TasksComponent = Task->GetGameplayTasksComponent();
return TasksComponent ? TasksComponent->GetGameplayTaskOwner(Task) : nullptr;
}UUtilityAIBaseUtility и UUtilityAIUtility_Blueprint
Теперь нам понадобится базовый класс для вычисления полезности. Как обсуждалось ранее, для вычисления полезности необходимо проанализировать состояние игрового мира, агента и т.д. Поэтому очевидно мы должны снабдить наш класс каким-то контекстом для выполнения.
USTRUCT(BlueprintType)
struct FUtilityAIActionContext
{
GENERATED_BODY()
public:
UPROPERTY()
AActor* Agent;
UPROPERTY()
AAIController* Controller;
UPROPERTY()
class UUtilityAIBehaviorComponent* OwnerComponent;
};Можно заметить, что этот контекст пока не отличается от контекста для наших действий, но в дальнейшем, когда мы будем расширять наши действия и функции полезности, это может измениться, поэтому будет архитектурно верно разделить эти сущности, не смотря на то, что они одинаковые.
Как обсуждалось ранее, удобнее, когда функции полезности выступают в роли двух сущностей: фильтра и обычной полезности. Также, как показал пример EQS, очень удобно, когда одна функция полезности может одновременно выполнять обе роли. Поэтому заведем битовую маску, чтобы определять, какую роль выполняет наша полезность.
UENUM(meta = (Bitflags, UseEnumValuesAsMaskValuesInEditor = "true"))
namespace EUtilityType
{
enum Type
{
None UMETA(Hidden),
Filter = (1 << 0), //This flag means that utility function will be used to filter behaviour
Score = (1 << 1),//This flag means that score from utility function will be combine with other Score function to get final utility
};
}Теперь мы можем создать базовый класс нашей функции полезности
UCLASS(Abstract, EditInlineNew)
class UTILITYAI_API UUtilityAIBaseUtility : public UObject
{
GENERATED_BODY()
public:
virtual float GetUtilityValue(const struct FUtilityAIUtilityContext& Context) const
{
return -1.0f;
}
int32 GetUtilityType() const
{
return UtilityType;
}
protected:
UPROPERTY(EditAnywhere, Meta = (Bitmask, BitmaskEnum = "/Script/UtilityBasedAI.EUtilityType"))
int32 UtilityType = EUtilityType::None;
};Также давайте создадим утилитарный класс для Blueprint unreal, чтобы у нас была возможность создавать функцию полезности в Blueprint.
UCLASS(Blueprintable, BlueprintType, EditInlineNew)
class UTILITYAI_API UUtilityAIUtility_Blueprint : public UUtilityAIBaseUtility
{
GENERATED_BODY()
public:
virtual float GetUtilityValue(const FUtilityAIUtilityContext& Context) const override
{
return BP_GetUtilityValue(Context);
}
protected:
UFUNCTION(BlueprintImplementableEvent, DisplayName="GetUtilityValue")
float BP_GetUtilityValue(const FUtilityAIUtilityContext& Context) const;
};N.B. поскольку вычисление полезности в нашем ИИ является основным ударом по производительности, нам надо пытаться выиграть везде. где можем. Мы могли бы использовать BlueprintNativeEvent в нашем базовом классе и не создавать класс для Blueprint, но тогда все обертки для запуска таких функций выполнялись бы всегда даже для классов, написанных на чистом C++. В UE5 это быстрые операции, но если наш ИИ разрастется и будет пытаться посчитать полезность для сотни функций полезности, эти вызовы накопятся и станут заметными.
UUtilityAIBehaviorAsset и UUtilityAIArchetypeAsset
У нас есть все необходимое, чтобы собрать ассеты для нашего поведения и архетипа. С поведением все понятно - это набор действий и функций полезности.
UCLASS()
class UTILITYAI_API UUtilityAIBehaviorAsset : public UDataAsset
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, Instanced)
TArray<TObjectPtr<UUtilityAIBaseAction>> AIActions;
UPROPERTY(EditAnywhere, Instanced)
TArray<TObjectPtr<UUtilityAIBaseUtility>> Utilities;
};Теперь давайте вспомним три описанных способа вычислять суммарную полезность, их можно вынести в отдельный enum для удобства
UENUM()
enum class EUtilityCombinationType : uint8
{
Average,
Product,
GeometricAverage
};Создадим класс для описания архетипа нашего ИИ. Добавим настройку для диапазона выбора лучшего поведения. Это позволит нам сразу разнообразить наше поведение.
UCLASS()
class UTILITYAI_API UUtilityAIArchetypeAsset : public UDataAsset
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere)
TArray<TObjectPtr<UUtilityAIBehaviorAsset>> Behaviors;
//How we combine utility function to get final utility behavior
UPROPERTY(EditAnywhere)
EUtilityCombinationType CombinationType;
//When we utility we find best utility, after we select best behavior by picking random one from all behavior which
//utility in range [BestUtility, BestUtility - BestUtilityRange]
UPROPERTY(EditAnywhere)
float BestUtilityRange = 0.15;
};UUtilityAIBehaviorComponent
Перейдем к созданию основной логики реализации выбора и запуска нашего поведения. Для этого мы создадим компонент UUtilityAIBehaviorComponent, унаследованный от UBrainComponent, который будет добавлен в наш контролер. Для начала добавим функции, унаследованные от UBrainComponent, и добавим пока пустую функцию выбора поведения.
UCLASS(ClassGroup = AI, meta = (BlueprintSpawnableComponent))
class UTILITYAI_API UUtilityAIBehaviorComponent : public UBrainComponent
{
GENERATED_BODY()
public:
UUtilityAIBehaviorComponent(class FObjectInitializer const &);
//UBrainComponent
virtual void StartLogic() override;
virtual void StopLogic(const FString& Reason) override;
virtual void RestartLogic()override;
//~UBrainComponent
protected:
void SelectBehavior();
};
void UUtilityAIBehaviorComponent::StartLogic()
{
Super::StartLogic();
SelectBehavior();
}
void UUtilityAIBehaviorComponent::StopLogic(const FString& Reason)
{
Super::StopLogic(Reason);
}
void UUtilityAIBehaviorComponent::RestartLogic()
{
Super::RestartLogic();
}Наш ИИ будет запускать асинхронные действия и работать с нашими архетипами и поведениями, поэтому добавим необходимые делегаты функции и поля. Также добавим пару утилитарных массивов и обертку для хранения информации во время выбора (пока там будет храниться только финальная полезность, но в будущем мы добавим туда информацию для дебага)
USTRUCT()
struct FUtilityBehaviorWrapper
{
GENERATED_BODY()
float Utility;
};
class UTILITYAI_API UUtilityAIBehaviorComponent : public UBrainComponent
{
.
.
.
UFUNCTION(BlueprintCallable)
void SetupBehaviour(UUtilityAIArchetypeAsset* InArchetypeAsset);
void OnActionFinished(const EUtilityAIActionStatus& Status);
.
.
.
//Current Archetype we are using
UPROPERTY()
TObjectPtr<UUtilityAIArchetypeAsset> ArchetypeAsset;
//Main delegate that is passed to actions and used by actions to notify the component of completion
FUtilityAIActionFinished ActionFinished;
//Support arrays that used to find best actions
TArray<FUtilityBehaviorWrapper> BehaviorWrappers;
TArray<int32> IndexInRange;
//Behaviour that currently selected
UPROPERTY()
TObjectPtr<UUtilityAIBehaviorAsset> CurrentBehavior;
//Action that AI currently doing
UPROPERTY()
TObjectPtr<UUtilityAIBaseAction> ActiveAction;
//Current index of the action in the array
int32 CurrentIndex;
};
void UUtilityAIBehaviorComponent::SetupBehaviour(UUtilityAIArchetypeAsset* InArchetypeAsset)
{
ArchetypeAsset = InArchetypeAsset;
StartLogic();
}
UUtilityAIBehaviorComponent::UUtilityAIBehaviorComponent(const FObjectInitializer& ObjectInitializer) : Super(ObjectInitializer)
{
PrimaryComponentTick.bCanEverTick = false;
ActionFinished = FUtilityAIActionFinished::CreateUObject(this, &UUtilityAIBehaviorComponent::OnActionFinished);
}
void UUtilityAIBehaviorComponent::OnActionFinished(const EUtilityAIActionStatus& Status)
{
}Теперь давай реализуем основную часть нашей логики - функцию, которая находит подходящее поведение и запускает его .
void UUtilityAIBehaviorComponent::SelectBehavior()
{
if (!IsValid(ArchetypeAsset) || !IsValid(GetAIOwner()))
{
return;
}
BehaviorWrappers.Reset();
BehaviorWrappers.Reserve(ArchetypeAsset->Behaviors.Num());
const FUtilityAIUtilityContext AIUtilityContext{GetAIOwner()->GetPawn(), GetAIOwner(), this };
float BestUtility = 0.0f;
for (TObjectPtr<UUtilityAIBehaviorAsset> Behavior : ArchetypeAsset->Behaviors)
{
if (!IsValid(Behavior) ||Behavior->Utilities.IsEmpty() || Behavior->AIActions.IsEmpty())
{
BehaviorWrappers.Add({-1.0f});
continue;
}
float TotalUtility = 0.0f;
float TotalScoreCount = 0;
for (TObjectPtr<UUtilityAIBaseUtility> Utility : Behavior->Utilities)
{
if (!IsValid(Utility))
{
continue;
}
const float UtilityValue = Utility->GetUtilityValue(AIUtilityContext);
if ((Utility->GetUtilityType() & EUtilityType::Filter) != 0)
{
if (UtilityValue == 0)
{
TotalUtility = 0.0;
break;
}
}
if ((Utility->GetUtilityType() & EUtilityType::Score) != 0)
{
TotalScoreCount++;
switch (ArchetypeAsset->CombinationType) {
case EUtilityCombinationType::Average:
TotalUtility += UtilityValue;
break;
case EUtilityCombinationType::Product:
case EUtilityCombinationType::GeometricAverage:
TotalUtility *= UtilityValue;
break;
default: ;
}
}
}
if (TotalScoreCount == 0 || TotalUtility == 0)
{
BehaviorWrappers.Add({0.0f});
continue;
}
switch (ArchetypeAsset->CombinationType) {
case EUtilityCombinationType::Average:
TotalUtility /= TotalScoreCount;
break;
case EUtilityCombinationType::GeometricAverage:
FMath::Pow(TotalUtility, 1/TotalScoreCount);
break;
default: ;
}
if (BestUtility < TotalUtility)
{
BestUtility = TotalUtility;
}
BehaviorWrappers.Add({TotalUtility});
}
IndexInRange.Reset();
for(int Index = 0; Index < BehaviorWrappers.Num(); Index++)
{
if (FMath::IsNearlyEqual(BestUtility, BehaviorWrappers[Index].Utility)
|| (BestUtility - ArchetypeAsset->BestUtilityRange) < BehaviorWrappers[Index].Utility)
{
IndexInRange.Add(Index);
}
}
if (IndexInRange.IsEmpty())
{
return;
}
StartBehavior(ArchetypeAsset->Behaviors[IndexInRange[FMath::RandHelper(IndexInRange.Num())]]);
}Функция проходит по всем поведениям, вычисляет все функции полезности, затем комбинирует их согласно настройкам. Затем выбирает случайное поведение из тех поведений, у которых полезность находится в диапазоне между наивысшей полезностью и наивысшей полезностью минус наше поле настройки архетипа. В конце мы запускаем поведение. Но мы еще не объявили и не реализовали функцию запуска поведения и действий. Давайте исправим это, плюс - добавим пару утилитарных функций, чтобы у нас была возможность затребовать выбор нового поведения и действия в следующий кадр.
class UTILITYAI_API UUtilityAIBehaviorComponent : public UBrainComponent
{
.
.
.
//Schedule selection of new behavior on next frame
void ScheduleNewBehaviour();
//Schedule start of next action on next frame
void ScheduleNewAction();
//start of next action in current behavior
void StartNextAction();
//start of action with index in current behavior
void StartAction(int Index);
void StartBehavior(const TObjectPtr<UUtilityAIBehaviorAsset>& NewCurrentBehavior);
//Selects new behavior form archetype
void SelectBehavior();
.
.
.
};
void UUtilityAIBehaviorComponent::ScheduleNewBehaviour()
{
GetWorld()->GetTimerManager().SetTimerForNextTick(this, &UUtilityAIBehaviorComponent::SelectBehavior);
}
void UUtilityAIBehaviorComponent::ScheduleNewAction()
{
GetWorld()->GetTimerManager().SetTimerForNextTick(this, &UUtilityAIBehaviorComponent::StartNextAction);
}
void UUtilityAIBehaviorComponent::StartNextAction()
{
StartAction(CurrentIndex + 1);
}
void UUtilityAIBehaviorComponent::StartAction(int Index)
{
if (!IsValid(CurrentBehavior) || !CurrentBehavior->AIActions.IsValidIndex(Index)
|| !IsValid(CurrentBehavior->AIActions[Index]))
{
ScheduleNewBehaviour();
return;
}
ActiveAction = DuplicateObject(CurrentBehavior->AIActions[Index], this);
CurrentIndex = Index;
if (!IsValid(ActiveAction))
{
ScheduleNewBehaviour();
return;
}
const FUtilityAIActionContext AIActionContext{GetAIOwner()->GetPawn(), GetAIOwner(), this };
EUtilityAIActionStatus Status = ActiveAction->RunAction(AIActionContext, ActionFinished);
switch (Status) {
case EUtilityAIActionStatus::BadContext:
ScheduleNewBehaviour();
break;
case EUtilityAIActionStatus::Failed:
ScheduleNewBehaviour();
break;
case EUtilityAIActionStatus::Running:
break;
case EUtilityAIActionStatus::Success:
ScheduleNewAction();
break;
default: ;
}
}
void UUtilityAIBehaviorComponent::StartBehavior(const TObjectPtr<UUtilityAIBehaviorAsset>& NewCurrentBehavior)
{
CurrentBehavior = NewCurrentBehavior;
StartAction(0);
}
Как видно, логика достаточно простая: мы запускаем первое действие в списке нашего поведения, если действия завершились с ошибкой, мы запрашиваем выбор нового поведения. Если действие завершилось успешно, мы запрашиваем новое действие. Нам осталось дополнить наш делегат, который ожидает асинхронных действий.
void UUtilityAIBehaviorComponent::OnActionFinished(const EUtilityAIActionStatus& Status)
{
switch (Status) {
case EUtilityAIActionStatus::BadContext:
ScheduleNewBehaviour();
break;
case EUtilityAIActionStatus::Failed:
ScheduleNewBehaviour();
break;
case EUtilityAIActionStatus::Running:
break;
case EUtilityAIActionStatus::Success:
ScheduleNewAction();
break;
default: ;
}
}UUtilityAIAction_RunEQSQuery и UUtilityAIAction_MoveTo
Для простого тестирования нашего кода также нужно реализовать простое действие запроса EQS и навигации. Реализация этих действий по факту является копией UBTTask_RunEQSQuery и UBTTask_MoveTo, так как эти классы являются обертками для соответствующих тасок и запросов к другим системам. Подробный разбор этих действий выходит за рамки этой статьи, но исходный код доступен на git.
Сбор тестового поведения.
Теперь мы можем собрать простое тестовое поведение и посмотреть, что все работает.
Создадим константную функцию полезности, используя UUtilityAIUtility_Blueprint
Создадим ассет для нашего поведения
Создадим ассет для нашего архетипа
Создадим простой контроллер для нашего ИИ. В контроллере мы пересипользуем наш blackboard из статьи по BT
Запускаем игру и наблюдаем, как наш ИИ последовательно выполняет наше простое поведение и, за неимением альтернатив, повторяет его снова и снова.
Наш ИИ выполняется, но у него отсутствует множество важных функций. Он не до конца взаимодействует с системами AI Unreal, мы не умеем прерывать действия или поведения, нет никакого дебага, отсутствуют кривые и нормализация полезностей. Именно в этом направлении мы начнем расширять наш ИИ в следующий раз.