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