Игровой ИИ
March 17

Игровой ИИ. 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 является  реализацией асинхронных задач для ИИ или агента, которые запускаются и контролируются высокоуровневыми системами. Таски являются идеальными кандидатами для наших задач. Напомню, что в дереве поведения узел чаще всего является атомарным действием: передвинуться куда-то, найти цель, провести атаку. В ИИ, основанном на полезности, поведения скорее являются последовательностью простых действий, которые собираются в одно поведение. Например, поведение Атаки может включать в себя последовательность простых действий: передвинуться, проиграть атаку, отступить. Отсюда наш будущий ИИ будет запускать последовательно таски и тем самым реализовывать необходимое поведение. Поэтому сразу обозначим, что наше ИИ будет состоять из:

  1. Действий (Action) - обертка вокруг UAITask или простая атомарная логика.
  2. Полезности (Utility) - атомарного класса, который высчитывает одно из составляющих суммарной полезности.
  3. Поведения (Behavior)  - набора действий и полезностей, который наш ИИ будет выбирать и выполнять
  4. Архетипа(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, мы не умеем прерывать действия или поведения, нет никакого дебага, отсутствуют кривые и нормализация полезностей. Именно в этом направлении мы начнем расширять наш ИИ в следующий раз.