상세 컨텐츠

본문 제목

Unreal C++ 싱글플레이 FPS 슈터 게임 팀 프로젝트 적 AI - 2

Unreal C++

by hyunjunstar 2026. 5. 20. 23:55

본문

https://hyunjunstar.tistory.com/97

이전 글인 위 링크 의 Unreal C++ 싱글플레이 FPS 슈터 게임 팀 프로젝트 적 AI - 1 에서는 초기 구현을 정리하였다.

 

초기 버전에는 EnemyAIController가 Tick에서 매 프레임마다 플레이어와 거리를 계산하고,

그에 맞는 상태를(Idle / Chase / Attack / Dead) 직접 관리하는 코드 방식으로 구현했다.

 

이번 글에서는 구조를 개선하여 Behavior Tree와 Blackboard를 사용하고,

NavMesh 기반 경로 탐색과 장애물 회피까지 포함한 AI 구조를 구현해보려고 한다.

Behavior Tree 사용 후 변경된 부분

기존 구조에서는 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에서 즉시 데미지를 적용하는 구조라 이후 수정을 할 예정이다.

관련글 더보기