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

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

@prototypeindie

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

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

В следующий раз мы соберем полноценный дебаггер для нашего ИИ.

Cтатья подготовлена участником сообщества prototype.indie

Report Page