[DAY44] Wandering/Chashing AI

베리투스·2025년 10월 13일

TIL: Today I Learned

목록 보기
45/93

오늘은 언리얼 엔진의 AI 시스템을 사용해 간단한 인공지능 캐릭터를 만들어 보았다. 평소에는 정해진 반경 내를 무작위로 돌아다니다가, 플레이어를 발견하면 쫓아오도록 구현했다. Navigation System을 이용한 이동과 Perception System을 이용한 감지, 그리고 TimerManager를 활용한 상태 전환이 이번 학습의 핵심이었다. 코드가 조금 길어졌지만, 각 시스템이 어떻게 유기적으로 동작하는지 이해할 수 있는 좋은 기회였다. 😄


📌 목표

  • AI Controller와 Character 클래스를 생성하고 연결하기
  • Navigation System을 이용해 AI가 랜덤하게 돌아다니는 기능 구현하기
  • AIPerceptionComponent를 사용해 플레이어를 '볼' 수 있는 시야 기능 추가하기
  • 플레이어 감지 시, 추적 상태로 전환하고 이동 속도 변경하기
  • 플레이어를 놓치면 다시 랜덤 이동 상태로 복귀하기

📖 이론

1. 언리얼 AI 핵심 구성 요소

  • AI Controller: AI의 '뇌'에 해당한다. 무엇을 할지 결정하고, Pawn(Character)에게 명령을 내린다.
  • Pawn / Character: AI의 '몸'이다. Controller의 명령을 받아 월드에서 실제로 움직인다.
  • Navigation System: AI가 길을 찾을 수 있도록 도와주는 '지도' 시스템이다. 레벨에 NavMeshBoundsVolume을 배치해야 AI가 이동 가능한 영역을 계산할 수 있다.
  • Perception System: AI의 '감각' 기관이다. 시각, 청각 등을 통해 주변 환경 정보를 수집한다. 이번에는 시각(AISense_Sight)을 사용했다.
  • AIPerceptionStimuliSourceComponent: 다른 AI가 '감지할 수 있는 원천'이 되게 해주는 컴포넌트다. 플레이어 캐릭터에 이 컴포넌트를 추가해야 AI가 플레이어를 인식할 수 있다.

💻 코드

SpartaAIController.h

#pragma once

#include "CoreMinimal.h"
#include "AIController.h"
#include "Perception/AIPerceptionTypes.h"
#include "SpartaAIController.generated.h"

class UAIPerceptionComponent;
class UAISenseConfig_Sight;

UCLASS()
class SPARTAPROJECT_API ASpartaAIController : public AAIController
{
	GENERATED_BODY()

public:
	ASpartaAIController();

protected:
	// AI의 감각을 관리하는 컴포넌트
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "AI")
	UAIPerceptionComponent* AIPerception;

	// 시야 감각에 대한 상세 설정
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "AI")
	UAISenseConfig_Sight* SightConfig;

	// 현재 추적 중인 타겟
	UPROPERTY()
	AActor* CurrentTarget = nullptr;

	// Perception 컴포넌트가 무언가를 감지했을 때 호출될 함수
	UFUNCTION()
	void OnPerceptionUpdated(AActor* Actor, FAIStimulus Stimulus);

	bool bIsChasing = false;
	FTimerHandle ChaseTimer; // 추적 중일 때 주기적으로 타겟 위치를 업데이트하기 위한 타이머

	virtual void BeginPlay() override;
	virtual void OnPossess(APawn* InPawn) override; // AI가 Pawn에 빙의했을 때 호출

	void StartChasing(AActor* Target);
	void StopChasing();
	void UpdateChase();

private:
	void MoveToRandomLocation(); // 랜덤 위치로 이동하는 함수

	FTimerHandle RandomMoveTimer; // 랜덤 이동을 주기적으로 실행하기 위한 타이머

	// 랜덤 이동 반경
	UPROPERTY(EditAnywhere, Category = "AI")
	float MoveRadius = 1000.0f;
};

SpartaAIController.cpp

#include "SpartaAIController.h"
#include "TimerManager.h"
#include "NavigationSystem.h"
#include "Perception/AIPerceptionComponent.h"
#include "Perception/AISenseConfig_Sight.h"
#include "SpartaAICharacter.h"
#include "Kismet/GameplayStatics.h"

ASpartaAIController::ASpartaAIController()
{
	// Perception 컴포넌트와 Sight 설정 객체 생성
	AIPerception = CreateDefaultSubobject<UAIPerceptionComponent>(TEXT("AIPerception"));
	SightConfig = CreateDefaultSubobject<UAISenseConfig_Sight>(TEXT("SightConfig"));

	// 시야 범위, 잃는 범위, 시야각 등 설정
	SightConfig->SightRadius = 1500.0f;
	SightConfig->LoseSightRadius = 2000.0f;
	SightConfig->PeripheralVisionAngleDegrees = 90.0f;
	SightConfig->SetMaxAge(5.0f); // 감지 정보를 5초간 기억

	// 모든 유형(적, 중립, 아군)을 감지하도록 설정
	SightConfig->DetectionByAffiliation.bDetectEnemies = true;
	SightConfig->DetectionByAffiliation.bDetectNeutrals = true;
	SightConfig->DetectionByAffiliation.bDetectFriendlies = true;

	// Perception 컴포넌트에 Sight 설정 적용
	AIPerception->ConfigureSense(*SightConfig);
	AIPerception->SetDominantSense(SightConfig->GetSenseImplementation());
}

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

	if (AIPerception)
	{
		// OnPerceptionUpdated 함수를 델리게이트에 바인딩
		AIPerception->OnTargetPerceptionUpdated.AddDynamic(this, &ASpartaAIController::OnPerceptionUpdated);
	}

	// 1초 후부터 3초마다 MoveToRandomLocation 함수 실행
	GetWorldTimerManager().SetTimer(
		RandomMoveTimer, this, &ASpartaAIController::MoveToRandomLocation, 3.0f, true, 1.0f
	);
}

void ASpartaAIController::OnPossess(APawn* InPawn)
{
	Super::OnPossess(InPawn);
}

void ASpartaAIController::OnPerceptionUpdated(AActor* Actor, FAIStimulus Stimulus)
{
	// 감지된 액터가 플레이어인지 확인
	APawn* PlayerPawn = UGameplayStatics::GetPlayerPawn(GetWorld(), 0);
	if (Actor != PlayerPawn) return;

	if (Stimulus.WasSuccessfullySensed()) // 성공적으로 감지했다면
	{
		StartChasing(Actor);
	}
	else // 시야에서 놓쳤다면
	{
		StopChasing();
	}
}

void ASpartaAIController::MoveToRandomLocation()
{
	APawn* MyPawn = GetPawn();
	if (!MyPawn) return;

	// Navigation System을 이용해 이동 가능한 랜덤 위치 탐색
	UNavigationSystemV1* NavSystem = UNavigationSystemV1::GetCurrent(GetWorld());
	if (NavSystem)
	{
		FNavLocation RandomLocation;
		bool bFoundLocation = NavSystem->GetRandomReachablePointInRadius(
			MyPawn->GetActorLocation(), MoveRadius, RandomLocation
		);

		if (bFoundLocation)
		{
			MoveToLocation(RandomLocation.Location); // 해당 위치로 이동
		}
	}
}

void ASpartaAIController::StartChasing(AActor* Target)
{
	if (bIsChasing && CurrentTarget == Target) return;

	CurrentTarget = Target;
	bIsChasing = true;
	GetWorldTimerManager().ClearTimer(RandomMoveTimer); // 랜덤 이동 타이머 정지

	// AI 캐릭터의 속도를 '달리기'로 변경
	if (ASpartaAICharacter* AIChar = Cast<ASpartaAICharacter>(GetPawn()))
	{
		AIChar->SetMovementSpeed(AIChar->RunSpeed);
	}

	// 0.25초마다 추적 상태를 업데이트하는 타이머 시작
	GetWorldTimerManager().SetTimer(ChaseTimer, this, &ASpartaAIController::UpdateChase, 0.25f, true);
}

void ASpartaAIController::StopChasing()
{
	if (!bIsChasing) return;

	CurrentTarget = nullptr;
	bIsChasing = false;
	GetWorldTimerManager().ClearTimer(ChaseTimer); // 추적 타이머 정지
	StopMovement(); // 현재 이동 중지

	// AI 캐릭터의 속도를 '걷기'로 변경
	if (ASpartaAICharacter* AIChar = Cast<ASpartaAICharacter>(GetPawn()))
	{
		AIChar->SetMovementSpeed(AIChar->WalkSpeed);
	}

	// 2초 후부터 다시 랜덤 이동 시작
	GetWorldTimerManager().SetTimer(
		RandomMoveTimer, this, &ASpartaAIController::MoveToRandomLocation, 3.0f, true, 2.0f
	);
}

void ASpartaAIController::UpdateChase()
{
	if (CurrentTarget && bIsChasing)
	{
		MoveToActor(CurrentTarget, 100.0f); // 타겟을 향해 계속 이동
	}
}

SpartaAICharacter.h

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "SpartaAICharacter.generated.h"

UCLASS()
class SPARTAPROJECT_API ASpartaAICharacter : public ACharacter
{
	GENERATED_BODY()

public:
	ASpartaAICharacter();
	void SetMovementSpeed(float NewSpeed);

	// 걷기 속도
	UPROPERTY(EditAnywhere, Category = "AI")
	float WalkSpeed = 300.0f;

	// 달리기 속도
	UPROPERTY(EditAnywhere, Category = "AI")
	float RunSpeed = 600.0f;

protected:
	virtual void BeginPlay() override;
};

SpartaAICharacter.cpp

#include "SpartaAICharacter.h"
#include "SpartaAIController.h"
#include "GameFramework/CharacterMovementComponent.h"

ASpartaAICharacter::ASpartaAICharacter()
{
	// 이 캐릭터가 사용할 AI 컨트롤러 클래스 지정
	AIControllerClass = ASpartaAIController::StaticClass();
	// 월드에 배치되거나 스폰될 때 자동으로 AI 컨트롤러가 빙의하도록 설정
	AutoPossessAI = EAutoPossessAI::PlacedInWorldOrSpawned;

	UCharacterMovementComponent* Movement = GetCharacterMovement();
	Movement->MaxWalkSpeed = WalkSpeed; // 최대 걷기 속도 초기화
	Movement->bOrientRotationToMovement = true; // 이동 방향으로 캐릭터 회전
	Movement->RotationRate = FRotator(0.0f, 540.0f, 0.0f);
}

void ASpartaAICharacter::BeginPlay()
{
	Super::BeginPlay();
}

void ASpartaAICharacter::SetMovementSpeed(float NewSpeed)
{
	// 캐릭터의 이동 속도를 변경
	if (UCharacterMovementComponent* Movement = GetCharacterMovement())
	{
		Movement->MaxWalkSpeed = NewSpeed;
	}
}

PlayerCharacter 수정

// PlayerCharacter.h
#include "Perception/AIPerceptionStimuliSourceComponent.h"

protected:
    // AI가 이 캐릭터를 감지할 수 있도록 하는 컴포넌트
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "AI")
    UAIPerceptionStimuliSourceComponent* StimuliSource;

// PlayerCharacter.cpp
#include "Perception/AISense_Sight.h"

AThirdPersonCharacter::AThirdPersonCharacter()
{
    // ...
    StimuliSource = CreateDefaultSubobject<UAIPerceptionStimuliSourceComponent>(TEXT("StimuliSource"));
}

void AThirdPersonCharacter::BeginPlay()
{
    Super::BeginPlay();
    if (StimuliSource)
    {
        // 이 캐릭터를 '시각'으로 감지할 수 있도록 등록
        StimuliSource->RegisterForSense(TSubclassOf<UAISense_Sight>());
        StimuliSource->RegisterWithPerceptionSystem();
    }
}

⚠️ 실수

  • AI 애니메이션이 재생되지 않는 문제: AI가 잘 움직이는데도 걷거나 뛰는 애니메이션 없이 미끄러지듯 이동했다. 디버깅해보니 애니메이션 블루프린트에서 Get Acceleration 노드의 반환값이 항상 0이었다. AI Controller의 MoveToLocation이나 MoveToActor 함수는 캐릭터의 가속도 값을 직접 변경하지 않기 때문이었다. 이 문제는 Get Velocity 노드를 사용하고 벡터의 길이를 구해 속도를 체크하는 방식으로 해결했다.
  • AI가 플레이어를 인식하지 못하는 문제: Perception 설정을 다 했는데도 AI가 플레이어를 보고도 반응이 없었다. 플레이어 캐릭터AIPerceptionStimuliSourceComponent를 추가하고, BeginPlay에서 감각 시스템에 등록해주는 코드를 빠뜨렸기 때문이었다. AI가 '보는' 기능이 있어도, 플레이어가 '보여주는' 기능이 없으면 소용이 없었다.
  • Perception 컴포넌트를 Character에 작성한 문제: 처음에는 AI의 모든 로직을 한 곳에 모으고 싶어서 AIPerceptionComponentSpartaAICharacter 클래스에 추가했다. 하지만 이렇게 하니 AI가 Pawn에 빙의(Possess)하기 전에 Perception 시스템이 제대로 초기화되지 않거나, Controller가 감각 정보를 직접 참조하기 어려운 구조적인 문제가 발생했다. Perception은 AI의 '뇌'가 판단하는 정보이므로, AI Controller에 작성하는 것이 언리얼의 설계 의도에 더 적합했다.

✅ 핵심 요약

개념설명비고
AIControllerAI의 행동을 결정하는 '뇌' 역할을 하는 클래스이다.Pawn과 분리되어 있어 재사용성이 높다.
AIPerceptionComponentAI에게 시각, 청각 등 '감각'을 부여하는 컴포넌트이다.어떤 종류의 감각을 사용할지 ConfigureSense로 설정한다.
UNavigationSystemV1AI가 월드 내에서 길을 찾을 수 있도록 경로를 계산해주는 시스템이다.레벨에 NavMeshBoundsVolume이 반드시 필요하다.
FTimerManager특정 함수를 지연시키거나 주기적으로 호출할 때 사용하는 시스템이다.SetTimer로 타이머를 설정하고 ClearTimer로 해제한다.
AIPerceptionStimuliSource다른 AI가 자신을 감지할 수 있는 '자극원' 역할을 하게 만드는 컴포넌트다.플레이어나 다른 NPC에 부착해야 AI가 인식할 수 있다.
profile
Shin Ji Yong // Unreal Engine 5 공부중입니다~

0개의 댓글