본문 바로가기

개발 공부 기록/UnrealEngine5

이득우의 언리얼 C++ 게임 개발의 정석 UE5로 따라잡기 - 10

개요

'이득우의 언리얼 C++ 게임 개발의 정석'의 12장 AI 컨트롤러와 비헤이비어 트리 내용을 바탕으로 하고 있습니다.

알라딘 책 구매 링크

 

이득우의 언리얼 C++ 게임 개발의 정석

언리얼 엔진 학습에 목말라하는 게임 개발자에게 단비 같은 언리얼 엔진 프로그래밍 책이다. 에픽게임즈 본사의 개발자 프로그램 언리얼 데브 그랜트를 수상한 저자의 책으로, 언리얼 엔진의

www.aladin.co.kr

 

본문

언리얼 엔진에는 컴퓨터가 인공지능(AI)으로 NPC를 제어하도록 AI 컨트롤러를 제공한다.
폰은 플레이어 컨트롤러와 동일한 방식으로 AI 컨트롤러에 빙의될 수 있다.

새로운 AI 컨트롤러 클래스를 생성해서 NPC를 만들어본다.
C++ 클래스 추가에서 모든 클래스 → AIController를 검색해서 새로운 C++ 클래스를 추가한다.

 

ABCharacter의 AIController 클래스 속성을 ABAIController 클래스로 변경하고
AI의 생성 옵션을 PlaceInWorldOrSpawned로 설정하면 앞으로 레벨에 생성되고 배치되는 캐릭터는 ABAIController의 지배를 받게 된다.

// ABCharacter.cpp

#include "ABAIController.h"

AABCharacter::AABCharacter()
{
	// ... 생략
	AIControllerClass = AABAIController::StaticClass();
	AutoPossessAI = EAutoPossessAI::PlacedInWorldOrSpawned;
}

 

컴파일 후 플레이를 눌러 확인하면 레벨에 생성된 캐릭터의 수 만큼 AIController가 레벨에 생성되어 있고 ABCharcter 클래스의 캐릭터들의 설정에는 AI 자동 빙의 항목과 AI 컨트롤러 클래스가 변경되어 있음을 확인할 수 있다.

 

이제 NPC가 캐릭터를 따라다닐 수 있도록 내비게이션 메시 기능을 활용해보도록 한다.
선택 모드에서 상단의 빠르게 배치 아이콘 → 볼륨 → 내비메시 바운드 볼륨을 눌러서 배치한다.

 

배치되면 월드 아웃라이너에 NavMeshBoundsVolume이 생기는데 원점을 초기화하여 중앙으로 맞춘 후 크기를 넉넉하게 잡도록 한다.

 

그 후 뷰포트 창을 누른 상태로 키보드 P키를 입력하면 에디터에서 빌드한 내비 메시 영역이 녹색으로 표시된다.

 

내비 메시 영역이 생성되었다면 이제 ABAIController에 빙의한 폰에 목적지를 알려주고 스스로 움직이도록 명령을 추가한다.
그리고 AI 컨트롤러에 타이머를 설치해 3초마다 폰에게 목적지로 이동하는 명령을 내리도록 한다.

언리얼 엔진의 내비게이션 시스템에는 이동 가능한 목적지를 랜덤으로 가져오는 GetRandomPointInNavigableRadius 함수와 목적지로 폰을 이동시키는 SimpleMoveToLocation 함수가 제공되고 있다. 이들을 활용해서 코드를 구현한다.

// ABAIController.cpp

#include "ABAIController.h"
#include "NavigationSystem.h"
#include "Blueprint/AIBlueprintHelperLibrary.h"

AABAIController::AABAIController()
{
	// 3초마다 반복하는 반복 인터벌의 초 시간 설정
	RepeatInterval = 3.0f;
}

void AABAIController::OnPossess(APawn* InPawn)
{
	Super::OnPossess(InPawn);
	GetWorld()->GetTimerManager().SetTimer(RepeatTimerHandle, this, &AABAIController::OnRepeatTimer, RepeatInterval, true);
}

void AABAIController::OnUnPossess()
{
	Super::OnUnPossess();
	GetWorld()->GetTimerManager().ClearTimer(RepeatTimerHandle);
}

void AABAIController::OnRepeatTimer()
{
	auto CurrentPawn = GetPawn();
	ABCHECK(nullptr != CurrentPawn);

	UNavigationSystemV1* NavSystem = UNavigationSystemV1::GetNavigationSystem(GetWorld());
	if (nullptr == NavSystem) return;

	FNavLocation NextLocation;
	if (NavSystem->GetRandomPointInNavigableRadius(FVector::ZeroVector, 500.0f, NextLocation))
	{
		UAIBlueprintHelperLibrary::SimpleMoveToLocation(this, NextLocation.Location);
		ABLOG(Warning, TEXT("Next Location: %s"), *NextLocation.Location.ToString());
	}
}

 

비헤이비어 트리는 캐릭터가 해야 할 행동을 분석하고 우선순위가 높은 행동부터 실행할 수 있도록 트리 구조로 설계하는 기법이다.

언리얼에서 비헤이비어 트리를 제작하기 위해서는 비헤이비어 트리와 블랙보드 애셋을 생성해야 한다.
콘텐츠 브라우저에서 인공 지능 → 비헤이비어 트리, 블랙보드를 생성한다.

  • 블랙보드: 인공지능의 판단에 사용하는 데이터 집합을 의미한다.
  • 비헤이비어 트리: 블랙보드 데이터에 기반해 설계한 비헤이비어 트리의 정보를 저장한 애셋이다.

 

AI 컨트롤러에서 비헤이비어 트리를 구동시키기 위한 간단한 로직을 제작해보자.

작업 공간에서 우클릭을 누르면 컴포짓(Composites)태스크(Tasks) 노드를 생성할 수 있는 항목들이 나열된다.

태스크는 독립적으로 실행될 수 없고 반드시 컴포짓 노드를 거쳐 실행돼야 한다.

컴포짓 노드는 분기의 루트와 분기가 실행되는 방식의 기본 규칙을 정의한다.

  • 셀렉터(Selector): 왼쪽에서 오른쪽 순서로 자손을 실행한다. 자손 중 하나가 성공하면 실행을 중단한다. 자손이 하나라도 성공하면 셀렉터도 성공한다. 자손이 모두 실패하면 셀렉터도 실패한다.
  • 시퀀스(Sequence): 왼쪽에서 오른쪽 순서로 자손을 실행한다. 자손 중 하나가 실패하면 실행을 중단한다. 자손이 하나라도 실패하면 시퀀스도 실패한다. 자손이 모두 성공해야 시퀀스도 성공한다.
  • 심플 패러럴(Simple Parallel): 메인 태스크 노드 하나를 전체 트리와 함께 실행한다. 메인 태스크가 완료되면 모드 완료 세팅에 따라 보조 트리를 중단하고 즉시 완료되거나 아니면 보조 트리가 완료될 때까지 대기한다.

 

비헤이비어 트리 노드 레퍼런스 : 컴포짓 - 언리얼 엔진 공식 문서

 

C++ 코드에서 비헤이비어 트리 관련 기능을 사용하려면 AIModule 모듈을 추가 해야한다.

Build.cs 에서 AIModule을 추가해주고 태스크 기능을 사용하려면 GamePlayTasks를 추가해야 한다.
아래와 같이 모듈을 모두 추가하고 컴파일한다.

 

앞서 네비게이션을 사용하여 구현한 정찰 기능을 비헤이비어 트리와 태스크로 처리하도록 하자.

비헤이비어 트리는 태스크를 실행할 때 태스크 클래스의 ExecuteTask라는 멤버 함수를 실행한다.
ExecuteTask는 다음의 넷 중 하나의 값을 반환해야 한다.

  • Aborted: 태스크 실행 중에 중단됐다. 결과적으로 실패했다.
  • Failed: 태스크를 수행했지만 실패했다.
  • Succeeded: 태스크를 성공적으로 수행했다.
  • InProgress: 태스크를 계속 수행하고 있다. 태스크의 실행 결과는 향후 알려줄 예정이다.

ExecuteTask 함수의 실행 결과에 따라 컴포짓 내에 있는 다음 태스크를 계속 수행할지, 중단될지가 결정된다.
ExecuteTask 함수에서 다음 정찰 지점을 찾는 로직을 구현하고 바로 실행 결과를 반환하도록 구현한다.

// ABAIController.cpp

#include "ABAIController.h"
#include "NavigationSystem.h"
#include "Blueprint/AIBlueprintHelperLibrary.h"
#include "BehaviorTree/BehaviorTree.h"
#include "BehaviorTree/BlackboardData.h"
#include "BehaviorTree/BlackboardComponent.h"

const FName AABAIController::HomePosKey(TEXT("HomePos"));
const FName AABAIController::PatrolPosKey(TEXT("PatrolPos"));

AABAIController::AABAIController()
{
	// 3초마다 반복하는 반복 인터벌의 초 시간 설정
	// RepeatInterval = 3.0f;

	static ConstructorHelpers::FObjectFinder<UBlackboardData> BBObject(TEXT("/Script/AIModule.BlackboardData'/Game/AI/BB_ABCharacter.BB_ABCharacter'"));
	if (BBObject.Succeeded())
	{
		BBAsset = BBObject.Object;
	}

	static ConstructorHelpers::FObjectFinder<UBehaviorTree> BTObject(TEXT("/Script/AIModule.BehaviorTree'/Game/AI/BT_ABCharacter.BT_ABCharacter'"));
	if (BTObject.Succeeded())
	{
		BTAsset = BTObject.Object;
	}
}

void AABAIController::OnPossess(APawn* InPawn)
{
	Super::OnPossess(InPawn);
	// GetWorld()->GetTimerManager().SetTimer(RepeatTimerHandle, this, &AABAIController::OnRepeatTimer, RepeatInterval, true);

	UBlackboardComponent* BlackboardComp = Blackboard.Get();
	if (UseBlackboard(BBAsset, BlackboardComp))
	{
		BlackboardComp->SetValueAsVector(HomePosKey, InPawn->GetActorLocation());
		if (!RunBehaviorTree(BTAsset))
		{
			ABLOG(Error, TEXT("AIController couldn't run behavior tree!"));
		}
	}
}

void AABAIController::OnUnPossess()
{
	Super::OnUnPossess();
	// GetWorld()->GetTimerManager().ClearTimer(RepeatTimerHandle);
}

void AABAIController::OnRepeatTimer()
{
	auto CurrentPawn = GetPawn();
	ABCHECK(nullptr != CurrentPawn);

	UNavigationSystemV1* NavSystem = UNavigationSystemV1::GetNavigationSystem(GetWorld());
	if (nullptr == NavSystem)
	{
		ABLOG(Warning, TEXT("NavSystem is NULL"));
		return;
	}

	FNavLocation NextLocation;
	if (NavSystem->GetRandomPointInNavigableRadius(FVector::ZeroVector, 500.0f, NextLocation))
	{
		UAIBlueprintHelperLibrary::SimpleMoveToLocation(this, NextLocation.Location);
		ABLOG(Warning, TEXT("Next Location: %s"), *NextLocation.Location.ToString());
	}
}
// BTTask_FindPatrolPos.h

#pragma once

#include "CoreMinimal.h"
#include "BehaviorTree/BTTaskNode.h"
#include "BTTask_FindPatrolPos.generated.h"

/**
 * 
 */
UCLASS()
class ARENABATTLE_API UBTTask_FindPatrolPos : public UBTTaskNode
{
	GENERATED_BODY()
	
public:
	UBTTask_FindPatrolPos();

	virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
};

// BTTask_FindPatrolPos.cpp

#include "BTTask_FindPatrolPos.h"
#include "ArenaBattle/ABAIController.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "NavigationSystem.h"

UBTTask_FindPatrolPos::UBTTask_FindPatrolPos()
{
	NodeName = TEXT("FindPatrolPos");
}

EBTNodeResult::Type UBTTask_FindPatrolPos::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
	EBTNodeResult::Type Result = Super::ExecuteTask(OwnerComp, NodeMemory);

	auto ControllingPawn = OwnerComp.GetAIOwner()->GetPawn();
	if (nullptr == ControllingPawn)
	{
		return EBTNodeResult::Failed;
	}

	UNavigationSystemV1* NavSystem = UNavigationSystemV1::GetNavigationSystem(ControllingPawn->GetWorld());
	if (nullptr == NavSystem)
	{
		return EBTNodeResult::Failed;
	}

	FVector Origin = OwnerComp.GetBlackboardComponent()->GetValueAsVector(AABAIController::HomePosKey);
	FNavLocation NextPatrol;

	if (NavSystem->GetRandomPointInNavigableRadius(Origin, 500.0f, NextPatrol))
	{
		OwnerComp.GetBlackboardComponent()->SetValueAsVector(AABAIController::PatrolPosKey, NextPatrol.Location);
		return EBTNodeResult::Succeeded;
	}

	return EBTNodeResult::Failed;
}

 

새로운 FindPatrolPos 태스크의 제작을 완료하면 Wait 태스크 오른쪽에 이를 배치하고 FindPatrolPos 오른쪽에는 언리얼 엔진이 제공하는 MoveTo 태스크를 추가해 배치한다. 이어서 시퀀스 컴포짓에 의해 Wait 태스크가 성공하면 FindPatrolPos 태스크를 수행하고, FindPatrolPos 태스크가 성공하면 FindPatrolPos에서 설정한 블랙보드의 PatrolPos 키 값을 참고해 MoveTo 태스크가 실행된다.

 

이번에는 NPCC가 정찰 중에 플레이어를 발견하면 플레이어를 추격하도록 기능을 추가해본다.
NPC가 플레이어를 발견할 때 플레이어의 정보를 블랙보드에 저장하도록 Object 타입으로 Target 변수를 생성한다.
그리고 키 타입의 베이스 클래스를 ABCharacter로 설정한다.

NPC의 행동 패턴은 플레이어를 발견했는지, 발견하지 못했는지에 따라 추격과 정찰로 구분된다.
추격과 정찰 중 하나를 선택해 해동하기 때문에 이번에는 셀렉터 컴포짓을 사용해 로직을 확장하는 것이 적합하다.
추격과 정찰 중에서 추격에 더 우선권을 주고, 추격 로직은 블랙보드의 Target을 향해 이동하도록 비헤이비어 트리 설계를 확장해본다.

 

정찰 중 플레이어가 일정 반경 내에 있으면 이를 감지해 추격하는 기능을 넣어본다. 언리얼 엔진은 이를 위해 서비스 노드를 제공한다.

서비스 노드는 독립적으로 동작하지 않고 컴포짓 노드에 부착되는 노드다.
또한 서비스 노드는 해당 컴포짓에 속한 태스크들이 실행되는 동안 반복적인 작업을 실행하는 데 적합하다.

플레이어를 감지하는 서비스 노드를 새로 생성하고 이를 셀렉터 컴포짓에 추가하면 비헤이비어 트리는
플레이어를 감지하는 루틴을 계속 반복한다.

새로운 서비스 제작을 위해 BTService를 부모로 하는 BTService_Detect 클래스를 생성한다.

// BTServcice_Detect.h

#pragma once

#include "ArenaBattle/ArenaBattle.h"
#include "BehaviorTree/BTService.h"
#include "BTService_Detect.generated.h"

/**
 * 
 */
UCLASS()
class ARENABATTLE_API UBTService_Detect : public UBTService
{
	GENERATED_BODY()
	
public:
	UBTService_Detect();

protected:
	virtual void TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;
};
// BTService_Detect.cpp

#include "BTService_Detect.h"
#include "ArenaBattle/ABAIController.h"
#include "ArenaBattle/ABCharacter.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "DrawDebugHelpers.h"
#include "Engine/OverlapResult.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;

	UWorld* World = ControllingPawn->GetWorld();
	FVector Center = ControllingPawn->GetActorLocation();
	float DetectRadius = 600.0f;

	if (nullptr == World) return;
	TArray<FOverlapResult> OverlapResults;
	FCollisionQueryParams CollisionQueryParam(NAME_None, false, ControllingPawn);
	bool bResult = World->OverlapMultiByChannel(
		OverlapResults,
		Center,
		FQuat::Identity,
		ECollisionChannel::ECC_GameTraceChannel12,
		FCollisionShape::MakeSphere(DetectRadius),
		CollisionQueryParam
	);

	DrawDebugSphere(World, Center, DetectRadius, 16, FColor::Red, false, 0.2f);
}

 

서비스가 만들어지면 컴포짓을 우클릭하고 서비스 메뉴에서 Detect를 선택해 컴포짓에 이를 부착한다.

 

NPC가 탐지 영역 내의 캐릭터를 감지한다면, 그 중에서 우리가 조종하는 캐릭터를 추려내야 한다.

캐릭터를 조종하는 컨트롤러가 플레이어 컨트롤러인지 파악할 수 있도록 IsPlayerController 함수를 사용한다. 플레이어 캐릭터가 감지되면 블랙보드의 Target 값을 플레이어 캐릭터로 지정하고, 그렇지 않으면 nullptr 값을 지정한다. 또한 플레이어 캐릭터를 감지하면 녹색으로 구체를 그리고, NPC와 캐릭터까지 연결된 선을 추가로 그려준다.

// ABAIController.h

public:
	static const FName TargetKey;
// ABAIController.cpp

const FName AABAIController::TargetKey(TEXT("Target"));
// BTService_Detect.cpp

void UBTService_Detect::TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
	Super::TickNode(OwnerComp, NodeMemory, DeltaSeconds);

	APawn* ControllingPawn = OwnerComp.GetAIOwner()->GetPawn();
	if (nullptr == ControllingPawn) return;

	UWorld* World = ControllingPawn->GetWorld();
	FVector Center = ControllingPawn->GetActorLocation();
	float DetectRadius = 600.0f;

	if (nullptr == World) return;
	TArray<FOverlapResult> OverlapResults;
	FCollisionQueryParams CollisionQueryParam(NAME_None, false, ControllingPawn);
	bool bResult = World->OverlapMultiByChannel(
		OverlapResults,
		Center,
		FQuat::Identity,
		ECollisionChannel::ECC_GameTraceChannel12,
		FCollisionShape::MakeSphere(DetectRadius),
		CollisionQueryParam
	);

	//
	if (bResult)
	{
		OwnerComp.GetBlackboardComponent()->SetValueAsObject(AABAIController::TargetKey, nullptr);
		for(auto const& OverlapResult : OverlapResults)
		{
			AABCharacter* ABCharacter = Cast<AABCharacter>(OverlapResult.GetActor());
			if (ABCharacter && ABCharacter->GetController()->IsPlayerController())
			{
				OwnerComp.GetBlackboardComponent()->SetValueAsObject(AABAIController::TargetKey, ABCharacter);
				DrawDebugSphere(World, Center, DetectRadius, 16, FColor::Green, false, 0.2f);
				DrawDebugPoint(World, ABCharacter->GetActorLocation(), 10.0f, FColor::Blue, false, 0.2f);
				DrawDebugLine(World, ControllingPawn->GetActorLocation(), ABCharacter->GetActorLocation(), FColor::Blue, false, 0.2f);
				return;
			}
		}
	}

	DrawDebugSphere(World, Center, DetectRadius, 16, FColor::Red, false, 0.2f);
}

 

 

그런데 NPC가 이동할 때 회전이 부자연스럽게 꺾이는 것을 볼 수 있으므로 NPC를 위한 ControlMode를 추가하고
NPC는 이동 방향에 따라 회전하도록 캐릭터 무브먼트 설정을 변경해본다.

또한 NPC 최대 이동 속도를 플레이어보다 낮게 설정해 플레이어가 도망갈 수 있게 한다.

 

// ABCharacter.h

protected:
	enum class EControlMode
	{
		GTA,
		DIABLO,
		NPC
	};
	
public:
	virtual void PossessedBy(AController* NewController) override;
// ABCharacter.cpp

void AABCharacter::SetControlMode(EControlMode ControlMode)
{
	CurrentControlMode = ControlMode;

	switch (CurrentControlMode)
	{
		// ... 생략
		case EControlMode::NPC:
		{
			bUseControllerRotationYaw = false;
			GetCharacterMovement()->bUseControllerDesiredRotation = false;
			GetCharacterMovement()->bOrientRotationToMovement = true;
			GetCharacterMovement()->RotationRate = FRotator(0.0f, 480.0f, 0.0f);
			break;
		}
	}
}

void AABCharacter::PossessedBy(AController* NewController)
{
	Super::PossessedBy(NewController);

	if (IsPlayerControlled())
	{
		SetControlMode(EControlMode::DIABLO);
		GetCharacterMovement()->MaxWalkSpeed = 600.0f;
	}
	else
	{
		SetControlMode(EControlMode::NPC);
		GetCharacterMovement()->MaxWalkSpeed = 300.0f;
	}
}

 

이제 서비스가 실행된 결과에 따라 셀렉터 테코레이터 왼쪽의 추격과 오른쪽의 정찰 로직이 나눠지도록 비헤이비어 트리 로직을 구성해본다. 서비스 결과는 블랙보드의 Target 키에 값이 있는지, 없는지로 구분할 수 있다.

이를 위해 블랙보드의 값을 기반으로 특정 컴포짓의 실행 여부를 결정하는 데코레이터 노드를 사용하는 것을 추천한다.
왼쪽 시퀀스 컴포짓에 블랙보드 데코레이터 노드를 삽입한다.

 

해당 키 값의 변경이 감지되면 현재 컴포짓 노드의 실행을 곧바로 취소할 수 있도록 노티파이 옵저버 값을 On Value Change로 변경한다.
추가로 관찰자 중단 항목 값을 설정하지 않으면 컴포짓에 속한 태스크가 모두 마무리될 때까지 대기하므로 플레이어가 시야를 벗어나도 NPC는 플레이어를 따라잡을 때까지 계속 쫓아온다. 그러므로 Self로 변경해준다. 그리고 블랙보드 키 값Target으로 변경해준다.

 

오른쪽 시퀀스 컴포짓에도 동일하게 데코레이터를 추가하는데, 반대 조건인 Is Not Set으로 설정하고 동일하게
관찰자 중단 옵션도 설정한다. 그러면 NPC는 정찰 중에 플레이어를 감지할 경우 정찰을 중단하고 추격한다.

 

추격 로직을 발전시켜서 플레이어를 따라 잡으면 공격하는 기능을 추가해본다.
이를 위해서는 다시 분기를 만들어야 하기 때문에 비헤이비어 트리 왼쪽에 있던 TargetOn 시퀀스를 다시 셀렉터로 변경하고
그 아래로 시퀀스 컴포짓을 추가한다.

 

이번에는 블랙보드의 값을 참조하지 않고 목표물인 플레이어가 공격 범위 내에 있는지 판단하는 데코레이터를 하나 생성한다.
C++ 클래스로 BTDecorator를 상속받는 클래스를 생성한다.

데코레이터 클래스는 CalculateRawConditionValue 함수를 상속받아 원하는 조건이 달성됐는지를 파악하도록 설계됐다.

// BTDecorator_IsInAttackRange.h

#pragma once

#include "ArenaBattle/ArenaBattle.h"
#include "BehaviorTree/BTDecorator.h"
#include "BTDecorator_IsInAttackRange.generated.h"

/**
 * 
 */
UCLASS()
class ARENABATTLE_API UBTDecorator_IsInAttackRange : public UBTDecorator
{
	GENERATED_BODY()
	
public:
	UBTDecorator_IsInAttackRange();

protected:
	virtual bool CalculateRawConditionValue(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const override;
};
// BTDecorator_IsInAttackRange.cpp

#include "BTDecorator_IsInAttackRange.h"
#include "ArenaBattle/ABAIController.h"
#include "ArenaBattle/ABCharacter.h"
#include "BehaviorTree/BlackboardComponent.h"

UBTDecorator_IsInAttackRange::UBTDecorator_IsInAttackRange()
{
	NodeName = TEXT("CanAttack");
}

bool UBTDecorator_IsInAttackRange::CalculateRawConditionValue(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const
{
	bool bResult = Super::CalculateRawConditionValue(OwnerComp, NodeMemory);

	auto ControllingPawn = OwnerComp.GetAIOwner()->GetPawn();
	if (nullptr == ControllingPawn)
		return false;

	auto Target = Cast<AABCharacter>(OwnerComp.GetBlackboardComponent()->GetValueAsObject(AABAIController::TargetKey));
	if (nullptr == Target)
		return false;

	// 타겟이 컨트롤중인 폰 객체가 200 범위 내에 있는지 확인
	bResult = (Target->GetDistanceTo(ControllingPawn) <= 200.0f);
	return bResult;
}

 

양쪽 시퀀스 컴포짓에 Is In Attack Range 데코레이터를 추가하고, 오른쪽 추격 로직쪽에는 역 조건을 체크하도록 한다.
타겟은 찾았지만 공격 범위에 들어오지 않으면 계속 추격하는 로직으로 동작한다.

 

 

다음으로 Wait 대신 실제로 플레이어를 공격할 태스크를 생성해본다.
BTTaskNode를 부모로 하는 C++ 클래스를 생성하고 공격 태스크는 공격 애니메이션이 끝날 때까지 대기해야 하는 지연 태스크이므로 ExecuteTask의 결과 값을 InProgress로 일단 반환하고 공격이 끝났을 때 태스크가 끝났다고 알려줘야 한다.
이를 알려주는 함수가 FinishLatentTask다.
태스크에서 이 함수를 나중에 호출해주지 않으면 비헤이비어 트리 시스템은 현재 태스크에 계속 머물게 된다.

// BTTask_AttackNode.h

#pragma once

#include "ArenaBattle/ArenaBattle.h"
#include "BehaviorTree/BTTaskNode.h"
#include "BTTask_Attack.generated.h"

/**
 * 
 */
UCLASS()
class ARENABATTLE_API UBTTask_Attack : public UBTTaskNode
{
	GENERATED_BODY()
	
public:
	UBTTask_Attack();

	virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;

protected:
	virtual void TickTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSecond) override;

private:
	bool IsAttacking = false;
};
// BTTask_AttackNode.cpp

#include "BTTask_Attack.h"
#include "ArenaBattle/ABAIController.h"
#include "ArenaBattle/ABCharacter.h"

UBTTask_Attack::UBTTask_Attack()
{
	bNotifyTick = true;
	IsAttacking = false;
}

EBTNodeResult::Type UBTTask_Attack::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
	EBTNodeResult::Type Result = Super::ExecuteTask(OwnerComp, NodeMemory);

	auto ABCharacter = Cast<AABCharacter>(OwnerComp.GetAIOwner()->GetPawn());
	if (nullptr == ABCharacter)
	{
		return EBTNodeResult::Failed;
	}
	// ABCharacter의 Attack 함수를 public으로 변경해주고 OnAttackEnd 델리게이트를 생성해줘야 한다.
	ABCharacter->Attack();
	IsAttacking = true;
	ABCharacter->OnAttackEnd.AddLambda([this]() -> void {
		IsAttacking = false;
	});

	return EBTNodeResult::InProgress;
}

void UBTTask_Attack::TickTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSecond)
{
	Super::TickTask(OwnerComp, NodeMemory, DeltaSecond);
	if (!IsAttacking)
	{
		FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
	}
}

 

 

NPC가 플레이어를 따라가 공격을 하는 기능을 구현했지만,
공격할 때 제자리에 정지하기 때문에 플레이어가 NPC 뒤로 돌아가도 계속 공격하던 방향으로만 공격을 하는 문제가 존재한다.

이를 보완하기 위해 공격하면서 동시에 플레이어를 향해 회전하는 기능을 추가한다.
블랙보드의 Target으로 회전하는 태스크를 추가하여 구현한다.

// BTTask_TurnToTarget.h

#pragma once

#include "ArenaBattle/ArenaBattle.h"
#include "BehaviorTree/BTTaskNode.h"
#include "BTTask_TurnToTarget.generated.h"

/**
 * 
 */
UCLASS()
class ARENABATTLE_API UBTTask_TurnToTarget : public UBTTaskNode
{
	GENERATED_BODY()
	
public:
	UBTTask_TurnToTarget();
	virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
};
// BTTask_TurnToTarget.cpp

#include "BTTask_TurnToTarget.h"
#include "ArenaBattle/ABAIController.h"
#include "ArenaBattle/ABCharacter.h"
#include "BehaviorTree/BlackboardComponent.h"

UBTTask_TurnToTarget::UBTTask_TurnToTarget()
{
	NodeName = TEXT("Turn");
}

EBTNodeResult::Type UBTTask_TurnToTarget::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
	EBTNodeResult::Type Result = Super::ExecuteTask(OwnerComp, NodeMemory);

	auto ABCharacter = Cast<AABCharacter>(OwnerComp.GetAIOwner()->GetPawn());
	if (nullptr == ABCharacter)
	{
		return EBTNodeResult::Failed;
	}

	auto Target = Cast<AABCharacter>(OwnerComp.GetBlackboardComponent()->GetValueAsObject(AABAIController::TargetKey));
	if (nullptr == Target)
	{
		return EBTNodeResult::Failed;
	}

	FVector LookVector = Target->GetActorLocation() - ABCharacter->GetActorLocation();
	LookVector.Z = 0.0f;
	FRotator TargetRot = FRotationMatrix::MakeFromX(LookVector).Rotator();
	ABCharacter->SetActorRotation(FMath::RInterpTo(ABCharacter->GetActorRotation(), TargetRot, GetWorld()->GetDeltaSeconds(), 2.0f));

	return EBTNodeResult::Succeeded;
}

 

공격 로직에 사용했던 시퀀스 컴포짓을 심플 패러럴 컴포짓으로 변경하고 공격 분기를 메인 태스크로, 회전을 보조 태스크로 지정한다.

 

이제 NPC가 추격하며 플레이어가 공격 범위안에 들어오면 바라보면서 공격을 하게 된다.