[TIL] 늑대 적 구현 — ABP 바인딩·콜리전 설계·공격 데이터화·AI 인지 개선

Today I Learned | Unreal Engine 5.7 | AnimBlueprint | GAS | AIPerception | Collision | DataTable | C++

오늘은 일반 몬스터(늑대)의 이동·공격·인지를 구현하면서 마주친 ABP 바인딩 함정, 캐릭터 콜리전 설계 제약, 공격 몽타주 데이터화, 시야/소실 로직을 정리했습니다. 표면 증상이 진짜 원인과 다른 경우가 많아 소거법으로 추적한 기록입니다.


1부. 애니메이션 블루프린트(ABP)

1-1. property binding은 Anim Graph Override가 아니라 Class Defaults를 읽는다

자식 ABP(ABP_WolfEnemy)가 부모(ABP_EnemyBase)를 상속하는데, 이동 클립들이 재생되지 않았습니다(Dead만 정상). 스크린샷상 모든 노드가 동일하게 바인딩돼 있어 한참 헤맸습니다.

원인은 노드의 클립 출처였습니다. Sequence Player 노드가 변수에 property binding돼 있으면, 그 노드는 바인딩된 변수의 값(= 자식 ABP의 Class Defaults)을 읽습니다. 이때 Anim Graph Override 패널의 값은 완전히 무시됩니다.

❌ Anim Graph Override 패널에만 클립 지정  → 바인딩된 노드가 무시 → null → 재생 안 됨
✅ Class Defaults의 변수 값에 클립 지정     → 바인딩된 노드가 읽음 → 정상 재생
  • Dead는 Class Defaults에 클립이 들어가 있어서 정상 재생됐습니다.
  • MoveStart/IdleBreak 등은 Override 패널에만 넣고 Class Defaults는 비어 있어 null이 나왔습니다.

배운 것: 바인딩된 노드의 애니가 안 나오면 Override 패널이 아니라 Class Defaults의 변수 값을 확인해야 합니다. showdebug animation은 BP 변수 값을 안 보여주므로, ABP 디버그-오브젝트 드롭다운이나 Rewind Debugger로 변수 값을 직접 봐야 합니다.

1-2. 몽타주 슬롯과 출력 경로 — 슬롯은 스켈레톤이 소유한다

"공격 중 Idle이 보인다"를 추적하며 AnimGraph 출력 경로를 역추적했습니다.

Main SM → cache 'Main'
  → LayeredBoneBlend ( Base = 'Main',  BlendPoses_0 = Slot 'UpperBody' )
  → cache 'UpperPose'
  → Slot 'DefaultSlot'        ← 출력 직전 최종 풀바디 슬롯
  → Output Pose
  • DefaultSlot이 출력 직전 최종 노드라, 여기 재생되는 몽타주는 전신을 완전히 덮습니다.
  • UpperBody 슬롯은 LayeredBoneBlend의 상체 레이어라, 여기 재생되면 상체 본만 덮고 하체는 locomotion이 비칩니다.

또한 montage 에디터의 슬롯 드롭다운에 UpperBody가 안 보이는 이유는, 슬롯 목록이 Skeleton의 Anim Slot Manager에 등록된 것만 나오기 때문입니다. ABP의 슬롯 노드와 별개로, 스켈레톤에 등록돼 있어야 montage가 그 슬롯을 쓸 수 있습니다.

구분DefaultSlotUpperBody 슬롯
위치출력 직전 최종 노드LayeredBoneBlend의 상체 레이어
효과전신 덮어쓰기상체 본만, 하체는 locomotion
용도전신 공격/리액션이동 중 상체 동작

배운 것: 몽타주가 전신을 덮으려면 출력 경로의 슬롯에 재생돼야 합니다. 슬롯은 ABP가 아니라 스켈레톤이 소유하므로, 슬롯이 안 보이면 Skeleton Slot Manager를 확인합니다. (이번 늑대 공격은 DefaultSlot이 맞아 결과적으로 슬롯 문제는 아니었습니다.)


2부. 캐릭터 콜리전 설계

2-1. Character 루트 캡슐은 수직·원형으로 고정된다

4족 보행 늑대의 콜리전을 "앞으로 길쭉하게" 만들려다 막혔습니다. Character의 루트 캡슐은 두 가지가 강제됩니다.

  1. 항상 수직 — CharacterMovementComponent가 "캡슐 축 = 위"를 가정합니다. 눕히면 바닥 감지·스텝업·이동이 깨집니다.
  2. 수평 단면은 항상 원 — 좌우 길이를 다르게 못 합니다.

따라서 "앞으로만 길쭉한 캡슐"은 불가능합니다. 4족 보행은 낮고 적당히 넓은 수직 캡슐 + 코·꼬리 오버행 허용으로 처리합니다. 몸통 모양이 꼭 필요하면 메시에 자식 BoxComponent를 붙입니다(루트만 캡슐 강제).

추가로 배운 것: CharacterMovement의 이동/월드 충돌은 루트 캡슐만 사용합니다. 자식 박스는 늑대 자신의 이동에 영향을 주지 못하고, 다른 액터/트레이스가 부딪힐 때만 작동합니다. 그래서 "긴 몸이 벽을 안 뚫게" 같은 월드 블로킹도 결국 캡슐의 몫입니다.

2-2. 피격 판정은 채널이 아니라 ObjectType(Pawn) 스윕이었다

플레이어 무기가 적을 때리는 판정을 코드에서 확인했습니다.

// GA_Attack.cpp
FCollisionObjectQueryParams ObjectQueryParams;
ObjectQueryParams.AddObjectTypesToQuery(ECC_Pawn);          // ObjectType = Pawn
World->SweepMultiByObjectType(HitResults, SweepStart, CurrentTraceOrigin,
    FQuat::Identity, ObjectQueryParams, FCollisionShape::MakeSphere(TraceRadius), QueryParams);

SweepMultiByObjectType(ECC_Pawn)ObjectType이 Pawn인 컴포넌트만 때립니다. 채널 응답이나 PhysicsAsset과 무관합니다. 그래서 늑대 캡슐(ObjectType=Pawn)이 이미 이 스윕에 맞고, 피격은 박스 없이도 작동했습니다. 몸 모양 정밀 판정이 필요하면 메시의 ObjectType을 Pawn으로 + Query를 켜면 PhysicsAsset 바디가 맞습니다.

배운 것: 회피·박스 추가 전에 "무엇을 기준으로 때리는가"를 코드에서 확인해야 했습니다. ObjectType 질의는 채널 응답 설정과 독립이라, 뒤(2-3)의 Camera 채널 수정과도 충돌하지 않습니다.

2-3. 적이 카메라를 미는 이유 — SpringArm의 Camera 채널 프로브

적이 카메라와 플레이어 사이에 들어오면 스프링암이 당겨졌습니다.

  • SpringArm은 bDoCollisionTestCamera 채널 프로브를 쏩니다.
  • 기본 "Pawn" 프리셋은 Visibility만 Ignore로 바꾸고 Camera는 Block 그대로입니다 → 다른 폰이 카메라를 밀어냅니다. (플레이어 자기 캡슐은 SpringArm이 owner를 무시해서 안 밀립니다.)

해결은 적 콜리전이 Camera 채널만 Ignore하게 하는 것입니다. ObjectType 판정(2-2)과 별개라 피격이 깨지지 않습니다.

// RetrieveEnemyCharacter 생성자 — 모든 적에 일괄 적용
GetCapsuleComponent()->SetCollisionResponseToChannel(ECC_Camera, ECR_Ignore);
GetMesh()->SetCollisionResponseToChannel(ECC_Camera, ECR_Ignore);
FistHitbox->SetCollisionResponseToChannel(ECC_Camera, ECR_Ignore);

배운 것: 캡슐만이 아니라 메시도 콜리전이 켜져 있으면 Camera를 막습니다. "모든 적 일괄"이 필요한 옵션은 C++ 생성자가 맞고, BP에서 추가한 컴포넌트(박스 등)는 따로 처리하거나 커스텀 프리셋으로 통일합니다.


3부. 공격 시스템 데이터화

3-1. 공격 몽타주를 패턴 행 데이터로 — 2계층 설계

공유 GA_EnemyBasicAttack이 몽타주를 하드코딩하던 것을 데이터화했습니다.

// FMonsterPatternRow — 미사용 FName을 몽타주 포인터로 교체
TSoftObjectPtr<UAnimMontage> AttackMontage;
// EnemyCombatComponent::RequestBasicAttack — 이벤트에 실어 전달
EventData.OptionalObject = Row->AttackMontage.LoadSynchronous();

GA는 이미 Event Data → Optional Object → Cast To AnimMontage → PlayMontageAndWait로 배선돼 있어, C++에서 몽타주만 실어 보내면 끝이었습니다.

설계의 핵심은 2계층 분리입니다.

계층발동 경로몽타주
일반 공격Enemy.Attack공유 GA패턴 행 AttackMontage (데이터)늑대 물기
특수 패턴Enemy.SpecialAttack전용 GAGA가 자체 소유(다중 몽타주/로직)와이번 AerialDive

배운 것: 다중 몽타주·가변 시간 패턴(AerialDive 등)은 "긴 몽타주 + 캔슬"이 아니라 루프 애니 + 이동 AbilityTask(ApplyRootMotionMoveToActor)로 풀어야 자연스럽습니다. 공유 데이터 필드(AttackMontage)는 일반 공격 전용이고, 특수 패턴을 막지 않습니다.

3-2. DataTable의 부분 사용 필드는 안티패턴이 아니다

AttackMontage를 패턴 행에 두면 특수 패턴 행에도 빈 필드가 생긴다는 점이 걸렸습니다. 하지만 FMonsterPatternRow는 이미 bCanBeParried, CounterEventTag, HitboxBoneName일부 패턴만 쓰는 필드 투성이였습니다. 한 종류(공격 패턴)의 속성 합집합을 한 struct에 담고 행마다 필요한 것만 채우는 것이 DataTable의 정상 패턴입니다.

배운 것: "한 점에 몰리니 빼야 하나"를 고민하기 전에, granularity(어느 단위의 속성인가)응집(같이 정의돼야 하는가)을 봅니다. 몽타주는 패턴당이고 히트박스와 같은 행에 있어야 하므로 패턴 행이 맞습니다. MonsterData(몬스터당)는 단위가 틀립니다.

3-3. 상속된 MonsterDataRowName 버그 — Monolith 소거법 디버깅

데이터를 다 세팅했는데 늑대가 엉뚱하게 동작했습니다. Monolith로 데이터 → 참조를 따라 역추적했습니다.

DT_MonsterPattern  → Wolf_Normal_Attack 행 정상 (AttackMontage/Jaw)   ✅
DT_MonsterData     → 늑대 행 PatternSlots 정상, 단 행 이름이 "NewRow"  ⚠️
BP_EnemyWolf (CDO) → MonsterDataRowName = "Zombie_Basic"               ❌ 범인

자식 BP_EnemyWolf가 부모 BP_EnemyZombieMonsterDataRowName = "Zombie_Basic"상속한 채 안 바꿔서, 늑대가 좀비 데이터(좀비 몽타주, hand_r 히트박스)로 초기화되고 있었습니다.

배운 것: 상속 BP는 부모의 EditDefaultsOnly 값을 그대로 물려받으니, 오버라이드 여부를 CDO에서 확인해야 합니다. 데이터 기반 시스템의 버그는 "데이터 행 → 참조하는 BP/컴포넌트"를 한 단계씩 따라가며 소거하는 것이 빠릅니다.


4부. AI 인지·이동

4-1. Sight는 단일 3D 콘이다 — 수평/수직 분리는 커스텀으로

"360도 감지"의 원인은 PeripheralVisionAngleDegrees = 140이었습니다. 이 값은 콘의 절반각이라 실제 시야는 280° 콘(뒤 80°만 사각)입니다.

그런데 60으로 줄였더니 근접한 플레이어를 못 봤습니다. UAISenseConfig_Sight콘 각도가 하나뿐인 3D 원뿔이라 좌우와 함께 상하도 같이 좁아지기 때문입니다. 늑대는 키가 낮아 근접 시 플레이어로 가는 벡터가 수직으로 가팔라져 좁은 콘 밖으로 나갑니다.

해결은 역할 분리입니다.

// 엔진 콘은 넓게(수직 담당) + evaluator에서 yaw-only 게이트(수평 FOV)
FVector ToTarget = Actor->GetActorLocation() - PawnLocation;  ToTarget.Z = 0.f;
FVector Forward  = Pawn->GetActorForwardVector();             Forward.Z  = 0.f;
ToTarget.Normalize();  Forward.Normalize();
if (FVector::DotProduct(Forward, ToTarget) < FMath::Cos(FMath::DegreesToRadians(HorizontalHalfFOV)))
{
    continue;   // 수평 시야 밖 → 무시
}

배운 것: 엔진 Sight는 수평/수직 FOV를 분리 설정할 수 없습니다. 콘은 넓게 두어 수직·근접을 보장하고, 수평 FOV는 yaw만 보는 커스텀 게이트로 구현하면 사실상 분리됩니다.

4-2. Perception은 NavMesh를 모른다 — 소실 로직이 안 먹은 진짜 이유

"NavMesh를 벗어나도 계속 감지된다"의 원인은 두 가지가 맞물린 것이었습니다.

  • 소실 로직(TimeSinceLastSeen ≥ TargetLostDelay)은 타깃이 인지 목록에서 빠졌을 때만 누적됩니다.
  • 하지만 Perception(Sight)은 LOS + 반경 기반이라 NavMesh와 무관합니다. 벗어나도 보이면 계속 인지됩니다.
  • 거기에 280° 콘까지 겹쳐, 타깃이 "안 보이는 상태"가 거의 안 생겨 소실 타이머가 시작조차 안 됐습니다.

해결은 "NavMesh 밖 타깃은 미인지로 처리"해서 기존 타이머를 재활용하는 것입니다.

if (NearestTarget)
{
    UNavigationSystemV1* NavSys = FNavigationSystem::GetCurrent<UNavigationSystemV1>(Pawn->GetWorld());
    FNavLocation ProjectedLoc;
    const bool bOnNavMesh = NavSys && NavSys->ProjectPointToNavigation(
        NearestTarget->GetActorLocation(), ProjectedLoc, FVector(100.f, 100.f, 250.f));
    if (!bOnNavMesh) { NearestTarget = nullptr; }   // 보여도 NavMesh 밖이면 "안 보임" 처리
}

이렇게 하면 NavMesh 밖에서는 타이머가 누적되어 해제되고, 해제 후엔 재획득도 막힙니다(획득은 NearestTarget 유효 분기에서만 일어나므로).

배운 것: "일정 시간 후 해제" 로직이 안 먹으면, 그 로직의 발동 조건이 실제로 만들어지는지부터 확인합니다. 인지(Perception)와 도달성(NavMesh)은 완전히 다른 시스템입니다.

4-3. 회전이 한 박자 느린 이유 — TargetLocation throttle

추격 중 방향 전환이 0.2초쯤 늦었습니다. evaluator가 TargetLocation을 0.2초(TickInterval) throttle 블록 안에서만 갱신하고 있었습니다 → MoveTo 목적지가 그만큼 지연.

// 무거운 perception 쿼리는 throttle 유지, 타깃 위치 갱신만 매 틱으로 분리
APawn* Pawn = Context.GetExternalDataPtr(PawnHandle);
if (Pawn && IsValid(InstanceData.TargetPlayer))
{
    InstanceData.TargetLocation   = InstanceData.TargetPlayer->GetActorLocation();
    InstanceData.DistanceToTarget = FVector::Dist(Pawn->GetActorLocation(), InstanceData.TargetLocation);
}
InstanceData.AccumulatedTime += DeltaTime;
if (InstanceData.AccumulatedTime < TickInterval) { return; }   // 이하 무거운 쿼리만 throttle

배운 것: 성능을 위한 throttle 안에 싼 갱신(위치)까지 같이 묶이면 반응성이 죽습니다. 무거운 작업(perception 쿼리)과 싼 작업(위치 refresh)을 분리합니다.

4-4. RVO vs Detour Crowd

여러 적이 플레이어에 몰리면 서로 밀리는 문제를 두고 두 회피 시스템을 비교했습니다.

항목RVO (튜닝)Detour Crowd
정체CharacterMovement 내장 로컬 속도 회피네비메시 기반 군중 시뮬
성능(10개체)매우 가벼움약간 무거움(절대량은 작음)
한 점 수렴 품질약함(진동·밀림)강함(부드러움)
셋업파라미터 몇 개C++ 컴포넌트 교체 + 튜닝
스케일(수십+)나빠짐우수

배운 것: 10개체 규모에선 성능상 Detour Crowd가 과합니다. 다만 근접 적이 한 점(플레이어)에 몰리는 문제는 회피 시스템만으로 완전 해결이 안 되고 — 공격 위치 분산(포위 슬롯)이 더 근본적입니다. 회피는 "이동 중 분리"만 담당합니다.


5부. 빌드 / 모듈

5-1. NavigationSystem은 별도 모듈 의존성이 필요하다

UNavigationSystemV1::ProjectPointToNavigation을 쓰자 헤더는 컴파일됐지만 링크 에러(LNK2019)가 났습니다. AIModule은 있어도 NavigationSystem 모듈이 의존성에 없었기 때문입니다.

// Retrieve.Build.cs — PublicDependencyModuleNames
"AIModule",
"NavigationSystem",   // UNavigationSystemV1 링크용

배운 것: 컴파일은 되는데 LNK2019(unresolved external)가 나면 거의 모듈 의존성 누락입니다. 심볼이 속한 모듈을 Build.cs에 추가하고 리빌드합니다.


핵심 요약

  • ABP에서 property-binding된 노드는 Class Defaults의 변수 값을 읽고 Anim Graph Override 패널은 무시합니다.
  • Character 루트 캡슐은 수직·원형 고정이고 이동 충돌은 캡슐만 씁니다. 피격은 SweepMultiByObjectType(Pawn) — ObjectType 기준이라 Camera 채널 Ignore와 무관합니다.
  • 카메라가 당겨지면 SpringArm의 Camera 채널 프로브 + Pawn 프리셋의 Camera Block을 의심하고, 적 콜리전을 Camera=Ignore 처리합니다.
  • 공격 몽타주는 패턴 행 데이터(AttackMontage)로, 일반(공유 GA)/특수(전용 GA) 2계층으로 나눕니다. DataTable의 부분 사용 필드는 정상입니다.
  • Perception(Sight)은 NavMesh와 무관하고 콘은 단일 3D입니다. 수평 FOV는 커스텀 yaw 게이트로, NavMesh 이탈 해제는 "미인지 처리"로 기존 타이머를 재활용합니다.
  • 데이터 기반 버그는 데이터 → 참조 BP/CDO를 따라 소거하고, LNK2019는 모듈 의존성 누락을 먼저 의심합니다.

Retrieve Project — 일반 몬스터(늑대) 구현: ABP·콜리전·공격 데이터화·AI 인지 (2026-06-01)

profile
게임 개발 지망생입니다.

0개의 댓글