그냥 게임개발자

[UE5] LAST - AI 공격 BehaviorTree로 구현(C++) 본문

UE5_Tutorial1

[UE5] LAST - AI 공격 BehaviorTree로 구현(C++)

sudoju 2023. 10. 2. 00:50

1. BehaviorTree Service

2. BehaviorTree Decorator

3. AIAttack

 

일단 필자가 만든 BehaviorTree를 먼저 설명하겠다.

 

BehaviorTree

일단 저번 글과는 좀 다르게 무언가가 많이 추가가 되었다.

이 BehaviorTree를 먼저 설명하기 전 Composite, Service, Decorator를 먼저 설명하겠다.

 

Composite

컴포짓은 상태들의 시작점이다.
상태가 복귀와 실행 플로우에서 어떻게 행동해야하는지 정의한다.
Selector, Sequence, Simple Parallel 세가지 종류가 있다.

Service

Service는 연결된 Composite이 활성화 되어있는 한 계속해서 구동된다.
속성에서 설정한 간격에 따라 Tick이 일어난다.

서비스는 항상 호출되기 때문에 AI 상태를 변경하는데 주로 사용

 

Decorator

디자인 구조 개념에서 Decorator는 어떤 기능을 하던 모듈은 고치지 않은 채 그 모듈을 감싸서 어떤 기능을 추가하는 것을 말한다.

언리얼 엔진에서는 각 Task에 붙어서 Task가 할 일을 더 추가해주는 일을 말한다.
또한 Task에 붙기도 하지만 Composite에도 붙는다.
즉 Decorator는 노드가 실행 될지 말지를 체크하는 역할을 한다.

여기서 한 노드에 실행 순서는 Decorator, Service, Task 순서로 실행된다.

 

이 것을 만들기 위해서는 일단 우리는 BTService를 추가해준다.

먼저 추가해줘야 할 것은 SerachTarget이라는 서비스이다.

우리는 지금까지 만든 것은 AI가 무작정 랜덤위치로 이동하는 것까지만 만들었다.

하지만 여기서 어떤 구를 그려서 그 구안에 적이 있다면 움직이거나 공격하는 AI를 만드려고 한다.

구 안에 적을 찾는 것을 대충 표현

그렇다면 일단 우리는 Service 클래스를 추가해보자.

BTService_SerachTarget생성

이렇게 생성한 서비스에서 생성자와 이미 언리얼에서 만들어져있는 TickNode함수를 생성할 것이다.

 

BTService_SerachTarget.h

UCLASS()
class FIRSTPROJECT_API UBTService_SearchTarget : public UBTService
{
	GENERATED_BODY()
	
public:
	UBTService_SearchTarget();

	virtual void TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;
};

우리는 이 Tick을 1초마다 실행할 것을 설정할 것이다.

 

BTService_SearchTarget.cpp

#include "FirstProject\AI\MyAIController.h"
#include "FirstProject\Player\MyCharacter.h"
#include "BehaviorTree\BehaviorTreeComponent.h"
#include "BehaviorTree\BlackboardComponent.h"
#include "DrawDebugHelpers.h"

UBTService_SearchTarget::UBTService_SearchTarget()
{
	// Interval로 Tick 딜레이를 설정해줄 수 있다.
	NodeName = TEXT("SerachTarget");
	Interval = 1.0f;
}

void UBTService_SearchTarget::TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
	// 주기적으로 이 TickNode가 실행이되는데.
	Super::TickNode(OwnerComp, NodeMemory, DeltaSeconds);

	// 폰을 가져온다.
	auto CurrentPawn = OwnerComp.GetAIOwner()->GetPawn();

	if (CurrentPawn == nullptr)
		return;

	// 월드와 Actor의 Location을 가져온다.
	// 반경은 500f
	UWorld* World = CurrentPawn->GetWorld();
	FVector Center = CurrentPawn->GetActorLocation();
	float SerachRadius = 500.f;

	if (World == nullptr)
		return;

	// 충돌결과를 받아올 Result
	TArray<FOverlapResult> OverlapResults;
	// 콜리전의 인자들을 받아오는 변수
	FCollisionQueryParams QueryParams(NAME_None, false, CurrentPawn);

	// 주변을 검색해서 콜라이더 채널에 맞는지 확인
	bool bResult = World->OverlapMultiByChannel(
		OverlapResults,
		Center,
		FQuat::Identity,
		ECollisionChannel::ECC_GameTraceChannel2,
		FCollisionShape::MakeSphere(SerachRadius),
		QueryParams);

	if (bResult)
	{
		// 찾음
		// OverlapResults를 한바퀴 돌아 체크
		for (auto& OverlapResult : OverlapResults)
		{
			// MyCharacter를 가져온다.
			AMyCharacter* MyCharacter = Cast<AMyCharacter>(OverlapResult.GetActor());
			if (MyCharacter && MyCharacter->GetController()->IsPlayerController())
			{
				// MyCharacter를 넣어줌
				OwnerComp.GetBlackboardComponent()->SetValueAsObject(FName(TEXT("Target")), MyCharacter);

				DrawDebugSphere(World, Center, SerachRadius, 16, FColor::Green, false, 0.2f);
			}
		}

		//DrawDebugSphere(World, Center, SerachRadius, 16, FColor::Red, false, 0.2f);
	}
	else
	{
		// 못찾음
		// nullptr로 넣어줌
		OwnerComp.GetBlackboardComponent()->SetValueAsObject(FName(TEXT("Target")), nullptr);

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

이렇게 우리의 TickNode를 만들어줬다.

이제 BehaivorTree로 다시가서 우리가 만든 Service를 추가해주자.

Service추가

우리가 설정한 NodeName으로 추가가 되어있는 것을 볼 수가 있다.(없으면 빌드 다시)

 

2. BehaviorTree Decorator

이제 우리는 Decorator를 추가할 것인데 Target(Player)를 찾았냐 안찾았냐를 분기로 나눌 것이다.

BehaviorTree TargetOn, TargetOff

이런식으로 분기를 나눌 것이다.

Target을 찾지 못했다면 

랜덤 이동

저번에 만들어두었던 것처럼 그냥 랜덤 포지션으로 이동하게 되고

Target을 찾았다면

CanAttack, CanNotAttack

으로 나누어질 것이다.

그러면 우리는 Blackboard에서 Object키(Target)를 만들 것이다

TargetKey 추가

그렇다면 우리는 TargetOn과 TargetOff의 데코레이터를 추가하자.

Target은 우리가 만들어놓은 MyCharacter로 설정해주자.

Target->MyCharater로 설정

이제 여기서 우리가 살짝 알아야 할 것은

SelectorSequence다.

 

Selctor

자식 노드가 성공하변 바로 그 결과를 반환, 하나의 자식노드가 실패하면 그 다음 자식 노드로 진입하는데 모든 자식노드가 실패할 경우를 제외하면 성공 시점에서 실행을 멈춘다.
switch - case문이라고 생각하면 편하다.

Sequence

자식노드가 성공하면 계속 순차방문을 한다.
자식 노드가 실패하는 경우 실행을 중지하는 구조로 이루어져 있다.
foreach문과 비슷하다라고 생각하면 편하다.

Decorator Blackboard추가

우리가 만든 Blackboard를 데코레이터로 사용할 것이다.

Decorator 분기 설정

자 우리는 여기서 Blackboard Decorator를 다 추가해주었다.

여기서

TargetOn
TargetOff

이렇게 설정해줄 것이다.

Blackboard Key는 우리가 만들었던 Object로 설정해주면 된다.

 

그 다음 우리가 설정할 것은 AI가 Target을 찾으면 공격할 수 있는지 없는지를 체크해야 하는 데코레이터가 필요하다.

Decorator 분기 설정
BTDecorator_CanAttack 클래스 추가

BTDecorator_CanAttack.h

UCLASS()
class FIRSTPROJECT_API UBTDecorator_CanAttack : public UBTDecorator
{
	GENERATED_BODY()
	
public:
	UBTDecorator_CanAttack();

	virtual bool CalculateRawConditionValue(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const override;

};

 

BTDecorator_CanAttack.cpp

#include "FirstProject/AI/BTDecorator_CanAttack.h"
#include "FirstProject\AI\MyAIController.h"
#include "FirstProject\Player\MyCharacter.h"
#include "BehaviorTree\BlackboardComponent.h"

UBTDecorator_CanAttack::UBTDecorator_CanAttack()
{
	// 노드 네임은 CanAttack으로 설정
	NodeName = TEXT("CanAttack");
}

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

	// AI오너에 폰을 가져온다.
	auto CurrentPawn = OwnerComp.GetAIOwner()->GetPawn();
	if (CurrentPawn == nullptr)
		return false;

	// 비헤비어 트리의 블랙보드에서 Object(Target)를 MyCharacter로 설정
	auto Target = Cast<AMyCharacter>(OwnerComp.GetBlackboardComponent()->GetValueAsObject(FName(TEXT("Target"))));
	if (Target == nullptr)
		return false;

	// 거리가 200정도의 거리가 있으면 true
	return bResult && Target->GetDistanceTo(CurrentPawn) <= 200.f;
}

클래스를 작성 해준 뒤 데코레이터를 추가하자.

CanAttack
CanAttack(Inversed)

위와 같이 CanAttack부분은 그냥 추가 CanAttack(Inversed)는 조건을 반대로 해주면 된다.

공격할 수 없다면 Target쪽으로 이동

공격할 수 없으면 MoveTo를 이용해 Target으로 이동

Attack 부분은 다시 우리가 TaskNode를 만들어줘야 한다.

BTTask_Attack 클래스 추가

 

BTTask_Attack.h

UCLASS()
class FIRSTPROJECT_API UBTTask_Attack : public UBTTaskNode
{
	GENERATED_BODY()
	
public:
	UBTTask_Attack();

	// 태스크 실행이 될 때
	virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;

	virtual void TickTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;

private:
	bool bIsAttacking = false;
};
#include "FirstProject\AI\MyAIController.h"
#include "FirstProject\Player\MyCharacter.h"

UBTTask_Attack::UBTTask_Attack()
{
	// 틱을 사용한다.
	bNotifyTick = true;
}

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

	auto MyCharacter = Cast<AMyCharacter>(OwnerComp.GetAIOwner()->GetPawn());

	if (MyCharacter == nullptr)
		return EBTNodeResult::Failed;

	MyCharacter->Attack();
	bIsAttacking = true;


	// 람다
	// bIsAttacking이 BTTask_Attack 클래스의 멤버 변수이기에 this 포인터를 캡쳐
	MyCharacter->OnAttackEnd.AddLambda([this]()
		{
			bIsAttacking = false;
		});
	return Result;
}

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

	// 공격이 끝나면 Task를 종료 처리
	if (bIsAttacking == false)
		FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
}

자 이제 Decorator를 추가해보자.

추가가 된 것을 확인할 수 있다.

근데 bIsAttacking이란 함수는 공격 한 것을 체크해야 하는 변수이다.

이것은 MyCharacter에서 델리게이트를 통해 구독형으로 만들자.

 

MyCharacter.h

DECLARE_MULTICAST_DELEGATE(FOnAttackEnd);

UCLASS()
class FIRSTPROJECT_API AMyCharacter : public ACharacter
{
	GENERATED_BODY()
    
    ...
    
    
	void AttackCheck();

	FOnAttackEnd OnAttackEnd;

....

}

MyCharacter.cpp

void AMyCharacter::OnAttackMontageEnded(UAnimMontage* Montage, bool bInterrupted)
{
	if (IsAttacking == false) return;
	IsAttacking = false;
	OnAttackEnd.Broadcast();
	UE_LOG(LogTemp, Log, TEXT("MontageEnd %s"), (IsAttacking ? TEXT("true") : TEXT("false")));
}

공격이 끝나면 OnAttackMontageEnded함수가 들어오니 여기서 OnAttackEnd.Broadcast()를 해준다.

 

그러면 공격이 끝나게 되면 BTTask_Attack.cpp에 있는 AddLambda에 있는 함수가 실행되어 bIsAttacking = false;가 실행되기 때문에 잘 된 것을 할수가 있다.

 

이제 빌드 해보고 잘 되는지 실행해보자.

공격, 이동 테스트

잘 된다.

 

후... 이제 튜토리얼은 끝났다...

 

이제는 내가 직접 여러 구글 서칭과 유튜브를 통해 어드벤처 게임을 만들어 볼 예정이다.

 

지금까지 잘 따라와줘서 정말 고맙다라고 할 수 있다.

 

잘 안되는 부분들은 댓글 달면 열심히 알려주겠다.