Игровой ИИ. Utility Based AI
Продолжаю мой цикл про игровому ИИ. В предыдущей части мы начали собирать плагин для UE 5.4, в котором реализуем ИИ, основанный на полезности. В этой частим мы добавим возможность прерывать наши действия, подключимся к системе сообщений ИИ Unreal и улучшим наши функции полезности. В этой части также будет много кода, поэтому дублирую ссылку на git с кодом из этой статьи.
Логирование
Пока у нашей системы нет удобного дебага, нам будет очень сложно понять, что пошло не так в нашем ИИ, и искать баги. Поэтому одним из базовых способов улучшения дебаггинга является логирование. В прошлый раз мы почти не добавляли логи, чтобы не перегружать и без того большие вставки с кодом. Сейчас мы исправим эту ситуацию. Тогда мы добавили новую категорию логов:
UTILITYAI_API DECLARE_LOG_CATEGORY_EXTERN(LogAIUtility, Display, All); DEFINE_LOG_CATEGORY(LogAIUtility);
Для начала, давайте логировать ошибки и странные состояния при поиске нового поведения в UUtilityAIBehaviorComponent::SelectBehavior()
if (!IsValid(ArchetypeAsset) || !IsValid(GetAIOwner()))
{
UE_VLOG(GetOwner(), LogAIUtility, Error, TEXT("Invalid ArchetypeAsset"));
.
.
.
if (!IsValid(Behavior) ||Behavior->Utilities.IsEmpty() || Behavior->AIActions.IsEmpty())
{
UE_VLOG(GetOwner(), LogAIUtility, Error, TEXT("Invalid behaviour returning invalid Utility"));
.
.
.
if (!IsValid(Utility))
{
UE_VLOG(GetOwner(), LogAIUtility, Error, TEXT("Invalid Utility returning %s"), *Behavior->GetName());Мы используем VisLogger, который позволяет нам удобно разбивать логи на акторы и кадры. Подробнее про VisLogger можно почитать тут .
Теперь, когда мы прологировали ошибки, давайте добавим дополнительной информации в логи при обычной работе ИИ, когда никаких ошибок нет.
UE_VLOG(GetOwner(), LogAIUtility, VeryVerbose, TEXT("Start Looking for new behaviour"));
.
.
.
if ((Utility->GetUtilityType() & EUtilityType::Filter) != 0)
{
if (UtilityValue == UUtilityAIBaseUtility::FILTERED_UTILITY)
{
UE_VLOG(GetOwner(), LogAIUtility, VeryVerbose, TEXT("Behaviour %s, was filtered by %s"), *Behavior->GetName(), *Utility->GetName());
.
.
.
UE_VLOG(GetOwner(), LogAIUtility, VeryVerbose, TEXT("Behaviour %s, utility %s : %2.f"), *Behavior->GetName(), *Utility->GetName(), UtilityValue);
.
.
.
UE_VLOG(GetOwner(), LogAIUtility, VeryVerbose, TEXT("Behaviour %s, TotalUtility %2.f"), *Behavior->GetName(), TotalUtility);
.
.
.
UE_VLOG(GetOwner(), LogAIUtility, VeryVerbose, TEXT("Behaviour %s, Normalized totalUtility %2.f"), *Behavior->GetName(), TotalUtility);
.
.
.
UE_VLOG(GetOwner(), LogAIUtility, VeryVerbose, TEXT("Found suitable %d behaviour "), IndexInRange.Num());Теперь с помощью лога мы можем отслеживать, что происходит с нашей ИИ.
Это все еще далеко до удобного дебаггера, но уже позволит нам поймать и исправить часть багов
По аналогии добавим логи в UUtilityAIBehaviorComponent::StartBehavior, UUtilityAIBehaviorComponent::StartAction, UUtilityAIBehaviorComponent::OnActionFinished и остальные функции нашего компонента. Я не буду приводить тут все логи, которые были добавлены, но все изменения доступны на git.
Остановка поведения.
Теперь давайте реализуем логику остановки нашего действия и поведения.
Для начала давайте добавим еще один статус, который может возвращать действия при своем завершении.
UENUM()
enum class EUtilityAIActionStatus : uint8
{
.
.
.
Interrupted,//utility action was interrupted
};Также добавим enum, который описывает причину остановки.
UENUM()
enum class EUtilityAIActionStopReason : uint8
{
StopAI,//AI is shutting down
Replanning,// We decided to replan our action
};
После чего добавим в UUtilityAIBaseAction функцию остановки действия
UCLASS(EditInlineNew, Abstract)
class UUtilityAIBaseAction : public UObject, public IGameplayTaskOwnerInterface
{
.
.
.
public:
void StopAction(const FUtilityAIActionContext& Context, EUtilityAIActionStopReason Status);
.
.
.
protected:
//Clean up function in case we interrupt our action doesn't trigger in regular flow of actions
virtual void StopAction_Internal(const FUtilityAIActionContext& Context, EUtilityAIActionStopReason Status);
void UUtilityAIBaseAction::StopAction(const FUtilityAIActionContext& Context, EUtilityAIActionStopReason Reason)
{
StopAction_Internal(Context, Reason);
if (bOwnsGameplayTasks && Context.Controller)
{
UGameplayTasksComponent* GTComp = Context.Controller->GetGameplayTasksComponent();
if (GTComp)
{
GTComp->EndAllResourceConsumingTasksOwnedBy(*this);
}
}
}
void UUtilityAIBaseAction::StopAction_Internal(const FUtilityAIActionContext& Context, EUtilityAIActionStopReason Status)
{
FinishedDelegate.Execute(EUtilityAIActionStatus::Interrupted);
}Поскольку наш базовый класс действий достаточно абстрактный, все что мы можем сделать при остановке действия - это сообщить делегатам, что нас прервали.
Каждое действие само будет принимать решение, что делать в случае прерывания. Например UUtilityAIAction_MoveTo останавливает внутреннюю UAITask_MoveTo
void UUtilityAIAction_MoveTo::StopAction_Internal(const FUtilityAIActionContext& Context,
EUtilityAIActionStopReason Status)
{
bObserverCanFinishTask = false;
if (UAITask_MoveTo* MoveTask = Task.Get())
{
MoveTask->ExternalCancel();
}
Super::StopAction_Internal(Context, Status);
}Теперь, когда у нас есть остановка действия, мы можем реализовать недостающие функции UBrainComponent в UUtilityAIBehaviorComponent
void UUtilityAIBehaviorComponent::StopLogic(const FString& Reason)
{
Super::StopLogic(Reason);
if (IsValid(ActiveAction))
{
const FUtilityAIActionContext AIActionContext{GetAIOwner()->GetPawn(), GetAIOwner(), this };
ActiveAction->StopAction(AIActionContext, EUtilityAIActionStopReason::StopAI);
ActiveAction = nullptr;
}
CurrentBehavior = nullptr;
CurrentIndex = 0;
}
void UUtilityAIBehaviorComponent::RestartLogic()
{
Super::RestartLogic();
if (IsValid(ActiveAction))
{
const FUtilityAIActionContext AIActionContext{GetAIOwner()->GetPawn(), GetAIOwner(), this };
ActiveAction->StopAction(AIActionContext, EUtilityAIActionStopReason::StopAI);
ActiveAction = nullptr;
}
CurrentBehavior = nullptr;
CurrentIndex = 0;
SelectBehavior();
}
Также мы теперь можем позволять внешним системам запрашивать поиск нового поведения.
class UTILITYAI_API UUtilityAIBehaviorComponent : public UBrainComponent
{
public:
.
.
.
UFUNCTION(BlueprintCallable, Category = "AI|Logic")
void RestartBehaviourSelection();void UUtilityAIBehaviorComponent::RestartBehaviourSelection()
{
if (IsValid(ActiveAction))
{
const FUtilityAIActionContext AIActionContext{GetAIOwner()->GetPawn(), GetAIOwner(), this };
ActiveAction->StopAction(AIActionContext, EUtilityAIActionStopReason::Replanning);
ActiveAction = nullptr;
}
CurrentBehavior = nullptr;
CurrentIndex = 0;
SelectBehavior();
}Также стоит добавить обработку нового статуса EUtilityAIActionStatus в UUtilityAIBehaviorComponent::StartAction и в UUtilityAIBehaviorComponent::OnActionFinished
void UUtilityAIBehaviorComponent::OnActionFinished(const EUtilityAIActionStatus& Status)
{
.
.
.
case EUtilityAIActionStatus::Interrupted:
UE_VLOG(GetOwner(), LogAIUtility, VeryVerbose, TEXT("Behaviour %s, action %s was interrupted"), *CurrentBehavior->GetName(), *ActiveAction->GetName());
break;
default: ;
}
}void UUtilityAIBehaviorComponent::StartAction(int Index)
{
.
.
.
case EUtilityAIActionStatus::Interrupted:
UE_VLOG(GetOwner(), LogAIUtility, Error, TEXT("Behaviour %s, action %s bad status: Interrupted"), *CurrentBehavior->GetName(), *ActiveAction->GetName());
break;
default: ;
}
}Теперь у нас реализован полный цикл ИИ в фреймворке UE5, и есть возможность перезапускать поиск оптимального поведения.
AIMessage
Одна из удобных систем взаимодействия между системами ИИ Unreal - это AIMessage. Это простая система, которая пересылает FAIMessage между разными компонентами ИИ. Сами сообщения представляют из себя простую структуру.
struct FAIMessage
{
enum EStatus
{
Failure,
Success,
};
/** type of message */
FName MessageName;
/** message source */
FWeakObjectPtr Sender;
/** message param: ID */
FAIRequestID RequestID;
/** message param: status */
TEnumAsByte<EStatus> Status;
/** message param: custom flags */
uint8 MessageFlags;
}Поскольку тип сообщения определяется через строчку FName, добавлять новые сообщения можно просто заводя новые строчки при отправлении сообщения, без каких-либо изменений в движке.
В базовой версии UE5 доступны следующие сообщения
const FName UBrainComponent::AIMessage_MoveFinished = TEXT("MoveFinished");
const FName UBrainComponent::AIMessage_RepathFailed = TEXT("RepathFailed");
const FName UBrainComponent::AIMessage_QueryFinished = TEXT("QueryFinished");Для начала определим функции, которые позволяют нашему действию подписываться на сообщения и получать их.
class UUtilityAIBaseAction : public UObject, public IGameplayTaskOwnerInterface
{
.
.
.
public:
void ReceivedMessage(UBrainComponent* BrainComponent, const FAIMessage& Message);
protected:
//Call back for Unreal AI message
virtual void OnReceivedMessage(const FUtilityAIActionContext& AIActionContext, const FAIMessage& Message);
//Unreal AI message support functions
void WaitForMessage(const FUtilityAIActionContext& Context, FName MessageType);
void StopWaitingForMessages(const FUtilityAIActionContext& Context);
Теперь определим и реализуем функции нашего UUtilityAIBehaviorComponent для работы с сообщениями.
class UTILITYAI_API UUtilityAIBehaviorComponent : public UBrainComponent
{
.
.
.
public:
void RegisterMessageObserver(UUtilityAIBaseAction* UtilityAIBaseAction, FName Name);
void UnRegisterMessageObserver(UUtilityAIBaseAction* UtilityAIBaseAction);
FUtilityAIActionContext CreateContextFor(UUtilityAIBaseAction* UtilityAIBaseAction);
protected:
//For future if we want few action run in parallel
TMultiMap<TWeakObjectPtr<UUtilityAIBaseAction>, FAIMessageObserverHandle> ActionMessageObservers;void UUtilityAIBehaviorComponent::RegisterMessageObserver(UUtilityAIBaseAction* UtilityAIBaseAction, FName MessageType)
{
if (!IsValid(UtilityAIBaseAction))
{
UE_VLOG(GetOwner(), LogAIUtility, Error, TEXT("Null action in RegisterMessageObserver"));
return;
}
ActionMessageObservers.Add(TWeakObjectPtr<UUtilityAIBaseAction>(UtilityAIBaseAction),
FAIMessageObserver::Create(this, MessageType, FOnAIMessage::CreateUObject(UtilityAIBaseAction, &UUtilityAIBaseAction::ReceivedMessage))
);
UE_VLOG(GetOwner(), LogAIUtility, Log, TEXT("Message[%s] observer added for %s"),
*MessageType.ToString(), *UtilityAIBaseAction->GetName());
}
void UUtilityAIBehaviorComponent::UnRegisterMessageObserver(UUtilityAIBaseAction* UtilityAIBaseAction)
{
if (const int32 NumRemoved = ActionMessageObservers.Remove(TWeakObjectPtr<UUtilityAIBaseAction>(UtilityAIBaseAction)))
{
UE_VLOG(GetOwner(), LogAIUtility, Log, TEXT("Message observers removed for action [%s] (num:%d)"),
*UtilityAIBaseAction->GetName(), NumRemoved);
}
}
FUtilityAIActionContext UUtilityAIBehaviorComponent::CreateContextFor(UUtilityAIBaseAction* UtilityAIBaseAction)
{
return FUtilityAIActionContext{GetAIOwner()->GetPawn(), GetAIOwner(), this };
}Мы также добавили функцию CreateContextFor , чтобы наши действия при получении сообщения могли получить актуальный контекст для работы.
Теперь мы можем реализовать функции в UUtilityAIBaseAction
void UUtilityAIBaseAction::ReceivedMessage(UBrainComponent* BrainComponent, const FAIMessage& Message)
{
UUtilityAIBehaviorComponent* OwnerComp = static_cast<UUtilityAIBehaviorComponent*>(BrainComponent);
check(OwnerComp);
const FUtilityAIActionContext AIActionContext = OwnerComp->CreateContextFor(this);
OnReceivedMessage(AIActionContext, Message);
}
void UUtilityAIBaseAction::OnReceivedMessage(const FUtilityAIActionContext& AIActionContext, const FAIMessage& Message)
{
}
void UUtilityAIBaseAction::WaitForMessage(const FUtilityAIActionContext& Context, FName MessageType)
{
if (IsValid(Context.OwnerComponent))
{
// messages delegates should be called on node instances (if they exists)
Context.OwnerComponent->RegisterMessageObserver(this, MessageType);
}
}
void UUtilityAIBaseAction::StopWaitingForMessages(const FUtilityAIActionContext& Context)
{
if (IsValid(Context.OwnerComponent))
{
// messages delegates should be called on node instances (if they exists)
Context.OwnerComponent->UnRegisterMessageObserver(this);
}
}Примером использования сообщений будет является действие UUtilityAIAction_MoveTo, где мы подписываемся на сообщения на старте действия.
EUtilityAIActionStatus UUtilityAIAction_MoveTo::RunAction_Internal(const FUtilityAIActionContext& Context)
{
.
.
.
WaitForMessage(Context, UBrainComponent::AIMessage_RepathFailed);
Отписываемся при завершении действия
void UUtilityAIAction_MoveTo::OnGameplayTaskDeactivated(UGameplayTask& InTask)
{
.
.
.
OnFinishMoveTo(BehaviorComp->CreateContextFor(this));
.
.
.
}
void UUtilityAIAction_MoveTo::OnFinishMoveTo(const FUtilityAIActionContext& AIActionContext)
{
StopWaitingForMessages(AIActionContext);
}И реагируем на сообщения в переопределенной функции
void UUtilityAIAction_MoveTo::OnReceivedMessage(const FUtilityAIActionContext& AIActionContext,
const FAIMessage& Message)
{
if (Message.MessageName == UBrainComponent::AIMessage_RepathFailed)
{
OnFinishMoveTo(AIActionContext);
FinishedDelegate.Execute(EUtilityAIActionStatus::Failed);
}
Super::OnReceivedMessage(AIActionContext, Message);
}Нормализация полезности
В прошлый раз мы не сделали ещё одну полезную вещь - нормализацию полезности. Давайте исправим это. Дополним наш базовый класс UUtilityAIBaseUtility несколькими функциями.
class UTILITYAI_API UUtilityAIBaseUtility : public UObject
{
.
.
.
protected:
virtual float GetUtilityValueNormal(const struct FUtilityAIUtilityContext& Context) const;
virtual float GetUtilityValue(const struct FUtilityAIUtilityContext& Context) const;
virtual float GetUtilityValueMax(const struct FUtilityAIUtilityContext& Context) const;
Также поменяем название основной функции для получение полезности
class UTILITYAI_API UUtilityAIBaseUtility : public UObject
{
.
.
.
public:
float GetUtility(const struct FUtilityAIUtilityContext& Context) const;Поскольку мы её уже используем в UUtilityAIBehaviorComponent , мы также поменяем вызов этой функции в UUtilityAIBehaviorComponent::SelectBehavior
также добавим несколько удобных констант
class UTILITYAI_API UUtilityAIBaseUtility : public UObject
{
.
.
.
static float INVALID_UTILITY;
static float FILTERED_UTILITY;Теперь реализуем наши функции.
float UUtilityAIBaseUtility::INVALID_UTILITY = -1.0f;
float UUtilityAIBaseUtility::FILTERED_UTILITY = 0.0f;
float UUtilityAIBaseUtility::GetUtilityValue(const FUtilityAIUtilityContext& Context) const
{
return INVALID_UTILITY;
}
float UUtilityAIBaseUtility::GetUtilityValueMax(const FUtilityAIUtilityContext& Context) const
{
return 1.0f;
}
float UUtilityAIBaseUtility::GetUtilityValueNormal(const FUtilityAIUtilityContext& Context) const
{
return GetUtilityValue(Context)/ GetUtilityValueMax(Context);
}
float UUtilityAIBaseUtility::GetUtility(const FUtilityAIUtilityContext& Context) const
{
const float UtilityValue = GetUtilityValueNormal(Context);
if (IsValid(UtilityModifier) && UtilityValue >= 0.0f)
{
return UtilityModifier->ModifyUtility(UtilityValue);
}
return UtilityValue;
}
После таких изменений нам надо поменять наш Blueprint класс UUtilityAIUtility_Blueprint , чтобы он соответствовал новой логике. Мы хотим позволить пользователю нашего плагина удобно переопределять функции полезности без необходимости генерировать дополнительный код. Добавим проверку на то, что в Blueprint переопределена логика нормализации. Если нет, то мы используем стандартную логику в UUtilityAIBaseUtility .
/**
* Utility Class for Blueprint implementation want to keep it separate with Native cause of performance
*/
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);
}Модификатор полезности
Как обсуждалось раньше, очень удобно иметь возможность модифицировать полезность с помощью некой кривой или модификатора. Давайте добавим такую возможность в наш код.
UCLASS(EditInlineNew)
class UTILITYAI_API UUtilityAIBaseUtilityModifier : public UObject
{
GENERATED_BODY()
public:
UUtilityAIBaseUtilityModifier();
float ModifyUtility(float CurrentValue) const;
protected:
float ModifyUtility_Inner(float CurrentValue) const;
UFUNCTION(BlueprintImplementableEvent, DisplayName="ModifyUtility")
float BP_ModifyUtility(float CurrentValue) const;
UPROPERTY(EditAnywhere)
FRuntimeFloatCurve Curve;
private:
bool bHasModifyUtility;
};
UUtilityAIBaseUtilityModifier::UUtilityAIBaseUtilityModifier()
{
auto ImplementedInBlueprint = [](const UFunction* Func) -> bool
{
return Func && ensure(Func->GetOuter())
&& Func->GetOuter()->IsA(UBlueprintGeneratedClass::StaticClass());
};
{
static FName FuncName = FName(TEXT("BP_ModifyUtility"));
UFunction* ActivateFunction = GetClass()->FindFunctionByName(FuncName);
bHasModifyUtility = ImplementedInBlueprint(ActivateFunction);
}
}
float UUtilityAIBaseUtilityModifier::ModifyUtility(float CurrentValue) const
{
if (bHasModifyUtility)
{
return BP_ModifyUtility(CurrentValue); // Call the Blueprint function if it's implemented.
}
return ModifyUtility_Inner(CurrentValue);
}
float UUtilityAIBaseUtilityModifier::ModifyUtility_Inner(float CurrentValue) const
{
if (Curve.GetRichCurveConst())
{
return Curve.GetRichCurveConst()->Eval(CurrentValue);
}
return CurrentValue;
}Класс позволяет нам определить кривую для модификации полезности, или вызывать переопределенную функцию из Blueprint.
Теперь давайте модифицируем наш класс UUtilityAIBaseUtility
class UTILITYAI_API UUtilityAIBaseUtility : public UObject
{
.
.
.
protected:
UPROPERTY(EditAnywhere, Instanced)
TObjectPtr<class UUtilityAIBaseUtilityModifier> UtilityModifier;float UUtilityAIBaseUtility::GetUtility(const FUtilityAIUtilityContext& Context) const
{
const float UtilityValue = GetUtilityValueNormal(Context);
if (IsValid(UtilityModifier) && UtilityValue >= 0.0f)
{
return UtilityModifier->ModifyUtility(UtilityValue);
}
return UtilityValue;
}Теперь любая функция полезности может определить модификатор, который повлияет на финальное значение полезности.
В следующий раз мы соберем полноценный дебаггер для нашего ИИ.