Unreal/따라해보기

AI 적 만들어 보기 (3) - Unreal Engine (C++)

workbench34 2025. 2. 20. 19:53

 전 시간에는 상대가 나를 보면 쫓아오게 하는 로직까지 구현하였다. 이번 시간엔 동작을 좀더 유연하게 만들면서 지정한 위치를 패트롤 하는 로직을 만들어 보겠다.

 

 일단 AI 가 지정한 위치를 가게 하려면 무엇이 필요할까? 바로 이정표다. 우리는 이 이정표를 Actor 를 상속받은 클래스와 BTTask 로 정의하여 사용하도록 하겠다. 먼저, C++ 클래스를 생성해 주도록 하겠다. Actor 를 상속받게 클릭 해준 뒤 다음을 눌러 이름은 PatrolPath 로 붙여주도록 하겠다.

이미 만들었어서 다른 클래스에 사용 중이 뜬다

 그 뒤 들어가 다음과 같이 코드를 고쳐준다. 아래는 헤더 파일 이다.

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "PatrolPath.generated.h"

UCLASS()
class AITEST_API APatrolPath : public AActor
{
	GENERATED_BODY()
	
public:	
	APatrolPath();

	FVector GetPatrolPoint(int const index) const;
	int Num() const;
private:
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="AI", meta=(MakeEditWidget="true", AllowPrivateAccess="true"))
	TArray<FVector> PatrolPoints;

};

 

 보면 TArray 로 FVector 의 배열인 PatrolPoints 를 만들었다. PatrolPath 는 한개 이상의 경로를 지정해 줄 것이기 때문에 배열로 지점들을 생성할 수 있게 만드는 것이다. 그리고 함수 GetPatrolPoint 와 Num 을 통해 지점의 위치, 지점이 몇번째 인지 알려준다. 아래는 Cpp 파일이다.

#include "PatrolPath.h"

APatrolPath::APatrolPath()
{
	PrimaryActorTick.bCanEverTick = false;

}

FVector APatrolPath::GetPatrolPoint(int const index) const
{
	return PatrolPoints[index];
}

int APatrolPath::Num() const
{
	return PatrolPoints.Num();
}

 

 이로써 PatolPath 는 FVector 의 위치를 가진 Point 와 몇 번째인지 알려주는 Num 으로 이루어지게 되었다. 그렇다면 이를 토대로 Task 를 만들어보자. 우리가 만들어야하는 BTTask 는 총 두개인데 하나는 FindPathPoint 로 PathPoint 의 지점을 찾는 Task 와 IncrementPathIndex 로 다음에 가야할 Point 를 찾는 Task 를 만들어 보겠다.

 

 Behavior Tree 의 예상 진행은 "패트롤 포인트의 위치를 가져오고 -> 그곳으로 움직인 뒤 -> 패트롤 포인트의 인덱스를 하나 늘린다" 이다. 

 

 BTTask 를 C++ 클래스로 만들텐데 이는 전과 동일한 방법으로 BTTask_BlackBoardBase 를 상속받아 만들어 준다. 이름은 BTTask_FindPathPoint 로 지어준다.

꼭 모든 클래스에서 찾아주자

 

 그 뒤 다음과 같이 코드를 작성해준다. 아래는 헤더파일이다.

#pragma once

#include "CoreMinimal.h"
#include "BehaviorTree/Tasks/BTTask_BlackboardBase.h"
#include "BTTask_FindPathPoint.generated.h"

UCLASS()
class AITEST_API UBTTask_FindPathPoint : public UBTTask_BlackboardBase
{
	GENERATED_BODY()
public:
	explicit UBTTask_FindPathPoint(FObjectInitializer const& ObjectInitializer);
	virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
private:
	UPROPERTY(EditAnywhere, Category = "Blackboard", meta = (AllowPrivateAccess = "true"))
	FBlackboardKeySelector PatrolPathVectorKey;

};

 

 다른 BTTask 와 같은 로직을 public 에 추가해주고 private 에는 PatrolPathVectorKey 를 저장해줄 KeySelector 를 선언해준다. 아래는 Cpp 파일이다.

#include "BTTask_FindPathPoint.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "NPCAIController.h"
#include "NPC.h"

UBTTask_FindPathPoint::UBTTask_FindPathPoint(FObjectInitializer const& ObjectInitializer):
	UBTTask_BlackboardBase{ ObjectInitializer }
{
	NodeName = TEXT("Find Path Point");
}

EBTNodeResult::Type UBTTask_FindPathPoint::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
	// attemp to get the NPC's Controller
	if (auto* const cont = Cast<ANPCAIController>(OwnerComp.GetAIOwner()))
	{
		// attemp to get the blackboard component from the behavior Tree
		if (auto* const bc = OwnerComp.GetBlackboardComponent())
		{
			// get the current patrol path index from the blackboard
			auto const Index = bc->GetValueAsInt((GetSelectedBlackboardKey()));
			// get the NPC
			if (auto* npc = Cast<ANPC>(cont->GetPawn()))
			{
				// get the current patrol path vector from the NPC - this is local to the patrol path actor
				auto const Point = npc->GetPatrolParth()->GetPatrolPoint(Index);

				// convert the local vector to a global point
				auto const GlobalPoint = npc->GetPatrolParth()->GetActorTransform().TransformPosition(Point);
				bc->SetValueAsVector(PatrolPathVectorKey.SelectedKeyName, GlobalPoint);

				// finish with success
				FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
				return EBTNodeResult::Succeeded;
			}
		}
	}
	return EBTNodeResult::Failed;
}

 

 코드 자체는 복잡하지 않다. 가야할 PatrolPoint 의 인덱스를 가져온 뒤 PatrolPoint 의 좌표를 가져온다. 그 뒤 그 값을 반환하여 주고 Task가 끝나게 된다. Behavior Tree 에서는 Move To 태스크가 이미 구현되어 있기에 따로 그곳으로 이동하는 로직을 만들어줄 필요는 없다. FindPatrolPoint 에서 반환된 좌표값을 Move To 태스크가 움직여 줄 테니.

 

 이제 다른 Task 인 IncrementPathIndex 를 만들어 보겠다. 위와 동일하게 BTTask_BlackBoardBase 를 상속받아 C++ 클래스를 만들어 준다.

 

 그 뒤 다음과 같이 코드를 작성해준다. 아래는 헤더파일이다.

#pragma once

#include "CoreMinimal.h"
#include "BehaviorTree/Tasks/BTTask_BlackboardBase.h"
#include "BTTask_IncrementPathIndex.generated.h"

UCLASS()
class AITEST_API UBTTask_IncrementPathIndex : public UBTTask_BlackboardBase
{
	GENERATED_BODY()
public:
	explicit UBTTask_IncrementPathIndex(FObjectInitializer const& ObjectInitializer);
	virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
private:
	enum class EDirectionType {Forward, Reverse};

	EDirectionType Direction = EDirectionType::Forward;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="AI", meta=(AllowPrivateAccess = "true"))
	bool bBiDirectional = false;

};

 

 여기선 privat 에 EDirectionType 이 나오게 되는데 enum class 즉, 열거형으로 선언해 준다. enum 에 대해서는 다음에 다루도록 하겠다. EDirectionType 은 두가지의 열거자를 가지는데 Forward, Reverse 이다. 이들은 나중에 인스턴스 값으로 사용될 것이며 Forward 면 PathPoint 에 끝에 도달하면 다시 0번부터 시작하고 Reverse 면 역순으로 돌아가게 된다.

bool 형을 통해 Forward 인지 Reverse 인지 판단하게 된다.

 

 아래는 Cpp 파일이다.

#include "BTTask_IncrementPathIndex.h"
#include "NPCAIController.h"
#include "NPC.h"
#include "BehaviorTree/BlackboardComponent.h"

UBTTask_IncrementPathIndex::UBTTask_IncrementPathIndex(FObjectInitializer const& ObjectInitializer) :
	UBTTask_BlackboardBase{ObjectInitializer}
{
	NodeName = TEXT("Increment Path Index");
}

EBTNodeResult::Type UBTTask_IncrementPathIndex::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
	// try and get the AI controller
	if (auto* const cont = Cast<ANPCAIController>(OwnerComp.GetAIOwner()))
	{
		// try and get the NPC
		if (auto* const NPC = Cast<ANPC>(cont->GetPawn()))
		{
			// try and get the blackboard
			if (auto* const bc = OwnerComp.GetBlackboardComponent())
			{
				// get number of patrol points and min and max indices
				auto const NoOfPoints = NPC->GetPatrolParth()->Num();
				auto const MinIndex = 0;
				auto const MaxIndex = NoOfPoints - 1;
				auto Index = bc->GetValueAsInt(GetSelectedBlackboardKey());

				// change direction if we are at the first or last index if we are in bidirectional mode
				if (bBiDirectional)
				{
					if (Index >= MaxIndex && Direction == EDirectionType::Forward)
					{
						Direction = EDirectionType::Reverse;
					}
					else if (Index == MinIndex && Direction == EDirectionType::Reverse)
					{
						Direction = EDirectionType::Forward;
					}
				}

				//write new value of index to blackboard
				bc->SetValueAsInt(GetSelectedBlackboardKey(),
					(Direction == EDirectionType::Forward ? ++Index : --Index) % NoOfPoints);

				// finish with success
				FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
				return EBTNodeResult::Succeeded;
			}
		}
	}
	return EBTNodeResult::Failed;
}

 

 이번엔 문장이 좀 길었는데 영어로 주석을 달아놓았다. 보면 NPC 에서 GetPatrolPath 를 가져와 사용하는데 이는 NPC 인스턴스에 GetPatrolPath() 함수를 구현할 것이다. 이는 어떤 PatrolPath 액터를 사용할 것인지 선택하는 인스턴스 이다. MinIndex 는 0 이고 MaxIndex 는 총 Points 값에서 1을 빼준다. 이는 0 부터 시작하기 때문이다. 아래의 코드를 보면 Index 가 MaxIndex 에 도달할 시 Forward 할지 Reverse 할지 선택한다. 이를 토대로 bc 에 Value 를 집어넣을 때 3항 방정식으로 Forward 면 ++ 해주고 아니라면 -- 해준다. % NoOfPoint 를 하는 이유는 이래야 Loop 하기 때문이다. (여기서 Loop 한다는 의미는 끝에 도달해도 다시 처음으로 가던지 되돌아가던지 한다는 의미이다.) 이렇게 코드를 작성한 뒤 NPC 헤더와 Cpp 파일을 약간 수정해주자.

 

 아래는 NPC의 헤더파일이다.

 

#pragma once

#include "CoreMinimal.h"
#include "BehaviorTree/BehaviorTree.h"
#include "CppAICharacterBase.h"
#include "PatrolPath.h"
#include "NPC.generated.h"


UCLASS()
class AITEST_API ANPC : public ACppAICharacterBase, public ICombatInterface
{
	GENERATED_BODY()

public:
	// Sets default values for this character's properties
	ANPC();

	// Called every frame
	virtual void Tick(float DeltaTime) override;

	// Called to bind functionality to input
	virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;

	UBehaviorTree* GetBehaviorTree() const;

	APatrolPath* GetPatrolParth() const;

protected:
	// Called when the game starts or when spawned
	virtual void BeginPlay() override;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="AI", meta=(AllowPrviateAccess="true"))
	UBehaviorTree* Tree;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "AI", meta = (AllowPrviateAccess = "true"))
	APatrolPath* PatrolPath;

};

 

 기존에 PatrolPath 를 추가하였다. 그리고 GetPatrolPath 를 통해 이를 가져올 수 있게 만들겠다. (Parth 는 오타이다.)

 

// Fill out your copyright notice in the Description page of Project Settings.


#include "NPC.h"
#include "NPCAIController.h"

// Sets default values
ANPC::ANPC()
{
 	// Set this character to call Tick() every frame.  You can turn this off to improve performance if you don't need it.
	PrimaryActorTick.bCanEverTick = false;

}

// Called when the game starts or when spawned
void ANPC::BeginPlay()
{
	Super::BeginPlay();
	
}

// Called every frame
void ANPC::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);

}

// Called to bind functionality to input
void ANPC::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
	Super::SetupPlayerInputComponent(PlayerInputComponent);

}

UBehaviorTree* ANPC::GetBehaviorTree() const
{
	return Tree;
}

APatrolPath* ANPC::GetPatrolParth() const
{
	return PatrolPath;
}

 

 이렇게 해주면 이제 NPC 블루프린트에서 PatrolPath 의 액터를 선택할 수 있게 된다. 그리고 저번 시간에 AI 의 움직임이 딱딱했는데 이를 해결해줄 ChasePlayer 라는 새로운 BTTask 로 플레이어를 쫓아 올 수 있게 만들어 주겠다. C++ 클래스 생성에서 BTTask_BlackBoardBase 를 상속받은 새로운 BTTask 인 BTTask_ChasePlayer 를 만들어 준다. 그리고 아래와 같이 코드를 작성해 주자.

 

 아래는 BTTask_ChasePlayer 의 헤더이다.

#pragma once

#include "CoreMinimal.h"
#include "BehaviorTree/Tasks/BTTask_BlackboardBase.h"
#include "BTTask_ChasePlayer.generated.h"

UCLASS()
class AITEST_API UBTTask_ChasePlayer : public UBTTask_BlackboardBase
{
	GENERATED_BODY()
public:
	explicit UBTTask_ChasePlayer(FObjectInitializer const& ObjectInitializer);
	virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
};

 

 아주 간단한 로직이기에 private 에 무엇인가를 추가할 필요는 없다. 다음은 Cpp 파일이다.

 

#include "BTTask_ChasePlayer.h"
#include "NPCAIController.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "Blueprint/AIBlueprintHelperLibrary.h"

UBTTask_ChasePlayer::UBTTask_ChasePlayer(FObjectInitializer const& ObjectInitializer) :
	UBTTask_BlackboardBase{ ObjectInitializer }
{
	NodeName = TEXT("Chase Player");
}

EBTNodeResult::Type UBTTask_ChasePlayer::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
	//get target location from blackboard via the NPC'S Controller
	if (auto* const cont = Cast<ANPCAIController>(OwnerComp.GetAIOwner()))
	{
		auto const PlayerLocation = OwnerComp.GetBlackboardComponent()->GetValueAsVector(GetSelectedBlackboardKey());

		// move to the Player's Location
		UAIBlueprintHelperLibrary::SimpleMoveToLocation(cont, PlayerLocation);

		//finish with Success
		FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
		return EBTNodeResult::Succeeded;
	}

	return EBTNodeResult::Failed;
}

 

 간단하게 Move To Task 를 대신해줄 Task 이다. BlueprintHelperLibrary 에 SimpleMoveToLocation 이 존재하니 이를 가져다 사용한다.

 

이제 언리얼 에디터를 켜 새로운 Behavior Tree 를 만들어주자. 전에 만들었던 것 처럼 콘텐츠 드로우에서 우클릭 -> 인공지능 -> 비헤비어 트리 를 선택한 뒤 이름은 BT_NPC_PatrolPath 로 지어주겠다.

 

 그 뒤 다음과 같이 세팅한다.

 

 

 Chasing Player 오른쪽으로 길게 뻗어있는 노드는 아직 사용할 것이 아니기에 신경은 안써도 된다. 설정들을 살펴보기 전에 BlackBoard 로 들어가 몇개의 키값을 만들어 준다.

 

 Int 값 인 PatrolPathIndex 와 Vector 값 인 PatrolPathPoint 를 만들어준다. 그 뒤 다시 비헤비어 트리로 돌아가 다음과 같이 설정들을 바꾸어 주자. 아래의 Leap 부터 왼쪽에서 오른쪽 순으로 보여주겠다.

Find Path Point 의 Detail

 

Move To 의 Detail
Increment Path Index 의 Detail

 

 여기서 Bi Directional 이라는 값이 체크 되어있는데 이는 체크를 해제하면 인덱스의 끝에 도달할 때 왔던 길을 되돌아가게 된다. 체크가 되어있다면 다시 0번 인덱스부터 다시 Patrol 을 시작한다.

Find Player Location 의 Detail

 

Chase Player 의 Detail

 

 Can't See Player 데코레이터의 모습이다. 여기서 관찰자 중단을 Both 로 설정하면 패트롤 지점의 끝에 도달하지 않아도 플레이어를 인식해 쫓아오게 만들 수 있다. 전에 만들었던 BT_NPC 에도 이렇게 적용해주자.

 

 Can See Player 데코레이터도 동일하게 관찰자 중단을 Both 로 둔다. 이제 PatrolPath Actor 을 World 에 배치한 뒤 이를 실험해보자. 월드에 PatrolPath Actor 을 배치한 뒤 Top View 로 시점을 전환해 봐보자. 그럼 다음과 같이 보일 것이다.

 

 

 이 네모난 PatrolPoints 는 옆의 디테일 창에 AI 하위의 PatrolPoints 들을 추가하면 하나 씩 생기는데 이를 계속 추가해 5개 정도 만들고 월드에 각각 배치해준다. 

 

 총 5개의 포인트를 만들어 주었다. 이제 NPC 의 블루프린트를 복제해 하나를 더 만들고 그 뒤 디테일에서 AI 안의 Tree 에 새로만든 BT_NPC_PatrolPath 를 선택해주고 아래의 PatrolPath 는 월드에 배치한 BP_PatrolPath 를 선택해준다. 그 뒤 실행을 시켜보면 NPC 가 PatrolPoints 를 잘 쫓아간다!

설정한 곳으로 잘 움직이긴한다

 

 그리고 쫓아오는 모습도 자연스럽게 지점으로 이동중에 플레이어를 쫓아온다. 오늘은 NPC 가 정해진 루트를 이동하게 하는 것과 쫓아오는 로직을 바꾸어 자연스럽게 만들어 보았다. 다음은 NPC 가 공격을 하는 로직을 구현해 보도록 하겠다.