Unreal

보스 비헤이비어 트리 및 코드 - UnrealEngine

workbench34 2025. 3. 5. 14:44

 지금 진행하는 프로젝트는 3주안에 간단한 슈팅 게임을 만드는 프로젝트이다. 이번에 내가 맡은 역할은 NPC / AI 관련이었다. 적을 만들고 그 적이 플레이어를 쫓아오고 공격하게 만드는 방식이다. 이번 만들었던 기능중 가장 어려웠지만 가장 재미있었던 기능을 소개하고자 한다. 또한 이 기능을 만드는데 어떤 시행착오와 그걸 어떻게 해결하였는지 적어보겠다.

보스 비헤이비어 트리

 이번 프로젝트의 도전 과제는 보스를 만들어 일반 적들과는 다르게 여러개의 패턴, 높은 체력, 광폭화 등의 기능들이 존재해야 했다. 아래가 그 결과물이다.

 

 전체적인 부분은 이렇다. 트리의 작동방식을 순서대로 설명해보자. 트리는 왼쪽부터 오른쪽으로 순환하니 그에 맞추어 설명하겠다.

 

1. 루트 아래의 셀렉터

 

 나는 이 분기를 보스가 캐릭터를 포착 했나 안했나 분기로 나누었다. 

 

 왼쪽 트리 : 플레이어를 발견할 수 없다면 랜덤한 위치를 찾아 그곳으로 움직인 뒤 1.0 초를 기다림

 

 오른쪽 트리 : 플레이어를 발견했다면 플레이어의 위치를 알아냄.

 

 오른쪽 트리는 시퀀스 이기에 Find Player Location 이 실행되고 다음 분기로 넘어간다.

 

2. 보스의 공격 패턴 셀렉터

 

 이번 분기는 보스의 체력에 따라 나뉜다. 왼쪽은 광폭화 이전, 오른쪽은 광폭화 이후이다. 왼쪽의 분기부터 순회해 보자.

 

2 - 1. 보스의 체력이 임계점 보다 높을때

 

 이번 분기는 원거리 공격, 근거리 공격, 추적 세 분기로 나뉜다. 왼쪽 부터 설명하겠다.

 

 왼쪽 트리 : 근접공격 중이 아니거나 원거리 공격중이 아니라면 플레이어를 쫓아간다.

 

 가운데 트리근접 공격이 가능하고 (플레이가 사정거리 안이라면) 원거리 공격 중이 아니라면 플레이어를 공격한 후 플레이어 위치로 로테이션 한 뒤 조금씩 쫓아간다. 0.25 초의 텀을 주어 천천히 추격하도록 만든다.

 

오른쪽 트리 : 근접 공격이 불가능하고 공격 범위 안에 있어 공격 가능 하다면 일정 확률로 원거리 공격을 한다.

 

2 - 2. 보스의 체력이 임계점 보다 낮을때

 

 이번 분기는 2개이다. 그러나 Boss Rage 부분은 한번만 실행하기에 실질적으로는 위와 같다. 그렇기에 Boss Rage 부분만 설명하겠다.

 

 왼쪽 트리 : Boss Rage Task 가 실행되지 않았다면 Boss Rage 를 실행한 뒤 이를 true 로 바꾼다.

 

 오른쪽 트리 : 위와 같음.

 

 전체적인 로직을 정리하자면 다음과 같다.

 

1. 플레이어가 시야에 포착이 되었는지에 따라 랜덤 로케이션 탐색 또는 플레이어 로케이션 탐색

 

2. 플레이어의 위치가 확인 되면 플레이어와 보스의 거리에 따라 근접 공격, 추적, 원거리 공격 중 하나를 택함.

 

3. 보스의 체력이 임계점까지 떨어지면 보스가 광폭화 애니메이션을 실행한 뒤 더 강화된 공격으로 플레이어를 공격함

 

 모든 태스크의 C++ 코드를 보여줄수 없으니 제일 힘들게 만들었던 Boss Attack Task 와 Range Attack 을 보여주겠다.


Boss Attack Task

#include "NPC/BTTask_BossAttack.h"
#include "Animation/AnimMontage.h"
#include "Animation/AnimInstance.h"
#include "Components/SkeletalMeshComponent.h"
#include "Engine/LatentActionManager.h"
#include "Kismet/KismetSystemLibrary.h"
#include "NPC/CombatInterface.h"
#include "NPC/GJBossAIController.h"
#include "Runtime/Engine/Classes/Engine/World.h"


UBTTask_BossAttack::UBTTask_BossAttack()
{
	NodeName = "Boss Attack";
}

EBTNodeResult::Type UBTTask_BossAttack::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
	// if npc are out of range, do not attack the player
	auto const* const cont = OwnerComp.GetAIOwner();
	auto* const npc = Cast<AGJBossNPC>(cont->GetPawn());
	auto* const Controller = Cast<AGJBossAIController>(npc->GetController());
	UBehaviorTreeComponent* Compo = Cast<UBehaviorTreeComponent>(Controller->GetBrainComponent());
	bool IsRage = Compo->GetBlackboardComponent()->GetValueAsBool(TEXT("IsBossDoRage"));
	auto const OutOfRange = !OwnerComp.GetBlackboardComponent()->GetValueAsBool(GetSelectedBlackboardKey());
	if (OutOfRange)
	{
		//finish the task
		Compo->GetBlackboardComponent()->SetValueAsBool(TEXT("IsAttacking"), false);
		FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
		return EBTNodeResult::Succeeded;
	}
	// npc's are in range so get the AI's Controller and the NPC itself

	const float RandValue = FMath::FRandRange(0.0f, 9.f);
	if (RandValue <= 4.0f)
	{
		// if the NPC supports the ICombatInterface, cast and call the Execute_MeleeAttack function
		if (auto* const icombat = Cast<ICombatInterface>(npc))
		{
			if (!IsRage) 
			{
				// necessary check to see if the montage has finished so we don't try and play it again
				if (SpecialMontageHasFinished(npc) && WeakMontageHasFinished(npc) && StrongMontageHasFinished(npc) && RangeMontageHasFinished(npc))
				{
					icombat->Execute_WeakAttack(npc);
					npc->SetIsFist(true);
					npc->SetIsRight(false);
					npc->SetIsLeft(false);
				}
			}
			else
			{
				// necessary check to see if the montage has finished so we don't try and play it again
				if (SpecialMontageHasFinished(npc) && WeakMontageHasFinished(npc) && StrongMontageHasFinished(npc) && RangeMontageHasFinished(npc))
				{
					icombat->Execute_WeakAttack_Rage(npc);
					npc->SetIsFist(true);
					npc->SetIsRight(false);
					npc->SetIsLeft(false);
				}
			}

		}
	}
	else if (RandValue > 4.0f && RandValue <= 7.0f)
	{
		// if the NPC supports the ICombatInterface, cast and call the Execute_MeleeAttack function
		if (auto* const icombat = Cast<ICombatInterface>(npc))
		{
			// necessary check to see if the montage has finished so we don't try and play it again
			if (!IsRage)
			{
				// necessary check to see if the montage has finished so we don't try and play it again
				if (SpecialMontageHasFinished(npc) && WeakMontageHasFinished(npc) && StrongMontageHasFinished(npc) && RangeMontageHasFinished(npc))
				{
					icombat->Execute_StrongAttack(npc);
					npc->SetIsFist(true);
					npc->SetIsRight(false);
					npc->SetIsLeft(false);
				}
			}
			else
			{
				// necessary check to see if the montage has finished so we don't try and play it again
				if (SpecialMontageHasFinished(npc) && WeakMontageHasFinished(npc) && StrongMontageHasFinished(npc) && RangeMontageHasFinished(npc))
				{
					icombat->Execute_StrongAttack_Rage(npc);
					npc->SetIsFist(true);
					npc->SetIsRight(false);
					npc->SetIsLeft(false);
				}
			}
		}
	}
	else if (RandValue > 7.0f && RandValue <= 9.0f)
	{
		// if the NPC supports the ICombatInterface, cast and call the Execute_MeleeAttack function
		if (auto* const icombat = Cast<ICombatInterface>(npc))
		{
			if (!IsRage)
			{
				// necessary check to see if the montage has finished so we don't try and play it again
				if (SpecialMontageHasFinished(npc) && WeakMontageHasFinished(npc) && StrongMontageHasFinished(npc) && RangeMontageHasFinished(npc))
				{
					icombat->Execute_SpecialAttack(npc);
					npc->SetIsFist(true);
					npc->SetIsRight(false);
					npc->SetIsLeft(false);
				}
			}
			else
			{
				// necessary check to see if the montage has finished so we don't try and play it again
				if (SpecialMontageHasFinished(npc) && WeakMontageHasFinished(npc) && StrongMontageHasFinished(npc) && RangeMontageHasFinished(npc))
				{
					icombat->Execute_SpecialAttack_Rage(npc);
					npc->SetIsFist(true);
					npc->SetIsRight(false);
					npc->SetIsLeft(false);
				}
			}
		}
	}
	// finish the task
	Compo->GetBlackboardComponent()->SetValueAsBool(TEXT("IsAttacking"), false);
	FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
	return EBTNodeResult::Type();
}

bool UBTTask_BossAttack::WeakMontageHasFinished(AGJBossNPC* const npc)
{
	return npc->GetMesh()->GetAnimInstance()->Montage_GetIsStopped(npc->GetWeakMontage());
}

bool UBTTask_BossAttack::StrongMontageHasFinished(AGJBossNPC* const npc)
{
	return npc->GetMesh()->GetAnimInstance()->Montage_GetIsStopped(npc->GetStrongMontage());
}

bool UBTTask_BossAttack::SpecialMontageHasFinished(AGJBossNPC* const npc)
{
	return npc->GetMesh()->GetAnimInstance()->Montage_GetIsStopped(npc->GetSpecialMontage());
}

bool UBTTask_BossAttack::RangeMontageHasFinished(AGJBossNPC* const npc)
{
	return npc->GetMesh()->GetAnimInstance()->Montage_GetIsStopped(npc->GetRangeMontage());
}

 

 원래 생각했던 모습은 보스 비헤이비어 트리에 세가지 노드를 만들어 각각에 노드들에 대한 태스크를 작성한 뒤 그것을 약공격, 강공격, 스페셜 공격으로 나눌 예정이었다. 그리고 셀렉트를 이용해 그 중 하나를 선택하여 실행하는 것이었다. 그러나 비헤이비어 트리에서 랜덤으로 3개를 선택하는 로직을 구현할 수 없게 되어 실 구현은 위와 같이 완성되었다.

 

 위의 기능을 설명하자면 다음과 같다.

 

1. 보스가 근접 공격이 가능한 거리까지 왔다면 이 태스크가 실행된다.

 

2. 세가지의 공격 중 하나를 FMath::RandRange 를 통해 선택하도록 하여 선택된 공격을 시행하도록 만든다.

 

3. 만약 보스가 광폭화 중이라면 더 빠른 애니메이션을 재생하도록 만든다.


Range Attack

 원거리 공격 태스크 자체는 위와 같으나 여기서는 발사체를 만드는 방법과 그것을 플레이어에게 날아가게 만드는 것, 애니메이션이 끝나면 날아가게 만드는 것이 난제였다. 우선 아래가 발사체 코드이다.

#include "NPC/GJBossProjectile.h"
#include "Particles/ParticleSystem.h"
#include "Particles/ParticleSystemComponent.h"
#include "Components/SphereComponent.h"
#include "GameFramework/ProjectileMovementComponent.h"
#include "Kismet/GameplayStatics.h"

AGJBossProjectile::AGJBossProjectile()
{
	PrimaryActorTick.bCanEverTick = false;
	SceneRoot = CreateDefaultSubobject<USceneComponent>(TEXT("SceneRoot"));
	SetRootComponent(SceneRoot);
	ProjectileMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Projectile Mesh"));
	ProjectileMesh->SetupAttachment(SceneRoot);

	if (GetInstigator())
	{
		HitArea->IgnoreActorWhenMoving(GetInstigator(), true);
	}

	HitArea = CreateDefaultSubobject<USphereComponent>(TEXT("HitArea"));
	HitArea->SetupAttachment(SceneRoot);

	ProjectileComponent = CreateDefaultSubobject<UProjectileMovementComponent>(TEXT("Projectile Component"));
	ProjectileComponent->InitialSpeed = 800;
	ProjectileComponent->MaxSpeed = 8500;
	ProjectileComponent->bAllowAnyoneToDestroyMe = false;
	ProjectileComponent->bRotationFollowsVelocity = true;

	Damage = 10.f;
	DamageRadius = 200.f;
	ProjectileLifetime = 4.f;

}

void AGJBossProjectile::BeginOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
	UE_LOG(LogTemp, Warning, TEXT("Projectile Overlap: OtherActor=%s, Instigator=%s"), *GetNameSafe(OtherActor), *GetNameSafe(GetInstigator()));

	if (OtherActor && (OtherActor == GetInstigator() || OtherActor == this))
	{
		UE_LOG(LogTemp, Warning, TEXT("Projectile Overlap: Instigator collision ignored."));
		return; // Instigator와 충돌 시 무시
	}

	if (OtherActor && OtherActor != GetInstigator())
	{
		UE_LOG(LogTemp, Warning, TEXT("Projectile Overlap: AutoExplode triggered."));
		AutoExplode();
	}
}

void AGJBossProjectile::EndOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex)
{

}

void AGJBossProjectile::AutoExplode()
{
	if (Particle)
	{
		UParticleSystemComponent* SpawnedEffect = UGameplayStatics::SpawnEmitterAtLocation(GetWorld(), Particle, GetActorLocation());
		if (SpawnedEffect)
		{
			FTimerHandle ExplosionEffectTimer;
			GetWorldTimerManager().SetTimer(
				ExplosionEffectTimer,
				[SpawnedEffect]()
				{
					if (SpawnedEffect)
					{
						SpawnedEffect->DeactivateSystem();
						SpawnedEffect->DestroyComponent();
					}
				},
				3.0f,
				false
			);
		}
	}
	TArray<AActor*> OverlappedActors;
	TArray<TEnumAsByte<EObjectTypeQuery>> ObjectTypes;
	ObjectTypes.Add(UEngineTypes::ConvertToObjectType(ECC_Pawn));
	ObjectTypes.Add(UEngineTypes::ConvertToObjectType(ECC_PhysicsBody));
	ObjectTypes.Add(UEngineTypes::ConvertToObjectType(ECC_WorldDynamic));
	ObjectTypes.Add(UEngineTypes::ConvertToObjectType(ECC_GameTraceChannel2));

	TArray<AActor*> IgnoreActors;

	UKismetSystemLibrary::SphereOverlapActors(
		GetWorld(),
		GetActorLocation(),
		DamageRadius,
		ObjectTypes,
		nullptr,
		IgnoreActors,
		OverlappedActors
	);
	UGameplayStatics::ApplyRadialDamage(
		this,
		Damage,
		GetActorLocation(),
		DamageRadius,
		nullptr,
		TArray<AActor*>(),
		this,
		GetInstigatorController(),
		true
	);
	Destroy();
}

void AGJBossProjectile::BeginPlay()
{
	Super::BeginPlay();

	HitArea->OnComponentBeginOverlap.AddDynamic(this, &AGJBossProjectile::BeginOverlap);
	HitArea->OnComponentEndOverlap.AddDynamic(this, &AGJBossProjectile::EndOverlap);
	if (FireSound)
	{
		UGameplayStatics::PlaySoundAtLocation(
			GetWorld(),
			FireSound,
			GetActorLocation()
		);
	}
	GetWorldTimerManager().SetTimer(
		DestroyTimerHandle,
		this,
		&AGJBossProjectile::AutoExplode,
		ProjectileLifetime,
		false
	);
}

 

 발사체는 ProjectileLifetime 동안 날아가며 그전에 오버랩 되거나 시간이 끝나면 AutoExplode 함수에 따라 폭발하여 근처에 데미지를 주는 방식이다. 이를 날리기 위해서 보스에 다음과 같은 코드가 삽입된다.

 

void AGJBossNPC::FireProjectile()
{
	if (ProjectileClass)
	{
		ACharacter* PlayerCharacter = UGameplayStatics::GetPlayerCharacter(GetWorld(), 0);
		if (PlayerCharacter)
		{
			FVector PlayerLocation = PlayerCharacter->GetActorLocation();
			FVector NPCLocation = GetActorLocation();
			FVector LaunchDirection = (PlayerLocation - NPCLocation).GetSafeNormal();

			FActorSpawnParameters SpawnParams;
			SpawnParams.Owner = this;
			SpawnParams.Instigator = GetInstigatorController()->GetPawn();


			UE_LOG(LogTemp, Warning, TEXT("FireProjectile: Instigator=%s"), *GetNameSafe(SpawnParams.Instigator));

			AGJBossProjectile* Projectile = GetWorld()->SpawnActor<AGJBossProjectile>(ProjectileClass, NPCLocation, LaunchDirection.Rotation(), SpawnParams);

			if (Projectile)
			{
				UE_LOG(LogTemp, Warning, TEXT("It Spawnned"));
				Projectile->ProjectileComponent->Velocity = LaunchDirection * Projectile->ProjectileComponent->InitialSpeed;
			}
		}
	}
}

 

 발사체가 소환되기 전 플레이어의 위치와 방향을 찾아 소환될 때 그 위치와 방향으로 날아가게 만드는 방법이다. 이렇게 공격이 실행되면 발사체가 날아가게 만들어 주었다. 그리고 애니메이션이 끝나면 이를 소환하는 방식은 AnimNotifyState 를 통해 애니메이션 몽타주에 삽입하였다. 아래가 그 코드와 사진이다.

 

#include "NPC/RageMontageNotifyState.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "BehaviorTree/BehaviorTreeComponent.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "NPC/GJBossAIController.h"
#include "NPC/GJBossNPC.h"
#include "AICharacterBase.h"

void URageMontageNotifyState::NotifyBegin(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation, float TotalDuration)
{
	if (MeshComp && MeshComp->GetOwner())
	{
		if (AAICharacterBase* const character = Cast<AAICharacterBase>(MeshComp->GetOwner()))
		{
			if (AGJBossAIController* const Boss = Cast<AGJBossAIController>(character->GetController()))
			{
				if (UBehaviorTreeComponent* Compo = Cast<UBehaviorTreeComponent>(Boss->GetBrainComponent()))
				{
					Compo->GetBlackboardComponent()->SetValueAsBool(TEXT("IsBossDoRage"), true);
				}
			}
		}
		if (AGJBossNPC* const BossNPC = Cast<AGJBossNPC>(MeshComp->GetOwner()))
		{
			UCharacterMovementComponent* MovementComponent = BossNPC->GetCharacterMovement();
			if (MovementComponent)
			{
				MovementComponent->SetMovementMode(MOVE_None);
			}
		}
	}
}

void URageMontageNotifyState::NotifyEnd(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation)
{
	if (MeshComp && MeshComp->GetOwner())
	{
		if (AAICharacterBase* const character = Cast<AAICharacterBase>(MeshComp->GetOwner()))
		{
			if (AGJBossAIController* const Boss = Cast<AGJBossAIController>(character->GetController()))
			{
				if (UBehaviorTreeComponent* Compo = Cast<UBehaviorTreeComponent>(Boss->GetBrainComponent()))
				{
					Compo->GetBlackboardComponent()->SetValueAsBool(TEXT("IsMeleeRange"), false);
					Compo->GetBlackboardComponent()->SetValueAsBool(TEXT("IsCanRangeAttack"), false);
					Compo->GetBlackboardComponent()->SetValueAsBool(TEXT("IsAttacking"), false);
				}
			}
		}
		if (AGJBossNPC* const BossNPC = Cast<AGJBossNPC>(MeshComp->GetOwner()))
		{
			UCharacterMovementComponent* MovementComponent = BossNPC->GetCharacterMovement();
			if (MovementComponent)
			{
				MovementComponent->SetMovementMode(MOVE_Walking);
			}
		}
	}

}

 

투구가 끝나는 시점에 RangeAnimNotifyState 의 End 에 정의된 FireProjectile 함수가 실행된다.

 코드 중에 SeValueAsBool 과 SetMovementMode 가 존재하는데 이는 원거리 공격중 플레이어를 쫓아가거나 근접공격 하는 불상사를 대비하기 위해 넣어놓은 코드이다.

 


 

 결과적으로는 보스가 플레이어를 잘 쫓아가고 공격하고 피격 당하고 죽게 만드는 로직을 모두 만들었다. C++ 로 만드는게 여간 힘든일이 아니었다 생각하지만 그래도 잘 동작하고 원하는대로 움직여 주어서 정말 다행이다. 이것 말고도 여러 트러블 슈팅이 있었으나 그건 전에 적어놓은 스켈레탈 매시 관련 트러블 슈팅에서 봐 주었으면 좋겠다.