Today I Learned | Unreal Engine 5 | StateTree | GAS | AI Movement | AnimNotifyState
오늘은 보스 몬스터와 일반 몬스터의 공격 상태에서 발생하던 이동, 충돌, StateTree 문제를 집중적으로 디버깅했다.
처음에는 보스 공격이 정상적으로 실행되지 않았다. 이후 공격 몽타주가 재생되도록 고쳤지만, 공격 중 플레이어 쪽으로 미끄러지듯 이동하는 문제가 남았다. 일반 몬스터 쪽에서는 Enemy Attack! 로그 직후 End Attack!이 바로 출력되거나, 플레이어 주변을 빙빙 돌기만 하는 문제가 발생했다.
핵심 원인은 Attack State 안에서 StateTreeMoveToTask와 StateTreeTask_EnemyAttack이 동시에 이동을 제어하고 있었던 점이었다.
기존 구조는 다음과 같았다.
Attack State
├─ StateTreeTask_EnemyAttack
└─ StateTreeMoveToTask
StateTreeTask_EnemyAttack 안에서 공격 전 접근 이동을 처리하도록 수정했는데, 에셋 쪽 Attack State에 기존 MoveTo Task가 남아 있었다. 이 때문에 공격 Task가 StopMovement()를 호출해도 MoveTo Task가 다시 이동을 시도했고, 공격 중 미끄러짐 또는 공격 즉시 종료가 발생했다.
해결은 ST_Monster_Normal과 ST_Monster_Boss의 Attack State에서 MoveTo Task를 제거하는 것이었다.
Attack State
└─ StateTreeTask_EnemyAttack
이후 일반 몬스터의 공격 즉시 종료 문제는 정상화되었다.
이 과정에서 단순히 MoveTo Task를 제거하는 것만으로는 부족했다. Attack State에 이동 Task가 사라지면 공격 가능 거리까지 접근하는 로직도 같이 사라지기 때문이다. 그래서 StateTreeTask_EnemyAttack 내부에 접근 이동 로직을 직접 넣었다.
EnemyAttack Task
→ 공격 전: ChaseLocation으로 MoveToLocation 요청
→ 공격 시작 직전: StopMovement / AbortMove / StopMovementImmediately
→ 공격 시작 후: 이동 요청 중단, 공격 종료 감시
즉 이동 책임을 다음처럼 옮긴 셈이다.
| 이전 구조 | 변경 후 구조 |
|---|---|
StateTree MoveTo Task가 접근 담당 | EnemyAttack Task가 접근 담당 |
EnemyAttack Task는 공격 실행만 담당 | EnemyAttack Task가 접근/정지/공격 실행을 순차적으로 담당 |
| MoveTo와 공격 Task가 병렬 실행 | 공격 Task 안에서 이동과 공격 순서를 직접 제어 |
이 변경을 통해 StateTree의 병렬 Task 충돌을 줄이고, 공격 시작 시점에 이동을 확실히 끊을 수 있게 되었다.
MoveTo Task를 제거한 뒤에도 보스가 공격 중 미끄러지는 문제가 남아 있었다.
처음에는 ChaseLocation이 공격 중 계속 갱신되는 것이 원인이라고 판단했다. 그래서 EnemyCombatComponent에 이동 락 플래그를 추가하고, ANS_EnemyMovementLock을 만들어 몽타주 구간에서 ChaseLocation 갱신을 막았다.
const bool bFreezeChaseLocation =
InstanceData.CachedCombatComponent.IsValid()
&& InstanceData.CachedCombatComponent->IsMovementLockedByAttack();
하지만 로그를 확인해 보니 ANS_EnemyMovementLock은 정상 실행되고 있었다.
[EnemyMovementLock] Begin Owner=BP_Guardian_Wind_C_4 Combat=EnemyCombatComponent
[EnemyMovementLock] End Owner=BP_Guardian_Wind_C_4 Combat=EnemyCombatComponent
즉 NotifyState가 실행되지 않는 문제가 아니라, 이동 락이 실제 이동을 막을 만큼 강하지 않은 것이 문제였다.
최종적으로 SetMovementLockedByAttack(true)에서 기존 MaxWalkSpeed를 저장하고, MaxWalkSpeed = 0으로 만든 뒤 해제 시 원래 값으로 복구하도록 수정했다.
if (bLocked)
{
MovementLockOriginalMaxWalkSpeed = MoveComp->MaxWalkSpeed;
MoveComp->StopMovementImmediately();
MoveComp->MaxWalkSpeed = 0.f;
}
else
{
MoveComp->MaxWalkSpeed = MovementLockOriginalMaxWalkSpeed;
MovementLockOriginalMaxWalkSpeed = -1.f;
}
이후 공격 중 미끄러짐 문제가 해결되었다.
처음에는 ANS_EnemyMovementLock에서 직접 AIController::StopMovement()를 호출하도록 만들었다. 하지만 이 방식은 StateTree의 이동 Task와 충돌하면서 공격이 바로 종료되는 원인이 될 수 있었다.
그래서 NotifyState는 이동을 직접 멈추지 않고, 이동 락 플래그만 켜고 끄는 역할로 정리했다.
NotifyBegin
→ SetMovementLockedByAttack(true)
NotifyEnd
→ SetMovementLockedByAttack(false)
실제 이동 정지는 EnemyCombatComponent와 StateTreeTask_EnemyAttack 쪽에서 처리하도록 역할을 분리했다.
보스가 플레이어와 충돌할 때 튕겨 날아가는 문제도 확인했다. 원인은 보스/몬스터 캡슐 Collision Object Type이 Pawn이 아닌 Enemy 채널로 강제되는 것과 관련이 있었다.
팀원분이 BP_Enemy_Base의 캡슐 프리셋을 표준 Pawn으로 정규화했고, C++ 생성자에서 Enemy 채널을 강제하던 코드는 주석 처리했다.
// GetCapsuleComponent()->SetCollisionObjectType(ECC_GameTraceChannel1);
// GetCapsuleComponent()->SetCollisionResponseToChannel(ECC_GameTraceChannel1, ECR_Overlap);
이후 보스 튕김 문제는 해결되었다.
이 문제는 단순한 충돌 프리셋 문제가 아니라, 루트모션/모션 워핑과도 연결되어 있었다. 루트모션 공격에서 몬스터와 플레이어 캡슐이 강하게 밀어내면 디페너트레이션이 비정상적으로 발생할 수 있다. 해결 방법은 크게 세 가지가 있었다.
1. 서로의 캡슐 크기를 줄인다.
2. 루트모션 중 Pawn끼리 Overlap 처리한다.
3. Motion Warping으로 접근 위치를 보정한다.
이번 프로젝트에서는 이미 공격 몽타주 쪽에 모션 워핑 세팅이 준비되어 있었기 때문에, 캡슐을 표준 Pawn 충돌 기준으로 유지하고 모션 워핑이 정상 작동하도록 맞추는 방향이 가장 적절했다.
처음에는 Enemy 전용 채널을 사용해 타겟 탐색과 충돌을 분리하려고 했지만, 이 방식은 모션 워핑 기반 접근 보정의 전제와 충돌할 수 있었다. 결국 보스/일반 몬스터의 캡슐은 Pawn으로 유지하고, 공격 접근 문제는 모션 워핑과 거리 데이터로 해결하는 쪽이 맞다고 판단했다.
거리 조절도 몬스터 크기와 역할에 따라 기준을 나누어야 했다.
Boss가 너무 가까이서 공격함
→ 공격 토큰을 가진 상태의 위치 문제
→ OrbitInnerRadius 조정
Wolf들이 너무 가까이 몰림
→ 비공격 대기/Strafe 위치 문제
→ OrbitOuterRadius 조정
Strafe 중 플레이어를 뚫고 지나가려는 문제는 슬롯 이동 폭과 거리 값이 함께 영향을 준다. 빠른 완화책으로는 OrbitOuterRadius, StrafeOffRange, MoveAcceptableRadius 조정과 ShiftOrbitSlot의 MaxSlotShiftSteps 감소가 있다.
| 시도 | 결과 |
|---|---|
Attack State의 MoveTo Task 유지 | EnemyAttack Task 내부 이동과 충돌 |
Attack State에서 MoveTo Task만 제거 | 공격 가능 거리까지 접근하지 못하는 문제가 생김 |
접근 이동을 EnemyAttack Task 내부로 이전 | 접근 후 공격 실행 순서를 한 Task에서 제어 가능 |
ANS_EnemyMovementLock에서 StopMovement() 호출 | 공격 즉시 종료 가능성 발생 |
ChaseLocation 갱신만 중단 | 목적지 갱신은 막았지만 실제 이동은 남음 |
StopMovementImmediately()만 호출 | 잔여 이동이 완전히 사라지지 않음 |
MaxWalkSpeed = 0 후 복구 | 공격 중 미끄러짐 해결 |
| 캡슐 ObjectType을 Enemy 채널로 강제 | 모션 워핑 기반 충돌 해결 방향과 충돌 가능 |
| 캡슐을 표준 Pawn으로 유지 | 보스 튕김 문제 해결 |
처음에는 StateTree의 MoveTo Task를 유지하고, 공격 중에만 ChaseLocation 갱신을 막으면 충분할 것이라고 생각했다. 하지만 실제로는 StateTree의 MoveTo Task와 공격 Task가 병렬로 살아 있기 때문에, 공격 Task 내부 상태를 MoveTo Task가 알 수 없었다.
오늘의 결론은 다음과 같다.
StateTree는 큰 상태 전환을 담당한다.
공격 중 세부 이동 제어는 공격 Task가 담당한다.
몽타주 구간별 이동 금지는 AnimNotifyState가 신호만 전달한다.
실제 이동 차단은 EnemyCombatComponent가 수행한다.
역할을 분리하니 문제를 더 명확하게 볼 수 있었다.
StateTree
→ Combat / Attack / Strafe / Chase 같은 큰 흐름
EnemyAttack Task
→ 접근, 이동 정지, 공격 실행, 공격 종료 감시
ANS_EnemyMovementLock
→ 몽타주 구간에서 이동 락 신호 전달
EnemyCombatComponent
→ 이동 락 플래그와 MaxWalkSpeed 제어
이번 디버깅을 통해 단순히 “어디선가 움직인다”를 쫓는 것이 아니라, 누가 이동 명령을 내리고, 누가 속도를 유지하고, 누가 상태를 종료시키는지 책임을 나누어 추적해야 한다는 점을 배웠다.
StateTree에서 이동 Task와 공격 Task가 동시에 이동을 제어하면 충돌이 발생한다.
공격 중 이동 제어는 StateTree MoveTo보다 공격 Task와 몽타주 NotifyState 중심으로 관리하는 편이 안정적이다.
AnimNotifyState는 직접 이동 명령을 끊기보다 “이 구간은 이동 금지”라는 상태를 전달하는 역할이 적합하다.
실제 이동을 막으려면ChaseLocation갱신 중단만으로는 부족하고,CharacterMovement의 속도와MaxWalkSpeed까지 제어해야 한다.
충돌 문제는 캡슐 Object Type과 Motion Warping 전제 조건을 함께 봐야 한다.
문제가 복잡할수록 StateTree, Task, Component, Montage Notify의 책임을 분리해서 봐야 원인을 좁힐 수 있다.
Retrieve 프로젝트 AI/Enemy 전투 디버깅 기록