Игровой ИИ. Utility Based AI
Продолжаю мой цикл по игровому ИИ. В предыдущей части мы продолжили собирать плагин для UE 5.4, в котором реализуем ИИ, основанный на полезности. В этой частим мы попробуем собрать простое поведение на основе нашего плагина. Здесь также будет много кода, поэтому дублирую ссылку на git с кодом из этой статьи.
Подготовка
Для работы с нашим плагином сначала нам необходимо собрать пешку (Pawn) и контролер (AIController). Я не буду подробно останавливаться на настройках пешки и контролера, потому что они практически совпадают с пешкой и контролером из главы про Behavior Tree. Основное отличие - отсутствие UBehaviorTreeComponent и наличие UUtilityAIBehaviorComponent. Для настройки последнего добавим следующую инициализацию в Blueprint контроллера
Какое поведение мы хотим получить от нашего агента? Давайте попробуем воспроизвести поведение, реализованное с помощью 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;
}Очевидно что нам необходимо запускать это действие в определенных ситуациях:
- Когда у нас нет активной цели (наша запись в Blackboard не валидна)
- По истечению таймаута на смену цели (для того чтобы наш ИИ не переключался между целями при каждом старте выбора поведения)
- Нам не нужно запускать это действия два раза подряд, иначе наш агент будет бесконечно выполнять только поведение поиска цели.
Давайте создадим функции полезности и действия, которые позволят реализовать это поведение.
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.
}
Теперь давайте добавим две полезные вещи в наш базовый класс:
- Добавим Category="UtilityType" в UPROPERTY UtilityType - это позволит нам скрывать это поле в классах-наследниках и контролировать из кода использование такой функции. В частности для UUtilityAIUtility_ValidBBKey мы указываем, что эта функция всегда является фильтром.
- Добавим булевое поле
UPROPERTY(EditAnywhere, Category="UtilityModifiers")
И код в нашу функцию 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
И реализуем событие StartWaitForAbilityEnd
Сами способности достаточно тривиальны и сводятся к проигрыванию анимации.
BP_ConstatntUtility
Последнее, что нам понадобится - это полезность, которая возвращает константу. Так как наше поведение простое, то некоторые поведения остались только с полезностями фильтра. Для правильного функционирования системы нам нужно иметь хотя бы одну полезность-не-фильтр. В данном случае подойдет и константное значение.
Поведение
Теперь пришло время собрать все необходимые ассеты для нашего поведения. Для начала создадим схему нашего Blackboard BB_UtilityCharacter
- TargetLocation используется для навигации вне боя
- CombatTarget наша цель в бою
- TargetSelectionTime поле для записи cooldown поиска цели
Теперь соберем UAIB_MeleeAttack. Оно состоит из двух действий: навигации к цели, и проигрывания способности атаки ближнего боя
BP_ConstatntUtility
Последнее, что нам понадобится - это полезность, которая возвращает константу. Так как наше поведение простое, то некоторые поведения остались только с полезностями фильтра. Для правильного функционирования системы нам нужно иметь хотя бы одну полезность-не-фильтр. В данном случае подойдет и константное значение.
Поведение
Теперь пришло время собрать все необходимые ассеты для нашего поведения. Для начала создадим схему нашего Blackboard BB_UtilityCharacter
- TargetLocation используется для навигации вне боя
- CombatTarget наша цель в бою
- TargetSelectionTime поле для записи cooldown поиска цели
Теперь соберем UAIB_MeleeAttack. Оно состоит из двух действий: навигации к цели, и проигрывания способности атаки ближнего боя
В роли функции полезности у нас будет выступать UUtilityAIUtility_DistanceToBBKey с включенным флагом инверсии полезности (Мы хотим вступать в ближний бой только если противник достаточно близко)
Аналогично настраиваем UAIB_RangeAttack: добавляем одно действие вызова способности и функцию полезности UUtilityAIUtility_DistanceToBBKey с выключенным флагом инверсии
Теперь настроим UAIB_Roam. В этом поведении мы выбираем случайную точку вокруг нашего агента с помощью EQS и затем навигируемся к ней.
Полезностью будет выступать один фильтр UUtilityAIUtility_ValidBBKey с включенным флагом инверсии полезности
И последним собираем поведение выбора цели для боя UAIB_SelectTarget. Оно состоит из двух действий: сохранения времени выполнения для нашей функции полезности с cooldown, и непосредственно выбора цели.
Полезность этого поведения слегка сложнее. Для начала мы не должны выполнять его два раза подряд UUtilityAIUtility_NoRepeat. После чего мы должны проверить, есть ли у нас цель или прошел ли таймер cooldown. В конце выставить полезность в постоянное значение, чтобы поиск целей всегда был актуален.
Архетип
Теперь у нас есть все поведения, чтобы собрать архетип нашего ИИ.
Не забываем добавить настройки Blackboard и архетипа в наш контроллер, и ИИ готов к тестированию
На этом мы закончили часть, связанную с Utility AI. В следующий раз мы начнем рассматривать GOAP или Goal Oriented Action Planning.