오늘은 언리얼 엔진의 AI 시스템을 사용해 간단한 인공지능 캐릭터를 만들어 보았다. 평소에는 정해진 반경 내를 무작위로 돌아다니다가, 플레이어를 발견하면 쫓아오도록 구현했다. Navigation System을 이용한 이동과 Perception System을 이용한 감지, 그리고 TimerManager를 활용한 상태 전환이 이번 학습의 핵심이었다. 코드가 조금 길어졌지만, 각 시스템이 어떻게 유기적으로 동작하는지 이해할 수 있는 좋은 기회였다. 😄
Navigation System을 이용해 AI가 랜덤하게 돌아다니는 기능 구현하기AIPerceptionComponent를 사용해 플레이어를 '볼' 수 있는 시야 기능 추가하기NavMeshBoundsVolume을 배치해야 AI가 이동 가능한 영역을 계산할 수 있다.AISense_Sight)을 사용했다.#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;
};
#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); // 타겟을 향해 계속 이동
}
}
#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;
};
#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.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();
}
}
Get Acceleration 노드의 반환값이 항상 0이었다. AI Controller의 MoveToLocation이나 MoveToActor 함수는 캐릭터의 가속도 값을 직접 변경하지 않기 때문이었다. 이 문제는 Get Velocity 노드를 사용하고 벡터의 길이를 구해 속도를 체크하는 방식으로 해결했다.AIPerceptionStimuliSourceComponent를 추가하고, BeginPlay에서 감각 시스템에 등록해주는 코드를 빠뜨렸기 때문이었다. AI가 '보는' 기능이 있어도, 플레이어가 '보여주는' 기능이 없으면 소용이 없었다.AIPerceptionComponent를 SpartaAICharacter 클래스에 추가했다. 하지만 이렇게 하니 AI가 Pawn에 빙의(Possess)하기 전에 Perception 시스템이 제대로 초기화되지 않거나, Controller가 감각 정보를 직접 참조하기 어려운 구조적인 문제가 발생했다. Perception은 AI의 '뇌'가 판단하는 정보이므로, AI Controller에 작성하는 것이 언리얼의 설계 의도에 더 적합했다.| 개념 | 설명 | 비고 |
|---|---|---|
| AIController | AI의 행동을 결정하는 '뇌' 역할을 하는 클래스이다. | Pawn과 분리되어 있어 재사용성이 높다. |
| AIPerceptionComponent | AI에게 시각, 청각 등 '감각'을 부여하는 컴포넌트이다. | 어떤 종류의 감각을 사용할지 ConfigureSense로 설정한다. |
| UNavigationSystemV1 | AI가 월드 내에서 길을 찾을 수 있도록 경로를 계산해주는 시스템이다. | 레벨에 NavMeshBoundsVolume이 반드시 필요하다. |
| FTimerManager | 특정 함수를 지연시키거나 주기적으로 호출할 때 사용하는 시스템이다. | SetTimer로 타이머를 설정하고 ClearTimer로 해제한다. |
| AIPerceptionStimuliSource | 다른 AI가 자신을 감지할 수 있는 '자극원' 역할을 하게 만드는 컴포넌트다. | 플레이어나 다른 NPC에 부착해야 AI가 인식할 수 있다. |