2-12강 행동 트리 모델의 구현

Ryan Ham·2024년 7월 10일
1

이득우 Unreal

목록 보기
18/23
post-thumbnail

강의 목표

  • NPC의 행동 크리 모델의 구현
  • 길찾기 기능 수행 + 공격 로직 만들기

구현 목록

  • BlackBoard에 키 추가
  • NPC의 단순 정찰 기능 구현하기
    • Navigation Mesh 설정하기
    • BlackBoard에 Task기능 추가하기
  • Interface로 NPC 캐릭터가 필수적으로 있어야 하는 함수 및 변수 만들기
  • NPC가 정찰 중에 플레이어 감지 기능 구현하기
  • NPC가 감지된 캐릭터 따라가게 하기
  • NPC의 캐릭터 공격(때리면서 회전 기능까지)

BlackBoard

위 그림은 아주 간단한 정찰 기능을 나타내는 Behavior Tree이다. Sequence Node 밑에 쉬기(Idle), 랜덤 이동 좌표 선택, 이동(Move)의 3가지Task 노드를 만든다. 쉬기(Idle)에 해당하는 Wait이동(Move)에 해당하는 Move To는 언리얼 Behavior Tree에서 기본으로 제공해주지만 랜덤 이동 좌표 선택은 우리가 따로 BTTaskNode를 상속해 NPC Actor의 ActorLocation 값을 참고해 만들어주어야 한다. 이 custom 노드는 BlackBoard의 변수 값을 참고하는 과정을 거친다.

BlackBoard에 키 추가하는 방법

  • New Key를 통해 어떤 타입의 변수 추가할 지 정할 수 있다.

Navigation Mesh는 길찾기 기능을 가능하게 만들어주는 Mesh이다.

Level에 적용 방법

Place Actors 패널에서 Volume -> NavMeshBoundsVolume를 선택한 후 이를 level에 배치한다. 무한 맵 전체에 적용이 되어야 함으로 이 Volume의 크기를 매우매우 크게 잡아준다.

Viewport 상에서 p키를 누르면 녹색으로 길찾기를 수행할 수 있는 영역이 표시가 된다.

하지만 우리는 정적인 영역이 아니라 동적인 영역에 대해서 길찾기를 수행하여야 한다. 그림에서처럼 시작화면에서만 길찾기가 된다면 의미가 없다.

Project Setting -> Navigation Mesh -> Runtime Generation -> Dynamic으로 변경해서 새로 추가된 Stage에 대해서도 Nav Mesh가 적용되게 만들기.


BehaviorTree에 Task기능 추가하기

Wait Task

BehaviorTree에서 기본으로 제공한다. 여기서 얼마나 쉴지와 랜덤 편차의 값까지 넣을 수 있다.

Move To Task

마찬가지로 BehaviorTree에서 기본으로 제공한다. BlackBoard에 등록된 키 중 하나를 Move To의 목적지 지점으로 설정한다.

WaitMoveTo 노드는 BT에서 기본적으로 제공하는 노드들이다. 하지만 다음으로 순찰할 지점을 찾는 Task는 우리가 custom하게 만들어야 한다. 이를 어떻게 하는지 다음 과정에서 알아보자.


Custom BTTaskNode 만들기

사전 작업

1. Build.cs에 AI 관련 모듈 추가

Build.cs에 다음과 같은 module을 추가해주자.

NavigationSystem
AIModule
GamePlayTasks

2. define용 헤더파일 하나 만들기

폴더에서 바로 define 용을 위한 헤더 파일 하나 만들어보자.

#pragma once

#define BBKEY_HOMEPOS TEXT("HomePos")
#define BBKEY_PATROLPOS TEXT("PatrolPos")
#define BBKEY_TARGET TEXT("Target")

Custom BTTaskNode 만들기

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 함수란?

ExecuteTask는 작업이 수행할 실제 작업을 정의한다. 이 함수는 게임의 AI 행동에 특정한 로직을 구현하는 곳으로, 이를 통해 사용자 정의 작업이 행동 트리 시스템 내에서 올바르게 통합되고 작동할 수 있도록 함.

BTTask_FindPatrolPos.cpp 풀코드

// 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가 정찰 중에 플레이어 감지 기능 구현하기

NPC 캐릭터가 플레이어를 향시 감지할 수 있게 하려면 Service 클래스를 만들어야 한다. Service 클래스는 Decorater와 비슷하게 단일 노드로 존재하는 것이 아니라 기존에 존재하는 BTTaskNode에 같이 들어가게 되는데, 이 클래스는 순간순간마다 일정 조건을 계속 감시하고 있다.

TaskNode에서는 ExecuteTask가 메인 함수였다면 Service 모듈에서 TickNode가 메인 함수이다. 이 Service 클래스 부착된 composite 노드에서는 지정한 인터벌로 계속해서 Tick이 호출된다. BTService 노드 안을 들어가보면 일정한 시간 변수 Interval 마다 실행되는 TickNode 함수가 있다. 매시간마다 NPC를 기준으로 생성된 구안에 pawn과의 collision check를 실행해서 범위안에 캐릭터가 있는지 판단한다.

Custom BTService 풀코드

이번에는 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 사이에 점과 선이 초록색으로 그려진다.

Detecting하는 Service 모듈을 추가한 BT


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가지 노드를 만들 것이다.

  1. NPC 사정거리 안에 캐릭터가 들어왔는지 확인하는 custom Decorator.
  2. 최종 Attack 모션을 수행하는 BTTaskNode

먼저 Custon Decorator 노드부터 만들어보자.

NPC 사정거리 안에 캐릭터가 들어왔는지 확인하는 Decorator

위와 같이 NPC가 Target을 잡았을때 때릴 수 있는 거리인지 아닌지를 확인하는 Decorator는 다음과 같이 들어가야 한다. 오른쪽 노드에서는 이 기능이 negate가 되어야 한다(그래서 다가가서 때리므로).

custom Decorator 안 CalculateRawConditionValue 함수 구현

// 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

Attack BTTaskNode를 짜기 전에 공격 명령에 대한 고찰을 한번 해보아야 한다. 게임에 있어서 공격이란 공격의 시작과 동시에 공격 애니메이션 montage가 실행. 전체 공격 애니메이션이 끝나야 공격이 "끝났다"라고 말할 수 있다.

UBTTask_Attack::ExecuteTask에서는 기본 return을 InProgress로 설정을 해주고 공격이 끝났을 때 Succeeded을 리턴해 주게 설계를 해준다.

Tick 함수를 통해 공격이 끝났는 지를 확인하는 방법이 있을 수 있지만, 쌈뽕하게 delegate를 사용해서 공격 종료를 판단하는 방식으로 해보자.

공격 TaskNode

// 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 파일에 함수 구현

// 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

custom 회전 BTTaskNode의 회전을 구현하는 부분.

// 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 캐릭터의 사정거리 안에 들어와 한방 맞고 죽는 캐릭터의 모습

공격 반경에 들어왔을때 회전과 공격을 같이 하는 NPC

NPC의 공격 및 회전을 보여주기 위해 캐릭터 movement에서 speed를 2배로 올렸다. 처음 3번은 NPC가 공격을 하지만 캐릭터가 움직임으로 피했지만 마지막 4번째 공격을 맞아서 죽어버리는 모습. 마치 톰과 제리같다.


기타

람다 함수에서 캡쳐(Capture)란?

람다 함수에서 캡처는 람다 함수가 선언된 위치에서 외부 변수에 접근할 수 있도록 하는 메커니즘. 람다 함수는 일반적으로 함수 내부에서 정의되고 실행되므로, 해당 함수의 외부 변수들을 직접 참조할 수 없다. 이를 해결하기 위해 람다 함수는 선언될 때 이러한 외부 변수들을 "캡처"할 수 있다. 캡처 방식에는 주로 값에 의한 캡처(by value)와 참조에 의한 캡처(by reference)가 있다.

  • 값에 의한 캡처 (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 출력
profile
🏦KAIST EE | 🏦SNU AI(빅데이터 핀테크 전문가 과정) | 📙CryptoHipsters 저자

0개의 댓글