🔎AIController란?
언리얼에서 AI캐릭터를 제어하는 역할을 하는 클래스이다.
AController를 기반으로 확장된 클래스이며, 플레이어 캐릭터를 조작하는 APlayerCharacter와 유사하지만, AI 전용 기능들이 추가되어 있다.
AIController는 AI가 움직이고, 행동하고, 의사 결정을 내리도록 도와주는 핵심적인 요소이다.
📌AIController 구현
✅ ABaseEnemyAIController.h
#pragma once
#include "CoreMinimal.h"
#include "AIController.h"
#include "BaseEnemyAIController.generated.h"
UCLASS()
class GUNFIREPARAGON_API ABaseEnemyAIController : public AAIController
{
GENERATED_BODY()
public:
ABaseEnemyAIController();
virtual void Tick(float DeltaTime) override;
protected:
// AI가 Pawn을 소유할 때 호출되고 BeginPlay보다 먼저 호출된다.
virtual void OnPossess(APawn* InPawn) override;
virtual void BeginPlay() override;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "AI")
class UBehaviorTree* BehaviorTree;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "AI")
class UBlackboardData* BlackboardData;
// AI Perception Component: AI가 환경을 감지하는 컴포넌트
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "AI")
class UAIPerceptionComponent* AIPerceptionComponent;
// SightConfig: 시각 인식을 위한 설정
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "AI")
class UAISenseConfig_Sight* SightConfig;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "AI")
class UAISenseConfig_Damage* DamageConfig;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "AI")
class UBlackboardComponent* BBComp;
// Actor -> 감지된 액터, Stimulus -> 감지된 자극(시야, 소리 등)
UFUNCTION()
virtual void OnTargetPerceived(AActor* Actor, FAIStimulus Stimulus);
};
- UBehaviorTree: AI의 행동 흐름을 결정하는 트리 구조의 로직
- UBlackboardData: AI의 상태 및 데이터를 저장하는 공간
- UAIPerceptionComponent: AI가 주변 환경을 감지하고, 플레이어나 적을 인식할 수 있게 하는 컴포넌트
- UAISenseConfig_Sight: 시각적 감지에 대한 설정 클래스
- UAISenseConfig_Damage: 피해 감지에 대한 설정 클래스, 이 밖에도 청각(Hearing), 촉각(Touch) 등 이 존재
- OnTargetPerceived(AActor* Actor, FAIStimulus Stimulus): AI가 감각(시각, 청각, 피해 등)을 통해 대상을 인식하거나 놓쳤을 때 호출됨.
- 일반적으로 AIPerceptionComponent->OnTargetPerceptionUpdated.AddDynaminc() 함수에 델리게이트에 바인딩
✅ ABaseEnemyAIController.cpp
📍생성자
#include "AI/AIController/BaseEnemyAIController.h"
#include "AI/Enemy/BaseEnemy.h"
#include "Player/PlayerCharacter.h"
#include "Kismet/GameplayStatics.h"
#include "Perception/AIPerceptionComponent.h"
#include "Perception/AISenseConfig_Sight.h"
#include "Perception/AISenseConfig_Damage.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "BehaviorTree/BehaviorTree.h"
ABaseEnemyAIController::ABaseEnemyAIController()
{
// AI Perception 설정
PerceptionComponent = CreateDefaultSubobject<UAIPerceptionComponent>(TEXT("PerceptionComponent"));
SetPerceptionComponent(*PerceptionComponent);
// 시각 감지 설정
SightConfig = CreateDefaultSubobject<UAISenseConfig_Sight>(TEXT("SightConfig"));
SightConfig->SightRadius = 8000.0f; // AI의 감지 범위
SightConfig->LoseSightRadius = 8500.0f; // AI가 플레이어를 잃어버리는 거리
SightConfig->PeripheralVisionAngleDegrees = 180.0f; // 시야각 설정
SightConfig->DetectionByAffiliation.bDetectEnemies = true; // 적에 대한 감지 허용
SightConfig->DetectionByAffiliation.bDetectNeutrals = false; // 중립에 대한 감지 불허용
SightConfig->DetectionByAffiliation.bDetectFriendlies = false; // 아군에 대한 감지 불허용
// 피격 감지 설정
DamageConfig = CreateDefaultSubobject<UAISenseConfig_Damage>(TEXT("DamageConfig"));
DamageConfig->SetMaxAge(5.0f); // 피해 정보를 5초 동안 기억
// Perception 컴포넌트 설정
PerceptionComponent->ConfigureSense(*SightConfig); // 시각 감각 추가
PerceptionComponent->ConfigureSense(*DamageConfig); // 피해 감각 추가
PerceptionComponent->SetDominantSense(*SightConfig->GetSenseImplementation()); // 기본 감각을 시각으로 설정
PerceptionComponent->RequestStimuliListenerUpdate(); // AI가 감각 정보를 업데이트 하도록 요청
PerceptionComponent->OnTargetPerceptionUpdated.AddDynamic(this, &ABaseEnemyAIController::OnTargetPerceived); // 대상을 감지하면 델리게이트 함수 호출
// 기본 변수 초기화
BehaviorTree = nullptr;
BlackboardData = nullptr;
BBComp = nullptr;
}
📍OnPossess(APawn* InPawn)
// AI가 Pawn을 소유할 때 호출
void ABaseEnemyAIController::OnPossess(APawn* InPawn)
{
Super::OnPossess(InPawn);
if (BehaviorTree)
{
RunBehaviorTree(BehaviorTree);
}
if (BlackboardData)
{
ABaseEnemy* ControlledEnemy = Cast<ABaseEnemy>(InPawn);
if (!ControlledEnemy) return;
if (UseBlackboard(BlackboardData, BBComp))
{
BBComp = GetBlackboardComponent();
BBComp->SetValueAsVector("SpawnLocation", InPawn->GetActorLocation());
BBComp->SetValueAsFloat("AttackDelay", ControlledEnemy->AttackDelay);
float AttackRange = ControlledEnemy->AttackRange;
BBComp->SetValueAsFloat("AttackRange", AttackRange);
BBComp->SetValueAsFloat("AcceptableRadius", AttackRange - 50.0f);
APawn* PlayerPawn = UGameplayStatics::GetPlayerPawn(this, 0);
if (PlayerPawn)
{
if (BBComp)
{
BBComp->SetValueAsObject("TargetPlayer", PlayerPawn);
}
}
}
}
}
OnPossess 함수는 AI가 Pawn을 소유할 때 실행되는 함수이다.
BeginPlay 함수보다 먼저 실행되기 때문에, Behavior Tree를 실행하고 Blackboard에 초기 필요한 데이터를 초기화 시켜주는 작업을 하기에 좋다.
위 코드에선 Behavior Tree를 실행한 뒤, AI가 초기 스폰한 위치, AI의 공격 능력치, Target이 누군인지 등을 Blackboard 변수에 초기화 시켜주었다.
📍BeginPlay( )
void ABaseEnemyAIController::BeginPlay()
{
Super::BeginPlay();
APawn* PlayerPawn = UGameplayStatics::GetPlayerPawn(this, 0);
if (PlayerPawn)
{
if (BBComp)
{
BBComp->SetValueAsObject("TargetPlayer", PlayerPawn);
}
}
}
OnPossess 함수에서도 동일하게 TargetPlayer를 저장했었는데, BeginPlay에서 다시 한 번 TargetPlayer를 저장해주었다.
이유는 현재 프로젝트에서 AI 스폰을 오브젝트 풀링을 사용해 풀에 넣어두고 재사용하여 최적화를 진행하였는데, 레벨에 배치된 AI는 OnPossess 함수를 통해서 TargetPlayer가 잘 설정이 되었으나 풀에 있는 AI들은 TargetPlayer가 설정되지 않는 문제가 발생했었다.
오브젝트 풀링 방식으로 스폰된 AI는 비활성화되었다가 다시 활성화 되는 과정에서 OnPossess 함수가 호출되지 않았다. 즉 AIController가 Pawn을 이미 소유하고 있을 경우에, 다시 소유하는 과정 OnPossess 가 호출되지 않고 생략되었던 것이다.
BeginPlay 함수는 항상 보장된 라이프 사이클에서 실행되고 Actor가 게임에서 활성화 될 때 항상 호출되기에 안정적으로 TargetPlayer를 저장하고 AI가 Player에게 공격을 정상적으로 수행할 수 있도록 한 번 더 저장해준 것이다.
📍Tick(float DeltaTime)
void ABaseEnemyAIController::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
APlayerCharacter* TargetPlayer = Cast<APlayerCharacter>(BBComp->GetValueAsObject("TargetPlayer"));
if (TargetPlayer && TargetPlayer->IsPlayerDead())
{
BBComp->ClearValue("TargetPlayer");
BBComp->ClearValue("PlayerLocation");
BBComp->SetValueAsBool("HasSpottedPlayer", false);
return;
}
if (TargetPlayer && BBComp->GetValueAsBool("HasSpottedPlayer"))
{
FVector PlayerLocation = TargetPlayer->GetActorLocation();
BBComp->SetValueAsVector("PlayerLocation", PlayerLocation);
BBComp->ClearValue("LastKnownPlayerLocation");
}
}
Blackboard에서 TargetPlayer를 가져와 만약 플레이어가 사망했다면, TargetPlayer와 PlayerLocation 값을 삭제시켜 사망한 플레이어에게 공격이나 다른 행동을 하지 못하도록 막아주었다.
만약 플레이어가 살아있고 AI가 플레이어를 인식하고 있다면, 현재 플레이어의 위치를 업데이트 하였다.
📍OnTargetPerceived(AActor* Actor, FAIStimulus Stimulus)
// AI가 감지한 객체의 정보를 처리하는 함수
void ABaseEnemyAIController::OnTargetPerceived(AActor* Actor, FAIStimulus Stimulus)
{
if (!BBComp) return;
if (!Actor || !Actor->ActorHasTag("Player")) return;
ABaseEnemy* Enemy = Cast<ABaseEnemy>(GetPawn());
if (!Enemy) return;
// 플레이어가 시야에 들어온 경우
if (Stimulus.Type == UAISense::GetSenseID<UAISense_Sight>())
{
if (Stimulus.WasSuccessfullySensed())
{
BBComp->SetValueAsBool("HasSpottedPlayer", true);
}
else
{
FVector PlayerLocation = BBComp->GetValueAsVector("PlayerLocation");
BBComp->SetValueAsVector("LastKnownPlayerLocation", PlayerLocation);
BBComp->ClearValue("PlayerLocation");
BBComp->SetValueAsBool("HasSpottedPlayer", false);
}
}
// 플레이어가 데미지를 준 경우
else if (Stimulus.Type == UAISense::GetSenseID<UAISense_Damage>())
{
BBComp->SetValueAsBool("HasSpottedPlayer", true);
}
}
AIPerceptionComponent로 인해 플레이어를 감지하게 되면 생성자에서 이 함수를 동적 바인딩 해놨었기 때문에 자동으로 호출된다.
현재 감지된 Actor가 Player인지 확인.
시각에 의해 감지되었다면 HasSpottedPlayer에 true를 저장.
시야에서 사라졌다면 마지막으로 업데이트 되었던 PlayerLocation을 LastKnownPlayerLocation에 저장하고, Tick에서 실시간으로 플레이어의 위치를 업데이트 한 데이터를 삭제. 그리고 더이상 Tick에서 PlayerLocation에 실시간 위치를 저장하지 않도록 HasSpottedPlayer에 false를 저장하였다.
데미지로 인해 플레이어를 인지했다면 이 역시 HasSpottedPlayer에 true의 값을 저장해주었다.
이 데미지로 인해 플레이어를 인지한 경우 생성자에서 SetMaxAge(5.0f)로 설정하였기 때문에, 5초 뒤에 인지한 것을 초기화 한다. 물론 시각으로 계속 플레이어를 인지하고 있다면 계속 그에 맞는 행동이 수행될 것이다.
📌Behavior Tree 와 Blackboard 설계
✅ Behavior Tree 전체 로직
AIController에서 Blackboard에 설정했던 데이터를 기반으로, 플레이어를 인식하고 있는지 여부와 공격 사거리 내에 있는지 여부에 따라 [공격], [추적], [순찰] 상태로 전환되도록 설게하였다.
✅ Combat Sequence
- Combat 시퀀스에 Blackboard Condition 데코레이터를 사용해, PlayerLocation이 Set 된 상태이면 시퀀스가 실행
- Ready Attack 시퀀스에선 Set Movement Speed Task로 전투에 맞는 이동속도를 설정하고 Target에게 Focus
*BTT_SetMovementSpeed 테스크에 대한 추가 설명
더보기
#include "AI/BT/BTT_SetMovementSpeed.h"
#include "AI/Enemy/BaseEnemy.h"
#include "AIController.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "GameFramework/CharacterMovementComponent.h"
UBTT_SetMovementSpeed::UBTT_SetMovementSpeed()
{
NodeName = TEXT("Set Movement Speed");
}
EBTNodeResult::Type UBTT_SetMovementSpeed::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
Super::ExecuteTask(OwnerComp, NodeMemory);
AAIController* AIController = OwnerComp.GetAIOwner();
if (!AIController) return EBTNodeResult::Failed;
ABaseEnemy* Enemy = Cast<ABaseEnemy>(AIController->GetPawn());
if (!Enemy) return EBTNodeResult::Failed;
UBlackboardComponent* BBcomp = OwnerComp.GetBlackboardComponent();
if (!BBcomp) return EBTNodeResult::Failed;
bool bHasSpottedPlayer = BBcomp->GetValueAsBool(TEXT("HasSpottedPlayer"));
float NewSpeed = bHasSpottedPlayer ? Enemy->BaseWalkSpeed : Enemy->BaseWalkSpeed * 0.5f;
Enemy->GetCharacterMovement()->MaxWalkSpeed = NewSpeed;
return EBTNodeResult::Succeeded;
}
- 현재 플레이어를 보고 있는 상태라면 기본 이동속도를 적용하고 만약 플레이어를 발견하지 못한 상태라면 기본 이동속도의 절반으로 설정
- 우측에 Simple Parallel는 두 개의 테스크를 동시에 실행하는 기능. 일반적으로 BT는 순차적으로 테스크를 실행하지만 이 노드를 사용하면 좌측 Main Task와 우측 Background Task를 동시에 실행 가능
- Move To Target으로 플레이어게 이동함과 동시에 플레이어가 공격 사거리 내에 있는지 확인하고 공격을 수행하는 역할
Simple Parallel 노드에는 Finish Mode라는 옵션이 있다.
이것은 Main Task가 완료될 때, Background Task가 언제 종료될지를 결정.
Immediate: Main Task가 종료되면 즉시 Background Task 종료
Delayed: Main Task가 종료된 후에도 일정시간 동안 Background Task가 유지
위 설계에는 Background Task에서 공격을 수행하기 때문에 도중에 끊기면 부자연스럽기 때문에 Delayed로 설정
- Attack Sequence에서 플레이어를 너무 정확하게 공격하는 것을 방지하기 위해 공격 하기 전에 FocusClear 해주고 DefaultAttack을 한 뒤에 다시 FocusTarget 해주었다. 그리고 Attack Delay만큼 대기한다.
🔎공격 패턴
✅ Investigate Sequence
아까 AIController.cpp에서 플레이어를 시야에서 놓치면 HasSpottedPlayer 에 false 값이 저장되도록 하였다.
플레이어를 쫓다가 놓치게되면 마지막으로 발견된 위치까지 이동한 뒤 이동 속도를 감소시키고 TargetPlayer에게서 Focus를 Clear 해주었다.
이어서 현재 위치를 기준으로 랜덤한 좌표로 반복적으로 움직이는 것을 반복하다 다시 처음 스폰된 위치로 되돌아 간다.
* BTD_SetRandomLocation
더보기
// 헤더
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/Decorators/BTDecorator_BlackboardBase.h"
#include "BTD_SetRandomLocation.generated.h"
UCLASS()
class GUNFIREPARAGON_API UBTD_SetRandomLocation : public UBTDecorator_BlackboardBase
{
GENERATED_BODY()
public:
UBTD_SetRandomLocation();
protected:
virtual bool CalculateRawConditionValue(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const override;
UPROPERTY(EditAnywhere, Category = "AI")
float PatrolRadius = 500.0f;
};
// cpp
#include "AI/BT/BTD_SetRandomLocation.h"
#include "AIController.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "NavigationSystem.h"
UBTD_SetRandomLocation::UBTD_SetRandomLocation()
{
NodeName = TEXT("SetRandomLocation");
}
bool UBTD_SetRandomLocation::CalculateRawConditionValue(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const
{
AAIController* AIController = Cast<AAIController>(OwnerComp.GetAIOwner());
if (!AIController) return false;
APawn* AIPawn = AIController->GetPawn();
if (!AIPawn) return false;
UBlackboardComponent* BBComp = AIController->GetBlackboardComponent();
if (!BBComp) return false;
UNavigationSystemV1* NavSystem = FNavigationSystem::GetCurrent<UNavigationSystemV1>(AIPawn->GetWorld());
if (!NavSystem) return false;
FVector PivotLocation = BBComp->GetValueAsVector(GetSelectedBlackboardKey());
FNavLocation RandomLocation;
if (NavSystem->GetRandomReachablePointInRadius(PivotLocation, PatrolRadius, RandomLocation))
{
BBComp->SetValueAsVector("RandomLocation", RandomLocation.Location);
return true;
}
return false;
}
이 데코레이터는 AI의 블랙보드에 랜덤한 네비게이션 위치를 설정하는 역할을 한다.
주로 AI가 순찰이나 무작위 이동 동작을 수행할 때 사용할 수 있도록 구현하였다.
UNavigationSystemV1* NavSystem = FNavigationSystem::GetCurrent<UNavigationSystemV1>(AIPawn->GetWorld());
- AI의 UNavigationSystemV1을 가져와서 AI가 이동 가능한 네비게이션 데이터를 조회할 수 있도록 함
FVector PivotLocation = BBComp->GetValueAsVector(GetSelectedBlackboardKey());
FNavLocation RandomLocation;
if (NavSystem->GetRandomReachablePointInRadius(PivotLocation, PatrolRadius, RandomLocation))
{
BBComp->SetValueAsVector("RandomLocation", RandomLocation.Location);
return true;
}
- 이 데코레이터에 선택된 키의 Vector 값을 가져와 PivotLocation으로 설정. 위의 BT에선 LastKnownPlalyerLocation으로 선택하였다.
- GetRandomReachablePointInRadius 함수를 사용하여 지정 반경(Patrol Radius) 내에 이동 가능한 랜덤 위치를 찾음.
- 찾은 랜덤 위치를 Blackboard의 RandomLocation 키에 저장
🔎추적 & 조사 패턴
✅ Patrol Sequence
앞서 추적&조사 시퀀스랑 동일하게 기본적으로 AI가 월드에 스폰되면 스폰된 위치를 PivotLocation으로 설정해 그 주변 랜덤한 위치를 반복적으로 순찰하고 다시 스폰된 위치로 되돌아오는 것을 반복하게 된다.
🔎순찰 패턴
'UE_FPS 슈터 게임 팀프로젝트 > 일반 AI' 카테고리의 다른 글
일반 AI_공격 구현 (0) | 2025.03.04 |
---|---|
일반 AI 공격 Test/ UWorld::SweepSingleByChannel (0) | 2025.02.20 |
AIController, Behavior Tree 초기 Test (0) | 2025.02.19 |