위 그림은 아주 간단한 정찰 기능을 나타내는 Behavior Tree이다. Sequence
Node 밑에 쉬기(Idle)
, 랜덤 이동 좌표 선택
, 이동(Move)
의 3가지Task 노드
를 만든다. 쉬기(Idle)
에 해당하는 Wait
와 이동(Move)
에 해당하는 Move To
는 언리얼 Behavior Tree에서 기본으로 제공해주지만 랜덤 이동 좌표 선택
은 우리가 따로 BTTaskNode를 상속해 NPC Actor의 ActorLocation 값을 참고해 만들어주어야 한다. 이 custom 노드는 BlackBoard의 변수 값을 참고하는 과정을 거친다.
New Key
를 통해 어떤 타입의 변수 추가할 지 정할 수 있다. Navigation Mesh는 길찾기 기능을 가능하게 만들어주는 Mesh이다.
Place Actors 패널에서 Volume
-> NavMeshBoundsVolume
를 선택한 후 이를 level에 배치한다. 무한 맵 전체에 적용이 되어야 함으로 이 Volume의 크기를 매우매우 크게 잡아준다.
Viewport 상에서 p
키를 누르면 녹색으로 길찾기를 수행할 수 있는 영역이 표시가 된다.
하지만 우리는 정적인 영역이 아니라 동적인 영역에 대해서 길찾기를 수행하여야 한다. 그림에서처럼 시작화면에서만 길찾기가 된다면 의미가 없다.
Project Setting -> Navigation Mesh -> Runtime Generation -> Dynamic으로 변경해서 새로 추가된 Stage에 대해서도 Nav Mesh가 적용되게 만들기.
BehaviorTree에서 기본으로 제공한다. 여기서 얼마나 쉴지와 랜덤 편차의 값까지 넣을 수 있다.
마찬가지로 BehaviorTree에서 기본으로 제공한다. BlackBoard에 등록된 키 중 하나를 Move To의 목적지 지점으로 설정한다.
Wait
와 MoveTo
노드는 BT에서 기본적으로 제공하는 노드들이다. 하지만 다음으로 순찰할 지점을 찾는 Task는 우리가 custom하게 만들어야 한다. 이를 어떻게 하는지 다음 과정에서 알아보자.
Build.cs에 다음과 같은 module을 추가해주자.
NavigationSystem
AIModule
GamePlayTasks
폴더에서 바로 define 용을 위한 헤더 파일 하나 만들어보자.
#pragma once
#define BBKEY_HOMEPOS TEXT("HomePos")
#define BBKEY_PATROLPOS TEXT("PatrolPos")
#define BBKEY_TARGET TEXT("Target")
FindPatrolPosition
이라는 custom BTTaskNode를 만들어보자.
이 BTTaskNode의 핵심 역할은 NPC가 생성된 stage의 중심점 vector HomePos
를 통해 PatrolPos
를 구하는 것이다.
우선 우리가 구현한 Custom AIController.cpp에서 Actor의 Current Location을 구하고 이를 BlackBoard에 등록된 키 값에 넣는다.
// BlackBoard를 통해 값을 할당하는 방법
// RunAI에서 HomePos vector 값 spawn된 actor의 location으로 setting
Blackboard->SetValueAsVector(BBKEY_HOMEPOS, GetPawn()->GetActorLocation());
다음 이동할 지점을 구하는 메인 로직을 상속한 BTTaskNode에 작성한다. Custom BTTaskNode를 만들때에는 BTTaskNode에 존재하는 ExecuteTask
함수를 override 해주어야 한다(이것이 TaskNode의 핵심 기능을 구현하는 함수!).
ExecuteTask
는 작업이 수행할 실제 작업을 정의한다. 이 함수는 게임의 AI 행동에 특정한 로직을 구현하는 곳으로, 이를 통해 사용자 정의 작업이 행동 트리 시스템 내에서 올바르게 통합되고 작동할 수 있도록 함.
// BTTask_FindPatrolPos.cpp
#include "AI/BTTask_FindPatrolPos.h"
#include "AI/RyanAIController.h"
#include "AI/RyanAI.h"
#include "NavigationSystem.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "Interface/RyanCharacterAIInterface.h"
UBTTask_FindPatrolPos::UBTTask_FindPatrolPos()
{
}
EBTNodeResult::Type UBTTask_FindPatrolPos::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
EBTNodeResult::Type Result = Super::ExecuteTask(OwnerComp, NodeMemory);
// BT를 소유한 컴포넌트의 owner(AIController의 인스턴스)로부터 빙의된 pawn의 정보를 가져온다.
APawn* ControllingPawn = OwnerComp.GetAIOwner()->GetPawn();
if (nullptr == ControllingPawn)
{
return EBTNodeResult::Failed;
}
// Navigation system 가져오기
// Pawn을 통해 World에 접근하는 것도 가능하다.
UNavigationSystemV1* NavSystem = UNavigationSystemV1::GetNavigationSystem(ControllingPawn->GetWorld());
if (nullptr == NavSystem)
{
return EBTNodeResult::Failed;
}
// 안전을 위해 한번 더 체크
IRyanCharacterAIInterface* AIPawn = Cast<IRyanCharacterAIInterface>(ControllingPawn);
if (nullptr == AIPawn)
{
return EBTNodeResult::Failed;
}
// 앞서 AIController가 빙의되면서 BBKEY_HOMEPOS에 값을 넣어주었는데
// 이제 다시 BlackBoard 시스템에 접근해서 Origin의 값을 BBKEY_HOMEPOS로 설정한다.
// 참고로 BBKEY_HOMEPOS는 NPC의 현재 위치.
FVector Origin = OwnerComp.GetBlackboardComponent()->GetValueAsVector(BBKEY_HOMEPOS);
// PatrolRadius는 하드코딩하지말고 Interface를 구현해서 NPC로부터 값을 얻어오기!
float PatrolRadius = AIPawn->GetAIPatrolRadius();
FNavLocation NextPatrolPos;
// NavSystem에는 랜덤 점을 구하는 함수가 이미 존재한다.
if (NavSystem->GetRandomPointInNavigableRadius(Origin, PatrolRadius, NextPatrolPos))
{
OwnerComp.GetBlackboardComponent()->SetValueAsVector(BBKEY_PATROLPOS, NextPatrolPos.Location);
return EBTNodeResult::Succeeded;
}
return EBTNodeResult::Failed;
}
NPC 캐릭터가 플레이어를 향시 감지할 수 있게 하려면 Service
클래스를 만들어야 한다. Service
클래스는 Decorater와 비슷하게 단일 노드로 존재하는 것이 아니라 기존에 존재하는 BTTaskNode에 같이 들어가게 되는데, 이 클래스는 순간순간마다 일정 조건을 계속 감시하고 있다.
TaskNode에서는 ExecuteTask
가 메인 함수였다면 Service 모듈에서 TickNode
가 메인 함수이다. 이 Service 클래스 부착된 composite 노드에서는 지정한 인터벌로 계속해서 Tick이 호출된다. BTService 노드 안을 들어가보면 일정한 시간 변수 Interval 마다 실행되는 TickNode 함수가 있다. 매시간마다 NPC를 기준으로 생성된 구안에 pawn과의 collision check를 실행해서 범위안에 캐릭터가 있는지 판단한다.
이번에는 BTService
클래스를 상속해서 만들어보자.
// BTService_Detect.cpp
#include "AI/BTService_Detect.h"
#include "RyanAI.h"
#include "AIController.h"
#include "Interface/RyanCharacterAIInterface.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "DrawDebugHelpers.h"
UBTService_Detect::UBTService_Detect()
{
NodeName = TEXT("Detect");
Interval = 1.0f;
}
void UBTService_Detect::TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
Super::TickNode(OwnerComp, NodeMemory, DeltaSeconds);
APawn* ControllingPawn = OwnerComp.GetAIOwner()->GetPawn();
if (nullptr == ControllingPawn)
{
return;
}
FVector Center = ControllingPawn->GetActorLocation();
UWorld* World = ControllingPawn->GetWorld();
if (nullptr == World)
{
return;
}
IRyanCharacterAIInterface* AIPawn = Cast<IRyanCharacterAIInterface>(ControllingPawn);
if (nullptr == AIPawn)
{
return;
}
float DetectRadius = AIPawn->GetAIDetectRange();
TArray<FOverlapResult> OverlapResults;
FCollisionQueryParams CollisionQueryParam(SCENE_QUERY_STAT(Detect), false, ControllingPawn);
// 감지될 물체가 여럿 있다고 가정하고 OverlapMultiByChannel을 사용
// 따라서 감지된 물체에 대한 결과값은 배열로 들어온다.
bool bResult = World->OverlapMultiByChannel(
OverlapResults,
Center,
FQuat::Identity,
ECC_GameTraceChannel1,
FCollisionShape::MakeSphere(DetectRadius),
CollisionQueryParam
);
if (bResult)
{
for (auto const& OverlapResult : OverlapResults)
{
// OverlapResult에서 GetActor로 감지된 Actor에 접근할 수 있다.
APawn* Pawn = Cast<APawn>(OverlapResult.GetActor());
// 감지된 Pawn이 있고, 그 Pawn에 빙의된 Controller가 PlayerController인 경우에만
if (Pawn && Pawn->GetController()->IsPlayerController())
{
// 감지된 Pawn을 BBKEY_TARGET에 저장.
OwnerComp.GetBlackboardComponent()->SetValueAsObject(BBKEY_TARGET, Pawn);
// 플레이어가 감지 되었으면 초록색 구, 선, 점을 그려서 확실하게 표시
DrawDebugSphere(World, Center, DetectRadius, 16, FColor::Green, false, 0.2f);
DrawDebugPoint(World, Pawn->GetActorLocation(), 10.0f, FColor::Green, false, 0.2f);
DrawDebugLine(World, ControllingPawn->GetActorLocation(), Pawn->GetActorLocation(), FColor::Green, false, 0.27f);
return;
}
}
}
// 위에서 return 되었다는 것이 없다는 것은 detect이 안되었다는 의미
// Target을 nullptr로 만들어주고 빨간색으로 원을 그려준다.
OwnerComp.GetBlackboardComponent()->SetValueAsObject(BBKEY_TARGET, nullptr);
DrawDebugSphere(World, Center, DetectRadius, 16, FColor::Red, false, 0.2f);
}
그림을 자세히 보면, 캐릭터가 NPC와 일정 거리 안에 있을때 NPC 캐릭터 주변으로 초록색 원이 생기고 캐릭터와 NPC 사이에 점과 선이 초록색으로 그려진다.
왼쪽 Selector에서는 target == is set일때 오른쪽 Sequence에서는 target == not set일때 수행하도록 Defualt BlackBoard를 사용해서 Decorator
를 추가해준다.(밑 그림에서 빨간색 화살표)
하지만, 이렇게 해서는 NPC가 patrol하고 있는 도중에 Character pawn을 발견하게 되더라도 즉시 하던 일을 멈추고 쫓아가야 하는데 움직이는 동안 타겟이 사라지면 그 와중에 target 값이 null 이 되어서 쫓아가지 못하는 상황이 발생하게 된다.
이를 위해서는 Abort
(관찰자 중단 기능)을 넣어주자.
넣는 방법은 간단하다. 그냥 설정한 Defualt BlackBoard에서 flow control->notify observer를 On Value Change로 flow control -> observer aborts를 self로 설정.
이제 NPC가 캐릭터를 감지하고 쫓아가는 기능까지 구현 완료하였다! 마지막 기능인 NPC의 공격을 구현해보자.
이를 위해서는 2가지 노드를 만들 것이다.
먼저 Custon Decorator 노드부터 만들어보자.
위와 같이 NPC가 Target을 잡았을때 때릴 수 있는 거리인지 아닌지를 확인하는 Decorator는 다음과 같이 들어가야 한다. 오른쪽 노드에서는 이 기능이 negate가 되어야 한다(그래서 다가가서 때리므로).
// custom Decorator.cpp
// True일때 왼쪽 Selector, False일때 오른쪽 Sequence를 실행하게 return값을 bool로 설정.
bool UBTDecorator_AttackInRange::CalculateRawConditionValue(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const
{
bool bResult = Super::CalculateRawConditionValue(OwnerComp, NodeMemory);
APawn* ControllingPawn = OwnerComp.GetAIOwner()->GetPawn();
if (nullptr == ControllingPawn)
{
return false;
}
IRyanCharacterAIInterface* AIPawn = Cast<IRyanCharacterAIInterface>(ControllingPawn);
if (nullptr == AIPawn)
{
return false;
}
APawn* Target = Cast<APawn>(OwnerComp.GetBlackboardComponent()->GetValueAsObject(BBKEY_TARGET));
if (nullptr == Target)
{
return false;
}
float DistanceToTarget = ControllingPawn->GetDistanceTo(Target);
float AttackRangeWithRadius = AIPawn->GetAIAttackRange();
bResult = (DistanceToTarget <= AttackRangeWithRadius);
return bResult;
}
Recap을 해보자면 일반적인 BT TaskNode에서는 ExecuteTask
가 메인 함수였고 Service 모듈에서는 TickNode
가 메인 함수였다. 이제, Decorator 모듈에서는CalculateRawConditionValue
라는 함수가 메인이다. 이 함수를 override하고 핵심 로직을 안에 구현해주자.
Attack BTTaskNode를 짜기 전에 공격 명령에 대한 고찰을 한번 해보아야 한다. 게임에 있어서 공격이란 공격의 시작과 동시에 공격 애니메이션 montage가 실행. 전체 공격 애니메이션이 끝나야 공격이 "끝났다"라고 말할 수 있다.
UBTTask_Attack::ExecuteTask
에서는 기본 return을 InProgress
로 설정을 해주고 공격이 끝났을 때 Succeeded
을 리턴해 주게 설계를 해준다.
Tick 함수를 통해 공격이 끝났는 지를 확인하는 방법이 있을 수 있지만, 쌈뽕하게 delegate를 사용해서 공격 종료를 판단하는 방식으로 해보자.
// BTTask_Attack.cpp
#include "AI/BTTask_Attack.h"
#include "AIController.h"
#include "Character/RyanNPCCharacter.h"
#include "Interface/RyanCharacterAIInterface.h"
UBTTask_Attack::UBTTask_Attack()
{
}
EBTNodeResult::Type UBTTask_Attack::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
EBTNodeResult::Type Result = Super::ExecuteTask(OwnerComp, NodeMemory);
APawn* ControllingPawn = Cast<APawn>(OwnerComp.GetAIOwner()->GetPawn());
if (nullptr == ControllingPawn)
{
return EBTNodeResult::Failed;
}
IRyanCharacterAIInterface* AIPawn = Cast<IRyanCharacterAIInterface>(ControllingPawn);
if (nullptr == AIPawn)
{
return EBTNodeResult::Failed;
}
// FAICharacterAttackFinished라는 delegate 선언해주기
FAICharacterAttackFinished OnAttackFinished;
// 람다 함수 캡쳐하기
OnAttackFinished.BindLambda(
[&]()
{
FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
}
);
AIPawn->SetAIAttackDelegate(OnAttackFinished);
AIPawn->AttackByAI();
return EBTNodeResult::InProgress;
}
// NPC Character.cpp
void ARyanCharacterNonPlayer::SetAIAttackDelegate(const FAICharacterAttackFinished& InOnAttackFinished)
{
OnAttackFinished = InOnAttackFinished;
}
// 공격 입력이 들어왔을때 실행
void ARyanCharacterNonPlayer::AttackByAI()
{
ProcessComboCommand();
}
// 공격 애니메이션을 기준으로 공격이 끝나는 타이밍 체크
void ARyanCharacterNonPlayer::NotifyComboActionEnd()
{
Super::NotifyComboActionEnd();
OnAttackFinished.ExecuteIfBound();
}
기존 플레이어 캐릭터가 어떻게 공격 모션을 시작했는지 생각해보면, 공격에 대한 입력키를 눌렀을때 ProcessComboCommand()라는 함수를 호출했다. 이와 마찬가지로 NPC 캐릭터도 공격 명령이 들어오면 ProcessComboCommand 함수를 동일하게 호출해준다.
CharacterBase에서 공격 콤보가 끝날때 NotifyComboActionEnd라는 함수를 실행하게 하고, 이 함수를 NPC가 상속받아 OnAttackFinished 델리게이트에 바인딩 된 함수(EBTNodeResult::Succeeded를 반환하는)를 실행한다.
NPC가 공격을 했을때 같은 방향을 바라보면서 공격을 하는데, 여기서 해당 플레이어를 바라보는 방향으로 회전을 하는 기능 추가해보자.
BTTaskNode를 상속해서 만들자. 이름은 BTTask_TurnToTarget
// BTTask_TurnToTarget.cpp
...
float TurnSpeed = AIPawn->GetAITurnSpeed();
FVector LookVector = TargetPawn->GetActorLocation() - ControllingPawn->GetActorLocation();
LookVector.Z = 0.0f;
// TargetRotation 방향을 정하고 SetActorRotation으로 NPC를 그 방향으로 회전시킨다.
FRotator TargetRot = FRotationMatrix::MakeFromX(LookVector).Rotator();
ControllingPawn->SetActorRotation(FMath::RInterpTo(ControllingPawn->GetActorRotation(), TargetRot, GetWorld()->GetDeltaSeconds(), TurnSpeed));
...
회전 기능은 위와 같이 구현을 했다. 하지만, 공격을 한 다음에 회전 시키는 것은 매우매우 이상하다. 기존의 부모 노드가 Selector일때는 이와 같은 문제가 고질적으로 발생할 수 밖에 없다. 그럼 과연 이 문제를 어떻게 해결을 할까?
바로, Composite 노드 중 Parallel을 사용하면 된다! Parallel은 앞서 보았던 Sequence와 Selector 노드와 다르게 하위 자식 노드들을 동시에 실행한다.
이렇게 Parallel Composite Node까지 추가하면 이번 예제에서 다룰 최종 Behavior Tree가 다음과 같이 완성이 된다!!
NPC의 공격 및 회전을 보여주기 위해 캐릭터 movement에서 speed를 2배로 올렸다. 처음 3번은 NPC가 공격을 하지만 캐릭터가 움직임으로 피했지만 마지막 4번째 공격을 맞아서 죽어버리는 모습. 마치 톰과 제리같다.
람다 함수에서 캡처는 람다 함수가 선언된 위치에서 외부 변수에 접근할 수 있도록 하는 메커니즘. 람다 함수는 일반적으로 함수 내부에서 정의되고 실행되므로, 해당 함수의 외부 변수들을 직접 참조할 수 없다. 이를 해결하기 위해 람다 함수는 선언될 때 이러한 외부 변수들을 "캡처"할 수 있다. 캡처 방식에는 주로 값에 의한 캡처(by value)와 참조에 의한 캡처(by reference)가 있다.
int a = 10;
int b = 20;
auto lambda_by_value = [a, b]() {
return a + b; // a와 b는 값에 의해 캡처됨
};
auto lambda_by_reference = [&a, &b]() {
return a + b; // a와 b는 참조에 의해 캡처됨
};
a = 30;
b = 40;
std::cout << lambda_by_value() << std::endl; // 30 출력
std::cout << lambda_by_reference() << std::endl; // 70 출력