AI컨트롤러와 Behavior Tree를 이용한 NPC 공격

유영준·2023년 1월 20일
0
post-thumbnail

오늘은 저번에 만든 행동트리와 AI 컨트롤러에서 플레이어를 감지하고 추격해 공격하도록 만들것이다

이를 구현하기 위해서 먼저

  • 플레이어를 탐지하도록
  • 플레이어를 추격하도록
  • 플레이어를 공격하도록

3가지 순서를 따라 만들고자 한다


플레이어 탐색

먼저 탐색할 대상에 대한 정보가 필요하기 때문에, 블랙보드에 캐릭터 타겟 변수를 생성한다

이때 Base ClassABCharacter 로 설정해준다

추가한 타겟 변수는 ABAIController 클래스에서 변수로 사용될 수 있게 추가해준다

ABAIController.h

#pragma once

#include "ArenaBattle.h"
#include "AIController.h"
#include "ABAIController.generated.h"

/**
 * 
 */
UCLASS()
class ARENABATTLE_API AABAIController : public AAIController
{
	GENERATED_BODY()
	
public:
	AABAIController();
	virtual void OnPossess(APawn* InPawn) override;

	static const FName HomePosKey;
	static const FName PatrolPosKey;
	static const FName TargetKey;
    
    ...
}

ABAIController.cpp

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


#include "ABAIController.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"));
const FName AABAIController::TargetKey(TEXT("Target"));

...

플레이어를 감지하는 기능으로는 서비스 노드를 사용할 것이다

서비스 노드는 컴포닛에 속한 Task 가 실행되는 동안 반복적인 작업을 실행하는데 적합하다

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

일반적인 클래스에 Tick 함수가 있는것처럼 서비스 노드에는 TickNode 함수가 있다

Interval 속성값을 통해 TickNode 의 주기를 지정할 수 있다

BTService_Detect.h

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

#pragma once

#include "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

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


#include "BTService_Detect.h"
#include "ABAIController.h"
#include "ABCharacter.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;

	UWorld* World        = ControllingPawn->GetWorld();
	FVector Center       = ControllingPawn->GetActorLocation();
	float   DetectRadios = 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(DetectRadios),
		CollisionQueryParam
	);

	if (bResult)
	{
		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, DetectRadios, 16, FColor::Green, false, 0.4f);
				DrawDebugPoint(World, ABCharacter->GetActorLocation(), 10.0f, FColor::Blue, false, 0.4f);
				DrawDebugLine(World, ControllingPawn->GetActorLocation(), ABCharacter->GetActorLocation(), FColor::Blue, false, 0.4f);
				return;
			}
		}
	}

	DrawDebugSphere(World, Center, DetectRadios, 16, FColor::Red, false, 0.4f);
}

이 서비스 노드는 행동트리에 부착하게 될때 기본적으로 Detect 라는 이름을 가지게 될것이고, 1초마다 실행된다

OverlapMultiByChannel을 통해서 검출을 해주고, 그 안에 캐릭터가 있을 시 초록색으로 디버그를 그리며, Target 값을 플레이어로 주었다


상태 트리 수정

다음으로는 상태 트리를 수정해준다

여러가지 상황을 가질 수 있는 셀렉터를 Root로부터 추가해주고

우리가 만든 Detect 서비스를 추가해준다

먼저 체크할 왼쪽의 시퀀스에는 추적을, 오른쪽은 탐색을 둔다

이때 MoveTo 의 key는 Target으로 둔다

시퀀스에 있는 TargetOn, NoTarget은 데코레이터로서 해당 컴포짓 노드의 특성을 상세하게 설정할 수 있다

TargetOn에는 키 값의 변경이 감지되면 바로 실행이 되도록 Notify ObserverOn Value Change 로 바꿔준다

NoTarget은 동일하게 설정하되, Key Query 를 Is Not Set 으로 설정해준다

(캐릭터를 쫒아오는 모습)


NPC 공격 추가

이번에는 npc가 플레이어를 공격하도록 구현하겠다 이때 탐색한 왼쪽 노드에서 갈래가 나뉘어 공격과 추격으로 나누겠다

이 두 상태를 체크하는 것은 데코레이터 클래스 BTDecorator_IsInAttackRange를 만들어 구현하도록 하겠다

BTDecorator_IsInAttackRange.h

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

#pragma once

#include "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;
};

CalculateRawConditionValue 함수를 통해 원하는 조건이 달성했는지를 체크할 수 있다

BTDecorator_IsInAttackRange.cpp

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


#include "BTDecorator_IsInAttackRange.h"
#include "ABAIController.h"
#include "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;

	bResult = (Target->GetDistanceTo(ControllingPawn) <= 200.0f);
	return bResult;
}

CalculateRawConditionValue 를 통해 AI가 캐릭터와의 거리가 200.0f(공격 사거리) 보다 작다면 true 를 반환하도록 구현해주었다


이번에는 공격을 하기 위한 TaskNode BTTaskNode_Attack 을 만들자

태스크의 경우 태스크가 끝났을때 알려주는 FinishLatentTask 를 사용해 공격이 끝났음을 반환해야 한다

그렇지 않으면 공격 태스크에 계속해서 머물러 있게 된다 이를 위해 공격이 끝났음을 알리는 델리게이트를 선언하겠다

이때 추가로, 현재 AI가 플레이어와 속도가 같으며 움직임이 부자연스럽기 때문에 이를 살짝 보정해주겠다

ABCharacter.h

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

#pragma once

#include "ArenaBattle.h"
#include "GameFramework/Character.h"
#include "ABCharacter.generated.h"

DECLARE_MULTICAST_DELEGATE(FOnAttackEndDelegate);

UCLASS()
class ARENABATTLE_API AABCharacter : public ACharacter
{
	GENERATED_BODY()

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

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

	enum class EControlMode
	{
		TPS,
		QUARTERVIEW,
        //이동속도와 움직임을 보정한 NPC enum 타입을 추가해줬다
		NPC
	};

	void SetControlMode(EControlMode NewControlMode);

	//UPROPERTY를 쓰지 않는 변수 초기화
	EControlMode CurrentControlMode = EControlMode::TPS;
	FVector DirectionToMove = FVector::ZeroVector;

	FRotator ArmRotationTo    = FRotator::ZeroRotator;
	float	 ArmlengthTo      = 0.0f;
	float	 ArmLengthSpeed   = 0.0f;
	float	 ArmRotationSpeed = 0.0f;

public:	
	
    ...
    
	bool CanSetWeapon();
	void SetWeapon(class AABWeapon* NewWeapon);
    //태스크에서 공격을 받아야하기 때문에 public 으로 옮겨주었다
	void Attack();
	FOnAttackEndDelegate OnAttackEnd;

...
}

ABCharacter.cpp

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


#include "ABCharacter.h"
#include "ABAnimInstance.h"
#include "DrawDebugHelpers.h"
#include "ABWeapon.h"
#include "ABCharacterStatComponent.h"
#include "Components/WidgetComponent.h"
#include "ABCharacterWidget.h"
#include "ABAIController.h"

 ...

void AABCharacter::SetControlMode(EControlMode NewControlMode)
{
	CurrentControlMode = NewControlMode;
	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::QUARTERVIEW);
		GetCharacterMovement()->MaxWalkSpeed = 600.0f;
	}
	else
	{
		SetControlMode(EControlMode::NPC);
		GetCharacterMovement()->MaxWalkSpeed = 300.0f;
	}
}

void AABCharacter::OnAttackMontageEnded(UAnimMontage* Montage, bool bInterrupted)
{
	IsAttacking = false;
	AttackEndComboState();
	OnAttackEnd.Broadcast();
}

...

다음으로는 BTTaskNode_Attack 을 구현해준다

BTTaskNode_Attack.h

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

#pragma once

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

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

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

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

private:
	bool IsAttacking = false;
};

BTTaskNode_Attack.cpp

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


#include "BTTaskNode_Attack.h"
#include "ABAIController.h"
#include "ABCharacter.h"

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

EBTNodeResult::Type UBTTaskNode_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();
	IsAttacking = true;
	ABCharacter->OnAttackEnd.AddLambda([this]() -> void
	{
		IsAttacking = false;
	});

	return EBTNodeResult::InProgress;
}

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

OnAttackEnd 델리게이트에 IsAttacking이 false 가 되는 람다식을 추가해주었고, 이를 통해 FinishLatntTask 를 체크했다


이렇게 설정한다면, 캐릭터가 도망쳐 뒤를 가게 되더라도, AI는 같은 방향을 공격하게 된다

이를 보정해주는 BTTask_TurnToTarget 클래스를 추가해주자

BTTask_TurnToTarget.h

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

#pragma once

#include "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

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


#include "BTTask_TurnToTarget.h"
#include "ABAIController.h"
#include "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;
}

상태 트리 최종 작업

지금까지 구현해둔 Task 와 데코레이터를 통해 상태트리를 작업해주겠다

먼저 왼쪽은 다시 셀럭터로 나누고 오른쪽은 Sequence, 왼쪽은 SimpleParallel을 둔다

SimpleParallel은 메인 테스크와 보조 테스크가 있는 컴포짓으로 두가지를 동시에 실행하게 된다

SimpleParallel 에는 공격을 메인 테스크로, 회전을 보조 테스크로 추가해주고

마지막으로 Is in Attack Range 데코레이터를 추가해준다

(완성된 장면)

마지막으로 게임 플레이를 보자

profile
토비폭스가 되고픈 게임 개발자

0개의 댓글