히트리액트·히트스톱 단일 발사 경로 통합
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 첫 발만 흔들리고 이후 SKIP | bIsReacting 가드 | 이전 본 정리 후 새 본 재시작 (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)에서 일괄 분기된다. 발사 호출 경로가 늘어나도(샷건·근접무기 추후 추가) 이 진입점만 거치면 일관성 유지.
코드리뷰에서 강조할 설계 결정
- 단일 경로 강제 —
BaseWeapon::Fire를 데드 코드 처리 + WeaponComponent 위임으로 통일. BP/AnimNotify에서 누가 호출해도 HitReact·히트스톱이 동일하게 발동. 이중 경로 가능성을 코드 레벨에서 봉인 - 본 폴백 (
FindClosestBone_K2+_End부모 거슬러) — PhysicsAsset 본 콜리전이 듬성듬성한 캐릭터에서도 시각적 일관성._End처리가 없으면 말단 본에 BlendWeight를 걸어도 흔들림이 안 보이는 함정 회피 - AIController 포함 히트스톱 —
CustomTimeDilation이 액터 단위라 Pawn만 멈추면 BT가 계속 돈다. AIController까지 같이 멈춰야 BT 의사결정이 진짜로 멎고 타격감이 살아남 - BT 0줄 수정 —
CustomTimeDilation이 액터 단위라 BTTask의 Tick·타이머가 자동 스케일. B팀 코드에 손 대지 않고 히트스톱 기능을 얹은 점 — 모듈 경계 보존 - 콜리전 채널 정합 (
ECC_Weapon) — Mesh는 Block / Capsule은 Ignore. 라인트레이스가 캡슐 통과 후 본에 도달하는 흐름을 코드로 일괄 보장 (BP 콜리전 프리셋에 의존하지 않음) - BP 노출 파라미터 최소화 — 히트스톱 강도·지속만 컴포넌트 디테일에서 튜닝. DataAsset까지 안 가는 이유는 무기 종류별 차이가 작아서. 차후 차이가 커지면 DA로 승격
정리 — 이 통합에서 남은 것
CustomTimeDilation은 액터 단위 — Pawn만 멈추면 BT는 안 멈춘다 — AIController는 별개 액터라서 같이 0.1로 두지 않으면 의사결정이 풀스피드로 진행. 타격감의 핵심은 “몸과 결정이 같이 멈추는 것”Hit.BoneName == None은 콜리전 채널 누락의 신호 — PhysicsAsset 문제가 아니라 캡슐이 Block이라 본까지 트레이스가 못 가는 경우가 많음.FindClosestBone_K2는 안전망일 뿐, 근본 원인은 채널 응답 정합- PhysicsAsset의
_End본은 부모로 거슬러야 흔들림이 보인다 — 말단 본은 콜리전 빈약 + 등록 누락이 잦아서 SetAllBodiesBelow의 효과가 거의 없음. 최대 8단계 부모 거슬러 가드 BlueprintCallable노출 함수는 데드 코드라도 위험 — 호출 가능성이 BP 어디든 열려 있어 향후 다른 사람이 무심코 연결할 수 있음. 데드 확인되면 위임으로 단일화하는 게 안전- World Settings의 GameMode Override는 Project Settings보다 우선 — 레벨 단위 디버깅 환경 만들 땐 Project Settings 먼저 보지 말고 World Settings부터 확인. GameMode 안 바뀌면 첫 의심처
- B팀 코드는 로그만 정리하고 로직은 0줄 수정 — 모듈 경계 보존이 코드리뷰에서 가장 빨리 신뢰를 얻는 방식. “내가 책임지는 영역” vs “건드리지 않는 영역”이 디프에서 즉시 보이게
핵심 요약 —
CustomTimeDilation은 액터 단위라 좀비 Pawn만 멈추면 별개 액터인 AIController의 BT는 계속 돈다 — 히트스톱은 둘 다 멈춰야 완성된다. 그리고 모든 발사를TryFire한 곳으로 모아두면 새 효과를 진입점 한 군데에만 얹어도 모든 경로에 일관되게 적용된다.