[TIL_250424] 언리얼 엔진 AI Controller로 플레이어 자동 추적 구현하기
💭회고
언리얼 엔진에서 NPC가 플레이어를 자연스럽게 추적하도록 만드는 기본기이자, 실무에서 자주 쓰이는 핵심 기능이다.
- AIController 생성 및 기본 추적 기능 구현
- 거리 기반 상태 전환 추적 로직
- AI Perception(인지) 시스템과 자동 네비게이션 링크 활용
📒학습 내용
1. AIController 생성 및 기본 추적 기능 구현
CPP코드로 타겟액터를 설정하게 해뒀지만, 블루프린트를 이용하여 한번 더 안전코드를 적용할 수도 있다.
- New C++ Class → AIController 로 Chaser_AIController 생성
- 헤더에 TargetActor, StartChasing(), StopChasing(), ChaseRadius 변수·함수 선언
- BeginPlay()에서 초기화, Tick()에서 MoveToActor() 호출
- 추적 거리 이내일 때만 이동, 벗어나면 정지
“여러 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은 감지 반경과 시야각 등을 설정해 이벤트로 처리할 수 있는 강력한 기능이다.
- UAIPerceptionComponent와 UAISenseConfig_Sight 추가
- 시야 감지 파라미터 설정
- SightRadius: 인지 반경
- LoseSightRadius: 추적 중지 반경
- PeripheralVisionAngleDegrees: 시야각
- OnTargetPerceptionUpdated 델리게이트 바인딩
- 감지 성공/실패 시 상태 전환 및 최종 위치 저장
“Stimulus.WasSuccessfullySensed()를 통해 감지 여부를 핸들링한다.”
4. 자동 네비게이션 링크(NavLinkProxy) 활용
언리얼 엔진 5.5부터 자동 네비게이션 링크 기능을 지원해, 계단·언덕 등 경로가 끊긴 지점을 AI가 연결해 이동할 수 있다.
- RecastNavMesh의 Generation 옵션에서 자동 링크 생성 활성화
- 이 상태에서 AI가 점프해서 올라오지는 못함.
- 프로토타입으로 GeneratedNavLinksProxy 블루프린트 생성
- BP_NavLink
- 링크 도착 시 점프 로직 작성
- 시뮬레이션으로 AI가 끊긴 메쉬를 넘어가는지 확인
5. 트러블슈팅
문제 : RVOChaser AI가 왜 제대로 된 추적을 안하는가...
원인 : 생성한 BP_Chaser_AIController 블루프린트 에디터에서 컨트롤러 클래스를 제대로 연결하지 않았다.
해결 : AIContoller로 세팅되어 있던 부분 -> BP_Chaser_AIController
그리고 RVO 캐릭터 블루프린트 내부에도 위와 같이 로직으로 오류(공백) 방지를 위해 세팅해준다.