June 2

Игровой ИИ. Utility Based AI

Продолжаю мой цикл по игровому ИИ. В предыдущей части мы продолжили собирать плагин для UE 5.4, в котором реализуем ИИ, основанный на полезности. В этой частим мы попробуем собрать простое поведение на основе нашего плагина. Здесь также будет много кода, поэтому дублирую ссылку на git с кодом из этой статьи.

Подготовка

Для работы с нашим плагином сначала нам необходимо собрать пешку (Pawn) и контролер (AIController). Я не буду подробно останавливаться на настройках пешки и контролера, потому что они практически совпадают с пешкой и контролером из главы про Behavior Tree. Основное отличие - отсутствие UBehaviorTreeComponent и наличие UUtilityAIBehaviorComponent. Для настройки последнего добавим следующую инициализацию в Blueprint контроллера

Инициализация UUtilityAIBehaviorComponent

Какое поведение мы хотим получить от нашего агента? Давайте попробуем воспроизвести поведение, реализованное с помощью Behavior Tree. Наш агент должен передвигаться в случайном направлении и, если замечает игрока, вступать с ним в бой. Если игрок близко, то агент проигрывает анимацию атаки в ближнем бою, если игрок далеко от агента, агент проигрывает анимацию атаки дальнего боя.

Поиск и назначение цели.

Давайте реализуем логику поиска и назначения цели для нашего ИИ. Создадим класс, унаследованный от нашего базового класса действий UUtilityAIBaseAction, который будет искать и сохранять ближайшего актора, которого заметил Perception нашего ИИ.

UCLASS(DisplayName="Add Perception Target To Blackboard")
class UTILITYBASEDAICONTENT_API UUtilityAIAction_AddPerceptionTargetToBB : public UUtilityAIBaseAction
{
	GENERATED_BODY()
protected:
	virtual EUtilityAIActionStatus RunAction_Internal(const FUtilityAIActionContext& Context) override;
	UPROPERTY(EditAnywhere, Category=Blackboard)
	FName Blackboard
Key;
};
EUtilityAIActionStatus UUtilityAIAction_AddPerceptionTargetToBB::RunAction_Internal(const FUtilityAIActionContext& Context)
{
	if (!IsValid(Context.Controller) || !IsValid(Context.Controller->GetPerceptionComponent())
		|| !IsValid(Context.OwnerComponent) || !IsValid(Context.OwnerComponent->GetBlackboardComponent())
		|| !IsValid(Context.Agent))
	{
		return EUtilityAIActionStatus::BadContext;
	}

	TArray<AActor*> PerceivedActors;
	Context.Controller->GetPerceptionComponent()->GetCurrentlyPerceivedActors(nullptr, PerceivedActors);

	float MinDistance = TNumericLimits<float>::Max();
	AActor* ClosestActor = nullptr;
	for (AActor* PerceivedActor : PerceivedActors)
	{
		if (!IsValid(PerceivedActor))
		{
			continue;
		}
		
		float DistanceSq = (Context.Agent->GetActorLocation() - PerceivedActor->GetActorLocation()).SizeSquared();
		if (DistanceSq < MinDistance)
		{
			ClosestActor = PerceivedActor;
			MinDistance = DistanceSq;
		}
	}

	if (IsValid(ClosestActor))
	{
		Context.OwnerComponent->GetBlackboardComponent()->SetValue<UBlackboardKeyType_Object>(BlackboardKey, ClosestActor);
	}
	return EUtilityAIActionStatus::Success;
}

Очевидно что нам необходимо запускать это действие в определенных ситуациях:

  1. Когда у нас нет активной цели (наша запись в Blackboard не валидна)
  2. По истечению таймаута на смену цели (для того чтобы наш ИИ не переключался между целями при каждом старте выбора поведения)
  3. Нам не нужно запускать это действия два раза подряд, иначе наш агент будет бесконечно выполнять только поведение поиска цели.

Давайте создадим функции полезности и действия, которые позволят реализовать это поведение.

N.B. мы создаем этот класс в отличие от остальных в отдельном модуле UtilityBasedAIContent остальных. Дело в том, что этот класс достаточно специфический и ,в отличие от остальных, которые мы будем создавать сегодня, не может быть использован для любого ИИ. Поэтому имеет смысл расположить его отдельно от базовых классов, которые будут полезны в любом ИИ

UUtilityAIUtility_ValidBBKey

Реализуем функцию, которая возвращает положительную полезность только если запись в Blackboard валидна.

UCLASS(HideCategories=(UtilityType), DisplayName="Validity of BBKey")
class UTILITYAI_API UUtilityAIUtility_ValidBBKey : public UUtilityAIBaseUtility
{
	GENERATED_BODY()
public:
	UUtilityAIUtility_ValidBBKey();
protected:
	//Blackboard key for our target
	UPROPERTY(EditAnywhere, Category=Blackboard)
	FName BlackboardKey;
	virtual float GetUtilityValueNormal(const struct FUtilityAIUtilityContext& Context) const override;	
};
UUtilityAIUtility_ValidBBKey::UUtilityAIUtility_ValidBBKey()
{
	UtilityType = EUtilityType::Filter;
}

float UUtilityAIUtility_ValidBBKey::GetUtilityValueNormal(const FUtilityAIUtilityContext& Context) const
{
	if (!IsValid(Context.OwnerComponent) || !IsValid(Context.OwnerComponent->GetBlackboardComponent()))
	{
		UE_VLOG(GetOuter(), LogAIUtility, Error, TEXT("UUtilityAIUtility_ValidBBKey::GetUtilityValueNormal failed since OwnerComponent or BlackboardComponent is missing."));
		return INVALID_UTILITY;
	}
	const UBlackboardComponent* BlackboardComponent = Context.OwnerComponent->GetBlackboardComponent();
	const FBlackboard::FKey KeyID = BlackboardComponent->GetKeyID(BlackboardKey);

	if (KeyID == FBlackboard::InvalidKey)
	{
		UE_VLOG(Context.OwnerComponent->GetOwner(), LogAIUtility, Error, TEXT("UUtilityAIUtility_ValidBBKey::GetUtilityValueNormal failed since Blackboard key %s is missing"), *BlackboardKey.ToString());
		return INVALID_UTILITY;
	}

	if (BlackboardComponent->GetKeyType(KeyID) == UBlackboardKeyType_Object::StaticClass())
	{
		UObject* KeyValue = BlackboardComponent->GetValue<UBlackboardKeyType_Object>(KeyID);
		if (!IsValid(KeyValue))
		{
			return FILTERED_UTILITY;
		}
	}
	else if (BlackboardComponent->GetKeyType(KeyID) == UBlackboardKeyType_Vector::StaticClass())
	{
		if (BlackboardComponent->GetValue<UBlackboardKeyType_Vector>(KeyID) == FAISystem::InvalidLocation)
		{
			return FILTERED_UTILITY;
		}
	}

	return MAX_UTILITY; // Key is valid, return max utility value.
}

Теперь давайте добавим две полезные вещи в наш базовый класс:

  1. Добавим Category="UtilityType" в UPROPERTY UtilityType - это позволит нам скрывать это поле в классах-наследниках и контролировать из кода использование такой функции. В частности для UUtilityAIUtility_ValidBBKey мы указываем, что эта функция всегда является фильтром.
  2. Добавим булевое поле
    UPROPERTY(EditAnywhere, Category="UtilityModifiers")

bool bInverseUtility = false;

И код в нашу функцию UUtilityAIBaseUtility::GetUtility

    if (bInverseUtility && UtilityValue >= 0.0f)
	{
		return MAX_UTILITY - UtilityValue;
	}

Это позволит нам получать обратную полезность для любой функции полезности.

UUtilityAIAction_SaveTimestamp и UUtilityAIUtility_Cooldown

Теперь давайте добавим возможность выставлять время выполнения поведения и проверять это время в  функции полезности.

UCLASS(DisplayName="Save Timestamp")
class UTILITYAI_API UUtilityAIAction_SaveTimestamp : public UUtilityAIBaseAction
{
	GENERATED_BODY()
public:
	virtual EUtilityAIActionStatus RunAction_Internal(const FUtilityAIActionContext& Context) override;
protected:
	UPROPERTY(EditAnywhere, Category=Blackboard)
	FName BlackboardKey;
};
EUtilityAIActionStatus UUtilityAIAction_SaveTimestamp::RunAction_Internal(const FUtilityAIActionContext& Context)
{
	if (!IsValid(Context.OwnerComponent) || !IsValid(Context.OwnerComponent->GetBlackboardComponent())
		|| !IsValid(Context.OwnerComponent->GetWorld()))
	{
		return EUtilityAIActionStatus::BadContext;
	}

	Context.OwnerComponent->GetBlackboardComponent()->SetValue<UBlackboardKeyType_Float>(BlackboardKey, Context.OwnerComponent->GetWorld()->GetTimeSeconds());

	return EUtilityAIActionStatus::Success;
}
UCLASS(DisplayName="Cooldown")
class UTILITYAI_API UUtilityAIUtility_Cooldown : public UUtilityAIBaseUtility
{
	GENERATED_BODY()
protected:
	//Blackboard key for our timestamp
	UPROPERTY(EditAnywhere, Category=Blackboard)
	FName BlackboardKey;
	//Blackboard key for our target
	UPROPERTY(EditAnywhere, Category=Blackboard)
	FValueOrBBKey_Float Cooldown = 2.0f;
	//if false we return continuous utility proportional to time until cooldown ends 
	UPROPERTY(EditAnywhere, Category=Blackboard)
	FValueOrBBKey_Bool bStepUtility = true;
	
	virtual float GetUtilityValueNormal(const struct FUtilityAIUtilityContext& Context) const override;	
};
float UUtilityAIUtility_Cooldown::GetUtilityValueNormal(const FUtilityAIUtilityContext& Context) const
{
	if (!IsValid(Context.OwnerComponent) || !IsValid(Context.OwnerComponent->GetBlackboardComponent())
		|| !IsValid(Context.OwnerComponent->GetWorld()))
	{
		UE_VLOG(GetOuter(), LogAIUtility, Error, TEXT("UUtilityAIUtility_Cooldown::GetUtilityValueNormal failed since OwnerComponent, World or BlackboardComponent is missing."));
		return INVALID_UTILITY;
	}
	const UBlackboardComponent* BlackboardComponent = Context.OwnerComponent->GetBlackboardComponent();
	const FBlackboard::FKey KeyID = BlackboardComponent->GetKeyID(BlackboardKey);
	if (KeyID == FBlackboard::InvalidKey)
	{
		UE_VLOG(Context.OwnerComponent->GetOwner(), LogAIUtility, Error, TEXT("UUtilityAIUtility_Cooldown::GetUtilityValueNormal failed since Blackboard key %s is missing"), *BlackboardKey.ToString());
		return INVALID_UTILITY;
	}

	if (BlackboardComponent->GetKeyType(KeyID) == UBlackboardKeyType_Float::StaticClass())
	{
		const bool bStepUtilityValue = bStepUtility.GetValue(BlackboardComponent);
		const float CooldownValue = Cooldown.GetValue(BlackboardComponent);
		const float KeyValue = BlackboardComponent->GetValue<UBlackboardKeyType_Float>(KeyID);
		if (KeyValue < 0.f)
		{
			return MAX_UTILITY;
		}
		const float TimeSince = Context.OwnerComponent->GetWorld()->GetTimeSeconds() - KeyValue;
		if (bStepUtilityValue)
		{
			return TimeSince > CooldownValue ? MAX_UTILITY : FILTERED_UTILITY; 
		}
		else
		{
			return FMath::Clamp(TimeSince/CooldownValue, FILTERED_UTILITY, MAX_UTILITY);
		}
	}
	else
	{
		UE_VLOG(Context.OwnerComponent->GetOwner(), LogAIUtility, Error, TEXT("UUtilityAIUtility_Cooldown::GetUtilityValueNormal failed since Blackboard key %s is wrong type"), *BlackboardKey.ToString());
		return INVALID_UTILITY;
	}
}

UUtilityAIUtility_FilterOr

Из описания поведения нашего ИИ следовало, что у нас может быть ситуация ИЛИ для условий выполнения некоторых действий. Например мы хотим искать цель, если цели нет ИЛИ мы давно не обновляли цель. Такую логику можно реализовать двумя способами: либо завести два отдельных независимых поведения, либо добавив OR функцию полезности для фильтров. Мы выберем второй вариант:

UCLASS(HideCategories=(UtilityType), DisplayName="Composite Or")
class UTILITYAI_API UUtilityAIUtility_FilterOr : public UUtilityAIBaseUtility
{
	GENERATED_BODY()
public:
	UUtilityAIUtility_FilterOr();
	UPROPERTY(EditAnywhere, Instanced)
	TArray<TObjectPtr<UUtilityAIBaseUtility>> Utilities;

	virtual float GetUtilityValueNormal(const struct FUtilityAIUtilityContext& Context) const override;	
};
UUtilityAIUtility_FilterOr::UUtilityAIUtility_FilterOr()
{
	UtilityType = EUtilityType::Filter;
}

float UUtilityAIUtility_FilterOr::GetUtilityValueNormal(const FUtilityAIUtilityContext& Context) const
{
	for (TObjectPtr<UUtilityAIBaseUtility> Utility : Utilities)
	{
		if (Utility->GetUtility(Context) > FILTERED_UTILITY)
		{
			return MAX_UTILITY;
		}		
	}
	
	return FILTERED_UTILITY;
}

UUtilityAIUtility_NoRepeat

Последняя нереализованная функция полезности - это функция-фильтр, которая запрещает повторное выполнение одного и того же поведения.

Давайте добавим Getter в UUtilityAIBehaviorComponent

UUtilityAIBehaviorAsset* GetCurrentBehavior() {return CurrentBehavior; }

Теперь реализуем нашу функцию полезности

UCLASS(HideCategories=(UtilityType), DisplayName="Forbid Repeat")
class UTILITYAI_API UUtilityAIUtility_NoRepeat : public UUtilityAIBaseUtility
{
	GENERATED_BODY()
public:
	UUtilityAIUtility_NoRepeat();
	
	virtual float GetUtilityValueNormal(const struct FUtilityAIUtilityContext& Context) const override;	
	
};
UUtilityAIUtility_NoRepeat::UUtilityAIUtility_NoRepeat()
{
	UtilityType = EUtilityType::Filter;
}

float UUtilityAIUtility_NoRepeat::GetUtilityValueNormal(const FUtilityAIUtilityContext& Context) const
{
	if (!IsValid(Context.OwnerComponent))
	{
		UE_VLOG(GetOuter(), LogAIUtility, Error, TEXT("UUtilityAIUtility_Cooldown::GetUtilityValueNormal failed since OwnerComponent is missing."));
		return INVALID_UTILITY;
	}
	return GetOwnerAIBehaviorAsset() == Context.OwnerComponent->GetCurrentBehavior() ? FILTERED_UTILITY : MAX_UTILITY;
}

UUtilityAIUtility_DistanceToBBKey

Еще одна функция полезности, которая нам понадобится, это функция полезности пропорционально расстоянию до цели из BlackBoard. Поскольку мы хотим, чтобы наши полезности были нормализованы, очень часто будет вставать вопрос - относительно чего мы их будем нормализовывать. Чаще всего такие параметры лучше выносить в настройки функции полезности. В данном случае мы вынесем в параметр максимальное расстояние, после достижения которого полезность не будет расти и будет иметь максимальное значение.

UCLASS(DisplayName="Distance To BBKey")
class UTILITYAI_API UUtilityAIUtility_DistanceToBBKey : public UUtilityAIBaseUtility
{
	GENERATED_BODY()
protected:
	//Blackboard key to measure distance to target
	UPROPERTY(EditAnywhere, Category=Blackboard)
	FName BlackboardKey;
	UPROPERTY(EditAnywhere, Category=Blackboard)
	FValueOrBBKey_Bool b2DDistance;
	//Distance at which our utility will be 1.0 
	UPROPERTY(EditAnywhere, Category=Blackboard)
	FValueOrBBKey_Float MaxDistance = 5000.0;
	virtual float GetUtilityValueNormal(const struct FUtilityAIUtilityContext& Context) const override;	
};
float UUtilityAIUtility_DistanceToBBKey::GetUtilityValueNormal(const FUtilityAIUtilityContext& Context) const
{
	if (!IsValid(Context.OwnerComponent) || !IsValid(Context.OwnerComponent->GetBlackboardComponent()))
	{
		UE_VLOG(GetOuter(), LogAIUtility, Error, TEXT("UUtilityAIUtility_DistanceToBBKey::GetUtilityValueNormal failed since OwnerComponent or BlackboardComponent is missing."));
		return INVALID_UTILITY;
	}

	if (!IsValid(Context.Agent))
	{
		UE_VLOG(GetOuter(), LogAIUtility, Error, TEXT("UUtilityAIUtility_DistanceToBBKey::GetUtilityValueNormal failed since Agent  is missing."));
		return INVALID_UTILITY;
	}
	
	const UBlackboardComponent* BlackboardComponent = Context.OwnerComponent->GetBlackboardComponent();
	const FBlackboard::FKey KeyID = BlackboardComponent->GetKeyID(BlackboardKey);

	if (KeyID == FBlackboard::InvalidKey)
	{
		UE_VLOG(Context.OwnerComponent->GetOwner(), LogAIUtility, Error, TEXT("UUtilityAIUtility_DistanceToBBKey::GetUtilityValueNormal failed since Blackboard key %s is missing"), *BlackboardKey.ToString());
		return INVALID_UTILITY;
	}

	
	FVector TargetLocation = FAISystem::InvalidLocation;

	if (BlackboardComponent->GetKeyType(KeyID) == UBlackboardKeyType_Object::StaticClass())
	{
		UObject* KeyValue = BlackboardComponent->GetValue<UBlackboardKeyType_Object>(KeyID);
		AActor* TargetActor = Cast<AActor>(KeyValue);
		if (TargetActor)
		{
			TargetLocation = TargetActor->GetActorLocation();
		}
		else if (IsValid(KeyValue))
		{
			UE_VLOG(Context.OwnerComponent->GetOwner(), LogAIUtility, Warning, TEXT("UUtilityAIUtility_DistanceToBBKey::GetUtilityValueNormal tried to go to actor while BB %s entry was empty"), *BlackboardKey.ToString());
			return INVALID_UTILITY;
		}
		else
		{
			return INVALID_UTILITY;
		}
	}
	else if (BlackboardComponent->GetKeyType(KeyID) == UBlackboardKeyType_Vector::StaticClass())
	{
		TargetLocation = BlackboardComponent->GetValue<UBlackboardKeyType_Vector>(KeyID);		
	}

	const float MaxDistanceValue = MaxDistance.GetValue(BlackboardComponent);
	const bool b2DDistanceValue =  b2DDistance.GetValue(BlackboardComponent);
	const FVector Translation = (Context.Agent->GetActorLocation() - TargetLocation);
	const float Distance = b2DDistanceValue ? Translation.Size2D() : Translation.Size();
	return FMath::Clamp(Distance / MaxDistanceValue, FILTERED_UTILITY, MAX_UTILITY);
}

UUtilityAIAction_Blueprint

Для реализации атак нашего ИИ мы будем так же, как и в случае с деревом поведения, использовать GameAbilitySystem. Поэтому логично создать класс, который мы можем расширять в Blueprint, чтобы не добавлять зависимость нашему плагину от плагина GAS.

UCLASS(Blueprintable, BlueprintType, EditInlineNew)
class UTILITYAI_API UUtilityAIUtility_Blueprint : public UUtilityAIBaseUtility
{
	GENERATED_BODY()
public:
	UUtilityAIUtility_Blueprint();
	virtual float GetUtilityValueNormal(const FUtilityAIUtilityContext& Context) const override final;
protected:
	virtual float GetUtilityValue(const FUtilityAIUtilityContext& Context) const override final;
	virtual float GetUtilityValueMax(const FUtilityAIUtilityContext& Context) const override final;
	UFUNCTION(BlueprintImplementableEvent, DisplayName="GetUtilityValueNormal")
	float BP_GetUtilityValueNormal(const FUtilityAIUtilityContext& Context) const;
	UFUNCTION(BlueprintImplementableEvent, DisplayName="GetUtilityValue")
	float BP_GetUtilityValue(const FUtilityAIUtilityContext& Context) const;
	UFUNCTION(BlueprintImplementableEvent, DisplayName="GetUtilityValueMax")
	float BP_GetUtilityValueMax(const FUtilityAIUtilityContext& Context) const;
private:
	bool bHasBlueprintNormal;
};
UUtilityAIUtility_Blueprint::UUtilityAIUtility_Blueprint()
{
	auto ImplementedInBlueprint = [](const UFunction* Func) -> bool
	{
		return Func && ensure(Func->GetOuter())
			&& Func->GetOuter()->IsA(UBlueprintGeneratedClass::StaticClass());
	};
	
	{
		static FName FuncName = FName(TEXT("BP_GetUtilityValueNormal"));
		UFunction* ActivateFunction = GetClass()->FindFunctionByName(FuncName);
		bHasBlueprintNormal = ImplementedInBlueprint(ActivateFunction);
	}
}

float UUtilityAIUtility_Blueprint::GetUtilityValueNormal(const FUtilityAIUtilityContext& Context) const
{
	//if BP Has it's onw normalization logic let it run otherwise run default
	if (bHasBlueprintNormal)
	{
		return BP_GetUtilityValueNormal(Context);
	}
	return Super::GetUtilityValueNormal(Context);
}

float UUtilityAIUtility_Blueprint::GetUtilityValue(const FUtilityAIUtilityContext& Context) const
{
	return BP_GetUtilityValue(Context);
}

float UUtilityAIUtility_Blueprint::GetUtilityValueMax(const FUtilityAIUtilityContext& Context) const
{
	return BP_GetUtilityValueMax(Context);
}

UAI_Action_StartAbility

Как говорилось раньше, мы будем использовать GAS для реализации атак нашего ИИ. Поэтому давайте сделаем простое действие, которое вызывает способность и дожидается её завершения. Создадим новый Blueprint расширив класс UUtilityAIUtility_Blueprint

Перепишем функцию RunAction

https://blueprintue.com/blueprint/lyj-de_q/

И реализуем событие StartWaitForAbilityEnd

https://blueprintue.com/blueprint/zd7qv0zi/

Сами способности достаточно тривиальны и сводятся к проигрыванию анимации.

BP_ConstatntUtility

Последнее, что нам понадобится - это полезность, которая возвращает константу. Так как наше поведение простое, то некоторые поведения остались только с полезностями фильтра. Для правильного функционирования системы нам нужно иметь хотя бы одну полезность-не-фильтр. В данном случае подойдет и константное значение.

Поведение

Теперь пришло время собрать все необходимые ассеты для нашего поведения. Для начала создадим схему нашего Blackboard BB_UtilityCharacter

Настройка схемы Blackboard
  • TargetLocation используется для навигации вне боя
  • CombatTarget наша цель в бою
  • TargetSelectionTime поле для записи cooldown поиска цели

Теперь соберем UAIB_MeleeAttack. Оно состоит из двух действий: навигации к цели, и проигрывания  способности атаки ближнего боя

BP_ConstatntUtility

Последнее, что нам понадобится - это полезность, которая возвращает константу. Так как наше поведение простое, то некоторые поведения остались только с полезностями фильтра. Для правильного функционирования системы нам нужно иметь хотя бы одну полезность-не-фильтр. В данном случае подойдет и константное значение.

Поведение

Теперь пришло время собрать все необходимые ассеты для нашего поведения. Для начала создадим схему нашего Blackboard BB_UtilityCharacter

  • TargetLocation используется для навигации вне боя
  • CombatTarget наша цель в бою
  • TargetSelectionTime поле для записи cooldown поиска цели

Теперь соберем UAIB_MeleeAttack. Оно состоит из двух действий: навигации к цели, и проигрывания  способности атаки ближнего боя

Навигация к цели из Blackboard

Тригер способности атаки

В роли функции полезности у нас будет выступать UUtilityAIUtility_DistanceToBBKey с включенным флагом инверсии полезности (Мы хотим вступать в ближний бой только если противник достаточно близко)

Настройка функции полезности

Аналогично настраиваем UAIB_RangeAttack: добавляем одно действие вызова способности и функцию полезности UUtilityAIUtility_DistanceToBBKey с выключенным флагом инверсии

Тригер способности дальнего боя

Настройка функции полезности

Теперь настроим UAIB_Roam. В этом поведении мы выбираем случайную точку вокруг нашего агента с помощью EQS и затем навигируемся к ней.

Поиск подходящей точки для навигации

Навигация к выбранной точке

Полезностью будет выступать один фильтр UUtilityAIUtility_ValidBBKey с включенным флагом инверсии полезности

И последним собираем поведение выбора цели для боя UAIB_SelectTarget. Оно состоит из двух действий: сохранения времени выполнения для нашей функции полезности с cooldown, и непосредственно выбора цели.

Настройки действий в UAIB_SelectTarget.

Полезность этого поведения слегка сложнее. Для начала мы не должны выполнять его два раза подряд UUtilityAIUtility_NoRepeat. После чего мы должны проверить, есть ли у нас цель или прошел ли таймер cooldown. В конце выставить полезность в постоянное значение, чтобы поиск целей всегда был актуален.

Запрет на повторение поведения

Композитная полезность OR с двумя полезностями: валидность записи в Blackboard и Cooldown

Константная полезность.

Архетип

Теперь у нас есть все поведения, чтобы собрать архетип нашего ИИ.

Настройки архетипа

Не забываем добавить настройки Blackboard и архетипа в наш контроллер, и ИИ готов к тестированию

Настройки контроллера

На этом мы закончили часть, связанную с Utility AI. В следующий раз мы начнем рассматривать GOAP или Goal Oriented Action Planning.