Chapter 08. AI
8.1 AIController
8.1-1 AIController 생성
- 언리얼 엔진의 폰은 조종당할 수 있게 설계된 액터를 의미함.
지금까지 폰은 플레이어 컨트롤러에 의해 수동적으로 조종당함.
비헤이비어 트리 모델을 사용해 인공지능을 설계하고,
플레이어가 아닌 인공지능이 NPC를 제어하도록 AIController를 활용해보고자 함.
폰은 플레이어 컨트롤러와 동일한 방식으로 AI 컨트롤러에 빙의됨.
- NPC(Non-Player Character)
플레이어가 조종하지 않지만 레벨에 배치되어 스스로 행동하는 캐릭터를 NPC라고 함.
- 실습 준비 사항
믹사모 > Characters > "Brute" 검색 후 다운로드
Animations > "Standing Idle" 검색 후 다운로드. Standing Idle > Idle_Axe로 이름 변경
"Great Sword Slash" 검색 후 다운로드. Great Sword Slash > Attack_Axe로 이름 변경
"Fast Run" 검색 후 다운로드. Fast Run > Run_Axe로 이름 변경
"Walking" 검색 후 다운로드. Walking > Walk_Axe로 이름 변경
Content Browser > Mixamo > Meshes 우클릭 > 새 폴더 "Brute"
Brute 우클릭 > Import /Game/... > Brute.fbx 임포트.
Animations 우클릭 > 새 폴더 "Brute"
Brute 우클릭 > Import /Game/... > 위에서 다운로드 받은 애니메이션들 모두 선택
Import Mesh 체크 해제 후 Skeleton에 Brute_Skeleton 지정 후 Import All
- AI Controller 생성 실습
새 C++ 클래스 > AIController 부모 클래스 > "SAIController"
Path > Controllers
새 C++ 클래스 > Character 부모 클래스 > "SNonPlayerCharacter"
Path > Characters
아래와 같이 작성 후 컴파일.
Content Browser > StudyProject > Characters > 새 블루프린트 애셋
SNonPlayerCharacte 부모 클래스 > "BP_NPC" 생성
SkeletalMesh에 Brute 지정.
Animations > AnimationBlueprints > 새 Animation 애셋 > Animation Bluerpint
Brute Skeleton 지정. AnimIntance > "ABP_NPC" 생성
<hide/>
// SAIController.h
#pragma once
#include "CoreMinimal.h"
#include "AIController.h"
#include "SAIController.generated.h"
/**
*
*/
UCLASS()
class STUDYPROJECT_API ASAIController : public AAIController
{
GENERATED_BODY()
};
<hide/>
// SAIController.cpp
#include "Controllers/SAIController.h"
<hide/>
// SNonPlayerCharacter.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "SNonPlayerCharacter.generated.h"
UCLASS()
class STUDYPROJECT_API ASNonPlayerCharacter : public ACharacter
{
GENERATED_BODY()
public:
ASNonPlayerCharacter();
private:
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = ASNonPlayerCharacter, meta = (AllowPrivateAccess))
TObjectPtr<class USkeletalMeshComponent> CurrentWeaponSkeletalMeshComponent;
};
<hide/>
// SNonPlayerCharacter.cpp
#include "Characters/SNonPlayerCharacter.h"
#include "Controllers/SAIController.h"
#include "Components/CapsuleComponent.h"
#include "GameFramework/CharacterMovementComponent.h"
ASNonPlayerCharacter::ASNonPlayerCharacter()
{
PrimaryActorTick.bCanEverTick = false;
AIControllerClass = ASAIController::StaticClass();
AutoPossessAI = EAutoPossessAI::PlacedInWorldOrSpawned;
float CharacterHalfHeight = 90.f;
float CharacterRadius = 40.f;
GetCapsuleComponent()->InitCapsuleSize(CharacterRadius, CharacterHalfHeight);
GetCapsuleComponent()->SetCollisionProfileName(TEXT("SCharacterPreset"));
FVector PivotPosition(0.f, 0.f, -CharacterHalfHeight);
FRotator PivotRotation(0.f, -90.f, 0.f);
GetMesh()->SetRelativeLocationAndRotation(PivotPosition, PivotRotation);
GetCharacterMovement()->MaxWalkSpeed = 500.f;
GetCharacterMovement()->MinAnalogWalkSpeed = 20.f;
GetCharacterMovement()->JumpZVelocity = 700.f;
GetCharacterMovement()->AirControl = 0.35f;
GetCharacterMovement()->BrakingDecelerationWalking = 2000.f;
CurrentWeaponSkeletalMeshComponent = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("CurrentWeaponSkeletalMeshComponent"));
CurrentWeaponSkeletalMeshComponent->SetupAttachment(GetMesh(), TEXT("WeaponSocket"));
}
- AI의 빙의 옵션을 PlaceInWorldOrSpawned로 설정함.
그러면 앞으로 레벨에 배치하거나 새롭게 생성되는 SNonPlayerCharacter마다
SAIController 액터가 생성되고, 플레이어가 조종하는 캐릭터를 제외한
SNonPlayerCharacter 액터는 SAIController의 빙의를 받게됨.
- 내비 메시 영역 생성
NPC는 스스로 움직여야 하기 때문에 이를 보조할 장치가 필요함.
이때 많이 사용하는 것이 네비게이션 메시(Navigation Mesh)
Place Actors > Volumes > NavMeshBoundsVolume을 월드에 배치. 트랜스폼 위치 초기화.
Details > Brush Settings > 4000 X 4000 X 200cm
Viewport 클릭 > P키
그러면 에디터에서 빌드한 내비 메시 영역이 녹색으로 뷰포트에 표시됨.
- 네비 메시 활용 실습
빙의된 폰에게 목적지를 알려줘서 목적지까지 스스로 움직이도록 명령 추가.
AI 컨트롤러에 타이머를 설치해서 3초마다 폰에게 목적지로 이동하는 명령.
네비게이션 시스템은 이동 가능한 목적지를 랜덤으로 가져오는
GetRandomPointInNavigableRadius() 함수와
목적지로 폰을 이동시키는 SimpleMoveToLocation() 함수를 제공함.
새 블루프린트 애셋 > SNonPlayerCharacter 부모 클래스 > "BP_NPC" 생성
스켈레톤 메시와 애님인스턴스 지정. 애님인스턴스는 ABP_SPawn 지정.
<hide/>
// StudyProject.Build.cs
using UnrealBuildTool;
public class StudyProject : ModuleRules
{
public StudyProject(ReadOnlyTargetRules Target) : base(Target)
{
...
PublicDependencyModuleNames.AddRange(new string[]
{
...
// AI
"NavigationSystem",
});
PrivateDependencyModuleNames.AddRange(new string[] { });
}
}
<hide/>
// SAIController.h
#pragma once
#include "CoreMinimal.h"
#include "AIController.h"
#include "SAIController.generated.h"
/**
*
*/
UCLASS()
class STUDYPROJECT_API ASAIController : public AAIController
{
GENERATED_BODY()
public:
ASAIController();
protected:
virtual void OnPossess(APawn* InPawn) override;
virtual void OnUnPossess() override;
private:
void OnPatrolTimerElapsed();
public:
FTimerHandle PatrolTimerHandle = FTimerHandle();
static const float PatrolRepeatInterval;
static const float PatrolRadius;
};
<hide/>
// SAIController.cpp
#include "Controllers/SAIController.h"
#include "NavigationSystem.h"
#include "Blueprint/AIBlueprintHelperLibrary.h"
const float ASAIController::PatrolRepeatInterval(3.f);
const float ASAIController::PatrolRadius(500.f);
ASAIController::ASAIController()
{
}
void ASAIController::OnPossess(APawn* InPawn)
{
Super::OnPossess(InPawn);
GetWorld()->GetTimerManager().SetTimer(PatrolTimerHandle, this, &ThisClass::OnPatrolTimerElapsed, PatrolRepeatInterval, true);
}
void ASAIController::OnUnPossess()
{
Super::OnUnPossess();
GetWorld()->GetTimerManager().ClearTimer(PatrolTimerHandle);
}
void ASAIController::OnPatrolTimerElapsed()
{
if (APawn* ControlledPawn = GetPawn())
{
if (UNavigationSystemV1* NavSystem = UNavigationSystemV1::GetNavigationSystem(GetWorld()))
{
FNavLocation NextLocation;
if (true == NavSystem->GetRandomPointInNavigableRadius(FVector::ZeroVector, PatrolRadius, NextLocation))
{
UAIBlueprintHelperLibrary::SimpleMoveToLocation(this, NextLocation.Location);
}
}
}
}
8.1-2 비헤이비어 트리와 블랙 보드
- Finite State Machine
상태들을 연결하여 인공지능을 설계하는 기법.
모든 상태 노드들은 상하 관계가 없음. 수평 구조
즉, 모든 상태 노드에서 모든 상태 노드로 전환 가능함. 구현하기가 쉬움.
다만, 이러한 자유로움 때문에 상태 노드가 많아질 수록 굉장히 복잡해짐.
- Behavior Tree
우선 순위와 트리 구조를 사용해 인공지능을 설계하는 기법.
즉, 수직 구조를 가짐.
상위 노드는 어떤 서브 트리를 동작시킬지 결정하고,
하위 노드는 자신이 맡은 작업만 수행하는 구조.
적절한 규칙을 통해 노드가 많아져도 가독성이 높음.
- Blackboard
Behavior Tree가 판단하는데 필요한 데이터들을 저장한 애셋.
매순간 업데이트 되며 Behavior Tree의 동작에 영향을 줌.
- Behavior Tree의 규칙
트리의 위, 좌측일수록 우선순위가 높음.
시작 상태(Entry)를 설정할 필요 없음. 항상 왼쪽 노드부터 깊이 우선 탐색 진행.
각 노드는 Task라고 부름.

- 부모 노드에서 하위 서브 트리(다수의 작업)를 결정함.
이를 Composite이라고 함. Composite에는 아래와 같은 종류가 있음
라면을 끓여주는 AI 로봇에 예시를 들어서 설명.
Sequence: 여러 Child Task를 왼쪽부터 순서대로 수행.
ex. 라면을 꺼낸다. 냄비에 물을 담는다. 물을 끓인다.
Selector: 여러 Child Task 중 하나의 Child Task를 선택해서 수행.
ex. 주문에 따라 신라면을 고른다. Vs. 짜파게티를 고른다.
Parallel: 여러 Child Task를 동시에 수행.
ex. 라면 봉지를 뜯으면서 물을 끊인다.

- Task 수행 결과
성공: Task 성공
실패: 내부 요인으로 인한 Task 실패
중지: 외부 요인으로 인한 Task 실패
진행 중: Task 결과를 pending
- Child Task의 결과에 따른 Composite Task의 반응
Sequence: 실패한 Child Task가 나올 때까지 수행.
ex. 라면이 없으면 냄비에 물을 담지도 않음.
물이 안나와서 냄비에 물을 담지 못하면, 물을 끓이지도 않음.(좌측에 우선순위가 더 높게 배치)
Selector: 실패한 Child Task는 넘어가고 다음 Child Task 수행.
ex. 신라면이 없어서 신라면을 고르는데 실패하면 짜파게티를 선택함.
Paraller: 실패한 Child Task가 나올 때까지 수행.
ex. 라면 봉지를 뜯는데 죽어도 안뜯기면 물 끓이던 것도 중지한다.
- Composite Task에 부착 가능한 기능.
Decorator: 해당 Composite Task가 실행되는 조건을 지정.
ex. 주문을 확인한다.
Service: 해당 Composite Task가 활성화될 때 주기적으로 수행 할 부가 작업.
ex. 물이 끓는지 주기적으로 확인한다.
Observer Abort: Decorator 조건에 부합되면 해당 Composite Task 내 작을 모두 중단
ex. 갑자기 주문이 취소되었다. 그럼 현재 수행중인 Composite를 즉시 취소 후 루트부터 다시 수행.
- Behavior Tree와 Blackboard의 생성
프로젝트 폴더 > StudyProject.Build.cs > AIModule 모듈 추가.
Content Browser > StudyProject 우클릭 > 새 폴더 "AI"
AI > 새 Artificial Intelligence 애셋 > Blackboard > "BB_NPC"
새 Artificial Intelligence 애셋 > Behavior Tree > "BT_NPC"
BT_NPC 더블 클릭 > 그래프 우클릭 > Tasks > Wait 생성.
<hide/>
// StudyProject.Build.cs
using UnrealBuildTool;
public class StudyProject : ModuleRules
{
public StudyProject(ReadOnlyTargetRules Target) : base(Target)
{
...
PublicDependencyModuleNames.AddRange(new string[]
{
...
// AI
"NavigationSystem",
"AIModule",
});
...
}
}
- Task는 독립적으로 실행될 수 없고 반드시 Composite Task를 거쳐 실행되어야 함.
그래프 우클릭 > Composites > Sequence 생성.
아래와 같이 연결. 연결 후 그래프 우클릭 > Auto Arrange 클릭하면 자동 정렬됨.

- Behavior Tree 애셋, Blackboard 애셋을 AIController 클래스와 연결 실습
아래와 같이 작성 후 컴파일.
Content Browser > StudyProject > Controller 우클릭 > 새 블루프린트 애셋
SAIController 부모 클래스 > "BP_AIController"
Blackboard와 BehaviorTree 지정.
BP_NPC > Details > AI Controller Class를 BP_AIController로 지정.
<hide/>
// SAIController.h
#pragma once
#include "CoreMinimal.h"
#include "AIController.h"
#include "SAIController.generated.h"
/**
*
*/
UCLASS()
class STUDYPROJECT_API ASAIController : public AAIController
{
GENERATED_BODY()
public:
ASAIController();
void BeginAI();
void EndAI();
protected:
virtual void OnPossess(APawn* InPawn) override;
virtual void OnUnPossess() override;
public:
static const float PatrolRadius;
private:
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = ASAIController, meta = (AllowPrivateAccess))
TObjectPtr<class UBlackboardData> BlackboardDataAsset;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = ASAIController, meta = (AllowPrivateAccess))
TObjectPtr<class UBehaviorTree> BehaviorTree;
};
<hide/>
// SAIController.cpp
#include "Controllers/SAIController.h"
#include "NavigationSystem.h"
#include "Blueprint/AIBlueprintHelperLibrary.h"
#include "BehaviorTree/BehaviorTree.h"
#include "BehaviorTree/BlackboardData.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "Kismet/KismetSystemLibrary.h"
const float ASAIController::PatrolRadius(500.f);
ASAIController::ASAIController()
{
Blackboard = CreateDefaultSubobject<UBlackboardComponent>(TEXT("Blackboard"));
BrainComponent = CreateDefaultSubobject<UBehaviorTreeComponent>(TEXT("BrainComponent"));
}
void ASAIController::BeginAI()
{
if (UBlackboardComponent* BlackboardComponent = Cast<UBlackboardComponent>(Blackboard))
{
if (true == UseBlackboard(BlackboardDataAsset, BlackboardComponent))
{
bool bRunSucceeded = RunBehaviorTree(BehaviorTree);
ensure(true == bRunSucceeded);
UKismetSystemLibrary::PrintString(this, FString::Printf(TEXT("BeginAI() has been called.")));
}
}
}
void ASAIController::EndAI()
{
if (UBehaviorTreeComponent* BehaviorTreeComponent = Cast<UBehaviorTreeComponent>(BrainComponent))
{
BehaviorTreeComponent->StopTree();
UKismetSystemLibrary::PrintString(this, FString::Printf(TEXT("EndAI() has been called.")));
}
}
void ASAIController::OnPossess(APawn* InPawn)
{
Super::OnPossess(InPawn);
BeginAI();
}
void ASAIController::OnUnPossess()
{
Super::OnUnPossess();
EndAI();
}
- 비헤이 비어 트리 애셋을 더블 클릭해서 띄운 후에 Alt + P를 누르면
뷰포트에서 게임 플레이 시 비헤이비어 트리의 로직 흐름을 함께 볼 수 있음.
8.2 NPC AI
8.2-1 BTTaskNode
- 순찰 기능 구현
Blackboard에는 특정 유형의 데이터를 저장하고
이를 Behavior Tree가 활용하도록 구성할 수 있음.
NPC의 순찰 기능을 구현하려면 두 가지 데이터가 필요함.
BB_NPC > New Key > Vector 타입 > "StartPatrolPosition"
New Key > Vector 타입 > "EndPatrolPosition"
아래와 같이 작성 후 컴파일. 플레이 후에 BB_NPC에 StartPatrolPosition의 값 확인.
<hide/>
// SAIController.h
...
class STUDYPROJECT_API ASAIController : public AAIController
{
...
public:
...
void BeginAI(APawn* InPawn);
void EndAI();
protected:
...
public:
...
static const FName StartPatrolPositionKey;
static const FName EndPatrolPositionKey;
private:
...
};
<hide/>
// SAIController.cpp
...
const FName ASAIController::StartPatrolPositionKey(TEXT("StartPatrolPosition"));
const FName ASAIController::EndPatrolPositionKey(TEXT("EndPatrolPosition"));
...
void ASAIController::BeginAI(APawn* InPawn)
{
if (UBlackboardComponent* BlackboardComponent = Cast<UBlackboardComponent>(Blackboard))
{
if (true == UseBlackboard(BlackboardDataAsset, BlackboardComponent))
{
BlackboardComponent->SetValueAsVector(StartPatrolPositionKey, InPawn->GetActorLocation());
bool bRunSucceeded = RunBehaviorTree(BehaviorTree);
ensure(true == bRunSucceeded);
UKismetSystemLibrary::PrintString(this, FString::Printf(TEXT("BeginAI() has been called.")));
}
}
}
...
void ASAIController::OnPossess(APawn* InPawn)
{
Super::OnPossess(InPawn);
BeginAI(InPawn);
}
...
- 이번 코드에서는 앞으로 관련 키 이름이 절대 변하지 않는다는 가정하에
static const를 사용해 변수 초기 값을 지정함. 이렇게 선언하면 향후 다른 코드에서
관련 값을 참조하기가 편하지만, 하드코딩으로 값을 변경해야하는 단점이 있음.
- 비헤이비어 트리에서 블랙보드 값 갱신
프로젝트 폴더 > StudyProject.Build.cs > GameplayTasks 모듈 추가
새 C++ 클래스 > BTTaskNode 부모 클래스 > "BTTask_GetEndPatrolPosition"
Path > AI
이때 UI에서 표현할 때는 BTTask_ 접두사 부분은 자동으로 걸러짐.
<hide/>
// StudyProject.Build.cs
using UnrealBuildTool;
public class StudyProject : ModuleRules
{
public StudyProject(ReadOnlyTargetRules Target) : base(Target)
{
...
PublicDependencyModuleNames.AddRange(new string[]
{
...
// AI
"NavigationSystem",
"AIModule",
"GameplayTasks",
});
...
}
}
<hide/>
// BTTask_GetEndPatrolPosition.h
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTTaskNode.h"
#include "BTTask_GetEndPatrolPosition.generated.h"
/**
*
*/
UCLASS()
class STUDYPROJECT_API UBTTask_GetEndPatrolPosition : public UBTTaskNode
{
GENERATED_BODY()
public:
UBTTask_GetEndPatrolPosition();
private:
virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
};
<hide/>
// BTTask_GetEndPatrolPosition.cpp
#include "AI/BTTask_GetEndPatrolPosition.h"
#include "Controllers/SAIController.h"
#include "Characters/SNonPlayerCharacter.h"
#include "NavigationSystem.h"
#include "BehaviorTree/BlackboardComponent.h"
UBTTask_GetEndPatrolPosition::UBTTask_GetEndPatrolPosition()
{
NodeName = TEXT("GetEndPatrolPosition"); // Behavior Tree에 보일 노드 이름.
}
EBTNodeResult::Type UBTTask_GetEndPatrolPosition::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
EBTNodeResult::Type Result = Super::ExecuteTask(OwnerComp, NodeMemory);
if (EBTNodeResult::Failed == Result)
{
return Result;
}
ASAIController* AC = Cast<ASAIController>(OwnerComp.GetAIOwner());
ASNonPlayerCharacter* NPC = Cast<ASNonPlayerCharacter>(AC->GetPawn());
if (nullptr == NPC)
{
return Result = EBTNodeResult::Failed;
}
UNavigationSystemV1* NS = UNavigationSystemV1::GetNavigationSystem(NPC->GetWorld());
if (nullptr == NS)
{
return Result = EBTNodeResult::Failed;
}
FVector StartPatrolPosition = OwnerComp.GetBlackboardComponent()->GetValueAsVector(ASAIController::StartPatrolPositionKey);
FNavLocation EndPatrolLocation;
if (true == NS->GetRandomPointInNavigableRadius(FVector::ZeroVector, AC->PatrolRadius, EndPatrolLocation))
{
OwnerComp.GetBlackboardComponent()->SetValueAsVector(ASAIController::EndPatrolPositionKey, EndPatrolLocation.Location);
return Result = EBTNodeResult::Succeeded;
}
return Result;
}
- ExecuteTask() 함수의 반환값
Aborted
태스크 실행 중에 중단되었다. 결과적으로 실패했다.
Failed
태스크를 수행했지만 실패했다.
Succeeded
태스크를 성공적으로 수행했다.
InProgress
태스크를 계속 수행하고 있다.
태스크의 실행 결과는 향후 알려줄 예정이다.
- BT_NPC Behavior Tree 그래프 수정
Wait Task 오른쪽에 GetEndPatrolPosition Task 배치.
GetEndPatrolPosition Task 오른쪽에는 MoveTo Task 추가.
MoveTo Task 클릭 > Details > BlackboardKey를 EndPatrolPosition로 지정

8.2-2 Selector
- 추격 관련 정보 준비
BB_NPC > New Key > Object 타입 > "TargetActor"
TargetActor 키 클릭 > Details > Key Type > Base Class를 SPlayerCharacter로 변경.

<hide/>
// SAIController.h
...
class STUDYPROJECT_API ASAIController : public AAIController
{
...
public:
...
static const FName TargetActorKey;
private:
...
};
<hide/>
// SAIController.cpp
...
const float ASAIController::PatrolRadius(500.f);
const FName ASAIController::StartPatrolPositionKey(TEXT("StartPatrolPosition"));
const FName ASAIController::EndPatrolPositionKey(TEXT("EndPatrolPosition"));
const FName ASAIController::TargetActorKey(TEXT("TargetActor"));
...
- Selector Composite
NPC의 행동 패턴은 플레이어를 발견 했는지,
발견하지 못했는지에 따라 추격과 정찰로 구분하고자 함.
추격과 정찰 중 하나를 선택해 행동하기 때문에 셀렉터 컴포짓을 사용해
로직을 확장하는 것이 적합함. 추격에 우선권을 주고 추격 로직은
Blackboard의 TargetActor를 향해 이동하도록 Behavior Tree 그래프 수정.

8.2-3 Service
- Service
Service가 부착된 Composite가 활성화 될 경우 주기적으로 TickNode() 함수를 호출함.
호출하는 주기는 interval 속성으로 설정할 수 있음.
- 주위에 캐릭터가 있는지 감지 구현
새 C++ 클래스 > BTService 부모 클래스 > "BTService_DetectPlayerCharacter"
- TickNode() 함수에는 NPC의 위치를 기준으로 반경 6미터 내에
캐릭터가 있는지 감지구현.
- 반경 내에 다른 NPC TUCharacter가 있는 경우도 가정해 반경 내에
모든 캐릭터를 감지하는 OverlapMultipleByChannel() 함수를 사용함.
- 반경 내에 감지된 모든 캐릭터 정보는 목록을 관리하는데 적합한
자료구조인 TArray로 전달됨.
- 서비스가 만들어지면 셀렉트 컴포짓을 우클릭 하고 서비스 메뉴에서
Detect를 선택해서 컴포짓에 부착한다.
<hide/>
// BTService_DetectPlayerCharacter.h
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTService.h"
#include "BTService_DetectPlayerCharacter.generated.h"
/**
*
*/
UCLASS()
class STUDYPROJECT_API UBTService_DetectPlayerCharacter : public UBTService
{
GENERATED_BODY()
public:
UBTService_DetectPlayerCharacter();
protected:
virtual void TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;
};
<hide/>
// BTService_DetectPlayerCharacter.cpp
#include "AI/BTService_DetectPlayerCharacter.h"
#include "Controllers/SAIController.h"
#include "Characters/SNonPlayerCharacter.h"
UBTService_DetectPlayerCharacter::UBTService_DetectPlayerCharacter()
{
NodeName = TEXT("DetectPlayerCharacter");
Interval = 1.f;
}
void UBTService_DetectPlayerCharacter::TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
Super::TickNode(OwnerComp, NodeMemory, DeltaSeconds);
if (ASAIController* AC = Cast<ASAIController>(OwnerComp.GetAIOwner()))
{
if (ASNonPlayerCharacter* NPC = Cast<ASNonPlayerCharacter>(AC->GetPawn()))
{
if (UWorld* World = NPC->GetWorld())
{
FVector CenterPosition = NPC->GetActorLocation();
float DetectRadius = 300.f;
TArray<FOverlapResult> OverlapResults;
FCollisionQueryParams CollisionQueryParams(NAME_None, false, NPC);
bool bResult = World->OverlapMultiByChannel(
OverlapResults,
CenterPosition,
FQuat::Identity,
ECollisionChannel::ECC_GameTraceChannel2,
FCollisionShape::MakeSphere(DetectRadius),
CollisionQueryParams
);
DrawDebugSphere(World, CenterPosition, DetectRadius, 16, FColor::Green, false, 0.5f);
}
}
}
}

- 플레이어 가려내기
캐릭터를 조종하는 컨트롤러가 플레이어 컨트롤러인지 파악 할 수 있도록
IsPlayerController() 함수 사용. 플레이어 캐릭터가 감지되면 블랙보드의
Target 값을 플레이어 케릭터로 지정하고 그렇지 않으면 nullptr 값을 지정함.
<hide/>
// BTService_DetectPlayerCharacter.cpp
// BTService_DetectPlayerCharacter.cpp
...
#include "BehaviorTree/BlackboardComponent.h"
#include "Kismet/KismetSystemLibrary.h"
...
void UBTService_DetectPlayerCharacter::TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
Super::TickNode(OwnerComp, NodeMemory, DeltaSeconds);
if (ASAIController* AC = Cast<ASAIController>(OwnerComp.GetAIOwner()))
{
if (ASNonPlayerCharacter* NPC = Cast<ASNonPlayerCharacter>(AC->GetPawn()))
{
if (UWorld* World = NPC->GetWorld())
{
...
if (true == bResult)
{
for (auto const& OverlapResult : OverlapResults)
{
if (ASPlayerCharacter* PC = Cast<ASPlayerCharacter>(OverlapResult.GetActor()))
{
if (true == PC->GetController()->IsPlayerController())
{
OwnerComp.GetBlackboardComponent()->SetValueAsObject(ASAIController::TargetActorKey, PC);
UKismetSystemLibrary::PrintString(this, FString::Printf(TEXT("Detected!")));
DrawDebugSphere(World, CenterPosition, DetectRadius, 16, FColor::Red, false, 0.5f);
DrawDebugPoint(World, PC->GetActorLocation(), 10.f, FColor::Red, false, 0.5f);
DrawDebugLine(World, NPC->GetActorLocation(), PC->GetActorLocation(), FColor::Red, false, 0.5f, 0u, 3.f);
return;
}
else
{
OwnerComp.GetBlackboardComponent()->SetValueAsObject(ASAIController::TargetActorKey, nullptr);
DrawDebugSphere(World, CenterPosition, DetectRadius, 16, FColor::Green, false, 0.5f);
}
}
}
}
UKismetSystemLibrary::PrintString(this, FString::Printf(TEXT("No target detected...")));
DrawDebugSphere(World, CenterPosition, DetectRadius, 16, FColor::Green, false, 0.5f);
}
}
}
}
- NPC 회전 보정
NPC가 이동할 때 회전이 부자연스럽게 꺾이는 것을 볼 수 있음.
별도로 NPC를 위한 ControlMode를 추가하고
NPC는 이동방향에 따라 회전하도록 캐릭터 무브먼트 설정을 변경.
NPC의 최대 이동 속도를 플레이어보다 낮게 설정해서
플레이어가 NPC로부터 도망갈 수 있도록 만들어줌.
<hide/>
// SNonPlayerCharacter.h
...
class STUDYPROJECT_API ASNonPlayerCharacter : public ACharacter
{
...
public:
...
virtual void PossessedBy(AController* NewController) override;
private:
...
};
<hide/>
// SNonPlayerCharacter.cpp
...
void ASNonPlayerCharacter::PossessedBy(AController* NewController)
{
Super::PossessedBy(NewController);
if (false == IsPlayerControlled())
{
bUseControllerRotationYaw = false;
GetCharacterMovement()->bOrientRotationToMovement = false;
GetCharacterMovement()->bUseControllerDesiredRotation = true;
GetCharacterMovement()->RotationRate = FRotator(0.f, 480.f, 0.f);
GetCharacterMovement()->MaxWalkSpeed = 300.f;
}
}
8.2-4 Decorator
- Decorator
Blackboard의 값을 기반으로 특정 Composite의 수행 분기를 결정하는 노드.
- Decorator에 따라 추격과 정찰로 나누기
BT_NPC > 왼쪽 Sequence Task 우클릭 > Add Decorator > Blackboard 클릭
해당 Decorator 클릭 > Details > Notify Observer를 On Value Change로 설정.
해당 키 값의 변경이 감지되면, 해당 Composite의 수행을 곧바로 취소하도록 함
Observer Aborts를 None으로 두면 Composite에 속한 모든 Task가 마무리 될 때까지 대기함.
대신 Self로 두면, Notify Observer가 발생하면 자기 자신을 중단 시킴.
Key Query는 Is Set, Blackboard Key에는 TargetActor 지정.
정리하자면, Blackboar의 TargetActor가 Is Set이 되었는지 Query하고,
Value가 변경 되면 Notify가 발생되면서 Self를 중단.
- 오른쪽 Sequence Task에도 동일하게 Decorator를 추가.
다만, 오른쪽 Decorator에는 반대 조건인 Is Not Set으로 지정.

8.3 NPC 공격
8.3-1 공격 구현 준비
- 실습 준비
Content Browser > StudyProject > Animations > AnimationMontages
새 Animation 애셋 > Animation Montage > Brute_Skeleton 선택 > "AM_Attack_Axe"
DefaultGroup.DefaultSlot 옆 역삼각형 클릭 > Slot Manager > Add Slot > "FullBody"
DefaultGroup.DefaultSlot 옆 역삼각형 클릭 > Slot Name > DefaultGroup.FullBody 지정.
DefaultGroup.FullBody 타임라인에 Attack_Axe 드래그 드랍.
Content Browser > ABP_NPC > Class Settings > Parent Class를 SAnimInstance로 지정.
Detalis > Attack Anim Montage에 AM_Attack_Axe 지정.
아래 그림과 같이 그래프 작성.






- NPC 공격 로직 구현
<hide/>
// SAnimInstance.h
...
class STUDYPROJECT_API USAnimInstance : public UAnimInstance
{
...
friend class ASPlayerCharacter;
friend class ASNonPlayerCharacter;
public:
...
};
<hide/>
// SNonPlayerCharacter.h
...
class STUDYPROJECT_API ASNonPlayerCharacter : public ACharacter
{
GENERATED_BODY()
public:
...
bool IsNowAttacking() const { return bIsAttacking; }
private:
void Attack();
UFUNCTION()
void OnAttackAnimMontageEnded(class UAnimMontage* Montage, bool bIsInterrupt);
private:
...
float AttackRange = 200.f;
float AttackRadius = 50.f;
bool bIsAttacking = false;
};
<hide/>
// SNonPlayerCharacter.cpp
...
#include "Kismet/KismetSystemLibrary.h"
#include "Animations/SAnimInstance.h"
...
void ASNonPlayerCharacter::Attack()
{
FHitResult HitResult;
FCollisionQueryParams Params(NAME_None, false, this);
bool bResult = GetWorld()->SweepSingleByChannel(
HitResult,
GetActorLocation(),
GetActorLocation() + AttackRange,
FQuat::Identity,
ECollisionChannel::ECC_EngineTraceChannel2,
FCollisionShape::MakeSphere(AttackRadius),
Params
);
if (true == bResult)
{
if (true == ::IsValid(HitResult.GetActor()))
{
UKismetSystemLibrary::PrintString(this, FString::Printf(TEXT("Hit Actor Name: %s"), *HitResult.GetActor()->GetName()));
}
}
USAnimInstance* AnimInstance = Cast<USAnimInstance>(GetMesh()->GetAnimInstance());
if (true == ::IsValid(AnimInstance))
{
AnimInstance->PlayAttackAnimMontage();
bIsAttacking = true;
AnimInstance->OnMontageEnded.AddDynamic(this, &ThisClass::OnAttackAnimMontageEnded);
}
#pragma region CollisionDebugDrawing
FVector TraceVec = GetActorForwardVector() * AttackRange;
FVector Center = GetActorLocation() + TraceVec * 0.5f;
float HalfHeight = AttackRange * 0.5f + AttackRadius;
FQuat CapsuleRot = FRotationMatrix::MakeFromZ(TraceVec).ToQuat();
FColor DrawColor = true == bResult ? FColor::Green : FColor::Red;
float DebugLifeTime = 5.f;
DrawDebugCapsule(
GetWorld(),
Center,
HalfHeight,
AttackRadius,
CapsuleRot,
DrawColor,
false,
DebugLifeTime
);
#pragma endregion
}
void ASNonPlayerCharacter::OnAttackAnimMontageEnded(UAnimMontage* Montage, bool bIsInterrupt)
{
bIsAttacking = false;
}
8.3-2 공격 로직 구현
- 추격 후 공격 구현
NPC의 행동은 플레이어와의 거리에 따라 추격이나 공격으로 분기되어야 함.
분기를 위해 왼쪽 Sequence Task를 Select Task로 변경.
공격에 우선 순위를 주기 위해 왼쪽 배치. 지금은 임시로 Wait Task 배치.
공격 타이밍에 NPC는 잠시 멈춤. 멈추면 공격 했다고 가정하자.

- 플레이어가 공격 범위 내에 있는지 판단하는 Decorator
새로운 C++ 클래스 > BTDecorator 부모 클래스 > "BTDecorator_IsInAttackRange"
Decorator 클래스는 CalculateRawConditionValue() 함수를 상속받아
원하는 조건이 달성되었는지를 파악하도록 설계되었음.
이 함수는 const로 선언되어 Decorator 클래스의 멤버 변수 값은 변경할 수 없음.
아래와 같이 작성 후 컴파일.
<hide/>
// BTDecorator_IsInAttackRange.h
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTDecorator.h"
#include "BTDecorator_IsInAttackRange.generated.h"
/**
*
*/
UCLASS()
class STUDYPROJECT_API UBTDecorator_IsInAttackRange : public UBTDecorator
{
GENERATED_BODY()
public:
UBTDecorator_IsInAttackRange();
protected:
virtual bool CalculateRawConditionValue(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const override;
public:
static const float AttackRange;
};
<hide/>
// BTDecorator_IsInAttackRange.cpp
#include "AI/BTDecorator_IsInAttackRange.h"
#include "Controllers/SAIController.h"
#include "Characters/SNonPlayerCharacter.h"
#include "Characters/SPlayerCharacter.h"
#include "BehaviorTree/BlackboardComponent.h"
const float UBTDecorator_IsInAttackRange::AttackRange(200.f);
UBTDecorator_IsInAttackRange::UBTDecorator_IsInAttackRange()
{
NodeName = TEXT("IsInAttackRange");
}
bool UBTDecorator_IsInAttackRange::CalculateRawConditionValue(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const
{
bool bResult = Super::CalculateRawConditionValue(OwnerComp, NodeMemory);
if (ASAIController* AIC = Cast<ASAIController>(OwnerComp.GetAIOwner()))
{
if (ASNonPlayerCharacter* NPC = Cast<ASNonPlayerCharacter>(AIC->GetPawn()))
{
if (ASPlayerCharacter* TargetPlayerCharacter = Cast<ASPlayerCharacter>(OwnerComp.GetBlackboardComponent()->GetValueAsObject(ASAIController::TargetActorKey)))
{
return bResult = (NPC->GetDistanceTo(TargetPlayerCharacter) <= AttackRange);
}
}
}
return bResult = false;
}
- 데코레이터 부착 실습
Content Browser > StudyProject > BT_NPC
공격 관련 Sequence 우클릭 > Add Decorator > IsInAttackRange 추가.
추격 관련 Sequence 우클릭 > Add Decorator > IsInAttackRange 추가.
추격 관련 Sequence 컴포짓에 IsInAttackRange 클릭 > Details > Inverse Condition에 체크.

- 공격 Task 구현
새 C++ 클래스 > BTTaskNode 부모 클래스 > "BTTask_Attack"
공격 Task는 공격 애니메이션이 끝날 때까지 대기해야 하는 지연 Task임.
그래서 ExecuteTask()가 InProgress를 일단 반환하고 공격이 끝났을 때
Task가 끝났다고 알려줘야함.
이를 알려주는 함수가 FinishLatentTask() 함수.
Task에서 이 함수를 나중에 호출해주지 않으면
비헤이비어 트리 시스템은 해당 Task에 계속 머물게 됨.
차후에 FinishLatentTask() 함수를 호출할 수 있도록
노드의 Tick() 기능을 활성화하고 Tick()에서 조건을 파악한 후
Task 종료 명령을 내려줘야 함.
<hide/>
// BTTask_Attack.h
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTTaskNode.h"
#include "BTTask_Attack.generated.h"
/**
*
*/
UCLASS()
class STUDYPROJECT_API UBTTask_Attack : public UBTTaskNode
{
GENERATED_BODY()
public:
UBTTask_Attack();
protected:
virtual void TickTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;
private:
virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
};
<hide/>
// BTTask_Attack.cpp
#include "AI/BTTask_Attack.h"
UBTTask_Attack::UBTTask_Attack()
{
bNotifyTick = true;
}
void UBTTask_Attack::TickTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
Super::TickTask(OwnerComp, NodeMemory, DeltaSeconds);
FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
}
EBTNodeResult::Type UBTTask_Attack::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
EBTNodeResult::Type Result = Super::ExecuteTask(OwnerComp, NodeMemory);
return EBTNodeResult::InProgress;
}
- NPC 공격이 끝난 시점에 지연 Task 종료 구현
임시로 부착했던 Wait 태스크를 제거 > Attack 태스크로 교체.
// SNonPlayerCharacter.h
...
class STUDYPROJECT_API ASNonPlayerCharacter : public ACharacter
{
GENERATED_BODY()
friend class UBTTask_Attack;
public:
...
};
<hide/>
// BTTask_Attack.cpp
#include "AI/BTTask_Attack.h"
#include "Controllers/SAIController.h"
#include "Characters/SNonPlayerCharacter.h"
...
void UBTTask_Attack::TickTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
Super::TickTask(OwnerComp, NodeMemory, DeltaSeconds);
if (ASAIController* AC = Cast<ASAIController>(OwnerComp.GetAIOwner()))
{
if (ASNonPlayerCharacter* NPC = Cast<ASNonPlayerCharacter>(AC->GetPawn()))
{
if (false == NPC->IsNowAttacking())
{
FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
}
}
}
}
EBTNodeResult::Type UBTTask_Attack::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
EBTNodeResult::Type Result = Super::ExecuteTask(OwnerComp, NodeMemory);
if (ASAIController* AC = Cast<ASAIController>(OwnerComp.GetAIOwner()))
{
if (ASNonPlayerCharacter* NPC = Cast<ASNonPlayerCharacter>(AC->GetPawn()))
{
NPC->Attack();
}
}
return EBTNodeResult::InProgress;
}
...

8.3-3 SimpleParallel Composite
- FMath::RInterpTo 함수를 이용한 회전 구현
플레이어 캐릭터가 NPC 뒤로 돌아가더라도 NPC는 같은 방향을 공격하고 있음.
이를 보완하기 위해서 공격함과 동시에 플레이어를 향해 회전하는 기능을 구현.
새 C++ 클래스 > BTTaskNode 부모클래스 > "BTTask_TurnToTarget"
해당 Task는 플레이어 폰을 향해 일정한 속도로 회전하도록
FMath::RInterpTo() 함수를 사용해서 회전시키는 기능을 구현
<hide/>
// BTTask_TurnToTarget.h
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTTaskNode.h"
#include "BTTask_TurnToTarget.generated.h"
/**
*
*/
UCLASS()
class STUDYPROJECT_API UBTTask_TurnToTarget : public UBTTaskNode
{
GENERATED_BODY()
public:
UBTTask_TurnToTarget();
protected:
virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
};
<hide/>
// BTTask_TurnToTarget.cpp
#include "AI/BTTask_TurnToTarget.h"
#include "Controllers/SAIController.h"
#include "Characters/SNonPlayerCharacter.h"
#include "Characters/SPlayerCharacter.h"
#include "BehaviorTree/BlackboardComponent.h"
UBTTask_TurnToTarget::UBTTask_TurnToTarget()
{
NodeName = TEXT("TurnToTargetActor");
}
EBTNodeResult::Type UBTTask_TurnToTarget::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
EBTNodeResult::Type Result = Super::ExecuteTask(OwnerComp, NodeMemory);
if (ASAIController* AC = Cast<ASAIController>(OwnerComp.GetAIOwner()))
{
if (ASNonPlayerCharacter* NPC = Cast<ASNonPlayerCharacter>(AC->GetPawn()))
{
if (ASPlayerCharacter* TargetPC = Cast<ASPlayerCharacter>(OwnerComp.GetBlackboardComponent()->GetValueAsObject(AC->TargetActorKey)))
{
FVector LookVector = TargetPC->GetActorLocation() - NPC->GetActorLocation();
LookVector.Z = 0.f;
FRotator TargetRotation = FRotationMatrix::MakeFromX(LookVector).Rotator();
NPC->SetActorRotation(FMath::RInterpTo(NPC->GetActorRotation(), TargetRotation, GetWorld()->GetDeltaSeconds(), 2.f));
return Result = EBTNodeResult::Succeeded;
}
}
}
return Result = EBTNodeResult::Failed;
}
- Simple-Parallel
공격 Task에서 사용한 Sequence를 Simple-Parallel으로 대체.
Main Task에 Attack Tast, Background Task에 TurnToTargetActor Task 지정.

- 몬스터 스폰 구현