https://hyunjunstar.tistory.com/97
이전 글인 위 링크 의 Unreal C++ 싱글플레이 FPS 슈터 게임 팀 프로젝트 적 AI - 1 에서는 초기 구현을 정리하였다.
초기 버전에는 EnemyAIController가 Tick에서 매 프레임마다 플레이어와 거리를 계산하고,
그에 맞는 상태를(Idle / Chase / Attack / Dead) 직접 관리하는 코드 방식으로 구현했다.
이번 글에서는 구조를 개선하여 Behavior Tree와 Blackboard를 사용하고,
NavMesh 기반 경로 탐색과 장애물 회피까지 포함한 AI 구조를 구현해보려고 한다.
기존 구조에서는 EnemyAIController가 모든 판단을 직접 처리했다.
EnemyAIController가 Tick()에서 직접 플레이어와의 거리를 계산하고,
Idle / Chase / Attack / Dead 상태를 관리하는 구조였다.
이번에는 이 구조를 개선해서 Behavior Tree와 Blackboard를 사용하도록 변경하였다.
EnemyAIController.h
처음에 구현했던 AIController가 직접 판단하고 관리하는 코드들을 제거하고,
Behavior Tree 실행, Blackboard에 TargetActor 설정 하는 식으로 변경하였다.
// EnemyAIController.h
#pragma once
#include "CoreMinimal.h"
#include "AIController.h"
#include "TimerManager.h"
#include "EnemyAIController.generated.h"
UCLASS()
class CH3_PROJECT_API AEnemyAIController : public AAIController
{
GENERATED_BODY()
public:
AEnemyAIController();
protected:
// 적 캐릭터를 조종하기 시작할 때 호출
virtual void OnPossess(APawn* InPawn) override;
private:
// 현재 추적 중인 플레이어 Pawn
UPROPERTY()
APawn* TargetPlayer;
// 현재 조종 중인 적 캐릭터
UPROPERTY()
class AEnemyCharacter* ControlledEnemy;
// 주기적으로 플레이어를 찾아 Blackboard에 갱신하기 위한 타이머
FTimerHandle FindPlayerTimerHandle;
// 현재 월드의 플레이어 Pawn을 찾음
void FindPlayer();
// 감지 범위에 따라 Blackboard의 TargetActor를 설정하거나 제거
void SetTargetPlayer();
};
EnemyAIController.cpp
RunBehaviorTree()를 사용하여 적 캐릭터에 지정된 Behavior Tree를 실행하고,
SetValueAsObject()를 사용하여 Blackboard에 플레이어를 TargetActor로 저장했다.
이렇게 Behavior Tree는 플레이어를 기준으로 추적, 공격 등 상황을 판단하고 실행하게 하였다.
// EnemyAIController.cpp
#include "EnemyAIController.h"
#include "EnemyCharacter.h"
#include "Kismet/GameplayStatics.h"
#include "BehaviorTree/BehaviorTree.h"
#include "BehaviorTree/BlackboardComponent.h"
// AIController 기본값 설정
AEnemyAIController::AEnemyAIController()
{
// Behavior Tree가 판단을 담당하므로 Tick 비활성화
PrimaryActorTick.bCanEverTick = false;
TargetPlayer = nullptr;
ControlledEnemy = nullptr;
}
// 적 캐릭터를 조종하기 시작할 때 호출
void AEnemyAIController::OnPossess(APawn* InPawn)
{
Super::OnPossess(InPawn);
// 현재 조종할 적 캐릭터 저장
ControlledEnemy = Cast<AEnemyCharacter>(InPawn);
if (!ControlledEnemy)
{
return;
}
// 적 캐릭터에 Behavior Tree가 없으면 실행 불가
if (!ControlledEnemy->BehaviorTree)
{
UE_LOG(LogTemp, Warning, TEXT("BehaviorTree is null"));
return;
}
// 적 캐릭터에 지정된 Behavior Tree 실행
RunBehaviorTree(ControlledEnemy->BehaviorTree);
// 일정 시간 뒤 플레이어를 찾아 Blackboard에 저장
GetWorldTimerManager().SetTimer(
FindPlayerTimerHandle,
this,
&AEnemyAIController::SetTargetPlayer,
0.2f,
false
);
}
// 현재 월드의 플레이어 Pawn 찾기
void AEnemyAIController::FindPlayer()
{
TargetPlayer = UGameplayStatics::GetPlayerPawn(this, 0);
}
// Blackboard에 TargetActor 설정
void AEnemyAIController::SetTargetPlayer()
{
FindPlayer();
UE_LOG(LogTemp, Warning, TEXT("TargetPlayer: %s"), *GetNameSafe(TargetPlayer));
UBlackboardComponent* BB = GetBlackboardComponent();
if (!BB)
{
UE_LOG(LogTemp, Warning, TEXT("BlackboardComponent is null"));
return;
}
if (!TargetPlayer)
{
UE_LOG(LogTemp, Warning, TEXT("TargetPlayer is still None"));
return;
}
// Behavior Tree에서 사용할 TargetActor 저장
BB->SetValueAsObject(TEXT("TargetActor"), TargetPlayer);
UE_LOG(LogTemp, Warning, TEXT("Blackboard TargetActor Set: %s"),
*GetNameSafe(TargetPlayer));
}
EnemyCharacter.h
Behavior Tree를 사용하기 위해 변수를 추가하였고,
UGameplayStatics::ApplyDamage()와 연결하기 위해 TakeDamage()를 override했다.
// EnemyCharacter.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "EnemyCharacter.generated.h"
class UBehaviorTree;
UCLASS()
class CH3_PROJECT_API AEnemyCharacter : public ACharacter
{
GENERATED_BODY()
public:
AEnemyCharacter();
// 최대 HP
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Enemy|Stat")
float MaxHP;
// 현재 HP
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Enemy|Stat")
float CurrentHP;
// 방어력
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Enemy|Stat")
float Defense;
// 처치 시 획득 점수
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Enemy|Reward")
int32 ScoreValue;
// 공격 데미지
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Enemy|Attack")
float AttackDamage;
// 플레이어 감지 범위
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Enemy|AI")
float DetectionRange;
// 공격 가능 범위
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Enemy|AI")
float AttackRange;
// AIController가 실행할 Behavior Tree
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Enemy|AI")
UBehaviorTree* BehaviorTree;
// 공격 쿨타임
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Enemy|Attack")
float AttackCooldown;
// Unreal 기본 데미지 시스템으로 받은 데미지를 HP에 반영
virtual float TakeDamage(
float DamageAmount,
struct FDamageEvent const& DamageEvent,
AController* EventInstigator,
AActor* DamageCauser) override;
// 방어력 계산 후 실제 HP를 감소시키는 함수
UFUNCTION(BlueprintCallable)
void TakeDamageFromEnemy(float Damage);
// 현재 적이 사망 상태인지 확인
bool IsDead() const;
protected:
// 게임 시작 시 초기화
virtual void BeginPlay() override;
// 사망 처리
void Die();
private:
// 중복 사망 처리를 막기 위한 상태값
bool bIsDead;
};
EnemyCharacter.cpp
처음에는 적이 죽으면 Destroy()로 바로 제거 하였는데,
체력이 0이 되면 이동과 충돌을 비활성화 시키고, 2초 뒤에 제거 되도록 수정하였다.
// EnemyCharacter.cpp
#include "EnemyCharacter.h"
#include "CH3_Project/ShooterGameMode.h"
#include "Kismet/GameplayStatics.h"
#include "Components/CapsuleComponent.h"
#include "GameFramework/CharacterMovementComponent.h"
// 적 기본 스탯 설정
AEnemyCharacter::AEnemyCharacter()
{
PrimaryActorTick.bCanEverTick = false;
MaxHP = 100.f;
CurrentHP = MaxHP;
Defense = 0.f;
ScoreValue = 1;
AttackDamage = 10.f;
DetectionRange = 1200.f;
AttackRange = 150.f;
AttackCooldown = 1.5f;
bIsDead = false;
}
// 시작 시 현재 HP를 최대 HP로 설정
void AEnemyCharacter::BeginPlay()
{
Super::BeginPlay();
CurrentHP = MaxHP;
}
// ApplyDamage로 들어온 데미지를 적 체력 시스템에 반영
float AEnemyCharacter::TakeDamage(
float DamageAmount,
FDamageEvent const& DamageEvent,
AController* EventInstigator,
AActor* DamageCauser)
{
const float ActualDamage = Super::TakeDamage(
DamageAmount,
DamageEvent,
EventInstigator,
DamageCauser
);
// 기존 데미지 처리 함수로 연결
TakeDamageFromEnemy(DamageAmount);
return ActualDamage;
}
// 받은 데미지에서 방어력을 뺀 후 HP 감소
void AEnemyCharacter::TakeDamageFromEnemy(float Damage)
{
if (bIsDead)
{
return;
}
const float FinalDamage = FMath::Max(Damage - Defense, 1.f);
CurrentHP -= FinalDamage;
if (CurrentHP <= 0.f)
{
Die();
}
}
// 현재 사망 상태 반환
bool AEnemyCharacter::IsDead() const
{
return bIsDead;
}
// 점수 지급 후 액터 제거
void AEnemyCharacter::Die()
{
if (bIsDead)
{
return;
}
bIsDead = true;
AShooterGameMode* GM = Cast<AShooterGameMode>(UGameplayStatics::GetGameMode(this));
if (GM)
{
GM->AddScore(ScoreValue);
}
// 사망 후 이동과 충돌 비활성화
GetCharacterMovement()->DisableMovement();
GetCapsuleComponent()->SetCollisionEnabled(ECollisionEnabled::NoCollision);
// 2초 후 제거
SetLifeSpan(2.0f);
}
BTDecorator_IsInAttackRange.h
플레이어가 공격 범위 안에 있는지 확인 후,
범위 안에 있으면 BTTask_AttackTarget이 실행할 수 있게 해주는 역할이다.
// BTDecorator_IsInAttackRange.h
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTDecorator.h"
#include "BTDecorator_IsInAttackRange.generated.h"
UCLASS()
class CH3_PROJECT_API UBTDecorator_IsInAttackRange : public UBTDecorator
{
GENERATED_BODY()
public:
UBTDecorator_IsInAttackRange();
protected:
// Behavior Tree가 이 Decorator 조건을 검사할 때 호출
virtual bool CalculateRawConditionValue(
UBehaviorTreeComponent& OwnerComp,
uint8* NodeMemory
) const override;
};
BTDecorator_IsInAttackRange.cpp
const float Distance = FVector::Dist(
Enemy->GetActorLocation(),
TargetActor->GetActorLocation()
);
return Distance <= Enemy->AttackRange;
위 코드가 true 일때만 공격 Task를 실행할 수 있다.
// BTDecorator_IsInAttackRange.cpp
#include "AI/BTDecorator_IsInAttackRange.h"
#include "AI/EnemyCharacter.h"
#include "AIController.h"
#include "BehaviorTree/BlackboardComponent.h"
// Behavior Tree에서 보일 노드 이름
UBTDecorator_IsInAttackRange::UBTDecorator_IsInAttackRange()
{
NodeName = TEXT("Is In Attack Range");
}
// TargetActor가 적의 공격 범위 안에 있는지 확인
bool UBTDecorator_IsInAttackRange::CalculateRawConditionValue(
UBehaviorTreeComponent& OwnerComp,
uint8* NodeMemory
) const
{
// 현재 Behavior Tree를 실행 중인 AIController 가져오기
const AAIController* AIController = OwnerComp.GetAIOwner();
if (!AIController)
{
return false;
}
// AIController가 조종 중인 Pawn을 EnemyCharacter로 변환
const AEnemyCharacter* Enemy = Cast<AEnemyCharacter>(AIController->GetPawn());
if (!Enemy || Enemy->IsDead())
{
return false;
}
// Blackboard에서 TargetActor 가져오기
const UBlackboardComponent* BlackboardComp = OwnerComp.GetBlackboardComponent();
if (!BlackboardComp)
{
return false;
}
const AActor* TargetActor = Cast<AActor>(
BlackboardComp->GetValueAsObject(TEXT("TargetActor"))
);
if (!TargetActor)
{
return false;
}
// 적과 타겟 사이의 거리를 계산
const float Distance = FVector::Dist(
Enemy->GetActorLocation(),
TargetActor->GetActorLocation()
);
// 공격 범위 안이면 true, 아니면 false
return Distance <= Enemy->AttackRange;
}
BTTask_AttackTarget.h
이 코드는 Behavior Tree에서 공격 노드가 실행 될 때 호출되게 하였다.
// BTTask_AttackTarget.h
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTTaskNode.h"
#include "BTTask_AttackTarget.generated.h"
UCLASS()
class CH3_PROJECT_API UBTTask_AttackTarget : public UBTTaskNode
{
GENERATED_BODY()
public:
UBTTask_AttackTarget();
protected:
// Behavior Tree가 공격 Task를 실행할 때 호출
virtual EBTNodeResult::Type ExecuteTask(
UBehaviorTreeComponent& OwnerComp,
uint8* NodeMemory
) override;
};
BTTask_AttackTarget.cpp
공격 Task가 실행되면 바로 데미지를 적용시켰다.
BTTask_AttackTarget 실행 > Blackboard에서 TargetActor 가져오기 > 이동 정지 >
타겟 바라보기 > ApplyDamage() 호출 > 플레이어에게 데미지 적용
// BTTask_AttackTarget.cpp
#include "AI/BTTask_AttackTarget.h"
#include "AI/EnemyCharacter.h"
#include "AIController.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "Kismet/GameplayStatics.h"
// Behavior Tree에서 보일 노드 이름 설정
UBTTask_AttackTarget::UBTTask_AttackTarget()
{
NodeName = TEXT("Attack Target");
}
// TargetActor에게 데미지를 주는 공격 Task
EBTNodeResult::Type UBTTask_AttackTarget::ExecuteTask(
UBehaviorTreeComponent& OwnerComp,
uint8* NodeMemory
)
{
// 현재 Behavior Tree를 실행 중인 AIController 가져오기
AAIController* AIController = OwnerComp.GetAIOwner();
if (!AIController)
{
return EBTNodeResult::Failed;
}
// AIController가 조종 중인 Pawn을 EnemyCharacter로 변환
AEnemyCharacter* Enemy = Cast<AEnemyCharacter>(AIController->GetPawn());
if (!Enemy || Enemy->IsDead())
{
return EBTNodeResult::Failed;
}
// Blackboard에서 TargetActor 가져오기
UBlackboardComponent* BlackboardComp = OwnerComp.GetBlackboardComponent();
if (!BlackboardComp)
{
return EBTNodeResult::Failed;
}
AActor* TargetActor = Cast<AActor>(
BlackboardComp->GetValueAsObject(TEXT("TargetActor"))
);
if (!TargetActor)
{
return EBTNodeResult::Failed;
}
// 공격 중에는 이동을 멈추고 타겟을 바라보게 함
AIController->StopMovement();
AIController->SetFocus(TargetActor);
// Unreal 기본 데미지 시스템으로 플레이어에게 데미지 적용
UGameplayStatics::ApplyDamage(
TargetActor,
Enemy->AttackDamage,
AIController,
Enemy,
nullptr
);
UE_LOG(
LogTemp,
Warning,
TEXT("Enemy BT Attack: %.1f Damage"),
Enemy->AttackDamage
);
// Task 성공 처리
return EBTNodeResult::Succeeded;
}
Behavior Tree를 사용하면서 구조가 아래처럼 바뀌었다.
EnemyAIController
- Behavior Tree 실행
- Blackboard에 TargetActor 설정
EnemyCharacter
- Behavior Tree asset 보유
- ApplyDamage를 TakeDamage로 받아 HP 처리
- 사망 시 이동/충돌 비활성화 후 제거
BTDecorator_IsInAttackRange
- TargetActor가 공격 범위 안에 있는지 검사
BTTask_AttackTarget
- TargetActor에게 데미지 적용
1. EnemyAIController가 EnemyCharacter를 Possess
2. EnemyCharacter에 설정된 Behavior Tree 실행
3. 플레이어 Pawn 탐색
4. Blackboard에 TargetActor 저장
5. Behavior Tree가 TargetActor를 기준으로 이동 / 공격 판단
6. BTDecorator_IsInAttackRange가 공격 거리 검사
7. 공격 범위 안이면 BTTask_AttackTarget 실행
8. ApplyDamage()로 플레이어에게 데미지 적용
이번 단계에서는 EnemyAIController가 직접 처리하던 AI 판단을 Behavior Tree와 Blackboard 기반 구조로 분리하였다.
근데 아직 TargetActor 갱신이 한 번만 실행되고, 공격 Task에서 즉시 데미지를 적용하는 구조라 이후 수정을 할 예정이다.
| Unreal C++ 싱글플레이 FPS 슈터 게임 팀 프로젝트 적 AI - 4 (0) | 2026.05.23 |
|---|---|
| Unreal C++ 싱글플레이 FPS 슈터 게임 팀 프로젝트 적 AI - 3 (0) | 2026.05.21 |
| Unreal C++ 싱글플레이 FPS 슈터 게임 팀 프로젝트 적 AI - 1 (0) | 2026.05.19 |
| Unreal C++ 게임 루프 및 UI 설계 후 코인 획득 게임 구현 트러블슈팅 (0) | 2026.04.30 |
| Unreal C++ 게임 루프 및 UI 설계 후 코인 획득 게임 구현 (0) | 2026.04.29 |