Dev./UE 언리얼 엔진

[TIL_250424] 언리얼 엔진 AI Controller로 플레이어 자동 추적 구현하기

raindrovvv 2025. 4. 24. 21:11

💭회고

🔹인공 지능

🔹자동 내비게이션 링크 생성

 

언리얼 엔진에서 NPC가 플레이어를 자연스럽게 추적하도록 만드는 기본기이자, 실무에서 자주 쓰이는 핵심 기능이다. 

  1. AIController 생성 및 기본 추적 기능 구현
  2. 거리 기반 상태 전환 추적 로직
  3. AI Perception(인지) 시스템과 자동 네비게이션 링크 활용

📒학습 내용

1. AIController 생성 및 기본 추적 기능 구현

CPP코드로 타겟액터를 설정하게 해뒀지만, 블루프린트를 이용하여 한번 더 안전코드를 적용할 수도 있다.

BP_ThirdPerson을 TargetActor로 설정하는 블루프린트 로

  1. New C++ Class → AIController 로 Chaser_AIController 생성
  2. 헤더에 TargetActor, StartChasing(), StopChasing(), ChaseRadius 변수·함수 선언
  3. BeginPlay()에서 초기화, Tick()에서 MoveToActor() 호출
  4. 추적 거리 이내일 때만 이동, 벗어나면 정지

“여러 AI Controller를 만들어두면 각 NPC마다 다른 행동 패턴을 모듈화할 수 있다.” 특히 RVO와 같은 기본기능의 AIController를 구현해두고 파생 AIController에서 상속받아 사용하는 구조의 개발은 생산성과 단위테스트 등을 용이하게 합니다.

// 핵심 멤버 변수
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="AI")
AActor* TargetActor;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="AI")
float ChaseRadius = 1000.0f;
bool bIsChasing = false;
더보기

🔹전체 코드

#pragma once
#include "CoreMinimal.h"
#include "AIController.h"
#include "Perception/AIPerceptionComponent.h"
#include "Perception/AISenseConfig_Sight.h"
#include "Chaser_AIController.generated.h"

// AI 상태 열거형 정의
UENUM(BlueprintType)
enum class EAIState : uint8
{
    Idle,
    Suspicious,
    Chasing
};

/**
 * 거리 기반 추적 로직이 구현된 AI 컨트롤러
 */
UCLASS()
class UE_AI_API AChaser_AIController : public AAIController
{
    GENERATED_BODY()

public:
    // 생성자
    AChaser_AIController();

    // 추적할 타겟(플레이어)
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "AI")
    AActor* TargetActor;

    // 추적 시작/중지 함수
    UFUNCTION(BlueprintCallable, Category = "AI")
    void StartChasing(AActor* Target);

    UFUNCTION(BlueprintCallable, Category = "AI")
    void StopChasing();

    // 거리 설정
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "AI")
    float DetectionRadius = 1500.0f;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "AI")
    float ChaseRadius = 1000.0f;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "AI")
    float LoseInterestRadius = 2000.0f;


    // 시야 감지 설정
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "AI")
    UAISenseConfig_Sight* SightConfig;

    // 감지 이벤트 처리 함수
    UFUNCTION()
    void OnPerceptionUpdated(AActor* Actor, FAIStimulus Stimulus);

    // 상태 변환 함수
    UFUNCTION(BlueprintCallable, Category = "AI")
    void UpdateAIState();

    // 현재 상태 반환
    UFUNCTION(BlueprintPure, Category = "AI")
    EAIState GetCurrentState() const { return CurrentState; }

protected:
    virtual void BeginPlay() override;
    virtual void Tick(float DeltaTime) override;

private:
    // 타겟 추적 중인지 여부
    bool bIsChasing = false;

    // 현재 AI 상태
    EAIState CurrentState = EAIState::Idle;

    // 마지막으로 타겟을 본 위치
    FVector LastKnownLocation;
};
#include "Chaser_AIController.h"
#include "Kismet/GameplayStatics.h"
#include "GameFramework/Character.h"
#include "DrawDebugHelpers.h"

AChaser_AIController::AChaser_AIController()
{
    // 매 프레임 틱 활성화
    PrimaryActorTick.bCanEverTick = true;

    // 시야 감지 설정 생성
    SightConfig = CreateDefaultSubobject<UAISenseConfig_Sight>(TEXT("SightConfig"));
    SightConfig->SightRadius = DetectionRadius;
    SightConfig->LoseSightRadius = LoseInterestRadius;
    SightConfig->PeripheralVisionAngleDegrees = 90.0f;
    SightConfig->DetectionByAffiliation.bDetectEnemies = true;
    SightConfig->DetectionByAffiliation.bDetectNeutrals = true;
    SightConfig->DetectionByAffiliation.bDetectFriendlies = true;


}

void AChaser_AIController::BeginPlay()
{
    Super::BeginPlay();

    // 인지 컴포넌트 초기화 후 컴포넌트 세팅
    if (SightConfig && GetPerceptionComponent())
    {
        GetPerceptionComponent()->ConfigureSense(*SightConfig);
        GetPerceptionComponent()->SetDominantSense(SightConfig->GetSenseImplementation());
        GetPerceptionComponent()->OnTargetPerceptionUpdated.AddDynamic(this, &AChaser_AIController::OnPerceptionUpdated);
    }

    // 기본 타겟으로 플레이어 설정 (선택적)
    ACharacter* PlayerCharacter = UGameplayStatics::GetPlayerCharacter(GetWorld(), 0);
    if (PlayerCharacter)
    {
        TargetActor = PlayerCharacter;
    }
}

void AChaser_AIController::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    // 상태 업데이트
    UpdateAIState();

    // 추적 로직
    if (bIsChasing && TargetActor)
    {
        APawn* ControlledPawn = GetPawn();
        if (ControlledPawn)
        {
            float Distance = FVector::Dist(ControlledPawn->GetActorLocation(), TargetActor->GetActorLocation());

            // 추적 거리 내에 있을 때만 추적
            if (Distance <= ChaseRadius)
            {
                // 플레이어를 향해 이동
                MoveToActor(TargetActor, 100.0f);

                // 마지막 위치 갱신
                LastKnownLocation = TargetActor->GetActorLocation();

                // 디버그 시각화 (시뮬레이션 중 확인용)
#if WITH_EDITOR
                DrawDebugLine(
                    GetWorld(),
                    ControlledPawn->GetActorLocation(),
                    TargetActor->GetActorLocation(),
                    FColor::Red,
                    false,
                    -1.0f,
                    0,
                    2.0f
                );
#endif
            }
            else if (Distance > LoseInterestRadius)
            {
                // 추적 중지
                StopChasing();
            }
        }
    }
}

void AChaser_AIController::StartChasing(AActor* Target)
{
    TargetActor = Target;
    bIsChasing = true;

    if (Target)
    {
        // 마지막 위치 업데이트
        LastKnownLocation = Target->GetActorLocation();
    }

    // 상태 변경
    CurrentState = EAIState::Chasing;
}

void AChaser_AIController::StopChasing()
{
    bIsChasing = false;
    StopMovement();

    // 상태 변경
    CurrentState = EAIState::Idle;
}

void AChaser_AIController::UpdateAIState()
{
    if (!TargetActor) return;

    APawn* ControlledPawn = GetPawn();
    if (!ControlledPawn) return;

    float DistanceToTarget = FVector::Dist(ControlledPawn->GetActorLocation(), TargetActor->GetActorLocation());

    switch (CurrentState)
    {
    case EAIState::Idle:
        if (DistanceToTarget <= DetectionRadius)
        {
            CurrentState = EAIState::Suspicious;
        }
        break;

    case EAIState::Suspicious:
        if (DistanceToTarget <= ChaseRadius)
        {
            StartChasing(TargetActor);
        }
        else if (DistanceToTarget > DetectionRadius)
        {
            CurrentState = EAIState::Idle;
        }
        break;

    case EAIState::Chasing:
        if (DistanceToTarget > LoseInterestRadius)
        {
            StopChasing();
        }
        break;
    }
}

void AChaser_AIController::OnPerceptionUpdated(AActor* Actor, FAIStimulus Stimulus)
{
    // 플레이어 캐릭터인지 확인
    ACharacter* PlayerCharacter = UGameplayStatics::GetPlayerCharacter(GetWorld(), 0);

    if (Actor == PlayerCharacter)
    {
        if (Stimulus.WasSuccessfullySensed())
        {
            // 플레이어 감지 성공
            TargetActor = Actor;

            // 거리에 따라 상태 변경
            APawn* ControlledPawn = GetPawn();
            if (ControlledPawn)
            {
                float Distance = FVector::Dist(ControlledPawn->GetActorLocation(), Actor->GetActorLocation());

                if (Distance <= ChaseRadius)
                {
                    StartChasing(Actor);
                }
                else if (Distance <= DetectionRadius)
                {
                    CurrentState = EAIState::Suspicious;
                }
            }
        }
        else
        {
            // 플레이어 감지 실패 (시야에서 사라짐)
            if (CurrentState == EAIState::Chasing)
            {
                // 마지막으로 본 위치로 이동
                MoveToLocation(LastKnownLocation, 50.0f);

                // 의심 상태로 전환
                CurrentState = EAIState::Suspicious;
            }
        }
    }
}

2. 거리 기반 상태 전환 로직

추적 여부를 보다 논리적으로 관리하려면 Detection → Suspicious → Chasing → Lose Interest 4단계 상태 전환이 필요하다.

상태  설명
Idle 플레이어를 인지 전 기본 상태
Suspicious DetectionRadius 이내로 진입했으나 ChaseRadius 이내는 아님
Chasing ChaseRadius 이내로 진입하여 실제로 추격 중
LoseInterest LoseInterestRadius를 벗어나면 추격 중단 및 Idle로 복귀
void UpdateAIState()
{
    float Distance = FVector::Dist(GetPawn()->GetActorLocation(), TargetActor->GetActorLocation());
    switch (CurrentState)
    {
        case EAIState::Idle:
            if (Distance <= DetectionRadius) CurrentState = EAIState::Suspicious;
            break;
        case EAIState::Suspicious:
            if (Distance <= ChaseRadius) StartChasing(TargetActor);
            else if (Distance > DetectionRadius) CurrentState = EAIState::Idle;
            break;
        case EAIState::Chasing:
            if (Distance > LoseInterestRadius) StopChasing();
            break;
    }
}

 


3. 인지 시스템(AI Perception) 추가

언리얼 엔진의 AI Perception은 감지 반경과 시야각 등을 설정해 이벤트로 처리할 수 있는 강력한 기능이다.

  1. UAIPerceptionComponent와 UAISenseConfig_Sight 추가
  2. 시야 감지 파라미터 설정
    • SightRadius: 인지 반경
    • LoseSightRadius: 추적 중지 반경
    • PeripheralVisionAngleDegrees: 시야각
  3. OnTargetPerceptionUpdated 델리게이트 바인딩
  4. 감지 성공/실패 시 상태 전환 및 최종 위치 저장

“Stimulus.WasSuccessfullySensed()를 통해 감지 여부를 핸들링한다.”


4. 자동 네비게이션 링크(NavLinkProxy) 활용

언리얼 엔진 5.5부터 자동 네비게이션 링크 기능을 지원해, 계단·언덕 등 경로가 끊긴 지점을 AI가 연결해 이동할 수 있다.

 

  1. RecastNavMesh의 Generation 옵션에서 자동 링크 생성 활성화
    1. 이 상태에서 AI가 점프해서 올라오지는 못함.
  2. 프로토타입으로 GeneratedNavLinksProxy 블루프린트 생성
    1. BP_NavLink
  3. 링크 도착 시 점프 로직 작성
  4. 시뮬레이션으로 AI가 끊긴 메쉬를 넘어가는지 확인


5. 트러블슈팅

문제 : RVOChaser AI가 왜 제대로 된 추적을 안하는가...
원인 : 생성한 BP_Chaser_AIController 블루프린트 에디터에서 컨트롤러 클래스를 제대로 연결하지 않았다.
해결 : AIContoller로 세팅되어 있던 부분 -> BP_Chaser_AIController

그리고 RVO 캐릭터 블루프린트 내부에도 위와 같이 로직으로 오류(공백) 방지를 위해 세팅해준다.


🟣오늘의 옵시디언 현황