무기 히트판정 — 메시 바운드 자동 트레이스
1. 문제: 트레이스 지오메트리가 "소켓 2개 + 행 단일 설정"에 묶여 있었음
기존 공격 판정은 무기 메시의 소켓 두 개(start/end)를 보간해 그 선을 구체로 스윕하는 방식이었음. 두 가지 한계가 있었다.
- 소켓 노가다: 무기마다 날 시작/끝 소켓을 수동으로 박아야 함. 메시 바뀌면 다시.
- 메시 1개만 판정:
GetWeaponMeshForTrace가 "두 소켓을 다 가진 메시 1개"만 반환 → 검+방패처럼 별도 메시 컴포넌트가 여럿이면 방패는 판정에서 누락. - 설정이 행(Row) 단위 단일 세트 → 파트별로 다른 트레이스 형상을 줄 수 없음.
2. 해결: 메시 바운드에서 트레이스를 자동으로 뽑는다
핵심 아이디어
로컬 바운딩박스의 최장축을 '날'로 간주한다. 시작·끝·스윕 반경을 메시 크기에서 자동 계산 → 소켓이 없어도 트레이스가 나온다.
// URetrieveWeaponTraceLibrary::BuildBoundsTrace (요지)
const FBoxSphereBounds LocalBounds = Mesh->CalcBounds(FTransform::Identity); // 컴포넌트 로컬 공간
const FVector Extent = LocalBounds.BoxExtent; // 절반 크기
int32 LongAxis = 0; // 최장축 = 날 방향
if (Extent.Y > Extent[LongAxis]) LongAxis = 1;
if (Extent.Z > Extent[LongAxis]) LongAxis = 2;
// 반경 = 나머지 두 축 절반 중 큰 값 × 배율 (단면 = 날 두께)
float CrossHalf = /* LongAxis 제외한 두 축의 max */;
const float Radius = FMath::Max(1.f, CrossHalf * RadiusScale);
// 날 축 ±(절반길이 + 패딩)을 시작/끝점으로, 컴포넌트 트랜스폼으로 월드 변환
FVector AxisDir; AxisDir[LongAxis] = 1.f;
const FVector WorldStart = CompXform.TransformPosition(Center - AxisDir * HalfSpan);
const FVector WorldEnd = CompXform.TransformPosition(Center + AxisDir * HalfSpan);
포인트:
CalcBounds(FTransform::Identity)로 부르면 컴포넌트 로컬 공간 바운드가 나옴 → 최장축 판정을 회전과 무관하게 할 수 있고, 마지막에 컴포넌트 트랜스폼으로 한 번에 월드로 보냄.
배율(RadiusScale)·패딩(LengthPadding)은 에디터 노출 → 빌드 없이 판정 두께를 튜닝.
3. 지오메트리 산출을 상태 없는 라이브러리로 추출
기존 GA_Attack::BuildTracePoints 안에 박혀 있던 보간 로직을, 상태 없는 UBlueprintFunctionLibrary로 빼냄. 어빌리티는 "어떤 모드냐"만 분기하고, 점 계산은 라이브러리가 담당.
// 상태 없음 — 한 번 만들면 #3 점프슬램 등 다른 판정도 같은 지오메트리를 재사용 가능
struct FWeaponTraceSegment { TArray<FVector> Points; float Radius; };
static bool BuildSocketTrace(const UMeshComponent*, FName Start, FName End, ...); // 레거시 경로 추출
static bool BuildBoundsTrace(const UMeshComponent*, float RadiusScale, ...); // 신규 (검)
static bool BuildBoundsSphere(const UMeshComponent*, float RadiusScale, ...); // 신규 (둥근 방패)
이득: ① 호출부가 얇아지고 ② 판정 형상이 한 곳에 모여 테스트/재사용이 쉬워짐(후속 #3 점프슬램이 검 바운드를 그대로 가져다 씀).
4. 무회귀 리팩토링 — 순수 가산(additive) opt-in
판정 코어를 건드리는 작업이라 회귀 가능성이 있었음. 그래서 기존 경로를 1도 안 바꾸고, 신규 경로만 옆에 추가하는 방식을 택함.
// GA_Attack::ApplyStepDamage
TArray<FRetrieveEquippedWeaponMesh> HitParts;
CachedWeaponComponent->GetHitVolumeMeshes(HitParts); // opt-in 한 파트만
if (HitParts.IsEmpty())
{
BuildTracePoints(CurrentPoints); // ← 레거시 소켓 경로 그대로
if (CurrentPoints.IsEmpty()) return;
}
// ... HitParts 있으면 위 CurrentPoints는 비어 no-op, 아래 파트별 신규 경로가 돈다
신규 어태치먼트 필드는 전부 default=꺼짐.
| 상황 | 동작 |
|---|---|
| 어떤 파트도 opt-in 안 함 | 기존 동작 100% 동일 (레거시 소켓 경로) |
검 어태치먼트만 bGeneratesHitVolume=true |
검만 바운드 트레이스, 나머지 무영향 |
이렇게 하면 ① DT/에셋이 안 깨지고(과거 바이너리 머지 사고 교훈) ② 데이터를 켜기 전까지 무회귀가 보장됨 → "완전 대체"는 나중에 안전하게.
5. 데이터 모델 재구성 — 평면 배열 → 구조체 배열
검+방패를 파트별로 다루려면 "메시 포인터만" 갖고 있던 배열로는 부족. 메시 + 그 파트의 트레이스 설정을 묶은 구조체 배열로 재구성.
❌ Before
TArray<TObjectPtr<UMeshComponent>> EquippedWeaponMeshComponents; // 메시만, 설정은 행 단일
✅ After
struct FRetrieveEquippedWeaponMesh {
TObjectPtr<UMeshComponent> Mesh;
bool bGeneratesHitVolume = false; // 이 파트가 판정을 만드는가
bool bUseBoundsTrace = false; // 바운드 자동 / 소켓 폴백
ERetrieveBoundsTraceShape BoundsTraceShape; // 검=스윕 / 방패=구체
float BoundsRadiusScale, BoundsLengthPadding;
FName TraceStartSocket, TraceEndSocket; // 비우면 무기 행 값 상속
};
TArray<FRetrieveEquippedWeaponMesh> EquippedWeaponMeshComponents;
ApplyWeaponVisuals가 어태치먼트를 스폰할 때 데이터에서 이 설정을 복사해 둠 → 런타임 판정 시 파트별로 독립적으로 트레이스.
6. 형상 모드 enum — 검과 방패는 판정 모양이 다르다
길쭉한 검과 둥근 방패는 자연스러운 판정 형상이 다름. enum + EditCondition으로 분기 + 관련 필드만 디테일 패널에 노출.
UENUM(BlueprintType)
enum class ERetrieveBoundsTraceShape : uint8 {
SweptLongAxis, // 최장축을 따라 구체 스윕 (검 — 기본)
SingleSphere, // 바운드 중심에 구체 1개 (둥근 방패)
};
// 방패 모드 필드는 bUseBoundsTrace일 때만 노출
UPROPERTY(EditAnywhere, meta = (EditCondition = "bGeneratesHitVolume && bUseBoundsTrace"))
ERetrieveBoundsTraceShape BoundsTraceShape = ERetrieveBoundsTraceShape::SweptLongAxis;
7. 프레임 간 스윕 연속성(터널링 방지)을 파트별로 보관
빠른 스윙은 프레임 사이에 적을 통과(터널링) 할 수 있음. 그래서 직전 프레임 점들과 현재 점들을 이어 추가로 스윕함. 파트가 여럿이 되면서 "직전 점들"도 파트별 2차원 배열로 확장해야 했다.
TArray<TArray<FVector>> PreviousTracePointsPerPart; // 파트 인덱스 → 직전 프레임 점들
for (int32 i = 0; i + 1 < Seg.Points.Num(); ++i)
SweepPart(Seg.Points[i], Seg.Points[i + 1]); // ① 이번 프레임 점들 연결
if (PartPrev.Num() == Seg.Points.Num())
for (int32 i = 0; i < Seg.Points.Num(); ++i)
SweepPart(PartPrev[i], Seg.Points[i]); // ② 직전→현재 프레임 연결 (터널링 방지)
PartPrev = Seg.Points;
단일 구체(둥근 방패)는 점이 1개라 0길이 스윕(오버랩) 으로 처리. 중복 히트 방지(HitActorsThisStep)는 파트 통합 유지 — 한 스텝에 한 적은 1회만.
8. 주의 노트 🐛
인프라를 깔았다고 데이터까지 켜면 안 됨
- #6 인프라는 검+방패 둘 다 히트볼륨을 지원하지만, 방패를 휘두르는 공격(배시)은 아직 없음.
- 방패 어태치먼트를 지금
bGeneratesHitVolume=true로 켜면 → 검 콤보 도중에도 방패 팔이 적을 때림(의도치 않은 히트). - 결정: #6은 인프라만. 방패 opt-in은 OFF 유지, 실제 활성화는 방패 배시 스킬 후속 작업에서. → "코드가 지원함"과 "데이터로 켬"을 분리.
메시 교체 시 OLD 메시 정리도 같이 손봐야 함
- 배열 원소가
UMeshComponent*→ 구조체로 바뀌면서, 무기 교체 시 OLD 메시를 PendingDestroy로 옮기는 루프도.Mesh를 꺼내 쓰도록 전부 수정해야 했음(EquipWeapon/ClearWeaponVisuals/GetPrimaryEquippedWeaponMesh/GetWeaponMeshForTrace). - 교훈: 컨테이너 원소 타입을 바꾸면 그 컨테이너를 순회하던 모든 곳이 컴파일 단위로 드러남 → 컴파일러를 체크리스트로 활용.
비교 정리
| 구분 | ❌ Before | ✅ After |
|---|---|---|
| 트레이스 점 산출 | 수동 소켓 2개 보간 | 메시 바운드 최장축 자동 (+소켓 폴백) |
| 판정 대상 메시 | 두 소켓 가진 메시 1개 | 파트별 독립(검+방패 동시 지원) |
| 설정 위치 | 무기 행 단일 세트 | 어태치먼트별 opt-in |
| 지오메트리 로직 | GA 내부에 박힘 | 상태 없는 URetrieveWeaponTraceLibrary |
| 데이터 모델 | TArray<UMeshComponent*> |
TArray<FRetrieveEquippedWeaponMesh> |
| 기존 무기 영향 | — | 무회귀(opt-in 안 하면 100% 동일) |
핵심 요약
- 공격 판정 지오메트리는 메시 로컬 바운드의 최장축을 '날'로 보면 소켓 없이 자동 산출된다.
CalcBounds(Identity)로 로컬 바운드를 얻고 컴포넌트 트랜스폼으로 월드 변환. - 판정 코어 리팩토링은 순수 가산 + opt-in default 꺼짐으로 — 켜기 전까지 무회귀가 보장돼 안전하다.
- 지오메트리 계산은 상태 없는 라이브러리로 추출 → 다른 판정(점프슬램 등)이 재사용.
- 빠른 스윙 터널링은 직전 프레임 점 ↔ 현재 점 스윕 연결로 막고, 멀티파트면 그 직전 점도 파트별로 보관해야 한다.
- "코드가 지원함" ≠ "데이터로 켬". 인프라는 검+방패 다 되지만, 소비자(배시 스킬) 없는 방패는 OFF로 둔다.
- 컨테이너 원소 타입 변경은 순회하던 모든 곳을 깨뜨린다 → 컴파일러를 마이그레이션 체크리스트로 쓴다.