
어제 만든 AI의 행동 트리를 대폭 업그레이드했다. 순찰, 추적, 공격뿐만 아니라 플레이어를 놓쳤을 때 마지막 위치를 수색하는 지능적인 행동을 C++ 커스텀 태스크로 구현했다. 이 과정에서 링커 오류, 비동기 처리, AI끼리 싸우는 문제 등 다양한 버그를 해결하며 AI 디버깅에 대한 이해도를 높였다. 🧠
행동 트리 태스크는 보통 한 프레임 안에 모든 일을 끝내고 Succeeded나 Failed를 반환한다. 하지만 공격 애니메이션처럼 시간이 걸리는 작업을 처리하려면 태스크가 "작업이 끝날 때까지 기다리게" 만들어야 한다.
EBTNodeResult::InProgress: 태스크의 ExecuteTask 함수가 이 값을 반환하면, 행동 트리는 이 태스크가 아직 "진행 중"이라고 인식하고 다른 행동으로 넘어가지 않는다.FinishLatentTask: 비동기 작업이 모두 끝났을 때, 이 함수를 호출하여 행동 트리에 최종적으로 Succeeded 또는 Failed 결과를 알려준다. UAnimInstance::Montage_SetEndDelegate 같은 콜백 함수와 함께 사용된다.행동 트리는 기본적으로 하나의 태스크가 끝나야만 다음 평가를 진행한다. 이 때문에 순찰(Move To) 중에 플레이어를 봐도 즉시 반응하지 못하는 문제가 생긴다.
Lower Priority로 바꾸면, 데코레이터가 자신의 조건을 지속적으로 감시하게 된다. 만약 조건이 만족되면(예: TargetActor가 설정되면), 현재 실행 중인 더 낮은 우선순위(더 오른쪽에 있는)의 모든 행동을 즉시 중단시키고 자신의 가지를 실행한다. AI의 반응성을 극대화하는 핵심 기능이다. 👍#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTTaskNode.h"
#include "BTT_Attack.generated.h"
UCLASS()
class SPARTA_TPROJECT_02_API UBTT_Attack : public UBTTaskNode
{
GENERATED_BODY()
public:
UBTT_Attack();
protected:
// BTTaskNode의 메인 실행 함수. 태스크가 시작될 때 호출된다.
virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
// 이 태스크가 실행되는 도중, 상위 노드(데코레이터 등)의 조건이 바뀌어 중단될 때 호출되는 함수.
virtual EBTNodeResult::Type AbortTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
private:
// 어떤 몽타주를 재생할지 행동 트리 에디터에서 직접 선택할 수 있도록 변수를 노출시킨다.
UPROPERTY(EditAnywhere, Category = "Animation")
UAnimMontage* MontageToPlay;
// 몽타주 재생이 끝났다는 신호를 받으면 호출될 콜백(Callback) 함수.
UFUNCTION()
void OnMontageEnded(UAnimMontage* Montage, bool bInterrupted, UBehaviorTreeComponent* OwnerComp);
// OnMontageEnded 콜백 함수에서 FinishLatentTask를 호출하려면, 태스크를 실행한 BehaviorTreeComponent가 필요하다.
// ExecuteTask가 실행될 때 그 주소를 여기에 저장해둔다.
UPROPERTY()
UBehaviorTreeComponent* MyOwnerComp;
};
#include "BTT_Attack.h"
#include "AIController.h"
#include "AIMonsterBase.h"
#include "Animation/AnimInstance.h"
#include "BehaviorTree/BehaviorTreeComponent.h"
using namespace std;
UBTT_Attack::UBTT_Attack()
{
NodeName = TEXT("Attack Montage");
// 이 태스크는 몽타주가 끝날 때까지 '대기'해야 하는 비동기(Asynchronous) 작업이다.
// bCreateNodeInstance = true로 설정해야 각 AI가 이 태스크를 실행할 때마다 고유한 인스턴스(복사본)를 생성해서 사용한다.
// 이렇게 해야 여러 AI가 동시에 공격해도 서로의 상태(예: 몽타주 종료 대기)에 영향을 주지 않는다.
bCreateNodeInstance = true;
}
EBTNodeResult::Type UBTT_Attack::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
Super::ExecuteTask(OwnerComp, NodeMemory);
// 콜백 함수에서 나중에 사용하기 위해 OwnerComp의 주소를 멤버 변수에 저장해둔다.
MyOwnerComp = &OwnerComp;
AAIMonsterBase* Monster = Cast<AAIMonsterBase>(OwnerComp.GetAIOwner()->GetPawn());
// 몽타주가 에디터에서 설정되지 않았거나, 몬스터가 유효하지 않으면 즉시 실패 처리.
if (Monster == nullptr || MontageToPlay == nullptr) return EBTNodeResult::Failed;
UAnimInstance* AnimInstance = Monster->GetMesh()->GetAnimInstance();
if (AnimInstance == nullptr) return EBTNodeResult::Failed;
// 몽타주 재생이 끝나면 어떤 함수를 호출할지 '예약'을 걸어두는 과정. (델리게이트 바인딩)
// "이 몽타주가 끝나면, 'this'(UBTT_Attack 인스턴스)의 'OnMontageEnded' 함수를 호출해줘" 라는 의미.
FOnMontageEnded MontageEndedDelegate;
MontageEndedDelegate.BindUObject(this, &UBTT_Attack::OnMontageEnded, &OwnerComp);
// 실제 몽타주 재생을 시작하고, 재생이 끝나면 호출될 델리게이트를 등록한다.
AnimInstance->Montage_Play(MontageToPlay);
AnimInstance->Montage_SetEndDelegate(MontageEndedDelegate, MontageToPlay);
// "이제 몽타주 재생을 시작했으니, 끝날 때까지 기다려줘" 라는 의미로 InProgress를 반환한다.
// 이 값을 반환해야 행동 트리가 다른 행동으로 넘어가지 않고 멈춰있는다.
return EBTNodeResult::InProgress;
}
void UBTT_Attack::OnMontageEnded(UAnimMontage* Montage, bool bInterrupted, UBehaviorTreeComponent* OwnerComp)
{
// 몽타주 재생이 정상적으로 완료되었거나, 혹은 중간에 중단되었다는 신호를 받으면 이 함수가 호출된다.
// 이제 행동 트리에 최종 결과를 보고할 시간이다.
if (bInterrupted)
{
// 몽타주가 중간에 끊겼다면, 태스크는 '실패'한 것으로 처리한다.
FinishLatentTask(*OwnerComp, EBTNodeResult::Failed);
}
else
{
// 몽타주가 끝까지 잘 재생되었다면, 태스크는 '성공'한 것으로 처리한다.
FinishLatentTask(*OwnerComp, EBTNodeResult::Succeeded);
}
}
EBTNodeResult::Type UBTT_Attack::AbortTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
// 이 태스크가 실행되는 도중(InProgress 상태일 때), Observer Aborts 같은 상위 노드의 조건 변화로 인해
// 강제로 중단 명령을 받으면 이 함수가 호출된다.
AAIMonsterBase* Monster = Cast<AAIMonsterBase>(OwnerComp.GetAIOwner()->GetPawn());
if (Monster && Monster->GetMesh() && Monster->GetMesh()->GetAnimInstance())
{
// 플레이어가 시야 밖으로 사라지는 등 상황이 변했으므로, 하던 공격 모션을 즉시 멈추는 것이 자연스럽다.
Monster->GetMesh()->GetAnimInstance()->Montage_Stop(0.1f, MontageToPlay);
}
// 태스크가 '중단'되었음을 BT에 알린다.
return EBTNodeResult::Aborted;
}
#include "AIC_Monster.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "Perception/AIPerceptionComponent.h"
#include "Sparta_TProject_02/Sparta_TProject_02Character.h"
using namespace std;
void AAIC_Monster::OnTargetPerceptionUpdated(AActor* Actor, FAIStimulus Stimulus)
{
// 감지된 액터가 'ACharacter'가 아닌, 정확히 'ASparta_TProject_02Character' 클래스인지 확인한다.
// 이 한 줄이 AI(ACharacter 상속)가 다른 AI를 적으로 인식하는 문제를 완벽하게 해결해준다.
ASparta_TProject_02Character* PlayerCharacter = Cast<ASparta_TProject_02Character>(Actor);
UBlackboardComponent* MyBlackboard = GetBlackboardComponent();
if (Stimulus.WasSuccessfullySensed())
{
// 감지된 대상이 플레이어가 맞을 경우에만 아래 로직을 실행한다.
if (PlayerCharacter != nullptr)
{
// 'TargetActor' 키에 플레이어 액터를 저장하여, BT가 추적/공격 상태로 전환되도록 한다.
MyBlackboard->SetValueAsObject(TEXT("TargetActor"), PlayerCharacter);
// 플레이어를 놓쳤을 때를 대비해, 마지막으로 본 위치를 계속해서 갱신한다.
MyBlackboard->SetValueAsVector(TEXT("LastKnownLocation"), PlayerCharacter->GetActorLocation());
}
}
else
{
// 시야에서 놓친 대상이 현재 추적하던 타겟과 동일한지 확인한다.
// (다른 타겟을 쫓는 중에 엉뚱한 대상이 사라졌다는 신호를 무시하기 위함)
AActor* CurrentTarget = Cast<AActor>(MyBlackboard->GetValueAsObject(TEXT("TargetActor")));
if (CurrentTarget == Actor)
{
// 'TargetActor'만 지워서 "이제 보이진 않는다"는 상태로 만든다.
// 'LastKnownLocation'은 그대로 남겨둬서, BT가 '수색' 상태로 전환되도록 유도한다.
MyBlackboard->ClearValue(TEXT("TargetActor"));
}
}
}
UNavigationSystemV1 관련 함수를 C++에서 사용했는데, 링커가 함수의 실체를 찾지 못해 발생한 오류였다. 코드 문법 문제가 아니라 프로젝트 설정 문제였다. 프로젝트이름.Build.cs 파일에 "NavigationSystem" 모듈 의존성을 추가하여 해결했다.UPROPERTY가 에디터에 표시되지 않는 문제: C++ 태스크에서 UPROPERTY(EditAnywhere)로 변수를 노출했는데도 디테일 패널에 보이지 않았다. 원인은 변수를 protected: 섹션에 선언했기 때문이었다. 에디터에 노출할 변수는 반드시 public: 섹션에 선언해야 한다.Cast<ACharacter>로 검사했더니, ACharacter를 상속받는 다른 AI까지 적으로 인식했다. Cast<MyPlayerCharacter>처럼 구체적인 플레이어 클래스로 캐스팅하여 문제를 해결했다.| 개념 | 설명 | 비고 |
|---|---|---|
| Observer Aborts | 데코레이터가 조건을 지속 감시하여, 만족 시 낮은 우선순위의 행동을 즉시 중단시키는 기능. | Lower Priority로 설정. AI의 반응성을 극대화하는 핵심 설정. |
| 비동기 BT Task | EBTNodeResult::InProgress를 반환하고, 작업 완료 후 FinishLatentTask를 호출하는 태스크. | 애니메이션 재생 등 시간이 걸리는 작업에 필수적이다. |
| LNK2019 링커 오류 | 코드의 '정의'를 찾지 못할 때 발생. 컴파일은 되지만 최종 실행 파일 생성을 못하는 상태. | .Build.cs 파일에 필요한 모듈(NavigationSystem 등)을 추가해야 해결. |
| 구체적 클래스 캐스팅 | Cast<ACharacter> 대신 Cast<MyPlayerCharacter>처럼 명확한 클래스로 검사하는 것. | AI가 아군이나 다른 적을 공격하는 문제를 방지하는 가장 확실한 방법. |