포스트

히트리액트·히트스톱 단일 발사 경로 통합

CustomTimeDilation은 액터 단위라 BT는 안 멈춘다

좀비 Pawn만 늦추면 몸은 멈춰도 AIController의 행동 트리는 풀스피드로 돈다. 흩어진 발사 경로를 하나로 모아 진입점에만 효과를 얹는 구조와, 본 이름이 비어 오던 채널 누락.

히트리액트·히트스톱 단일 발사 경로 통합

타격감은 이펙트를 얼마나 얹느냐가 아니라 맞은 순간 무엇이 같이 멈추느냐에서 갈린다. 이 글에서는 히트리액트와 히트스톱을 붙이면서, 흩어져 있던 발사 경로를 하나로 모으고 그 진입점에만 효과를 얹는 구조로 정리한 과정을 이야기하려 한다. 구현은 콜리전 채널 정합, 본 이름이 비어 돌아올 때의 폴백, 연사 중 히트리액트 재시작 로직, 발사 위임이고, 트러블슈팅은 좀비 몸은 멈췄는데 행동 트리는 계속 돌던 문제가 중심이다.

기술 구현 1 — 테스트 환경 셋업

A팀 BP_PlayerCharacter를 상속한 BP_TestPlayerCharacter를 만들어 GameMode/PlayerController 빙의가 정상인지 격리 검증. 본 캐릭 BP를 직접 건드리면 A팀 작업과 충돌 위험이 있어 테스트용 상속본을 따로 둠.

World Settings GameMode Override 함정

증상 — Project Settings에서 BP_TestGameMode로 바꿔도 PIE(Play In Editor, 에디터 안 테스트 실행) 진입 시 여전히 BP_NBC_GameMode가 떴다.

원인 — 레벨의 World Settings → GameMode Override 슬롯BP_NBC_GameMode로 잡혀 있었다. World Settings의 Override가 Project Settings보다 우선이라 변경이 통째로 무시된 것.

해결 — World Settings에서 직접 BP_TestGameMode로 교체.

교훈 — UE의 GameMode 해석 우선순위는 World Settings Override > Project Settings Default. 레벨 단위 디버깅 환경을 만들 땐 Project Settings 건드리기 전에 World Settings부터 본다.

PlayerController BP에 IMC + IA 직접 할당 구조

A팀이 짠 구조는 EnhancedInput을 PlayerController BP의 디테일 패널에 직접 슬롯으로 노출. C++에서 AddMappingContext 호출 같은 거 없이, BP의 InputMappingContext 슬롯에 IMC를 꽂고 IA_Move/Look/Jump/Fire 액션 각각에 함수 바인딩을 BP 그래프로 연결한 구조.

처음에 입력이 무반응이라 PlayerController.cpp부터 디버깅 시작했는데, 알고 보니 BP 슬롯에 IA가 비어 있어서 그랬다. BP의 4개 IA 슬롯(Move/Look/Jump/Fire) 전부 직접 할당해야 입력이 살아남.

이 구조의 장점은 디자이너가 C++ 건드리지 않고 입력 매핑을 바꿀 수 있다는 것, 단점은 슬롯 누락이 빌드 에러 없이 무반응으로만 드러난다는 것.



기술 구현 2 — 콜리전 채널 정합

ZombieCharacter.h/.cpp

HitReactComponent 자동 부착

1
2
3
4
5
6
// ZombieCharacter.h
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Combat")
TObjectPtr<UHitReactComponent> HitReactComp;

// ZombieCharacter.cpp - 생성자
HitReactComp = CreateDefaultSubobject<UHitReactComponent>(TEXT("HitReactComp"));

좀비 BP를 손대지 않아도 C++ 베이스만 상속하면 자동으로 컴포넌트가 붙음. B팀이 만든 다른 좀비 BP들이 있어도 동일.

Mesh 콜리전 코드 일괄 설정

좀비 Mesh가 CharacterMesh 프리셋(= Query Only, No Physics)으로 잡혀 있어 PhysicsAsset 시뮬레이션 자체가 차단된 상태였다. BP에서 일일이 바꾸지 않고 C++에서 일괄 설정.

1
2
3
4
5
6
7
8
9
10
USkeletalMeshComponent* MeshComp = GetMesh();
MeshComp->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
MeshComp->SetCollisionObjectType(ECC_Pawn);
MeshComp->SetCollisionResponseToAllChannels(ECR_Ignore);
MeshComp->SetCollisionResponseToChannel(ECC_Visibility,    ECR_Block);
MeshComp->SetCollisionResponseToChannel(ECC_Camera,        ECR_Block);
MeshComp->SetCollisionResponseToChannel(ECC_WorldStatic,   ECR_Block);
MeshComp->SetCollisionResponseToChannel(ECC_WorldDynamic,  ECR_Block);
MeshComp->SetCollisionResponseToChannel(ECC_Pawn,          ECR_Block);
MeshComp->SetCollisionResponseToChannel(ECC_Weapon,        ECR_Block);   // 핵심

핵심은 ECC_Weapon Block. 이게 빠지면 무기 라인트레이스가 Mesh를 그대로 통과해 본 정보가 안 잡힌다.

Capsule은 ECC_Weapon Ignore

1
2
3
4
UCapsuleComponent* Capsule = GetCapsuleComponent();
Capsule->SetCollisionResponseToChannel(ECC_Visibility, ECR_Ignore);
Capsule->SetCollisionResponseToChannel(ECC_Camera,     ECR_Ignore);
Capsule->SetCollisionResponseToChannel(ECC_Weapon,     ECR_Ignore);

캡슐이 ECC_Weapon에 Block이면 라인트레이스가 캡슐 표면에서 막혀 Hit.BoneName == None이 떨어진다. PhysicsAsset의 본까지 트레이스가 도달해야 HitReact의 본 지정이 의미가 있으므로 캡슐은 ECC_Weapon Ignore가 정답.

ECC_Weapon은 별도 정의 — Combat/CombatTypes.h:

1
#define ECC_Weapon ECC_GameTraceChannel1

Project Settings → Collision의 GameTraceChannel1을 “Weapon” 이름으로 등록한 뒤 C++에서 이 매크로로 참조한다. 처음에 ZombieCharacter 측에서 이 채널 응답을 누락해 라인트레이스가 캡슐에 막히던 게 이 디버깅의 시작점이었다.



기술 구현 3 — 본 폴백과 히트스톱

본인이 작성한 Combat 모듈의 UWeaponComponent. 핵심 기능 두 가지.

BoneName 폴백 (FindClosestBone_K2)

PhysicsAsset의 본 콜리전이 듬성듬성한 경우 Hit.BoneName이 None으로 떨어진다. 그대로 두면 HitReact가 SKIP되어 시각 반응이 사라짐.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
FName ResolvedBone = Hit.BoneName;

if (ResolvedBone.IsNone())
{
    if (USkeletalMeshComponent* SkelMesh = HitActor->FindComponentByClass<USkeletalMeshComponent>())
    {
        ResolvedBone = SkelMesh->FindClosestBone_K2(Hit.ImpactPoint);

        // _End 본은 PhysicsAsset 흔들림 효과가 미미 → 부모로 거슬러 (최대 8단계)
        int32 Hops = 0;
        while (!ResolvedBone.IsNone() && ResolvedBone.ToString().EndsWith(TEXT("_End")) && Hops < 8)
        {
            ResolvedBone = SkelMesh->GetParentBone(ResolvedBone);
            ++Hops;
        }
    }
}

_End 본은 스켈레톤 말단 — hand_r_End, foot_l_End 같은 것. 콜리전이 거의 없고 PhysicsAsset에 등록도 잘 안 돼 있어 거기에 BlendWeight를 걸어도 흔들림이 시각적으로 안 보인다. 부모(hand_r, foot_l)까지 거슬러 가야 의도한 흔들림이 잡힌다. 8단계 제한은 무한 루프 가드.

히트스톱 — Pawn + AIController + Player 동시 정지

ApplyHitDamage의 HealthComponent 분기 끝에 ApplyHitStop(HitActor, DamageInstigator) 호출.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
void UWeaponComponent::ApplyHitStop(AActor* HitActor, AActor* Instigator)
{
    // 1) 좀비 Pawn 정지
    HitActor->CustomTimeDilation = HitStopTimeScale;

    // 2) 좀비 AIController 정지 — Pawn만 멈추면 BT가 계속 돌아 의사결정이 멎지 않음
    if (APawn* HitPawn = Cast<APawn>(HitActor))
    {
        if (AController* HitController = HitPawn->GetController())
        {
            HitController->CustomTimeDilation = HitStopTimeScale;
        }
    }

    // 3) 플레이어도 정지 → 발사자도 같이 느려져야 충돌감이 살아남
    if (Instigator)
    {
        Instigator->CustomTimeDilation = HitStopTimeScale;
    }

    // 4) Duration 후 1.0으로 복귀
    FTimerHandle Handle;
    TWeakObjectPtr<AActor> WeakHit = HitActor;
    TWeakObjectPtr<AActor> WeakInst = Instigator;

    GetWorld()->GetTimerManager().SetTimer(Handle, [WeakHit, WeakInst]()
    {
        if (WeakHit.IsValid())
        {
            WeakHit->CustomTimeDilation = 1.f;
            if (APawn* P = Cast<APawn>(WeakHit.Get()))
            {
                if (AController* C = P->GetController()) C->CustomTimeDilation = 1.f;
            }
        }
        if (WeakInst.IsValid()) WeakInst->CustomTimeDilation = 1.f;
    }, HitStopDuration, false);
}

AIController도 같이 멈춰야 하는 이유CustomTimeDilation액터 단위로 작동한다. Pawn만 0.1로 두면 Pawn의 Tick·이동·애니메이션은 느려지지만, 별도 액터인 AIController가 매 프레임 돌리는 BehaviorTree 평가는 그대로 풀스피드로 굴러간다. 결과적으로 “몸은 멈췄는데 BT는 다음 노드로 진행”하는 비대칭이 생긴다.

AIController도 0.1로 두면 BT의 Selector/Sequence 평가, MoveTo Task의 Tick, Wait Task의 타이머까지 전부 0.1배로 스케일 → 진짜로 의사결정이 멎는다. 그래서 BTTask 자체는 0줄 수정으로 끝나는 것(아래 6번 항목 참조).

플레이어까지 같이 멈추는 건 타격 충돌의 무게감 연출용. 발사자만 정상 속도면 “총만 빠르고 화면은 멈춤”이 되어 어색하다.

BP 노출 파라미터

1
2
3
4
5
6
7
UPROPERTY(EditDefaultsOnly, BlueprintReadWrite, Category="Combat|HitStop",
          meta=(ClampMin="0.01", ClampMax="1.0"))
float HitStopTimeScale = 0.1f;

UPROPERTY(EditDefaultsOnly, BlueprintReadWrite, Category="Combat|HitStop",
          meta=(ClampMin="0.0", ClampMax="0.5"))
float HitStopDuration = 0.15f;

DataAsset이 아닌 컴포넌트 디테일 패널에서 튜닝하도록 노출. 무기 종류별로 강도 차이를 두고 싶을 때(샷건은 0.05/0.2, 라이플은 0.1/0.1 등) BP에서 즉시 조정.



기술 구현 4 — 연사 중 히트리액트 재시작

기존 구조의 버그 — bIsReacting == true면 신규 PlayHitReact 호출을 통째로 SKIP. 첫 발은 정상이지만 연사 두 번째 발 이후가 모두 무시되어 “첫 발만 흔들리고 끝”이 됨.

수정 — bIsReacting이면 이전 본을 즉시 정리하고 새 본에서 재시작.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void UHitReactComponent::PlayHitReact(FName BoneName, float Strength)
{
    if (!CachedMesh)        { UE_LOG(..., TEXT("SKIP: CachedMesh NULL")); return; }
    if (!BlendWeightCurve)  { UE_LOG(..., TEXT("SKIP: BlendWeightCurve NULL")); return; }
    if (BoneName.IsNone())  { UE_LOG(..., TEXT("SKIP: BoneName None")); return; }
    if (ExcludedBones.Contains(BoneName)) { UE_LOG(..., TEXT("SKIP: ExcludedBones")); return; }

    // 연사 중이면 이전 본 즉시 정리
    if (bIsReacting && !CurrentBone.IsNone() && CurrentBone != BoneName)
    {
        CachedMesh->SetAllBodiesBelowSimulatePhysics(CurrentBone, false);
        CachedMesh->SetAllBodiesBelowPhysicsBlendWeight(CurrentBone, 0.f);
    }

    // 새 본으로 재시작
    CurrentBone = BoneName;
    CachedMesh->SetAllBodiesBelowSimulatePhysics(BoneName, true);
    bIsReacting = true;
    Elapsed = 0.f;
    // ... Tick에서 BlendWeightCurve 평가
}

이상 케이스 SKIP 로그는 4종만 남기고(CachedMesh NULL / BlendWeightCurve NULL / BoneName None / ExcludedBones), 정상 동작 로그는 모두 정리했다. 코드리뷰에서 로그가 어수선해 보이지 않게 정돈.

같은 본 연사(CurrentBone == BoneName)는 이전 정리 없이 Elapsed만 0으로 리셋 — 같은 본에 SetAllBodies를 두 번 호출하면 PhysicsAsset이 잠깐 깜빡이는 시각 아티팩트가 생긴다.



기술 구현 5 — 발사 경로를 하나로 위임

Player 모듈의 ABaseWeapon은 A팀이 작성한 코드인데, 코드를 읽다가 Fire()BlueprintCallable로 노출돼 있는 걸 발견.

1
2
3
// 기존 ABaseWeapon.h (A팀)
UFUNCTION(BlueprintCallable, Category="Weapon")
void Fire();

문제 — BP/AnimNotify/Input 등 어디서든 BaseWeapon::Fire를 호출하면 내부에서 자체 LineTrace + UGameplayStatics::ApplyDamage로 별도 경로가 돈다. 이 경로는 UWeaponComponent::TryFire를 거치지 않으니 HitReact·히트스톱이 적용되지 않는다.

PIE 디버깅에서는 좌클릭 입력이 WeaponComponent로 잘 흘러가는 게 확인됐지만, BP 어딘가에서 BaseWeapon::Fire를 추가 호출하고 있을 가능성 — 즉 이중 발사 — 을 봉인하지 않으면 코드리뷰 후 다른 팀원이 무심코 BP 노드를 연결할 위험이 남는다.

해결 1단계 — 진단 로그로 호출 추적:

1
2
3
4
5
6
void ABaseWeapon::Fire()
{
    UE_LOG(LogTemp, Warning, TEXT("[BaseWeapon::Fire] CALLED by %s"),
           *GetNameSafe(GetInstigatorController()));
    // ... 기존 본체
}

PIE 1회 + 연사 1회를 돌려보고 로그가 안 찍히는 걸 확인 → 데드 코드(실제로 어디서도 호출되지 않는 코드) 판정.

해결 2단계 — 본체 통째로 주석 + WeaponComponent로 위임:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
void ABaseWeapon::Fire()
{
    // [2026-05-15] 데드 코드 봉인. 진단 로그로 호출 0회 확인.
    // 향후 BP/AnimNotify에서 호출되더라도 WeaponComponent 단일 경로로 강제.
    UE_LOG(LogTemp, Warning, TEXT("[BaseWeapon::Fire] redirected to WeaponComponent"));

    AActor* OwnerActor = GetOwner();
    if (!OwnerActor) return;

    UWeaponComponent* WC = OwnerActor->FindComponentByClass<UWeaponComponent>();
    if (!WC) return;

    // Muzzle 소켓이 있으면 그 위치, 없으면 액터 위치 폴백
    FVector StartLoc = GetActorLocation();
    if (USkeletalMeshComponent* WM = FindComponentByClass<USkeletalMeshComponent>())
    {
        if (WM->DoesSocketExist(TEXT("Muzzle")))
        {
            StartLoc = WM->GetSocketLocation(TEXT("Muzzle"));
        }
    }

    // AimRotation은 InstigatorController의 ControlRotation, 없으면 ActorRotation 폴백
    FRotator AimRot = GetActorRotation();
    if (AController* IC = GetInstigatorController())
    {
        AimRot = IC->GetControlRotation();
    }

    WC->TryFire(StartLoc, AimRot);

    /* ===== 기존 본체 전체 주석 (LineTrace + ApplyDamage 직접 호출 경로) =====
    FHitResult Hit;
    FVector Start = ...;
    FVector End = ...;
    GetWorld()->LineTraceSingleByChannel(...);
    UGameplayStatics::ApplyDamage(...);
    ==========================================================================*/
}

이렇게 두면 누가 BaseWeapon::Fire를 호출하든 자동으로 WeaponComponent 경로로 단일화된다. HitReact·히트스톱은 어느 호출 경로에서도 동일하게 발동.

진단 로그(redirected to WeaponComponent)는 1줄만 안전망으로 유지 — 향후 BP에서 호출이 다시 일어나는 게 발견되면 즉시 보임. 정리는 코드리뷰에서 합의 후.



기술 구현 6 — 로그 잡음 제거 (로직 0줄 수정)

B팀이 만든 UBTTask_Move가 매 사이클 MoveToActor를 재호출하면서 UE_LOG를 끝없이 찍어 출력 로그를 어지럽혔다. PIE 30초만 돌려도 콘솔이 가득.

수정 — 로그 줄만 주석 처리. 로직은 0줄 수정.

1
2
3
4
5
6
7
8
9
EBTNodeResult::Type UBTTask_Move::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
    // ... 기존 로직 그대로

    // UE_LOG(LogTemp, Log, TEXT("[BTTask_Move] MoveToActor: %s"), *GetNameSafe(TargetActor));
    AIController->MoveToActor(TargetActor, AcceptanceRadius);

    return EBTNodeResult::InProgress;
}

히트스톱 검증 작업 중에 콘솔 로그가 깨끗해야 다른 로그(BaseWeapon::Fire 진단 등)가 보인다. B팀 코드라 로직은 절대 손대지 않고 로그만 정리한 게 포인트.



트러블슈팅

증상원인해결
입력 무반응World Settings의 GameMode Override가 BP_NBC_GameMode로 박힘World Settings → BP_TestGameMode
디버그 라인 없음, 사격 무반응BP_Rifle의 WeaponConfig 슬롯이 None → SwitchWeapon이 가드에서 차단 → EquipWeapon 미호출BP_Rifle Class Defaults에 DA_WeaponConfig 할당
좀비 데미지는 들어가는데 HitReact 안 됨Mesh 콜리전이 CharacterMesh 프리셋(Query Only, No Physics)Custom + QueryAndPhysics로 코드 일괄 설정
PhysicsAsset 본 일부만 QueryAndPhysics본 일괄 선택이 안 됨Skeleton Tree 전체 선택 후 일괄 변경
Hit.BoneName == None캡슐 ECC_Weapon Block → 본까지 트레이스 미도달FindClosestBone_K2 폴백 + 캡슐 ECC_Weapon Ignore
HitReact 첫 발만 흔들리고 이후 SKIPbIsReacting 가드이전 본 정리 후 새 본 재시작 (RESTART 로직)
히트스톱 적용은 되는데 좀비가 안 멈춤Pawn만 정지, AIController는 별개 액터라 BT 계속 굴러감AIController에도 CustomTimeDilation 적용
Live Coding 후 변경 미반영UPROPERTY 추가는 정식 빌드 필요에디터 종료 후 IDE 빌드


발사 흐름 최종 정리

1
2
3
4
5
6
7
8
9
10
좌클릭 / BP Fire 호출 / AnimNotify
   ↓
WeaponComponent::TryFire (단일 경로)
   ↓
LineTrace (ECC_Weapon)
   ↓
ApplyHitDamage(Hit, Damage, Instigator)
   ├── HealthComponent->ApplyDamage          (체력 차감)
   ├── HitReactComponent->PlayHitReact       (본 흔들림, RESTART 로직)
   └── ApplyHitStop                          (Pawn + AIController + Player CustomTimeDilation 0.15s)

세 가지 효과(체력·본 흔들림·히트스톱)가 동일한 진입점(ApplyHitDamage)에서 일괄 분기된다. 발사 호출 경로가 늘어나도(샷건·근접무기 추후 추가) 이 진입점만 거치면 일관성 유지.



코드리뷰에서 강조할 설계 결정

  1. 단일 경로 강제BaseWeapon::Fire를 데드 코드 처리 + WeaponComponent 위임으로 통일. BP/AnimNotify에서 누가 호출해도 HitReact·히트스톱이 동일하게 발동. 이중 경로 가능성을 코드 레벨에서 봉인
  2. 본 폴백 (FindClosestBone_K2 + _End 부모 거슬러) — PhysicsAsset 본 콜리전이 듬성듬성한 캐릭터에서도 시각적 일관성. _End 처리가 없으면 말단 본에 BlendWeight를 걸어도 흔들림이 안 보이는 함정 회피
  3. AIController 포함 히트스톱CustomTimeDilation이 액터 단위라 Pawn만 멈추면 BT가 계속 돈다. AIController까지 같이 멈춰야 BT 의사결정이 진짜로 멎고 타격감이 살아남
  4. BT 0줄 수정CustomTimeDilation이 액터 단위라 BTTask의 Tick·타이머가 자동 스케일. B팀 코드에 손 대지 않고 히트스톱 기능을 얹은 점 — 모듈 경계 보존
  5. 콜리전 채널 정합 (ECC_Weapon) — Mesh는 Block / Capsule은 Ignore. 라인트레이스가 캡슐 통과 후 본에 도달하는 흐름을 코드로 일괄 보장 (BP 콜리전 프리셋에 의존하지 않음)
  6. BP 노출 파라미터 최소화 — 히트스톱 강도·지속만 컴포넌트 디테일에서 튜닝. DataAsset까지 안 가는 이유는 무기 종류별 차이가 작아서. 차후 차이가 커지면 DA로 승격


정리 — 이 통합에서 남은 것

  1. CustomTimeDilation은 액터 단위 — Pawn만 멈추면 BT는 안 멈춘다 — AIController는 별개 액터라서 같이 0.1로 두지 않으면 의사결정이 풀스피드로 진행. 타격감의 핵심은 “몸과 결정이 같이 멈추는 것”
  2. Hit.BoneName == None은 콜리전 채널 누락의 신호 — PhysicsAsset 문제가 아니라 캡슐이 Block이라 본까지 트레이스가 못 가는 경우가 많음. FindClosestBone_K2는 안전망일 뿐, 근본 원인은 채널 응답 정합
  3. PhysicsAsset의 _End 본은 부모로 거슬러야 흔들림이 보인다 — 말단 본은 콜리전 빈약 + 등록 누락이 잦아서 SetAllBodiesBelow의 효과가 거의 없음. 최대 8단계 부모 거슬러 가드
  4. BlueprintCallable 노출 함수는 데드 코드라도 위험 — 호출 가능성이 BP 어디든 열려 있어 향후 다른 사람이 무심코 연결할 수 있음. 데드 확인되면 위임으로 단일화하는 게 안전
  5. World Settings의 GameMode Override는 Project Settings보다 우선 — 레벨 단위 디버깅 환경 만들 땐 Project Settings 먼저 보지 말고 World Settings부터 확인. GameMode 안 바뀌면 첫 의심처
  6. B팀 코드는 로그만 정리하고 로직은 0줄 수정 — 모듈 경계 보존이 코드리뷰에서 가장 빨리 신뢰를 얻는 방식. “내가 책임지는 영역” vs “건드리지 않는 영역”이 디프에서 즉시 보이게


핵심 요약CustomTimeDilation은 액터 단위라 좀비 Pawn만 멈추면 별개 액터인 AIController의 BT는 계속 돈다 — 히트스톱은 둘 다 멈춰야 완성된다. 그리고 모든 발사를 TryFire 한 곳으로 모아두면 새 효과를 진입점 한 군데에만 얹어도 모든 경로에 일관되게 적용된다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.