6 분 소요

AI 특강 - 이동 & 감지

김하연 튜터님의 Notion 자료를 바탕으로 강의를 들으며
일부를 수정 및 재작성한 블로깅용 글

1. AI 시스템의 전체 구조 🧠

플레이어 vs AI의 차이점

  • 플레이어 캐릭터: 키보드/마우스 입력 → Player Controller → Character → 움직임
  • AI 캐릭터: 프로그래밍된 로직 → AI Controller → Character → 움직임

언리얼 AI의 핵심 요소들

  1. AI Controller (뇌)
    • 무엇을 할지 결정하는 곳
    • 의사 결정을 담당
  2. Pawn/Character (몸)
    • 실제로 맵에서 움직이는 메시
    • Controller의 명령을 받아 실행
    • 실제 World에 존재하는 Mesh 같은 것
    • Player/AI 인지에 따라 그 역할이 달라짐
  3. Navigation System (지도)
    • AI가 “어디로 갈 수 있는지” 알려주는 시스템
    • 장애물을 피해가는 경로 계산
  4. Perception System (감각)
    • 시각, 청각 등 감지 시스템
      (오늘은 여기까지 할 예정)
  5. Blackboard (기억)
    • AI의 단기 기억 저장소
    • “플레이어 위치”, “경계 상태” 등 저장
  6. Behavior Tree (행동 결정)
    • IF-THEN 로직을 시각적으로 표현
    • 만약 플레이어가 보이면 → 쫓아가기

2. 기본 AI 생성 - 아무것도 안하고 서있는 AI 😴

1. SpartaAIController 클래스 생성 및 작성

#pragma once

#include "CoreMinimal.h"
#include "AIController.h"
#include "SpartaAIController.generated.h"

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

public:
	ASpartaAIController();

protected:
	virtual void OnPossess(APawn* InPawn) override;
};
#include "SpartaAIController.h"

ASpartaAIController::ASpartaAIController()
{

}

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

	if (InPawn)
	{
		UE_LOG(LogTemp, Warning, TEXT("[Sparta] AI Controller is controlling %s."), *InPawn->GetName());
	}
}

2. SpartaAICharacter 클래스 생성 및 작성

#pragma once

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

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

public:
	ASpartaAICharacter();

protected:
	virtual void BeginPlay() override;
};
#include "SpartaAICharacter.h"
#include "SpartaAIController.h"

ASpartaAICharacter::ASpartaAICharacter()
{
	AIControllerClass = ASpartaAIController::StaticClass();
	AutoPossessAI = EAutoPossessAI::PlacedInWorldOrSpawned;
}

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

	UE_LOG(LogTemp, Warning, TEXT("[Sparta] AI character has been spawned."));
}

3. Navigation 추가 - 랜덤하게 돌아다니는 AI 🎲

1. SpartaAIController 클래스 수정

#pragma once

#include "CoreMinimal.h"
#include "AIController.h"
#include "SpartaAIController.generated.h"

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

public:
	ASpartaAIController();

protected:
	virtual void BeginPlay() override;
	virtual void OnPossess(APawn* InPawn) override;

private:
	void MoveToRandomLocation();

	FTimerHandle RandomMoveTimer;

	UPROPERTY(EditAnywhere, Category = "AI")
	float MoveRadius = 1000.0f;
};

#include "SpartaAIController.h"
#include "TimerManager.h"
#include "NavigationSystem.h"

ASpartaAIController::ASpartaAIController()
{

}

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

	GetWorldTimerManager().SetTimer(
		RandomMoveTimer,
		this,
		&ASpartaAIController::MoveToRandomLocation,
		3.0f,
		true,
		1.0f
	);
}

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

	if (InPawn)
	{
		UE_LOG(LogTemp, Warning, TEXT("[Sparta] AI Controller is controlling %s."), *InPawn->GetName());
	}
}

void ASpartaAIController::MoveToRandomLocation()
{
	APawn* MyPawn = GetPawn();
	if (!MyPawn)
	{
		UE_LOG(LogTemp, Error, TEXT("[Sparta] No Pawn to control."));
	}

	UNavigationSystemV1* NavSystem = UNavigationSystemV1::GetCurrent(GetWorld());
	if (!NavSystem)
	{
		UE_LOG(LogTemp, Error, TEXT("[Sparta] Could not find Navigation System."));
	}

	FNavLocation RandomLocation;
	bool bFoundLocation = NavSystem->GetRandomReachablePointInRadius(
		MyPawn->GetActorLocation(),
		MoveRadius,
		RandomLocation
	);

	if (bFoundLocation)
	{
		MoveToLocation(RandomLocation.Location);

		UE_LOG(LogTemp, Warning, TEXT("[Sparta] Move target: %s"), *RandomLocation.Location.ToString());
	}
	else
	{
		UE_LOG(LogTemp, Warning, TEXT("[Sparta] Could not find a reachable location."));
	}
}
  • MoveToLocation 같은 Nav 함수는
    AI로 조작하기에
    Get Current Accelation 같은 ‘Input’ 기반의 함수로 ABP에서 검사하는 경우는
    ABP가 망가질 수 있음

  • ABP 쪽에서 Player 와 AI 가 컨트롤하는지에 따라 분기를 나누는 것을 권장

2. AI 이동 속도 제어

#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()
{
	AIControllerClass = ASpartaAIController::StaticClass();
	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();

	UE_LOG(LogTemp, Warning, TEXT("[Sparta] AI character has been spawned."));
}

void ASpartaAICharacter::SetMovementSpeed(float NewSpeed)
{
	if (UCharacterMovementComponent* Movement = GetCharacterMovement())
	{
		Movement->MaxWalkSpeed = NewSpeed;
		UE_LOG(LogTemp, Warning, TEXT("[Sparta] Speed changed: %.1f"), NewSpeed);
	}
}

4. Perception 추가 - AI에게 눈을 달아주기 👀

1. SpartaAIController 클래스 수정

#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:
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "AI")
	UAIPerceptionComponent* AIPerception;

	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "AI")
	UAISenseConfig_Sight* SightConfig;

	UFUNCTION()
	void OnPerceptionUpdated(AActor* Actor, FAIStimulus Stimulus);

	virtual void BeginPlay() override;
	virtual void OnPossess(APawn* InPawn) override;

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"

ASpartaAIController::ASpartaAIController()
{
	AIPerception = CreateDefaultSubobject<UAIPerceptionComponent>(TEXT("AIPerception"));
	SetPerceptionComponent(*AIPerception);

	SightConfig = CreateDefaultSubobject<UAISenseConfig_Sight>(TEXT("SightConfig"));
	SightConfig->SightRadius = 1500.0f;
	SightConfig->LoseSightRadius = 2000.0f;
	SightConfig->PeripheralVisionAngleDegrees = 90.0f;
	SightConfig->SetMaxAge(5.0f);

	SightConfig->DetectionByAffiliation.bDetectEnemies = true;
	SightConfig->DetectionByAffiliation.bDetectNeutrals = true;
	SightConfig->DetectionByAffiliation.bDetectFriendlies = true;

	AIPerception->ConfigureSense(*SightConfig);
	AIPerception->SetDominantSense(SightConfig->GetSenseImplementation());
}

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

	if (AIPerception)
	{
		AIPerception->OnTargetPerceptionUpdated.AddDynamic(
			this,
			&ASpartaAIController::OnPerceptionUpdated
		);
	}

	GetWorldTimerManager().SetTimer(
		RandomMoveTimer,
		this,
		&ASpartaAIController::MoveToRandomLocation,
		3.0f,
		true,
		1.0f
	);
}

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

	if (InPawn)
	{
		UE_LOG(LogTemp, Warning, TEXT("[Sparta] AI Controller is controlling %s."), *InPawn->GetName());
	}
}

void ASpartaAIController::MoveToRandomLocation()
{
	APawn* MyPawn = GetPawn();
	if (!MyPawn)
	{
		UE_LOG(LogTemp, Error, TEXT("[Sparta] No Pawn to control."));
	}

	UNavigationSystemV1* NavSystem = UNavigationSystemV1::GetCurrent(GetWorld());
	if (!NavSystem)
	{
		UE_LOG(LogTemp, Error, TEXT("[Sparta] Could not find Navigation System."));
	}

	FNavLocation RandomLocation;
	bool bFoundLocation = NavSystem->GetRandomReachablePointInRadius(
		MyPawn->GetActorLocation(),
		MoveRadius,
		RandomLocation
	);

	if (bFoundLocation)
	{
		MoveToLocation(RandomLocation.Location);

		UE_LOG(LogTemp, Warning, TEXT("[Sparta] Move target: %s"), *RandomLocation.Location.ToString());
	}
	else
	{
		UE_LOG(LogTemp, Warning, TEXT("[Sparta] Could not find a reachable location."));
	}
}

void ASpartaAIController::OnPerceptionUpdated(AActor* Actor, FAIStimulus Stimulus)
{
	if (Stimulus.WasSuccessfullySensed())
	{
		UE_LOG(LogTemp, Warning, TEXT("[Sparta] Saw something! %s"), *Actor->GetName());

		DrawDebugString(
			GetWorld(),
			Actor->GetActorLocation() + FVector(0, 0, 100),
			FString::Printf(TEXT("Saw: %s"), *Actor->GetName()),
			nullptr,
			FColor::Green,
			2.0f,
			true
		);
	}
	else
	{
		UE_LOG(LogTemp, Warning, TEXT("[Sparta] Missed it! %s"), *Actor->GetName());

		DrawDebugString(
			GetWorld(),
			Actor->GetActorLocation() + FVector(0, 0, 100),
			FString::Printf(TEXT("Missed: %s"), *Actor->GetName()),
			nullptr,
			FColor::Red,
			2.0f,
			true
		);
	}
}
  • 감지 조건은 다양하기에
    (현재는 ‘시야’에 기반한 Sight 지정)

  • 우선시하는 ‘감지 조건’(Dominant) 설정도 가능

  • 아군/중립/적군 같은 감지 조건도 가능

  • 이렇게 AI에 대한 ‘감지’ 기능만 추가해도 플레이어는 감지 X
    Player가 ‘감지 될 수 있는’ 기능 또한 넣어주어야 함
    (아마도 불필요한 감지를 피하기 위해?)

// ... 기존 코드에 추가
#include "Perception/AIPerceptionStimuliSourceComponent.h"

protected:
	virtual void BeginPlay() override;
	
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "AI")
    UAIPerceptionStimuliSourceComponent* StimuliSource;
// ... 기존 코드에 추가
#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();
    }
}
  • UAIPerceptionStimuliSourceComponent 를 통해
    감지 시스템에 등록되도록 설정
    (Sight 와 같이 감지될 조건에 추가 가능)

5. 추적 기능 - 플레이어를 쫓아가는 AI 🏃🏻

1. SpartaAIController 클래스 수정

#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:
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "AI")
	UAIPerceptionComponent* AIPerception;

	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "AI")
	UAISenseConfig_Sight* SightConfig;

	UPROPERTY()
	AActor* CurrentTarget = nullptr;

	UFUNCTION()
	void OnPerceptionUpdated(AActor* Actor, FAIStimulus Stimulus);

	bool bIsChasing = false;
	FTimerHandle ChaseTimer;

	virtual void BeginPlay() override;
	virtual void OnPossess(APawn* InPawn) override;

	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()
{
	AIPerception = CreateDefaultSubobject<UAIPerceptionComponent>(TEXT("AIPerception"));
	SetPerceptionComponent(*AIPerception);

	SightConfig = CreateDefaultSubobject<UAISenseConfig_Sight>(TEXT("SightConfig"));
	SightConfig->SightRadius = 1500.0f;
	SightConfig->LoseSightRadius = 2000.0f;
	SightConfig->PeripheralVisionAngleDegrees = 90.0f;
	SightConfig->SetMaxAge(5.0f);

	SightConfig->DetectionByAffiliation.bDetectEnemies = true;
	SightConfig->DetectionByAffiliation.bDetectNeutrals = true;
	SightConfig->DetectionByAffiliation.bDetectFriendlies = true;

	AIPerception->ConfigureSense(*SightConfig);
	AIPerception->SetDominantSense(SightConfig->GetSenseImplementation());
}

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

	if (AIPerception)
	{
		AIPerception->OnTargetPerceptionUpdated.AddDynamic(
			this,
			&ASpartaAIController::OnPerceptionUpdated
		);
	}

	GetWorldTimerManager().SetTimer(
		RandomMoveTimer,
		this,
		&ASpartaAIController::MoveToRandomLocation,
		3.0f,
		true,
		1.0f
	);
}

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

	if (InPawn)
	{
		UE_LOG(LogTemp, Warning, TEXT("[Sparta] AI Controller is controlling %s."), *InPawn->GetName());
	}
}

void ASpartaAIController::MoveToRandomLocation()
{
	APawn* MyPawn = GetPawn();
	if (!MyPawn)
	{
		UE_LOG(LogTemp, Error, TEXT("[Sparta] No Pawn to control."));
	}

	UNavigationSystemV1* NavSystem = UNavigationSystemV1::GetCurrent(GetWorld());
	if (!NavSystem)
	{
		UE_LOG(LogTemp, Error, TEXT("[Sparta] Could not find Navigation System."));
	}

	FNavLocation RandomLocation;
	bool bFoundLocation = NavSystem->GetRandomReachablePointInRadius(
		MyPawn->GetActorLocation(),
		MoveRadius,
		RandomLocation
	);

	if (bFoundLocation)
	{
		MoveToLocation(RandomLocation.Location);

		UE_LOG(LogTemp, Warning, TEXT("[Sparta] Move target: %s"), *RandomLocation.Location.ToString());
	}
	else
	{
		UE_LOG(LogTemp, Warning, TEXT("[Sparta] Could not find a reachable location."));
	}
}

void ASpartaAIController::OnPerceptionUpdated(AActor* Actor, FAIStimulus Stimulus)
{
	APawn* PlayerPawn = UGameplayStatics::GetPlayerPawn(GetWorld(), 0);
	if (Actor != PlayerPawn)
	{
		return;
	}

	if (Stimulus.WasSuccessfullySensed())
	{
		UE_LOG(LogTemp, Warning, TEXT("[Sparta] Saw something! %s"), *Actor->GetName());

		DrawDebugString(
			GetWorld(),
			Actor->GetActorLocation() + FVector(0, 0, 100),
			FString::Printf(TEXT("Saw: %s"), *Actor->GetName()),
			nullptr,
			FColor::Green,
			2.0f,
			true
		);

		StartChasing(Actor);
	}
	else
	{
		UE_LOG(LogTemp, Warning, TEXT("[Sparta] Missed it! %s"), *Actor->GetName());

		DrawDebugString(
			GetWorld(),
			Actor->GetActorLocation() + FVector(0, 0, 100),
			FString::Printf(TEXT("Missed: %s"), *Actor->GetName()),
			nullptr,
			FColor::Red,
			2.0f,
			true
		);

		StopChasing();
	}
}

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

	CurrentTarget = Target;
	bIsChasing = true;

	GetWorldTimerManager().ClearTimer(RandomMoveTimer);

	if (ASpartaAICharacter* AIChar = Cast<ASpartaAICharacter>(GetPawn()))
	{
		AIChar->SetMovementSpeed(AIChar->RunSpeed);
	}

	UpdateChase();
	GetWorldTimerManager().SetTimer(
		ChaseTimer,
		this,
		&ASpartaAIController::UpdateChase,
		0.25f,
		true
	);
}

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

	CurrentTarget = nullptr;
	bIsChasing = false;

	GetWorldTimerManager().ClearTimer(ChaseTimer);

	StopMovement();

	if (ASpartaAICharacter* AIChar = Cast<ASpartaAICharacter>(GetPawn()))
	{
		AIChar->SetMovementSpeed(AIChar->WalkSpeed);
	}

	GetWorldTimerManager().SetTimer(
		RandomMoveTimer,
		this,
		&ASpartaAIController::MoveToRandomLocation,
		3.0f,
		true,
		2.0f
	);
}

void ASpartaAIController::UpdateChase()
{
	if (CurrentTarget && bIsChasing)
	{
		MoveToActor(CurrentTarget, 100.0f);
	}
}

댓글남기기