TIL - 파츠 구조 변경 및 트러블 슈팅

Kyu_·2026년 7월 16일

34주차

목록 보기
4/4

TIL

파츠 데이터 테이블 구조변경

StatTag 후보 배열 + 인스턴스구조로 확정
Stat배열이란 이 파츠가 가질 수 있는 스텟 후보 배열 (치명타, 공격력, 치명타 데미지 증가 등)

이로인해 FNSPartData에 FGameplayTag StatTag필드 추가

지금 파츠 GE에 들어있는게

  • 공격력 Damage
  • 최대체력
  • 최대쉴드
  • 방어력
  • 이동속도
  • 크리티컬확률
  • 크리티컬 데미지
  • 쉴드RechargeRate
  • 쉴드RechargeCooldown
  • 최대탄환수
  • DashRegenRate
  • MaxDashCount

몬스터 드롭 파츠 생성 함수 호출 순서

UNSRewardHandler::HandleRewardTrigger -> UNSRewardDropResolver::ResolveDropResultsFromTable (DropTable->DropResults) -> UNSRewardDropResolver::SelectDropRow -> UNSRewardDropResolver::ApplyDropRowToResult

UNSRewardHandler::HandleDropResults -> (RewardTypeTag == Part) -> UNSRewardHandler::HandlePartDropResult -> UNSRewardHandler::MakePartDataFromDropResult
이 과정을 통하고 있다.

  1. 등급이 가장 먼저 결정 파츠 Row에 박혀있는대로
  2. 그 다음 어떤 파츠인지가 결정 UNSRewardHandler::MakePartDataFromDropResult에서 가장먼저 파츠가 정해지고, 등급/슬롯 필터링없이 전체 파츠 풀에서 완전 균등 랜덤
  3. 파츠 다음이 이제 StatTag 순회, 균등확률로 어떤 StatTag를 고를것인지
  4. ValueRange는 그 후에 1번에서 정해진 그 등급대로 조회를 한다.
  5. 최종으로 ValueRange안에서 랜덤값을 뽑고 그것을 StatMaxValue와 곱해서 최종값

2차 구조 변경

목표

  1. 같은 파츠 종류(예: 암)가 여러 스탯 변형(치명타/공격력 등)을 가질 수 있게 — Definition 애셋/Row 복제 없이
  2. 스탯마다 등급별 수치 범위를 독립적으로 튜닝할 수 있게 — "공격력 30~40"과 "치명타 30~40"이 같은 범위를 공유하던 밸런스 문제 해소
  3. 특정 스탯(점프/대시 등 카운트 계열)을 특정 등급 전용으로 만들 수 있게 — 코드 하드코딩이 아니라 데이터로

데이터 모델 변화

1단계 — StatTag 단일 → 배열 (랜덤 변형)

BeforeAfter
FNSPartDefinitionRowStatTag(단일)StatTags(배열, 후보 목록)
FNSPartData스탯 없음 (Row에서 파생)StatTag(신규, 인스턴스 확정 스탯)

드롭/상점 생성 시 StatTags 후보 중 하나가 균등 확률로 뽑혀 인스턴스에 저장된다. 같은 "암" Definition 하나로 치명타 변형/공격력 변형을 동시에 표현할 수 있게 됐다.

2단계 — 품질 롤 → 스탯별 등급 범위 직접 지정

BeforeAfter
FNSStatDisplayInfoRowMaxStatValue(스탯 만점 1개)ValueRangesByRarity(TMap<ENSPartRarity, FNSPartValueRange>)
FNSPartUpgradeRowValueRange(품질 0~1) + 리롤/상점 필드FNSPartShopRerollRow로 개명, 리롤/상점 필드만 (수치 필드 없음)
최종 수치 계산품질 롤(0~1) × MaxStatValue등급 범위에서 직접 롤

"공격력: 커먼 0~10 / 레어 10~20 / 에픽 20~30 / 레전더리 30~40" 처럼 스탯마다 등급 곡선을 독립적으로 그릴 수 있게 됐다. 카운트 계열(점프/대시)은 등급 범위에 정수를 직접 넣으면 되어, 이전에 floor 오차를 피하려고 쓰던 안전마진 역산(1.5, 2 같은 값)이 더 이상 필요 없다.

아키텍처 — 값이 결정되는 흐름

파츠 인스턴스 생성 (드롭 / 인런 상점 / 아웃런 구매)
  1. 파츠 종류 확정 (전체 후보 균등 랜덤)
  2. 등급 확정 (드롭테이블 가중치 / 상점 가중치)
  3. StatTags 후보를 현재 등급 기준으로 필터링
     └ NSPartUtils::FilterStatTagsByRarity
        └ 후보마다 GetStatValueRange(스탯, 등급) 조회, 실패하면 후보에서 제외
  4. 남은 후보 중 스탯 하나 확정 (균등 랜덤) → FNSPartData::StatTag
  5. 그 스탯의 등급별 범위에서 최종 수치 롤 → FNSPartData::CurrentValue
     └ NSPartUtils::GetStatValueRange(확정 스탯, 등급) → FMath::RandRange

리롤 / 등급업
  - 스탯(StatTag)은 유지, 3~5단계만 재실행 (RollValueForPart 공용 함수)

공용 GE 적용 (장착 시)
  - GetPartStatTag(인스턴스 우선, 구세이브는 후보 첫 번째 폴백)
  - StatTag → SetByCaller 태그 매핑(NSCombatStatAttributeMapping) → GE Modifier 적용

PR

변경 사항

  • 파츠 GE 부여
  • DT로 스텟별 수치 조정 가능
  • 기존 CombatTag사용

참고 사항

DT_PartDefinition
  • Stat Tags에는 이 파츠에서 나오는 태그 지정
  • 가능한 태그 목록은 DT_PartStatDisplay에 지정되어 있음
  • 리롤 가능한지, 아웃런 해금가격 들어있음
DT_PartStatDisplay
  • 태그별 어떤 파츠 상호작용에 나오는 텍스트 지정
  • 레어도별 스텟 설정 가능
DT_PartShopReroll
  • 상점과 리롤에 사용되는 DT
DT_PartSlot
  • 슬롯 지정하는 DT
  • 슬롯 추가 or 아웃런 슬롯해금 가격 변경등에 사용

트러블슈팅

1. MaxJumpCount 파츠를 꼈는데 기본 점프 횟수가 0으로 보임

증상: MaxJumpCount 스탯을 파츠 시스템에 추가하고 캐릭터 스폰 시 기본값(1)이 적용되지 않고 0으로 보임.

원인: FGameplayAttributeData는 별도 초기값을 안 주면 기본이 0이다. 캐릭터 스폰 시 실제 시작값은 ApplyInitialAttributeEffect가 별도의 Init GE(CharacterBaseStatInitEffectClass)를 적용해서 DT 기본값을 SetByCaller로 박아 넣는 구조인데, 이 Init GE 애셋은 기존에 만들어진 것이라 신규 추가한 MaxJumpCount 스탯용 Modifier가 빠져 있었다. Internal_ApplySharedGE가 쓰는 공용 파츠 GE(GE_SharedPartEffectClass)와는 별개 애셋이라, 파츠 GE에만 Modifier를 추가하고 Init GE 쪽을 빠뜨리기 쉽다.

해결: Init GE에 Attribute=MaxJumpCount, Op=Override, SetByCaller=Effect.SetByCaller.Init.MaxJumpCount Modifier 추가. 기존 MaxHealth, MaxDashCount 등의 Modifier 구성을 그대로 참고.

교훈: 새 Attribute를 추가하면 GE 두 개(공용 파츠 GE + 캐릭터 Init GE)에 각각 Modifier를 넣어야 한다. 하나만 잊어도 컴파일 에러 없이 조용히 0으로 남아서 발견이 늦어진다.


2. 이중 점프 파츠 장착 후 스페이스를 계속 누르면 공중에서 점프가 계속 나감

증상: JumpMaxCount가 2 이상이 된 상태에서 스페이스를 떼지 않고 있으면, 착지하지 않았는데도 다음 점프가 즉시 또 나가버림. 아웃런에서는 파츠 없이도 항상 발생, 인런에서는 다단 점프 파츠를 얻은 순간부터 발생.

원인: 언리얼 점프 입력은 엣지(눌리는 순간) 감지가 아니라 홀드 방식이다. bPressedJump가 true인 동안 매 틱 CanJumpInternal()을 재검사하는데, JumpMaxCount=1일 때는 공중에서 JumpCurrentCount >= JumpMaxCount라 자연히 막혔던 것뿐이었다. JumpMaxCount를 2 이상으로 올리는 순간, 홀드 중에도 남은 충전량만큼 계속 재판정되어 발동되는 엔진 기본 동작이 그대로 드러났다. 아웃런은 ApplyInitialAttributeEffect가 실행되지 않아 네이티브 ACharacter::JumpMaxCount 엔진 기본값(2)이 그대로 남아있던 것으로 추정.

해결: ANSPlayerCharacterBase에 "마지막 점프 이후 키를 뗀 적이 있어야만 다음 점프 허용" 게이트 추가.

  • CanJumpInternal_Implementation(): JumpCurrentCount > 0 && !bHasReleasedJumpKeySinceLastJump면 거부
  • OnJumped_Implementation(): 점프 성공 시 플래그를 false로
  • ClearJumpInput(float DeltaTime): bPressedJump가 false인 프레임에 플래그를 true로 복귀

1차 구현의 버그 — 서버 러버밴딩: 처음엔 릴리즈 감지를 StopJumping()(입력 바인딩 함수)에서 했는데, 이 함수는 조종하는 클라이언트에서만 호출된다. 접속자 클라가 2단 점프에 성공해도, 호스트의 서버 파트는 StopJumping()이 호출되지 않아 플래그가 영영 false로 남아 서버가 그 점프를 거부 → 서버 보정으로 캐릭터가 끌어내려짐(러버밴딩). 호스트 본인은 서버 파트가 직접 도니까 이 버그가 재현되지 않아서, 호스트 단독 테스트로는 절대 못 잡는다.

교훈:

  • 캐릭터 이동 관련 로직은 "이 함수가 조종 클라에서만 호출되는지, 서버(리슨서버의 서버 파트)에서도 호출되는지"를 항상 구분해야 한다. StopJumping() 같은 입력 함수는 조종 클라 전용, ClearJumpInput/CanJumpInternal은 이동 검증 경로라 양쪽에서 실행된다.
  • 리슨서버 구조에서 호스트는 자기 캐릭터의 서버 로직을 직접 실행하기 때문에, 접속자 클라 관점의 버그는 호스트 혼자 테스트해서는 발견되지 않는다. 네트워크 관련 수정은 접속자 클라 시점으로 반드시 검증.

3. 인런 파츠 강화 UI에 "스텟 변동폭 : 0.65 ~ 0.8" 처럼 의미 없는 숫자가 표시됨

증상: 파츠 리롤/등급업 프리뷰 UI의 수치 범위 표시가 플레이어에게 의미 없는 소수(0.5~1.0 사이)로 보임.

원인: 파츠 품질 롤 시스템으로 전환하면서 DT_PartUpgrade::ValueRange의 의미가 "실제 수치 범위"에서 "품질 배율(0~1)"로 바뀌었는데, NSPartUpgradeWidget의 UI 코드는 이 값을 그대로 찍고 있었다(전환 당시 놓친 부분).

해결: UI에서 표시 직전에 ValueRange × 해당 파츠 StatTag의 MaxStatValue로 환산해서 실제 수치로 변환 후 표시.

교훈: DataTable 필드의 "의미"를 바꾸는 리팩터링(절대값 → 배율 등)을 하면, 그 필드를 직접 읽어 화면에 찍는 모든 지점을 찾아서 같이 고쳐야 한다. 게임 로직(값 계산)과 UI 표시 코드가 같은 원본 필드를 각자 따로 읽는 구조라 한쪽만 고치면 조용히 어긋난다.

0개의 댓글