Unreal - Behavior Tree(3) 플레이어와의 상호작용

2025. 5. 2. 21:18·Unreal5 프로젝트 다이어리

AI가 플레이어를 공격하고 데미지를 입고 미니맵에 적용시켜보도록하겠습니다

 

AI의 Behavior Tree의 구조를 변경하겠습니다

(변경전 BT)

AI의 Patrol은 필요없다고 판단해서 누락시키고  AI가 스폰할때와 죽을때의 처리분기를 추가해주겠습니다

state의 이너머레이터를 OutOfSight, Chase, Attack, Die, Spawn,Damaged으로 수정

 

EnumClass c++ 수정

서비스 타겟이 없을때 수정을 해주었습니다

Dissolve Material을 사용한 AI의 자연스러운 스폰과 죽음처리를 위해 미리 상태분기를 수정시켜주었습니다

(Behavior Tree의 우선순위는 왼쪽,위로 갈수록 우선순위가 높습니다)

 

우선 AI가 공격할수있도록 무기를 들려주었습니다

WeaponSocket을 만들어서 프리뷰메시를 달아준뒤 AI에게 맞춰주었습니다

sword = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("Sword"));
sword->SetupAttachment(GetMesh(), TEXT("WeaponSocket"));

swordCollision = CreateDefaultSubobject<UCapsuleComponent>(TEXT("SwordCollision"));
swordCollision->SetupAttachment(GetMesh(), TEXT("WeaponSocket"));
swordCollision->SetRelativeLocation(FVector(0.0f, 0.0f, 57.0f));
swordCollision->SetWorldScale3D(FVector(0.5f, 1.0f, 1.25f));

플레이어와 상호작용할 스피어 콜리전도 달아주었습니다

적을 공격할때 애니메이션 몽타쥬를 실행시키는 함수도 만들어줍니다

사용할 공격 애니메이션을 리타게팅해주고 애니메이션 몽타쥬로 만들어줍니다

RandRagne로 1,3까지 랜덤으로 attack01 ~ 03 중에 랜덤으로 한개를 실행시킵니다

void ABasicSkeletonEnemy::PlayAttackAnimation()
{
	int32 randomAttack = FMath::RandRange(1, 3);

	switch (randomAttack)
	{
		case 1:
			PlayAnimMontage(attack01);
			break;
		case 2:
			PlayAnimMontage(attack02);
			break;
		case 3:
			PlayAnimMontage(attack03);
			break;
		default:
			break;
	}
}

 

플레이어가 데미지를 받을수있는 데미지받는 함수를 만들어줍니다

void ASwordCharacter::GetDamage(float damage)
{
	playerCurrentHp -= damage;
	playerCurrentHp = FMath::Clamp(playerCurrentHp, 0.0f, playerMaxHp);
}

 

다시 AI에서 필요한 헤더를 추가해줬습니다

#include "Kismet/GameplayStatics.h"

Overlap처리를 추가해줍니다

void ABasicSkeletonEnemy::BeginPlay()
{
	Super::BeginPlay();
	swordCollision->OnComponentBeginOverlap.AddDynamic(this, &ABasicSkeletonEnemy::OnBeginOverlapSwordCollision);
	swordCharacter = Cast<ASwordCharacter>(UGameplayStatics::GetPlayerCharacter(GetWorld(), 0));
}

void ABasicSkeletonEnemy::OnBeginOverlapSwordCollision(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
	if (OtherActor == swordCharacter)
	{
		swordCharacter->GetDamage(200.0f);
	}
}

플레이어를 캐스팅한뒤 오버랩한대상 (OtherActor) 이 swordCharacter 즉 플레이어면

플레이어의 데미지받는 커스텀함수를 콜합니다

 

Attack State를 만들어보겠습니다

 

해당 애니메이션이 재생되고 공격을 담당할 태스크를 생성합니다

USTRUCT()
struct FTaskSkeletonAttackMemory
{
	GENERATED_BODY()

	float structCurrentTime = 0.0f;
	float randomEndTime;
};

UCLASS()
class BLASTERDREAM_API UTask_SkeletonAttack : public UBTTask_BlackboardBase
{
	GENERATED_BODY()
	
public:
	UTask_SkeletonAttack();

	virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& ownerComp, uint8* nodeMemory) override;
	virtual void TickTask(UBehaviorTreeComponent& ownerComp, uint8* nodeMemory, float deltaSeconds) override;

	EAIStateEnum currentState;
	bool onceAttackFlag;
};

사용할 기본 ExecuteTask , TickTask를 선언

사용하고있는 컨트롤러와 사용하고있는 폰인 BasicSkeletaonEnemy 그리고 tick에서 사용할

메모리변수 structCurrentTime선언

UTask_SkeletonAttack::UTask_SkeletonAttack()
{
	NodeName = TEXT("Attack");
	bNotifyTick = true;
}

EBTNodeResult::Type UTask_SkeletonAttack::ExecuteTask(UBehaviorTreeComponent& ownerComp, uint8* nodeMemory)
{
	Super::ExecuteTask(ownerComp, nodeMemory);;
	onceAttackFlag = false;
	
	FTaskSkeletonAttackMemory* taskMemory = reinterpret_cast<FTaskSkeletonAttackMemory*>(nodeMemory);
	taskMemory->structCurrentTime = 0.0f;

	return EBTNodeResult::InProgress;
	
}

void UTask_SkeletonAttack::TickTask(UBehaviorTreeComponent& ownerComp, uint8* nodeMemory, float deltaSeconds)
{
	Super::TickTask(ownerComp, nodeMemory, deltaSeconds);

	FTaskSkeletonAttackMemory* taskMemory = reinterpret_cast<FTaskSkeletonAttackMemory*>(nodeMemory);
	taskMemory->structCurrentTime += deltaSeconds;

	ASkeletonAIController* skeletonController = Cast<ASkeletonAIController>(ownerComp.GetAIOwner());
	
	
	UBlackboardComponent* blackBoard = ownerComp.GetBlackboardComponent();
	ABasicSkeletonEnemy* skeleton = Cast<ABasicSkeletonEnemy>(ownerComp.GetAIOwner()->GetPawn());
	if (skeleton->bDamaged)
	{
		currentState = EAIStateEnum::Damaged;
		blackBoard->SetValueAsEnum("AIState", static_cast<uint8>(currentState));
		FinishLatentTask(ownerComp, EBTNodeResult::Succeeded);

	}

	if (!onceAttackFlag)
	{
		if (taskMemory->structCurrentTime > 0.0f)
		{
			skeleton->PlayAttackAnimation();
			onceAttackFlag = true;
			taskMemory->randomEndTime = FMath::FRandRange(2.0f, 3.5f);
		}
	}
	if (taskMemory->structCurrentTime > taskMemory->randomEndTime)
	{
		onceAttackFlag = false;
		FinishLatentTask(ownerComp, EBTNodeResult::Succeeded);
	}
}

해당 노드가 실행이 되면 ticktask에서 structCurrentTime이 deltasSeconds에 축적이되어 시간이 측정이되며

structCurrentTime 이 0초면 skeleton의 애니메이션실행시키는 함수를 호출

2.0초가 되면 노드가 끝납니다

어색한점이 몇개 보입니다

Dist 조건만 충족하면 허공에 공격해버리는 형상이 일어납니다

이렇게 뒤에 있으면 Dist조건이 충족되어 계속 attack state인데 뒤에있어도 플레이어를 바라보지않습니다

애니메이션이 실행될때  모션워핑을 추가해주겠습니다

모션워핑 적용법은 전글을 참고해주시면 되겠습니다

 

2025.04.12 - [Unreal5 프로젝트 다이어리] - Unreal - 모션워핑(Motion Warping)

 

Unreal - 모션워핑(Motion Warping)

현재 공격하거나 스킬을 사용하면 애니메이션이 나오는데애니메이션 도중 방향을 꺾을수가없다그래서 적이 지나갔는데도 애니메이션은 계속 나오니조작감도 별로고 너무 딱딱한느낌이 든다.

lucodev.tistory.com

 

 

플레이어방향으로 회전시키는 모션워핑 함수를 짜줍니다

void ABasicSkeletonEnemy::MotionWarpingPlayer()
{
	FVector playerLocation = blackboardComp->GetValueAsVector(TEXT("PlayerLocation"));
	if (motionWarpComponent)
	{
		FVector enemyLocation = GetActorLocation(); 
		FVector playerDirection = playerLocation - enemyLocation;
		FRotator playerRotation = playerDirection.Rotation();

		FTransform playerTransform(playerRotation, playerLocation);
		motionWarpComponent->AddOrUpdateWarpTargetFromTransform(TEXT("PlayerTarget"), playerTransform);
	}
}

애니메이션 실행함수에 모션워핑함수를 달아줍니다

모션워핑 노티파이와 워프 타깃을 설정해주었습니다

또한 애니메이션을 inplace 애니메이션에서 root 애니메이션으로 변경해주었습니다

플레이어를 잘 조준합니다

 

지금 칼콜리전에 플레이어가 닿기만 해도 플레이어가 hp가 닳는현상이 있습니다

애니메이션 노티파이로 콜리전의 설정을 온 오프 해주도록 하겠습니다

 

콜리전을 온 오프해주는 함수를 만들어주겠습니다

void ABasicSkeletonEnemy::CollisionOff()
{
	swordCollision->SetCollisionEnabled(ECollisionEnabled::NoCollision);
}

void ABasicSkeletonEnemy::CollisionOn()
{
	swordCollision->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
}

 

애님인스턴스에서 ai를 캐스팅후에

온오프함수를 호출하는 노티파이를 생성해주었습니다

void USkeletonEnemyAnimInstance::NativeInitializeAnimation()
{
	Super::NativeInitializeAnimation();
	skeletonEnemy = Cast<ABasicSkeletonEnemy>(TryGetPawnOwner());
}

void USkeletonEnemyAnimInstance::AnimNotify_CollisionOn()
{
	skeletonEnemy->CollisionOn();
}

void USkeletonEnemyAnimInstance::AnimNotify_CollisionOff()
{
	skeletonEnemy->CollisionOff();
}

노티파이를 찍어줍니다

 

그리고 오버랩이벤트가 한번만 발생할수있도록 해주겠습니다

BHit 플래그 변수로 한번만 맞는 오버랩 처리를 해주었습니다

 

캐릭터의 메시는 반드시 블록 설정 Pawn 그리고 quert and physics 채널설정이 되어있어야합니다

 

--코드수정--

SwordActor의 BoxTraceSingle을 생성하는 주기가 오버랩될때의 아주 잠깐밖에없어서

조건체크가 어색했습니다 tick에 옮겨주었습니다

void ASwordActor::OnBeginOverlapSwordCollision(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
	if (!bSwordAttackCollisionActivate || !OtherActor || ignoreActors.Contains(OtherActor))
	{
		return;
	}

	if (IHitInterface* hitInterface = Cast<IHitInterface>(OtherActor))
	{
		FVector hitLocation = SweepResult.ImpactPoint;
		float damage = 0.f;
		if (swordCharacter->skillName == "Q")
		{
			damage = statGI->qAttackDamage + FMath::RandRange(5, 20);
		}
		else if (swordCharacter->skillName == "QF")
		{
			damage = statGI->qAttackDamage + FMath::RandRange(5, 20);
		}
		else if (swordCharacter->skillName == "W")
		{
			damage = statGI->wAttackDamage + FMath::RandRange(5, 20);
		}
		else if (swordCharacter->skillName == "WF")
		{
			damage = statGI->wAttackDamage + FMath::RandRange(5, 20) + 50;
		}
		else if (swordCharacter->skillName == "E")
		{
			damage = statGI->eAttackDamage + FMath::RandRange(5, 20);
		}
		else if (swordCharacter->skillName == "EF")
		{
			damage = statGI->eAttackDamage + FMath::RandRange(5, 20) + 50;
		}
		else if (swordCharacter->skillName == "R")
		{
			damage = statGI->rAttackDamage + FMath::RandRange(5, 20);
		}
		else if (swordCharacter->skillName == "RF")
		{
			damage = statGI->rAttackDamage + FMath::RandRange(5, 20);
		}
		else if (swordCharacter->skillName == "Normal")
		{
			damage = statGI->normalAttackDamage + FMath::RandRange(5, 20);
		}
		else if (swordCharacter->skillName == "NormalF")
		{
			damage = statGI->normalAttackDamage + FMath::RandRange(5, 20) + 30;
		}

		if (OtherActor->GetName().Contains("Crow"))
		{
			crow = Cast<AHitTestCrow>(OtherActor);
			if(crow)
			{
				crow->ShowHud(damage);
			}
		}
		else if (OtherActor->GetName().Contains("BasicSkeleton"))
		{
			skeleton = Cast<ABasicSkeletonEnemy>(OtherActor);
			if (skeleton)
			{
				skeleton->ShowHud(damage);
			}
		}
		hitInterface->GetHit(hitLocation);
	}
	ignoreActors.AddUnique(OtherActor);
}

오버랩처리의 코드를 깔끔하게 정리해주었습니다

또한 skeleton도 코드를 추가해주었습니다

 

나머지 광역공격 폭발공격도 skeleton의 조건을 추가해주었습니다

 

서로다른 객체를 때릴때 다른객체의 ui가 겹치는 현상이 일어납니다

iterator로 객체를 찾아 없애줍니다

for (TActorIterator<AHitTestCrow> it(GetWorld()); it; ++it)
{
	AHitTestCrow* crows = *it;
	if (crows)
	{
		if (crows->enemyUIDisplayWidgetInstance && ::IsValid(crows->enemyUIDisplayWidgetInstance))
		{
			if (crows->enemyUIDisplayWidgetInstance->IsInViewport())
			{
				crows->enemyUIDisplayWidgetInstance->RemoveFromParent();
				crows->enemyUIDisplayWidgetInstance = nullptr;
				crows->currentWidgetOwner = nullptr;
			}
		}
	}
}

 

ui를 띄우는 전체 코드

void ABasicSkeletonEnemy::SkeletonBeAttacked()
{
	if (enemyUIDisplayWidgetInstance && ::IsValid(enemyUIDisplayWidgetInstance))
	{
		if (enemyUIDisplayWidgetInstance->IsInViewport())
		{
			enemyUIDisplayWidgetInstance->RemoveFromParent();
		}
	}
	
	currentWidgetOwner = nullptr;
	GetWorld()->GetTimerManager().ClearTimer(th_HideUIAfterDelay);

	enemyUIDisplayWidgetInstance = CreateWidget<UEnemyUIDisplayWidget>(GetWorld(), enemyDisplayWidget);

	if (IsValid(enemyUIDisplayWidgetInstance))
	{
		enemyUIDisplayWidgetInstance->AddToViewport();

		GetWorld()->GetTimerManager().SetTimerForNextTick([this]()
			{
				if (IsValid(enemyUIDisplayWidgetInstance))
				{
					enemyUIDisplayWidgetInstance->updateHpBar(monsterName, currentHp, maxHp);
				}
			});
	}
	currentWidgetOwner = this;
	GetWorld()->GetTimerManager().SetTimer(th_HideUIAfterDelay, [this]()
		{
			if (this == currentWidgetOwner && enemyUIDisplayWidgetInstance && enemyUIDisplayWidgetInstance->IsInViewport())
			{
				enemyUIDisplayWidgetInstance->RemoveFromParent();
				enemyUIDisplayWidgetInstance = nullptr;
				currentWidgetOwner = nullptr;
			}
		}, 3.0f, false);

	for (TActorIterator<AHitTestCrow> it(GetWorld()); it; ++it)
	{
		AHitTestCrow* crows = *it;
		if (crows)
		{
			if (crows->enemyUIDisplayWidgetInstance && ::IsValid(crows->enemyUIDisplayWidgetInstance))
			{
				if (crows->enemyUIDisplayWidgetInstance->IsInViewport())
				{
					crows->enemyUIDisplayWidgetInstance->RemoveFromParent();
					crows->enemyUIDisplayWidgetInstance = nullptr;
					crows->currentWidgetOwner = nullptr;
				}
			}
		}
	}
}

 

ui도 뜨는것을 확인할수있습니다

이제 state의 Damaged 즉 데미지를 받았을때의 처리를 해보겠습니다

플레이어를 처다보면서 뒤로 밀리면 됩니다

블랙보드에 넣을 blackboardbase c++클래스를 추가하고 코드를 추가해주겠습니다

ai가 데미지를 받는곳에 bDamaged = true; 를 추가

일단 서비스노드에서 AI가 데미지를 받으면 Damaged 스테이트로 넘기는 조건부터 추가하겠습니다

if (skeleton->bDamaged)
{
	currentState = EAIStateEnum::Damaged;
	blackBoard->SetValueAsEnum("AIState", static_cast<uint8>(currentState));
}

 

데미지를 받는 태스크의 코드입니다

DamagedMontagePlay를 통해 데미지를 받는 몽타쥬를 플레이시키고

모션워핑으로 플레이어를 처다보면서 뒤로 밀립니다

캐릭터가 받은 데미지가 여러번일수도있기떄문에 그 횟수를 카운팅하기위한 횟수카운팅 전용 변수

PendingDamageCount변수를 선언하고 카운팅도 해줍니다

 

데미지를 받는곳에서 누적시켜줍니다

 

UTask_SkeletonBeDamaged::UTask_SkeletonBeDamaged()
{
	NodeName = TEXT("BeDamaged");
	bNotifyTick = true;
}

EBTNodeResult::Type UTask_SkeletonBeDamaged::ExecuteTask(UBehaviorTreeComponent& ownerComp, uint8* nodeMemory)
{
	Super::ExecuteTask(ownerComp, nodeMemory);
	ASkeletonAIController* skeletonController = Cast<ASkeletonAIController>(ownerComp.GetAIOwner());
	skeleton = Cast<ABasicSkeletonEnemy>(ownerComp.GetAIOwner()->GetPawn());
	FTaskSkeletonBeDamageMemory* taskMemory = reinterpret_cast<FTaskSkeletonBeDamageMemory*>(nodeMemory);
	taskMemory->structCurrentTime = 0.0f;
	return EBTNodeResult::InProgress;
	
}

void UTask_SkeletonBeDamaged::TickTask(UBehaviorTreeComponent& ownerComp, uint8* nodeMemory, float deltaSeconds)
{
	Super::TickTask(ownerComp, nodeMemory, deltaSeconds);
	currentTime += deltaSeconds;
	FTaskSkeletonBeDamageMemory* taskMemory = reinterpret_cast<FTaskSkeletonBeDamageMemory*>(nodeMemory);
	taskMemory->structCurrentTime += deltaSeconds;
	UBlackboardComponent* blackBoard = ownerComp.GetBlackboardComponent();
	if (skeleton->bDamaged)
	{
		currentTime = 0.0f;  
		onceAttackFlag = false;
	
		
	}
	if (skeleton->currentHp <= 0.0f)
	{
		FinishLatentTask(ownerComp, EBTNodeResult::Succeeded);
	}
	if (skeleton->pendingDamageCount > 0 && !onceAttackFlag)
	{
		skeleton->MotionWarpingPlayer();
		skeleton->DamagedMontagePlay();
		onceAttackFlag = true;
		skeleton->pendingDamageCount--; 
		if (skeleton->pendingDamageCount == 0)
			skeleton->bDamaged = false;
	}
	if (taskMemory->structCurrentTime > 0.9f)
	{
		onceAttackFlag = false;
		FinishLatentTask(ownerComp, EBTNodeResult::Succeeded);
	}
}

 

 

데미지를 받으면 신호가 Damaged 로 넘어가고 ai가 데미지를 받아 뒤로 밀립니다

미니맵에 표시될 Paper Sprite도 달아주었습니다

미니맵에 표시가 잘 됩니다

 

결과물

 

 

--2025.05.09 추가--

AI가 여러마리면 공격주기가 이상해지는 오류가있습니다

2025.05.09 - [Unreal 디버깅] - BehaviorTree의 currentTime 디버깅

 

BehaviorTree의 currentTime 디버깅

언리얼엔진에서 만약 BehaviorTree AI를 사용했을때그냥 currentTime을 선언한후 DeltaSeconds에 누적하여 그 currentTime에 따라 task의 실행을 좌우했엇다 하지만 이런경우 AI가 여러마리인 경우 CurrentTime의

lucodev.tistory.com

이글을 참고해주세요

Struct로 Memory안의 변수를 선언해서 사용하면해결됩니다

'Unreal5 프로젝트 다이어리' 카테고리의 다른 글

Unreal - Dissolve Material  (0) 2025.05.08
Unreal - Behavior Tree(4) AI Perception Team ID  (1) 2025.05.04
Unreal - Behavior Tree(2) 플레이어 따라가기  (0) 2025.04.29
Unreal - BehaviorTree(1) AI Perception  (0) 2025.04.27
Unreal - 8방향 블렌드스페이스(BlendSpace)  (0) 2025.04.27
'Unreal5 프로젝트 다이어리' 카테고리의 다른 글
  • Unreal - Dissolve Material
  • Unreal - Behavior Tree(4) AI Perception Team ID
  • Unreal - Behavior Tree(2) 플레이어 따라가기
  • Unreal - BehaviorTree(1) AI Perception
lucodev
lucodev
커피와 노트북 그리고 개발
  • lucodev
    루코 개발테이블
    lucodev
  • 전체
    오늘
    어제
    • 분류 전체보기 (125) N
      • Unreal5 프로젝트 다이어리 (73)
      • Unreal5 프로젝트 다이어리2 (5) N
      • Unreal 팁 (8)
      • Unreal 디버깅 (8)
      • C++ 프로그래머스 다이어리 (23) N
        • Stack (3)
        • Hash (4)
        • Heap (2)
        • Sort (3) N
      • 코드 개인보관함 (8)
  • 인기 글

  • 최근 글

  • 최근 댓글

  • 링크

  • 공지사항

  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 태그

    unreal look at
    언리얼 로딩창
    unreal 컷씬
    언리얼 페이드 아웃
    unreal 시퀀스
    unreal 모션매칭
    unreal loading
    언리얼 look at
    unreal 로딩
    언리얼 시퀀스
    언리얼 모션매칭
    언리얼 behavior tree
    언리얼 로딩
    언리얼 behaviortree
    언리얼 컷씬
    언리얼 foot step
    언리얼
    unreal sequence
    언리얼 motionmatching
    언리얼 비헤이비어트리
  • hELLO· Designed By정상우.v4.10.3
lucodev
Unreal - Behavior Tree(3) 플레이어와의 상호작용
상단으로

티스토리툴바