AI의 공격을 구현할 때, 현재 공격하는 중인지 아닌지를 AI 객체와 Animation과 Behavior Tree가 모두 타이밍에 맞게 연동되게 하는 것이 어려웠었다.
처음에 공격 애니메이션을 Animation Blueprint에서 State로 전환해서 Animation Sequence를 재생하게 하였는데 애니메이션이 끝나는 타이밍을 AI 객체와 BT에 정확하게 전달하기 위해 공격 애니메이션을 몽타주로 변환하여 C++ 클래스 내부에서 몽타주를 재생하는 것으로 해결하였다.
📌 Animation Blueprint
ABP에서는 기본 Idle State와 이동시 Walk/Run State만 전환되도록 설정해놓았고, 이후 공격 애니메이션은 C++클래스 내부에서 몽타주를 재생하는 방식으로 구현.
📌 BT_Combat Sequence
AI가 플레이어를 인식하면 현재 Player 위치가 Blackboard에 저장되고 플레이어 위치로 이동한 후, AI의 공격 사거리에 들어왔다면 공격하도록 구현하였다.
AI가 너무 플레이어를 정확하게 공격하는 문제가 있어서 공격 하기 직전에 Focus를 잠시 Clear 해주고, 공격이 끝난 직후에 바로 다시 Focus를 Target에 고정시켜주었다. 이후 각 AI마다 AttackDelay만큼 기다렸다가 다시 플레이어에게 이동하여 공격하는 흐름이 반복된다.
📌 기본 Enemy 클래스 구현
✅ BaseEnemy.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "BaseEnemy.generated.h"
DECLARE_MULTICAST_DELEGATE(FOnSkillMontageEnded);
UCLASS()
class GUNFIREPARAGON_API ABaseEnemy : public ACharacter
{
GENERATED_BODY()
public:
ABaseEnemy();
virtual void BeginPlay() override;
virtual void Attack(const FVector& TargetLocation);
UFUNCTION(BlueprintCallable, Category = "Combat")
virtual void StartAttack();
UFUNCTION(BlueprintCallable, Category = "Combat")
virtual void EndAttack();
void UpdateAimPitch();
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Combat")
float Damage;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Combat")
float AttackRange;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Combat")
float AttackDelay;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Combat")
float CurrentHealth;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Combat")
float MaxHealth;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Combat")
float BaseWalkSpeed;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Combat")
float EXP;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Combat")
bool bIsAttacking;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Combat")
bool bIsUsingSkill;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "State")
bool bIsDead;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Anim")
float AimPitch;
FOnSkillMontageEnded OnSkillMontageEnded;
protected:
virtual float TakeDamage(
float DamageAmount,
FDamageEvent const& DamageEvent,
AController* EventInstigator,
AActor* DamageCauser) override;
virtual void PerformMeleeAttack(const FVector& TargetLocation) PURE_VIRTUAL(ABaseEnemy::PerformMeleeAttack, );
virtual void PerformRangeAttack(const FVector& TargetLocation) PURE_VIRTUAL(ABaseEnemy::PerformRangeAttack, );
virtual void SetDeathState();
UFUNCTION()
void OnAttackMontageEnded(UAnimMontage* Montage, bool bInterrupted);
UPROPERTY(EditAnywhere, Category = "Effect")
USoundBase* DeathSound;
void OnDeath();
TArray<AActor*> AlreadyHitActors;
};
✅ BaseEnemy.cpp
ABaseEnemy::ABaseEnemy()
{
AIControllerClass = ABaseEnemyAIController::StaticClass();
AutoPossessAI = EAutoPossessAI::PlacedInWorldOrSpawned;
USkeletalMeshComponent* SkeletalMesh = GetMesh();
if (SkeletalMesh)
{
SkeletalMesh->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
SkeletalMesh->SetCollisionObjectType(ECollisionChannel::ECC_GameTraceChannel2);
}
Damage = 20.0f;
AttackRange = 200.0f;
AttackDelay = 2.0f;
MaxHealth = 100.0f;
BaseWalkSpeed = 300.0f;
CurrentHealth = MaxHealth;
bIsAttacking = false;
bIsUsingSkill = false;
bIsDead = false;
Tags.Add("Monster");
GetCharacterMovement()->MaxWalkSpeed = BaseWalkSpeed;
GetCharacterMovement()->bUseControllerDesiredRotation = true;
GetCharacterMovement()->bOrientRotationToMovement = false;
EnemyName = "BaseMonster";
}
void ABaseEnemy::BeginPlay()
{
Super::BeginPlay();
//OnTargetName.Broadcast(GetClass()->GetName());
OnTargetName.Broadcast(EnemyName);
OnTargetHPChanged.Broadcast(CurrentHealth, MaxHealth);
}
void ABaseEnemy::Attack(const FVector& TargetLocation)
{
}
void ABaseEnemy::StartAttack()
{
if (bIsAttacking) return;
bIsAttacking = true;
}
void ABaseEnemy::EndAttack()
{
if (!bIsAttacking) return;
bIsAttacking = false;
AlreadyHitActors.Empty();
UE_LOG(LogTemp, Warning, TEXT("EndAttack Called: %s"), *GetName());
}
void ABaseEnemy::UpdateAimPitch()
{
if (!Controller) return;
AActor* Player = UGameplayStatics::GetPlayerPawn(GetWorld(), 0);
if (!Player) return;
FVector Start = GetMesh()->GetSocketLocation("Muzzle_Front");
FVector Target;
USkeletalMeshComponent* PlayerMesh = Cast<USkeletalMeshComponent>(Player->GetComponentByClass(USkeletalMeshComponent::StaticClass()));
if (PlayerMesh)
{
Target = PlayerMesh->GetSocketLocation("upperarm_r");
}
FRotator LookAtRotation = UKismetMathLibrary::FindLookAtRotation(Start, Target);
AimPitch = FMath::Clamp(LookAtRotation.Pitch, -60.0f, 60.0f);
}
float ABaseEnemy::TakeDamage
(
float DamageAmount,
FDamageEvent const& DamageEvent,
AController* EventInstigator,
AActor* DamageCauser
)
{
float ActualDamage = Super::TakeDamage(DamageAmount, DamageEvent, EventInstigator, DamageCauser);
if (const FPointDamageEvent* PointDamageEvent = (DamageEvent.GetTypeID() == FPointDamageEvent::ClassID) ?
static_cast<const FPointDamageEvent*>(&DamageEvent) : nullptr)
{
FName BoneName = PointDamageEvent->HitInfo.BoneName;
if (BoneName == "head")
{
ActualDamage *= 2.0f;
}
if (DamageCauser && GetController())
{
UAISense_Damage::ReportDamageEvent
(
GetWorld(),
this,
DamageCauser,
ActualDamage,
GetActorLocation(),
PointDamageEvent->HitInfo.ImpactPoint
);
}
}
CurrentHealth = FMath::Clamp(CurrentHealth - ActualDamage, 0.0f, MaxHealth);
if (ActualDamage > 0.0f && !bIsDead && HitReactionMontage)
{
PlayHitReaction();
}
if (CurrentHealth <= 0.0f)
{
OnDeath();
}
OnTargetHPChanged.Broadcast(CurrentHealth, MaxHealth);
return ActualDamage;
}
void ABaseEnemy::OnDeath()
{
if (!bIsDead)
{
bIsDead = true;
}
if (DeathSound)
{
UGameplayStatics::PlaySoundAtLocation(this, DeathSound, GetActorLocation());
}
}
void ABaseEnemy::OnAttackMontageEnded(UAnimMontage* Montage, bool bInterrupted)
{
EndAttack();
if (OnSkillMontageEnded.IsBound())
{
OnSkillMontageEnded.Broadcast();
OnSkillMontageEnded.Clear();
}
}
모든 AI 적 클래스들의 부모 클래스이다.
기본적인 공격과 피격시 데미지처리, 죽음 처리들을 구현했고 몽타주가 끝난 시점에 호출할 델리게이트를 구현해주었다. 이 델리게이트로 BT의 Default Attack이나 다른 공격하는 Task들에게 공격이 끝났으니 Task를 종료하라고 전달할 것이다.
📌 Default Attack 테스크
✅ BTT_DefaultAttack.h
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTTaskNode.h"
#include "BTT_DefaultAttack.generated.h"
UCLASS()
class GUNFIREPARAGON_API UBTT_DefaultAttack : public UBTTaskNode
{
GENERATED_BODY()
public:
UBTT_DefaultAttack();
protected:
virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
UFUNCTION()
void OnMontageEnded(UBehaviorTreeComponent* OwnerComp);
};
✅ BTT_DefaultAttack.cpp
#include "AI/BTT_DefaultAttack.h"
#include "AI/BaseEnemy.h"
#include "AIController.h"
#include "BehaviorTree/BlackboardComponent.h"
UBTT_DefaultAttack::UBTT_DefaultAttack()
{
NodeName = TEXT("DefaultAttack");
}
EBTNodeResult::Type UBTT_DefaultAttack::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
Super::ExecuteTask(OwnerComp, NodeMemory);
AAIController* AIController = OwnerComp.GetAIOwner();
if (!AIController) return EBTNodeResult::Failed;
ABaseEnemy* Enemy = Cast<ABaseEnemy>(AIController->GetPawn());
if (!Enemy) return EBTNodeResult::Failed;
if (Enemy->bIsAttacking)
{
return EBTNodeResult::Failed;
}
UBlackboardComponent* BBComp = OwnerComp.GetBlackboardComponent();
if (!BBComp) return EBTNodeResult::Failed;
FVector LastKnownLocation = BBComp->GetValueAsVector("LastKnownPlayerLocation");
Enemy->Attack(LastKnownLocation);
OwnerComp.GetBlackboardComponent()->SetValueAsBool("IsAttacking", true);
Enemy->OnSkillMontageEnded.RemoveAll(this);
Enemy->OnSkillMontageEnded.AddUObject(this, &UBTT_DefaultAttack::OnMontageEnded, &OwnerComp);
return EBTNodeResult::InProgress;
}
void UBTT_DefaultAttack::OnMontageEnded(UBehaviorTreeComponent* OwnerComp)
{
if (OwnerComp)
{
OwnerComp->GetBlackboardComponent()->SetValueAsBool("IsAttacking", false);
FinishLatentTask(*OwnerComp, EBTNodeResult::Succeeded);
}
}
위에서 공격 사거리 범위 안에 있으면 이 Default Attack 테스크가 실행된다고 언급했었다.
Blackboard에서 FocusClear 된 시점의 플레이어 위치를 가져와 ABaseEnemy의 파생클래스의 Attack 함수를 호출하고,
EBTNodeResult::Inprogress를 return하여 테스크 대기 상태로 둔다.
이때 델리게이트에 OnMontageEnd 함수를 바인딩해두고 애니메이션 몽타주가 종료되면 이 함수가 호출되도록 해준다.
OnMontageEnd에서 FinishLatentTask 함수를 호출해 테스크를 성공적으로 종료시킨다.
📌 Normal Enemy 공격 흐름
✅ NormalRangeEnemy.h
#pragma once
#include "CoreMinimal.h"
#include "BaseEnemy.h"
#include "NormalRangeEnemy.generated.h"
UCLASS()
class GUNFIREPARAGON_API ANormalRangeEnemy : public ABaseEnemy
{
GENERATED_BODY()
public:
ANormalRangeEnemy();
virtual void Attack(const FVector& TargetLocation) override;
virtual void PerformRangeAttack(const FVector& TargetLocation) override;
protected:
virtual void PerformMeleeAttack(const FVector& TargetLocation) override;
void PlayAttackEffect(const FVector& Start, const FVector& Direction);
UPROPERTY(EditDefaultsOnly, Category = "Animation")
UAnimMontage* RangeAttackMontage;
UPROPERTY(EditAnywhere, Category = "Effects")
UParticleSystem* MuzzleFlashEffect;
UPROPERTY(EditAnywhere, Category = "Effects")
UParticleSystem* BulletEffect;
};
✅ NormalRangeEnemy.cpp
#include "AI/NormalRangeEnemy.h"
#include "AI/BaseEnemyAIController.h"
#include "Kismet/GameplayStatics.h"
#include "GameFramework/CharacterMovementComponent.h"
ANormalRangeEnemy::ANormalRangeEnemy()
{
AIControllerClass = ABaseEnemyAIController::StaticClass();
Damage = 20.0f;
AttackRange = 900.0f;
AttackDelay = 1.0f;
MaxHealth = 150.0f;
BaseWalkSpeed = 700.0f;
EXP = 10.0f;
CurrentHealth = MaxHealth;
GetCharacterMovement()->MaxWalkSpeed = BaseWalkSpeed;
EnemyName = "MinionRange";
}
void ANormalRangeEnemy::Attack(const FVector& TargetLocation)
{
if (!bIsAttacking && !bIsDead)
{
if (RangeAttackMontage)
{
UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance();
if (AnimInstance)
{
AnimInstance->Montage_Play(RangeAttackMontage);
FOnMontageEnded MontageEndDelegate;
MontageEndDelegate.BindUObject(this, &ANormalRangeEnemy::OnAttackMontageEnded);
AnimInstance->Montage_SetEndDelegate(MontageEndDelegate, RangeAttackMontage);
}
}
StartAttack();
}
}
void ANormalRangeEnemy::PerformRangeAttack(const FVector& TargetLocation)
{
if (!GetMesh() || !bIsAttacking || bIsDead) return;
UpdateAimPitch();
FVector Start = GetMesh()->GetSocketLocation("Muzzle_Front");
FVector ToTarget = (TargetLocation - Start).GetSafeNormal();
FVector ForwardDirection = GetActorForwardVector();
FRotator AdjustedRotation = ForwardDirection.Rotation();
AdjustedRotation.Pitch += AimPitch;
AdjustedRotation.Yaw -= 2.0f;
FVector AdjustedDirection = AdjustedRotation.Vector();
AdjustedDirection.Normalize();
float CapsuleRadius = 9.0f;
float CapsuleHalfHeight = AttackRange * 0.5f;
FVector CapsuleCenter = Start + (AdjustedDirection * CapsuleHalfHeight);
FQuat CapsuleRotation = FQuat::FindBetweenVectors(FVector::UpVector, AdjustedDirection);
FHitResult HitResult;
FCollisionQueryParams QueryParams;
QueryParams.AddIgnoredActor(this);
bool bHit = GetWorld()->SweepSingleByChannel(
HitResult,
CapsuleCenter,
CapsuleCenter + (AdjustedDirection * AttackRange),
CapsuleRotation,
ECC_Pawn,
FCollisionShape::MakeCapsule(CapsuleRadius, CapsuleHalfHeight),
QueryParams
);
if (bHit)
{
AActor* HitActor = HitResult.GetActor();
if (HitActor && HitActor->ActorHasTag("Player"))
{
GEngine->AddOnScreenDebugMessage(2, 2.0f, FColor::Blue, FString::Printf(TEXT("Range Monster Attack Hit Damage %f"), Damage));
UGameplayStatics::ApplyDamage(HitActor, Damage, GetController(), this, UDamageType::StaticClass());
}
}
}
void ANormalRangeEnemy::PerformMeleeAttack(const FVector& TargetLocation)
{
return;
}
Default Attack 테스크에서 Attack 함수를 호출하면 현재 이 객체에 저장되어있는 몽타주를 플레이 하게 된다.
몽타주에 AnimNotify를 설정해 PerformRangeAttack을 호출시킨다.
즉 Attack 함수는 애니메이션 몽타주를 실행할 뿐이고, 애니메이션 흐름에 맞춰 AnimNotify를 호출해 실질적인 공격 로직인 PerformRangeAttack을 호출하는 것이다.
*AnimNotify_NormalRange
더보기
✅ AnimNotify_NormalRange.h
#pragma once
#include "CoreMinimal.h"
#include "Animation/AnimNotifies/AnimNotify.h"
#include "AnimNotify_NormalRange.generated.h"
UCLASS()
class GUNFIREPARAGON_API UAnimNotify_NormalRange : public UAnimNotify
{
GENERATED_BODY()
public:
virtual void Notify(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation) override;
};
✅ AnimNotify_NormalRange.h
#include "AI/AnimNotify_NormalRange.h"
#include "AI/NormalRangeEnemy.h"
void UAnimNotify_NormalRange::Notify(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation)
{
if (!MeshComp) return;
ANormalRangeEnemy* Enemy = Cast<ANormalRangeEnemy>(MeshComp->GetOwner());
if (!Enemy) return;
Enemy->PerformRangeAttack(FVector::ZeroVector);
}
🔔 정리
글로 정리하려니 복잡한 과정이 다 담기지 않는 것 같다.
정리하면 BT에서 DefaultAttack 테스크가 실행되면 Enemy의 Attack 함수를 호출하고 Attack 함수에서 애니메이션 몽타주를 플레이 해준다. 몽타주가 재생되면서 미리 설정한 AnimNotify에서 실질적인 공격을 수행하는 PerformAttack 함수를 호출하게 된다.
이렇게 하는 이유는 공격이 끝나는 시점 자체를 공격 애니메이션 몽타주가 끝난 시점으로 설정할 수 있고, 몽타주의 특정 시점에 정확하게 공격을 수행할 수 있기 때문이다.
'UE_FPS 슈터 게임 팀프로젝트 > 일반 AI' 카테고리의 다른 글
AIController와 BT 설계 (0) | 2025.03.06 |
---|---|
일반 AI 공격 Test/ UWorld::SweepSingleByChannel (0) | 2025.02.20 |
AIController, Behavior Tree 초기 Test (0) | 2025.02.19 |