[DAY52] Shooter Project(3) : Advanced AI Behavior Tree

베리투스·2025년 10월 23일
0

Shooter Project

목록 보기
3/10

어제 만든 AI의 행동 트리를 대폭 업그레이드했다. 순찰, 추적, 공격뿐만 아니라 플레이어를 놓쳤을 때 마지막 위치를 수색하는 지능적인 행동을 C++ 커스텀 태스크로 구현했다. 이 과정에서 링커 오류, 비동기 처리, AI끼리 싸우는 문제 등 다양한 버그를 해결하며 AI 디버깅에 대한 이해도를 높였다. 🧠


📌 목표

  • AI 행동 패턴 고도화 (순찰 → 추적 → 공격 → 수색 → 순찰)
  • 블루프린트(BP)로 만들었던 BT 태스크를 C++ 코드로 모두 전환
  • 보스와 좀비가 서로 다른 행동 패턴(특수 공격 유무)을 갖도록 분리
  • AI의 반응성 향상 (순찰 중 플레이어 발견 시 즉시 추적)

📖 이론

1. 비동기(Asynchronous) BT Task

행동 트리 태스크는 보통 한 프레임 안에 모든 일을 끝내고 SucceededFailed를 반환한다. 하지만 공격 애니메이션처럼 시간이 걸리는 작업을 처리하려면 태스크가 "작업이 끝날 때까지 기다리게" 만들어야 한다.

  • EBTNodeResult::InProgress: 태스크의 ExecuteTask 함수가 이 값을 반환하면, 행동 트리는 이 태스크가 아직 "진행 중"이라고 인식하고 다른 행동으로 넘어가지 않는다.
  • FinishLatentTask: 비동기 작업이 모두 끝났을 때, 이 함수를 호출하여 행동 트리에 최종적으로 Succeeded 또는 Failed 결과를 알려준다. UAnimInstance::Montage_SetEndDelegate 같은 콜백 함수와 함께 사용된다.

2. 데코레이터의 "Observer Aborts"

행동 트리는 기본적으로 하나의 태스크가 끝나야만 다음 평가를 진행한다. 이 때문에 순찰(Move To) 중에 플레이어를 봐도 즉시 반응하지 못하는 문제가 생긴다.

  • "Observer Aborts": 데코레이터의 이 설정을 Lower Priority로 바꾸면, 데코레이터가 자신의 조건을 지속적으로 감시하게 된다. 만약 조건이 만족되면(예: TargetActor가 설정되면), 현재 실행 중인 더 낮은 우선순위(더 오른쪽에 있는)의 모든 행동을 즉시 중단시키고 자신의 가지를 실행한다. AI의 반응성을 극대화하는 핵심 기능이다. 👍

💻 코드

BTT_Attack.h

#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;
};

BTT_Attack.cpp

#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;
}

AIC_Monster.cpp

#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"));
        }
    }
}

⚠️ 실수

  • LNK2019 링커 오류: UNavigationSystemV1 관련 함수를 C++에서 사용했는데, 링커가 함수의 실체를 찾지 못해 발생한 오류였다. 코드 문법 문제가 아니라 프로젝트 설정 문제였다. 프로젝트이름.Build.cs 파일에 "NavigationSystem" 모듈 의존성을 추가하여 해결했다.
  • UPROPERTY가 에디터에 표시되지 않는 문제: C++ 태스크에서 UPROPERTY(EditAnywhere)로 변수를 노출했는데도 디테일 패널에 보이지 않았다. 원인은 변수를 protected: 섹션에 선언했기 때문이었다. 에디터에 노출할 변수는 반드시 public: 섹션에 선언해야 한다.
  • AI가 다른 AI를 공격하는 문제: Perception에서 감지 대상을 Cast<ACharacter>로 검사했더니, ACharacter를 상속받는 다른 AI까지 적으로 인식했다. Cast<MyPlayerCharacter>처럼 구체적인 플레이어 클래스로 캐스팅하여 문제를 해결했다.

✅ 핵심 요약

개념설명비고
Observer Aborts데코레이터가 조건을 지속 감시하여, 만족 시 낮은 우선순위의 행동을 즉시 중단시키는 기능.Lower Priority로 설정. AI의 반응성을 극대화하는 핵심 설정.
비동기 BT TaskEBTNodeResult::InProgress를 반환하고, 작업 완료 후 FinishLatentTask를 호출하는 태스크.애니메이션 재생 등 시간이 걸리는 작업에 필수적이다.
LNK2019 링커 오류코드의 '정의'를 찾지 못할 때 발생. 컴파일은 되지만 최종 실행 파일 생성을 못하는 상태..Build.cs 파일에 필요한 모듈(NavigationSystem 등)을 추가해야 해결.
구체적 클래스 캐스팅Cast<ACharacter> 대신 Cast<MyPlayerCharacter>처럼 명확한 클래스로 검사하는 것.AI가 아군이나 다른 적을 공격하는 문제를 방지하는 가장 확실한 방법.
profile
Shin Ji Yong // Unreal Engine 5 공부중입니다~

0개의 댓글