https://hyunjunstar.tistory.com/100
이전 글인 위 링크의 Unreal C++ 싱글플레이 FPS 슈터 게임 팀 프로젝트 적 AI - 3 에서는 Behavior Tree와 Blackboard의 블루프린트 구조를 정리하고, EnemyCharacter 블루프린트를 생성 및 Nav Mesh Bounds Volume를 설정 하였다.
하지만 2, 3 단계 구조에는 첫번째로 TargetActor 갱신이 한번만 실행되는 문제와,
ApplyDamage()를 즉시 호출 하는 문제가 있었다.
이번 글에서는 이 부분을 수정하고,
Montage와 Anim Notify, Anim Graph를 사용하여 더 자연스러운 AI 캐릭터를 만들어 보려고 한다.
EnemyAIController.cpp
SetTimer()를 반복 실행으로 변경하였다.
이제 0.2초마다 플레이어를 감지하고, 플레이어가 감지 범위 안에 있을 때만 Blackboard의 TargetActor를 설정한다.
감지 범위 밖으로 나가면 TargetActor를 제거하도록 수정하였다.
GetWorldTimerManager().SetTimer(
FindPlayerTimerHandle,
this,
&AEnemyAIController::SetTargetPlayer,
0.2f,
true
);
기존에는 마지막 인자가 false였기 때문에 SetTargetPlayer()가 한 번만 실행되었다.
그래서 이번에는 true로 변경해 0.2초마다 반복 실행되도록 했다.
또한 SetTargetPlayer()에서는 플레이어와 적 사이의 거리를 계산한다.
const float DistanceToPlayer = FVector::Dist(
ControlledEnemy->GetActorLocation(),
TargetPlayer->GetActorLocation()
);
그리고 감지 범위 안에 있을때만 TargetActor를 저장한다.
if (DistanceToPlayer <= ControlledEnemy->DetectionRange)
{
BB->SetValueAsObject(TEXT("TargetActor"), TargetPlayer);
}
else
{
BB->ClearValue(TEXT("TargetActor"));
}
이렇게 수정하면 플레이어가 감지 범위 밖으로 나갔을 때 Behavior Tree가 더 이상 추적이나 공격을 하지 않는다.
EnemyCharacter.h
공격 / 사망 애니메이션, 공격 쿨타임, 실제 데미지 적용을 위한 함수들을 추가하였다.
UFUNCTION(BlueprintImplementableEvent, BlueprintCallable, Category = "Enemy|Animation")
void PlayAttackAnimation();
UFUNCTION(BlueprintImplementableEvent, BlueprintCallable, Category = "Enemy|Animation")
void PlayDeathAnimation();
PlayAttackAnimation()과 PlayDeathAnimation()은 C++에서 호출하고,
실제 몽타주 재생은 BP_EnemyCharacter에서 구현하기 위해 BlueprintImplementableEvent로 선언하였다.
공격 쿨타임을 관리하기 위한 함수도 추가하였다.
bool CanAttack() const;
void MarkAttack();
그리고 공격 몽타주의 AttackHit Notify 시점에 실제 데미지를 적용할 함수도 추가하였다.
UFUNCTION(BlueprintCallable, Category = "Enemy|Attack")
void ApplyAttackDamage();
기존에는 BTTask_AttackTarget에서 바로 ApplyDamage()를 호출했지만,
이번에는 ApplyAttackDamage()에서 실제 데미지를 적용하도록 변경하였다.
EnemyCharacter.cpp
공격 쿨타임, RVO 회피, 회전 설정, 실제 데미지 적용, 사망 애니메이션 처리를 추가하였다.
// 공격 쿨타임 초기화
LastAttackTime = -AttackCooldown;
처음부터 바로 공격할 수 있도록 LastAttackTime을 -AttackCooldown으로 초기화 하였다.
// RVO 회피 설정
GetCharacterMovement()->bUseRVOAvoidance = true;
GetCharacterMovement()->AvoidanceConsiderationRadius = 300.f;
AI가 이동할 때 장애물에 막히거나 서로 겹치는 상황을 방지하기 위해 RVO를 활성화 하였다.
// 이동 방향 회전 설정
bUseControllerRotationYaw = false;
GetCharacterMovement()->bOrientRotationToMovement = true;
GetCharacterMovement()->RotationRate = FRotator(0.f, 720.f, 0.f);
AI가 이동할 때 이동 방향을 바라보도록 설정 하였다.
// 공격 쿨타임 처리
bool AEnemyCharacter::CanAttack() const
{
const UWorld* World = GetWorld();
if (!World)
{
return false;
}
return World->GetTimeSeconds() - LastAttackTime >= AttackCooldown;
}
void AEnemyCharacter::MarkAttack()
{
const UWorld* World = GetWorld();
if (!World)
{
return;
}
LastAttackTime = World->GetTimeSeconds();
}
CanAttack()은 마지막 공격 이후 AttackCooldown만큼 시간이 지났는지 계산 하도록 하였고,
MarkAttack()은 공격을 시작한 시간을 LastAttackTime에 저장하도록 하였다.
// 실제 데미지 적용
void AEnemyCharacter::ApplyAttackDamage()
{
if (bIsDead)
{
return;
}
AAIController* AIController = Cast<AAIController>(GetController());
if (!AIController)
{
return;
}
UBlackboardComponent* BlackboardComp = AIController->GetBlackboardComponent();
if (!BlackboardComp)
{
return;
}
AActor* TargetActor = Cast<AActor>(
BlackboardComp->GetValueAsObject(TEXT("TargetActor"))
);
if (!TargetActor)
{
return;
}
const float DistanceToTarget = FVector::Dist(
GetActorLocation(),
TargetActor->GetActorLocation()
);
if (DistanceToTarget > AttackRange + 50.f)
{
return;
}
UGameplayStatics::ApplyDamage(
TargetActor,
AttackDamage,
AIController,
this,
nullptr
);
}
ApplyAttackDamage()는 공격 몽타주의 AttackHit Notify에서 호출 되도록 하였고,
이 함수에서는 Blackboard의 TargetActor를 가져온 뒤, 공격 가능한 거리 안에 있는지 다시 확인한다.
공격 모션이 재생되는 동안 플레이어가 멀리 벗어날 수 있기 때문에 데미지를 적용하기 직전에 거리를 한 번 더 검사하였다.
조건을 만족하면 UGameplayStatics::ApplyDamage()를 호출해 플레이어에게 데미지를 주도록 하였다.
// 사망 처리 변경
GetCharacterMovement()->DisableMovement();
GetCharacterMovement()->StopMovementImmediately();
GetCapsuleComponent()->SetCollisionEnabled(ECollisionEnabled::NoCollision);
PlayDeathAnimation();
SetLifeSpan(3.0f);
적이 죽으면 이동을 비활성화하고 정지 시킨 후,
충돌을 제거한 뒤 PlayDeathAnimation()을 호출해 사망 몽타주를 재생한다.
그리고 사망 애니메이션이 보일 시간을 준 뒤 3초 후 제거하도록 변경하였다.
BTTask_AttackTarget.cpp
이전에는 공격 Task가 실행되면 바로 ApplyDamage()를 호출 하였는데,
이번에는 ApplyDamage()를 제거하고, 공격 몽타주만 재생하도록 변경하였다.
// 공격 쿨타임 확인
if (!Enemy->CanAttack())
{
return EBTNodeResult::Failed;
}
공격 Task가 실행되기 전에 CanAttack()을 호출하여 쿨타임이 끝났는지 확인 하도록 하였다.
// 이동 정지
AIController->StopMovement();
AIController->ClearFocus(EAIFocusPriority::Gameplay);
Enemy->GetCharacterMovement()->StopMovementImmediately();
공격 중에는 이동하지 않도록 이동을 멈추고, CharacterMovement도 정지 시켰다.
// 플레이어 방향으로 회전
FVector Direction = TargetActor->GetActorLocation() - Enemy->GetActorLocation();
Direction.Z = 0.f;
if (!Direction.IsNearlyZero())
{
FRotator LookRotation = Direction.Rotation();
Enemy->SetActorRotation(LookRotation);
AIController->SetControlRotation(LookRotation);
}
공격하기 직전에 플레이어 방향을 바라보도록 회전 시켰다.
// 공격 몽타주 재생
Enemy->PlayAttackAnimation();
Enemy->MarkAttack();
PlayAttackAnimation()을 호출해 BP_EnemyCharacter에서 공격 몽타주를 재생한다.
그리고 MarkAttack()으로 공격 시작 시간을 기록하고,
공격 몽타주의 AttackHit Anim Notify에서 ApplyAttackDamage()가 호출될 때 데미지를 주도록 하였다.
몽타주 원본 파일 우클릭 후 Create AnimMontage 선택해서 몽타주를 생성 하였고,
공격하는 모션이 실행 된 후 실제 타격이 발생하는 지점에 AttackHit Notify를 추가하였고,
이 Notify가 실행되는 시점에 EnemyCharacter의 ApplyAttackDamage()를 호출하여
플레이어에게 데미지를 입히도록 설정하였다.

1. AnimNotify_AttackHit
공격 몽타주에 추가한 AttackHit Notify가 실행될 때 호출된다.
Try Get Pawn Owner로 현재 애니메이션 블루프린트를 사용하는 Pawn을 가져오고,
BP_EnemyCharacter로 Cast한다.
Cast에 성공하면 EnemyCharacter에 구현해둔 ApplyAttackDamage()를 호출한다.
이렇게 해서 공격 모션의 실제 타격 프레임에 플레이어에게 데미지가 들어가도록 만들었다.
2.Speed
Try Get Pawn Owner로 현재 애니메이션을 사용하는 Pawn을 가져오고, Is Valid로 유효한지 확인한다.
유효하면 Get Velocity로 캐릭터의 현재 속도 벡터를 가져온다.
이후 Vector Length XY를 사용해 Z축을 제외한 이동 속도를 계산하고, 그 값을 Speed 변수에 저장한다.
Speed 값 0 = Idle
Speed 값 증가 = Walk / Run

EventGraph에서 계산한 Speed 값을 BS_MM_WalkRun Blend Space에 전달하여 Idle / Walk / Run 애니메이션이 전환되도록 하였다.
그리고 Blend Space 뒤에 Slot 'DefaultSlot'을 연결하였다.
이 Slot을 통해 공격 / 사망 몽타주가 기본 이동 애니메이션 위에 재생될 수 있도록 만들었다.
마지막으로 Slot의 결과를 Output Pose에 연결하여 AI 캐릭터의 최종 애니메이션으로 출력되도록 하였다.

1. Montage Attack
PlayAttackAnimation 이벤트는 C++의 EnemyCharacter에서 호출하는 Blueprint 이벤트다.
이 이벤트가 실행되면 Attack Montages 배열에 들어 있는 공격 몽타주 중 하나를 랜덤으로 선택한다.
먼저 배열의 Length가 0보다 큰지 확인하고,
배열이 비어 있지 않으면 Random Integer in Range로 랜덤 인덱스를 만든다.
그 후 Attack Montages 배열에서 해당 인덱스의 몽타주를 가져오고,
Mesh의 Anim Instance를 Target으로 넘겨 Montage Play를 실행한다.
공격할 때마다 Attack Montages 배열에 등록된 공격 모션 중 하나가 랜덤으로 재생되는 구조로 설계하였다.

2. Montage Damage
Event AnyDamage는 BP_EnemyCharacter가 데미지를 받았을 때 호출된다.
데미지가 정상적으로 들어오는지 확인하기 위해 Print String을 사용하였다.
Damage 값과 Current HP 값을 Append로 합쳐서 "Enemy Damage: / HP:" 형태의 문자열을 만들고,
화면에 출력하도록 설계하였다.

3. Montage Die
PlayDeathAnimation 이벤트는 C++의 EnemyCharacter::Die()에서 호출된다.
적의 HP가 0 이하가 되면 C++ 코드에서 이동과 충돌을 비활성화한 뒤 PlayDeathAnimation()을 호출한다.
BP_EnemyCharacter에서는 이 이벤트를 받아 Mesh의 Anim Instance를 가져오고,
Montage Play로 AM_Enemy_Die 사망 몽타주를 재생한다.

최종 버전에서는 Behavior Tree 구조는 유지하면서,
TargetActor 갱신 방식과 공격 데미지 적용 방식을 개선하였다.
EnemyAIController
- Behavior Tree 실행
- 0.2초마다 플레이어 감지
- 감지 범위 안이면 Blackboard에 TargetActor 설정
- 감지 범위 밖이면 TargetActor 제거
EnemyCharacter
- Behavior Tree asset 소유
- ApplyDamage를 TakeDamage로 받아서 HP를 처리
- 공격 쿨타임 관리
- 공격 / 사망 몽타주 재생 이벤트 제공
- Anim Notify 시점에 실제 데미지 적용
- 사망 시 이동/충돌 비활성화 후 사망 몽타주 재생
- RVO 회피 및 이동 방향 회전 처리
BTDecorator_IsInAttackRange
- TargetActor가 공격 범위 안에 있는지 검사
BTTask_AttackTarget
- 공격 쿨타임 확인
- 이동 정지
- 플레이어 방향으로 회전
- 공격 몽타주 재생
- 직접 데미지를 주지 않음
Montage
- 공격 / 사망 애니메이션을 원하는 시점에 재생하기 위해 사용
- 공격 몽타주에 AttackHit Notify 추가
- Notify 시점에 실제 데미지 적용
ABP_EnemyCharacter
- Speed 값을 계산해 Idle / Walk / Run 전환
- AnimGraph에서 Blend Space와 DefaultSlot 연결
- AttackHit Notify 발생 시 ApplyAttackDamage() 호출
BP_EnemyCharacter
- PlayAttackAnimation 이벤트에서 공격 몽타주 랜덤 재생
- PlayDeathAnimation 이벤트에서 사망 몽타주 재생
- Event AnyDamage로 데미지 / HP 디버그 출력
1. EnemyAIController가 EnemyCharacter를 Possess
2. EnemyCharacter에 설정된 Behavior Tree 실행
3. 0.2초마다 플레이어 Pawn 탐색
4. 감지 범위 안이면 Blackboard에 TargetActor 저장
5. 감지 범위 밖이면 TargetActor 제거
6. Behavior Tree가 TargetActor를 기준으로 이동 / 공격 판단
7. BTDecorator_IsInAttackRange가 공격 거리 검사
8. 공격 범위 안이면 BTTask_AttackTarget 실행
9. BTTask_AttackTarget이 이동을 멈추고 플레이어 방향으로 회전
10. PlayAttackAnimation() 호출
11. BP_EnemyCharacter에서 공격 몽타주 랜덤 재생
12. 공격 몽타주의 AttackHit Notify 발생
13. ABP_EnemyCharacter의 AnimNotify_AttackHit에서 BP_EnemyCharacter로 Cast
14. ApplyAttackDamage() 호출
15. ApplyDamage()로 플레이어에게 실제 데미지 적용
16. 적 사망 시 PlayDeathAnimation() 호출
17. BP_EnemyCharacter에서 사망 몽타주 재생
18. 3초 뒤 액터 제거
이번 버전에서는 SetTargetPlayer()를 0.2초마다 반복 실행하도록 수정하고,
플레이어가 감지 범위 밖으로 나가면 Blackboard의 TargetActor를 제거하도록 변경하였다.
또한 BTTask_AttackTarget에서 직접 ApplyDamage()를 호출하던 구조를 제거하고,
PlayAttackAnimation()만 실행하도록 수정하였다.
실제 데미지는 공격 몽타주의 AttackHit Notify 시점에 ApplyAttackDamage()가 호출되면서 적용된다.
공격 쿨타임은 EnemyCharacter의 CanAttack(), MarkAttack()으로 관리하도록 옮겼고,
공격 시 이동 정지와 플레이어 방향 회전도 추가하였다.
사망 시에는 이동과 충돌을 끄고 PlayDeathAnimation()을 호출한 뒤 3초 후 제거되도록 변경하였다.
결론은 플레이어 감지 흐름이 더 자연스러워졌고, 공격 애니메이션과 실제 데미지 적용 타이밍을 맞출 수 있었다.
| Unreal C++ 싱글플레이 FPS 슈터 게임 팀 프로젝트 적 AI - 최종 (0) | 2026.05.28 |
|---|---|
| Unreal C++ 싱글플레이 FPS 슈터 게임 팀 프로젝트 구조 정리 및 트러블슈팅 (0) | 2026.05.26 |
| Unreal C++ 싱글플레이 FPS 슈터 게임 팀 프로젝트 적 AI - 3 (0) | 2026.05.21 |
| Unreal C++ 싱글플레이 FPS 슈터 게임 팀 프로젝트 적 AI - 2 (0) | 2026.05.20 |
| Unreal C++ 싱글플레이 FPS 슈터 게임 팀 프로젝트 적 AI - 1 (0) | 2026.05.19 |