NPC 및 적 AI 시스템
Behavior Tree와 StateTree를 활용한 AI 구현
AI Controller 설계
적 AI의 기본 컨트롤러 구조
// EnemyAIController.h
#pragma once
#include "CoreMinimal.h"
#include "AIController.h"
#include "Perception/AIPerceptionTypes.h"
#include "EnemyAIController.generated.h"
class UAISenseConfig_Sight;
class UAISenseConfig_Hearing;
class UAISenseConfig_Damage;
class UBehaviorTreeComponent;
class UBlackboardComponent;
/**
* 적 AI 상태
*/
UENUM(BlueprintType)
enum class EEnemyAIState : uint8
{
Idle, // 대기
Patrol, // 순찰
Investigating, // 조사
Chasing, // 추격
Combat, // 전투
Retreating, // 후퇴
Dead // 사망
};
UCLASS()
class OPENWORLDRPG_API AEnemyAIController : public AAIController
{
GENERATED_BODY()
public:
AEnemyAIController();
// Blackboard 키 이름
static const FName KEY_TargetActor;
static const FName KEY_TargetLocation;
static const FName KEY_AIState;
static const FName KEY_HomeLocation;
static const FName KEY_PatrolIndex;
// AI 상태 변경
UFUNCTION(BlueprintCallable, Category = "AI")
void SetAIState(EEnemyAIState NewState);
UFUNCTION(BlueprintPure, Category = "AI")
EEnemyAIState GetAIState() const;
// 타겟 설정
UFUNCTION(BlueprintCallable, Category = "AI")
void SetTargetActor(AActor* NewTarget);
UFUNCTION(BlueprintPure, Category = "AI")
AActor* GetTargetActor() const;
// 위협 레벨 계산
UFUNCTION(BlueprintPure, Category = "AI")
float CalculateThreatLevel(AActor* PotentialThreat) const;
protected:
virtual void BeginPlay() override;
virtual void OnPossess(APawn* InPawn) override;
virtual void OnUnPossess() override;
// Perception 설정
UPROPERTY(VisibleAnywhere, Category = "AI")
TObjectPtr<UAIPerceptionComponent> AIPerception;
UPROPERTY(EditDefaultsOnly, Category = "AI")
TObjectPtr<UBehaviorTree> BehaviorTreeAsset;
// Sense 설정
UPROPERTY()
TObjectPtr<UAISenseConfig_Sight> SightConfig;
UPROPERTY()
TObjectPtr<UAISenseConfig_Hearing> HearingConfig;
UPROPERTY()
TObjectPtr<UAISenseConfig_Damage> DamageConfig;
// Perception 콜백
UFUNCTION()
void OnTargetPerceptionUpdated(AActor* Actor,
FAIStimulus Stimulus);
private:
void SetupPerception();
void ProcessSightStimulus(AActor* Actor, const FAIStimulus& Stimulus);
void ProcessHearingStimulus(AActor* Actor, const FAIStimulus& Stimulus);
void ProcessDamageStimulus(AActor* Actor, const FAIStimulus& Stimulus);
};
// EnemyAIController.cpp
#include "EnemyAIController.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "BehaviorTree/BehaviorTreeComponent.h"
#include "Perception/AIPerceptionComponent.h"
#include "Perception/AISenseConfig_Sight.h"
#include "Perception/AISenseConfig_Hearing.h"
#include "Perception/AISenseConfig_Damage.h"
const FName AEnemyAIController::KEY_TargetActor = FName("TargetActor");
const FName AEnemyAIController::KEY_TargetLocation = FName("TargetLocation");
const FName AEnemyAIController::KEY_AIState = FName("AIState");
const FName AEnemyAIController::KEY_HomeLocation = FName("HomeLocation");
const FName AEnemyAIController::KEY_PatrolIndex = FName("PatrolIndex");
AEnemyAIController::AEnemyAIController()
{
// Perception 컴포넌트 생성
AIPerception = CreateDefaultSubobject<UAIPerceptionComponent>(
TEXT("AIPerception"));
SetPerceptionComponent(*AIPerception);
SetupPerception();
}
void AEnemyAIController::SetupPerception()
{
// 시야 설정
SightConfig = CreateDefaultSubobject<UAISenseConfig_Sight>(
TEXT("SightConfig"));
SightConfig->SightRadius = 2000.0f;
SightConfig->LoseSightRadius = 2500.0f;
SightConfig->PeripheralVisionAngleDegrees = 60.0f;
SightConfig->SetMaxAge(5.0f);
SightConfig->AutoSuccessRangeFromLastSeenLocation = 500.0f;
SightConfig->DetectionByAffiliation.bDetectEnemies = true;
SightConfig->DetectionByAffiliation.bDetectNeutrals = true;
SightConfig->DetectionByAffiliation.bDetectFriendlies = false;
// 청각 설정
HearingConfig = CreateDefaultSubobject<UAISenseConfig_Hearing>(
TEXT("HearingConfig"));
HearingConfig->HearingRange = 1500.0f;
HearingConfig->SetMaxAge(3.0f);
HearingConfig->DetectionByAffiliation.bDetectEnemies = true;
HearingConfig->DetectionByAffiliation.bDetectNeutrals = true;
HearingConfig->DetectionByAffiliation.bDetectFriendlies = false;
// 데미지 감지 설정
DamageConfig = CreateDefaultSubobject<UAISenseConfig_Damage>(
TEXT("DamageConfig"));
DamageConfig->SetMaxAge(10.0f);
// Perception에 등록
AIPerception->ConfigureSense(*SightConfig);
AIPerception->ConfigureSense(*HearingConfig);
AIPerception->ConfigureSense(*DamageConfig);
AIPerception->SetDominantSense(SightConfig->GetSenseImplementation());
}
void AEnemyAIController::OnPossess(APawn* InPawn)
{
Super::OnPossess(InPawn);
// Perception 콜백 바인딩
AIPerception->OnTargetPerceptionUpdated.AddDynamic(
this, &AEnemyAIController::OnTargetPerceptionUpdated);
// Behavior Tree 시작
if (BehaviorTreeAsset)
{
RunBehaviorTree(BehaviorTreeAsset);
// 초기 상태 설정
if (UBlackboardComponent* BB = GetBlackboardComponent())
{
BB->SetValueAsVector(KEY_HomeLocation, InPawn->GetActorLocation());
BB->SetValueAsEnum(KEY_AIState,
static_cast<uint8>(EEnemyAIState::Patrol));
}
}
}
void AEnemyAIController::OnTargetPerceptionUpdated(
AActor* Actor, FAIStimulus Stimulus)
{
if (!Actor) return;
// 자극 타입에 따라 처리
if (Stimulus.Type == UAISense::GetSenseID<UAISense_Sight>())
{
ProcessSightStimulus(Actor, Stimulus);
}
else if (Stimulus.Type == UAISense::GetSenseID<UAISense_Hearing>())
{
ProcessHearingStimulus(Actor, Stimulus);
}
else if (Stimulus.Type == UAISense::GetSenseID<UAISense_Damage>())
{
ProcessDamageStimulus(Actor, Stimulus);
}
}
void AEnemyAIController::ProcessSightStimulus(
AActor* Actor, const FAIStimulus& Stimulus)
{
if (Stimulus.WasSuccessfullySensed())
{
// 적 발견!
SetTargetActor(Actor);
SetAIState(EEnemyAIState::Chasing);
}
else
{
// 시야에서 사라짐
EEnemyAIState CurrentState = GetAIState();
if (CurrentState == EEnemyAIState::Chasing ||
CurrentState == EEnemyAIState::Combat)
{
// 마지막 위치 저장 후 조사
if (UBlackboardComponent* BB = GetBlackboardComponent())
{
BB->SetValueAsVector(KEY_TargetLocation,
Stimulus.StimulusLocation);
}
SetAIState(EEnemyAIState::Investigating);
}
}
}
Behavior Tree Task
AI 행동을 위한 커스텀 태스크 구현
// BTTask_FindPatrolPoint.h - 순찰 포인트 찾기
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTTaskNode.h"
#include "BTTask_FindPatrolPoint.generated.h"
UCLASS()
class OPENWORLDRPG_API UBTTask_FindPatrolPoint : public UBTTaskNode
{
GENERATED_BODY()
public:
UBTTask_FindPatrolPoint();
virtual EBTNodeResult::Type ExecuteTask(
UBehaviorTreeComponent& OwnerComp,
uint8* NodeMemory) override;
virtual FString GetStaticDescription() const override;
protected:
// 결과를 저장할 Blackboard 키
UPROPERTY(EditAnywhere, Category = "Blackboard")
FBlackboardKeySelector TargetLocationKey;
// 순찰 반경
UPROPERTY(EditAnywhere, Category = "Patrol")
float PatrolRadius = 1000.0f;
// 홈 위치 기준 여부
UPROPERTY(EditAnywhere, Category = "Patrol")
bool bUseHomeLocation = true;
UPROPERTY(EditAnywhere, Category = "Blackboard",
meta = (EditCondition = "bUseHomeLocation"))
FBlackboardKeySelector HomeLocationKey;
};
// BTTask_FindPatrolPoint.cpp
#include "BTTask_FindPatrolPoint.h"
#include "AIController.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "NavigationSystem.h"
UBTTask_FindPatrolPoint::UBTTask_FindPatrolPoint()
{
NodeName = "Find Patrol Point";
}
EBTNodeResult::Type UBTTask_FindPatrolPoint::ExecuteTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
AAIController* AIController = OwnerComp.GetAIOwner();
if (!AIController) return EBTNodeResult::Failed;
APawn* Pawn = AIController->GetPawn();
if (!Pawn) return EBTNodeResult::Failed;
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
if (!BB) return EBTNodeResult::Failed;
// 기준점 결정
FVector Origin;
if (bUseHomeLocation)
{
Origin = BB->GetValueAsVector(HomeLocationKey.SelectedKeyName);
}
else
{
Origin = Pawn->GetActorLocation();
}
// 네비게이션 시스템에서 랜덤 포인트 찾기
UNavigationSystemV1* NavSys = FNavigationSystem::GetCurrent<UNavigationSystemV1>(
Pawn->GetWorld());
if (!NavSys) return EBTNodeResult::Failed;
FNavLocation RandomLocation;
bool bFound = NavSys->GetRandomReachablePointInRadius(
Origin,
PatrolRadius,
RandomLocation
);
if (bFound)
{
BB->SetValueAsVector(TargetLocationKey.SelectedKeyName,
RandomLocation.Location);
return EBTNodeResult::Succeeded;
}
return EBTNodeResult::Failed;
}
// BTTask_PerformAttack.h - 공격 수행
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTTaskNode.h"
#include "BTTask_PerformAttack.generated.h"
UCLASS()
class OPENWORLDRPG_API UBTTask_PerformAttack : public UBTTaskNode
{
GENERATED_BODY()
public:
UBTTask_PerformAttack();
virtual EBTNodeResult::Type ExecuteTask(
UBehaviorTreeComponent& OwnerComp,
uint8* NodeMemory) override;
protected:
virtual void TickTask(UBehaviorTreeComponent& OwnerComp,
uint8* NodeMemory, float DeltaSeconds) override;
UPROPERTY(EditAnywhere, Category = "Attack")
TObjectPtr<UAnimMontage> AttackMontage;
UPROPERTY(EditAnywhere, Category = "Attack")
float AttackRange = 200.0f;
UPROPERTY(EditAnywhere, Category = "Attack")
float AttackDamage = 25.0f;
private:
bool bIsAttacking = false;
};
// BTTask_PerformAttack.cpp
EBTNodeResult::Type UBTTask_PerformAttack::ExecuteTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
AAIController* AIController = OwnerComp.GetAIOwner();
if (!AIController) return EBTNodeResult::Failed;
ACharacter* Character = Cast<ACharacter>(AIController->GetPawn());
if (!Character) return EBTNodeResult::Failed;
// 타겟 확인
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
AActor* Target = Cast<AActor>(
BB->GetValueAsObject(AEnemyAIController::KEY_TargetActor));
if (!Target) return EBTNodeResult::Failed;
// 거리 확인
float Distance = FVector::Dist(
Character->GetActorLocation(),
Target->GetActorLocation()
);
if (Distance > AttackRange)
{
return EBTNodeResult::Failed;
}
// 공격 몽타주 재생
if (AttackMontage)
{
Character->PlayAnimMontage(AttackMontage);
bIsAttacking = true;
// 비동기 완료 대기
bNotifyTick = true;
return EBTNodeResult::InProgress;
}
return EBTNodeResult::Succeeded;
}
NPC 대화 시스템
상호작용 가능한 NPC 구현
// NPCCharacter.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "InteractionInterface.h"
#include "NPCCharacter.generated.h"
/**
* 대화 노드 구조체
*/
USTRUCT(BlueprintType)
struct FDialogueNode
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite)
FText SpeakerName;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
FText DialogueText;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
TObjectPtr<USoundBase> VoiceClip;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
TArray<FText> ResponseOptions;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
TArray<int32> NextNodeIndices;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
bool bIsEndNode = false;
};
/**
* 대화 데이터 에셋
*/
UCLASS(BlueprintType)
class OPENWORLDRPG_API UDialogueData : public UDataAsset
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, BlueprintReadOnly)
TArray<FDialogueNode> DialogueNodes;
UPROPERTY(EditAnywhere, BlueprintReadOnly)
int32 StartNodeIndex = 0;
};
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(
FOnDialogueStarted,
UDialogueData*, DialogueData
);
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnDialogueEnded);
UCLASS()
class OPENWORLDRPG_API ANPCCharacter : public ACharacter, public IInteractionInterface
{
GENERATED_BODY()
public:
ANPCCharacter();
// IInteractionInterface 구현
virtual bool CanInteract(AActor* Interactor) const override;
virtual void OnInteract(AActor* Interactor) override;
virtual FText GetInteractionPrompt() const override;
// 대화 시스템
UPROPERTY(BlueprintAssignable, Category = "Dialogue")
FOnDialogueStarted OnDialogueStarted;
UPROPERTY(BlueprintAssignable, Category = "Dialogue")
FOnDialogueEnded OnDialogueEnded;
UFUNCTION(BlueprintCallable, Category = "Dialogue")
void StartDialogue(AActor* Player);
UFUNCTION(BlueprintCallable, Category = "Dialogue")
void SelectResponse(int32 ResponseIndex);
UFUNCTION(BlueprintCallable, Category = "Dialogue")
void EndDialogue();
UFUNCTION(BlueprintPure, Category = "Dialogue")
const FDialogueNode& GetCurrentDialogueNode() const;
protected:
UPROPERTY(EditAnywhere, Category = "NPC")
FText NPCName;
UPROPERTY(EditAnywhere, Category = "Dialogue")
TObjectPtr<UDialogueData> DefaultDialogue;
UPROPERTY(EditAnywhere, Category = "Dialogue")
TMap<FName, TObjectPtr<UDialogueData>> ConditionalDialogues;
// 플레이어를 향해 회전
UPROPERTY(EditAnywhere, Category = "NPC")
bool bRotateToPlayer = true;
private:
UPROPERTY()
TObjectPtr<UDialogueData> CurrentDialogue;
int32 CurrentNodeIndex = 0;
UPROPERTY()
TObjectPtr<AActor> DialoguePartner;
bool bInDialogue = false;
UDialogueData* SelectDialogueForContext() const;
};
// NPCCharacter.cpp
void ANPCCharacter::StartDialogue(AActor* Player)
{
if (bInDialogue || !Player) return;
// 적절한 대화 선택
CurrentDialogue = SelectDialogueForContext();
if (!CurrentDialogue || CurrentDialogue->DialogueNodes.IsEmpty())
{
return;
}
bInDialogue = true;
DialoguePartner = Player;
CurrentNodeIndex = CurrentDialogue->StartNodeIndex;
// 플레이어를 향해 회전
if (bRotateToPlayer)
{
FVector Direction = Player->GetActorLocation() - GetActorLocation();
Direction.Z = 0;
SetActorRotation(Direction.Rotation());
}
// 플레이어 이동 제한
if (ACharacter* PlayerChar = Cast<ACharacter>(Player))
{
PlayerChar->DisableInput(nullptr);
}
OnDialogueStarted.Broadcast(CurrentDialogue);
}
void ANPCCharacter::SelectResponse(int32 ResponseIndex)
{
if (!bInDialogue || !CurrentDialogue) return;
const FDialogueNode& CurrentNode = CurrentDialogue->DialogueNodes[CurrentNodeIndex];
if (CurrentNode.bIsEndNode)
{
EndDialogue();
return;
}
// 다음 노드로 이동
if (CurrentNode.NextNodeIndices.IsValidIndex(ResponseIndex))
{
CurrentNodeIndex = CurrentNode.NextNodeIndices[ResponseIndex];
}
else if (!CurrentNode.NextNodeIndices.IsEmpty())
{
CurrentNodeIndex = CurrentNode.NextNodeIndices[0];
}
else
{
EndDialogue();
}
}
void ANPCCharacter::EndDialogue()
{
if (!bInDialogue) return;
bInDialogue = false;
// 플레이어 이동 복원
if (ACharacter* PlayerChar = Cast<ACharacter>(DialoguePartner))
{
PlayerChar->EnableInput(nullptr);
}
DialoguePartner = nullptr;
CurrentDialogue = nullptr;
OnDialogueEnded.Broadcast();
}
EQS 기반 전술 AI
Environment Query System으로 지능적 위치 선택
EQS(Environment Query System)는 AI가 주변 환경을 분석하여 최적의 위치를 선택하는 시스템입니다. 단순히 플레이어를 향해 돌진하는 것이 아니라, 엄폐물 뒤에 숨기, 측면 공격 위치 찾기, 힐러의 안전 위치 결정 등 전술적 AI 행동을 구현할 수 있습니다.
// EQSTestBase_TacticalPosition.h - 전술 위치 평가 테스트
#pragma once
#include "CoreMinimal.h"
#include "EnvironmentQuery/EnvQueryTest.h"
#include "EQSTest_FlankingScore.generated.h"
/**
* EQS 테스트: 측면 공격 점수 평가
* 타겟의 정면을 피하고 측면/후방을 선호
*/
UCLASS()
class OPENWORLDRPG_API UEQSTest_FlankingScore
: public UEnvQueryTest
{
GENERATED_BODY()
public:
UEQSTest_FlankingScore();
virtual void RunTest(
FEnvQueryInstance& QueryInstance) const override;
virtual FText GetDescriptionTitle() const override;
protected:
// 측면 공격 보너스 각도 (정면에서 벗어난 정도)
UPROPERTY(EditDefaultsOnly, Category = "Flanking")
float OptimalFlankAngle = 90.0f;
// 후방 공격 추가 보너스
UPROPERTY(EditDefaultsOnly, Category = "Flanking")
float BackstabBonus = 1.5f;
};
// EQSTest_FlankingScore.cpp
UEQSTest_FlankingScore::UEQSTest_FlankingScore()
{
Cost = EEnvTestCost::Low;
ValidItemType =
UEnvQueryItemType_Point::StaticClass();
}
void UEQSTest_FlankingScore::RunTest(
FEnvQueryInstance& QueryInstance) const
{
UObject* QueryOwner =
QueryInstance.Owner.Get();
if (!QueryOwner) return;
// 타겟 액터 가져오기 (Blackboard에서)
AActor* TargetActor = nullptr;
if (AAIController* AIC =
Cast<AAIController>(QueryOwner))
{
UBlackboardComponent* BB =
AIC->GetBlackboardComponent();
TargetActor = Cast<AActor>(
BB->GetValueAsObject(
FName("TargetActor")));
}
if (!TargetActor) return;
FVector TargetForward =
TargetActor->GetActorForwardVector();
FVector TargetLocation =
TargetActor->GetActorLocation();
for (FEnvQueryInstance::ItemIterator It(
this, QueryInstance); It; ++It)
{
FVector ItemLocation =
GetItemLocation(QueryInstance, It.GetIndex());
// 타겟에서 후보 위치로의 방향
FVector ToItem =
(ItemLocation - TargetLocation).GetSafeNormal();
// 정면과의 각도 계산
float DotProduct =
FVector::DotProduct(TargetForward, ToItem);
float AngleDeg = FMath::RadiansToDegrees(
FMath::Acos(DotProduct));
// 측면(90도)에 가까울수록 높은 점수
float Score = 1.0f - FMath::Abs(
AngleDeg - OptimalFlankAngle) / 180.0f;
// 후방(180도) 보너스
if (AngleDeg > 135.0f)
{
Score *= BackstabBonus;
}
It.SetScore(TestPurpose, FilterType,
Score, 0.0f, 1.0f * BackstabBonus);
}
}
// BTTask_RunEQS.cpp - BT에서 EQS 실행
EBTNodeResult::Type UBTTask_RunEQS::ExecuteTask(
UBehaviorTreeComponent& OwnerComp,
uint8* NodeMemory)
{
AAIController* AIC = OwnerComp.GetAIOwner();
if (!AIC || !EQSQuery) return EBTNodeResult::Failed;
// EQS 쿼리 비동기 실행
FEnvQueryRequest Request(EQSQuery, AIC);
Request.Execute(
EEnvQueryRunMode::SingleResult,
this,
&UBTTask_RunEQS::OnQueryFinished);
return EBTNodeResult::InProgress;
}
void UBTTask_RunEQS::OnQueryFinished(
TSharedPtr<FEnvQueryResult> Result)
{
if (Result->IsSuccessful())
{
FVector BestLocation =
Result->GetItemAsLocation(0);
// Blackboard에 결과 위치 저장
UBlackboardComponent* BB =
GetBlackboardComponent();
BB->SetValueAsVector(
TargetLocationKey, BestLocation);
FinishLatentTask(OwnerComp,
EBTNodeResult::Succeeded);
}
else
{
FinishLatentTask(OwnerComp,
EBTNodeResult::Failed);
}
}
- 근접 전사 -- 플레이어 측면/후방으로 우회 공격 위치 선택
- 원거리 궁수 -- 사거리 내의 엄폐 가능한 고지대 선호
- 힐러 NPC -- 아군과 가까우면서 적에게 노출이 적은 안전 위치
- 보스 AI -- 플레이어 밀집도가 높은 방향을 피하는 범위 공격 위치
AI Perception에서 감지된 적의 위치 정보를 EQS 쿼리의 Context로 전달하면, 감지된 모든 적의 위치를 고려한 최적 전술 위치를 계산할 수 있습니다. EnvQueryContext_AllPerceivedEnemies 같은 커스텀 Context를 구현하여 활용하세요.
EQS 쿼리는 많은 후보 포인트를 평가하므로 매 프레임 실행하면 안 됩니다. Behavior Tree의 Cooldown Decorator(2~3초 간격)와 함께 사용하거나, EEnvQueryRunMode::SingleResult로 최적 결과 하나만 반환하여 비용을 줄이세요.
핵심 요약
- AI Perception으로 시야/청각/피해 감지 및 상태 전환
- Behavior Tree Task로 순찰/공격 행동 구현
- Blackboard로 AI 상태 관리
- EQS로 측면 공격, 엄폐, 안전 위치 등 전술적 위치 선택
- DialogueData 에셋으로 분기형 대화 데이터 관리
도전 과제
배운 내용을 직접 실습해보세요
UBehaviorTree와 UBlackboardComponent를 설정하고, Selector/Sequence/Decorator를 조합하여 적 AI(순찰, 추적, 공격, 도주)를 구현하세요. Custom BTTask로 GAS 어빌리티 실행을 연동하세요.
마을 NPC와의 대화 시스템을 구현하세요. DataTable에서 대화 데이터를 로드하고, 선택지(분기)에 따른 대화 흐름을 제어하세요. 퀘스트 시스템과 연동하여 대화 중 퀘스트 수락/보고를 처리하세요.
UAIPerceptionComponent에 AISense_Sight, AISense_Hearing, AISense_Damage를 설정하세요. 어그로 테이블을 구현하여 탱커/딜러/힐러에 대한 타겟 우선순위를 결정하고, 위협 수치에 따라 타겟을 전환하세요.