UENUM(BlueprintType)
enum class ENSPartSlot : uint8
{
Body,
Arm,
Leg
};
슬롯은 C++ enum으로 고정. 파츠 관련 모든 구조체/컴포넌트/UI가 이 enum에 의존하고 있었다.
// 세이브 데이터
TSet<ENSPartSlot> UnlockedSlots; // 캐릭터별
TArray<FNSPartSaveData> OwnedParts; // 캐릭터별
// UI
UPROPERTY(meta=(BindWidget)) UNSPartSlotButton* BodySlotButton;
UPROPERTY(meta=(BindWidget)) UNSPartSlotButton* ArmSlotButton;
UPROPERTY(meta=(BindWidget)) UNSPartSlotButton* LegSlotButton;
// VisualComponent
for (const ENSPartSlot Slot : { ENSPartSlot::Body, ENSPartSlot::Arm, ENSPartSlot::Leg })
"슬롯을 하나 추가하면 어떻게 되는가?"를 생각했을 때 문제가 명확해졌다:
슬롯 3개인 지금도 관리하기 불편한데, 슬롯이 늘어날수록 이 비용이 선형으로 증가한다.
증강 시스템이 이미 FGameplayTag로 모든 카테고리를 구분하고 있었다. 슬롯도 같은 체계를 쓰면:
meta=(Categories="Part.Slot") 한 줄로 Part.Slot.* 네임스페이스만 표시태그만으로는 "이 슬롯의 해금 비용이 얼마인가", "기본 해금 상태인가"를 알 수 없다.
DT_PartSlot이 이 수치를 보관하고, 코드는 DataSubsystem을 통해 캐시된 맵으로 조회한다.
// DT 행 구조체
USTRUCT(BlueprintType)
struct FNSPartSlotRow : public FTableRowBase
{
FGameplayTag SlotTag; // 슬롯 정체성
int64 UnlockCost; // 해금 비용
bool bUnlockedByDefault; // 기본 해금 여부
bool bEnabled; // 활성화 여부
};
// DataSubsystem 캐시
TMap<FGameplayTag, FNSPartSlotRow> CachedSlotRowsBySlot;
| 항목 | 변경 전 | 변경 후 |
|---|---|---|
| 슬롯 식별자 | ENSPartSlot | FGameplayTag |
| 슬롯 수치(비용 등) | 코드 하드코딩 | DT_PartSlot DataTable |
FNSPartSlotRow.Slot | ENSPartSlot Slot | FGameplayTag SlotTag |
FNSPartDefinitionRow.PartSlot | ENSPartSlot | FGameplayTag |
FNSPartData.Slot | ENSPartSlot | FGameplayTag |
UnlockedSlots 세이브 키 | TSet<ENSPartSlot> | TSet<FGameplayTag> |
| UI 슬롯 버튼 | 3개 하드코딩 UPROPERTY | DT 기반 런타임 동적 생성 |
| VisualComponent 순회 | {Body, Arm, Leg} 리터럴 | DataSS->GetAllSlotRows() |
// 변경 전 — UMG에 버튼 3개 고정 배치, C++에서 이름으로 참조
UPROPERTY(meta=(BindWidget)) UNSPartSlotButton* BodySlotButton;
UPROPERTY(meta=(BindWidget)) UNSPartSlotButton* ArmSlotButton;
UPROPERTY(meta=(BindWidget)) UNSPartSlotButton* LegSlotButton;
void HandlePartChanged(ENSPartSlot Slot, ...)
{
switch(Slot)
{
case Body: BodySlotButton->...; break;
case Arm: ArmSlotButton->...; break;
case Leg: LegSlotButton->...; break;
// 슬롯 추가 시 여기도 수정 필요
}
}
// 변경 후 — 컨테이너만 UMG에 배치, 버튼은 DT 읽고 런타임 스폰
void BuildSlotButtons()
{
for (const auto& Pair : DataSS->GetAllSlotRows())
{
UNSPartSlotButton* Btn = CreateWidget<UNSPartSlotButton>(this, SlotButtonTemplate);
SlotButtonContainer->AddChild(Btn);
SlotButtonMap.Add(Pair.Key, Btn); // 태그 → 버튼 맵
}
}
void HandlePartChanged(FGameplayTag Slot, ...)
{
if (auto* Btn = SlotButtonMap.Find(Slot))
ApplySlot(Slot, *Btn); // switch 없음, 슬롯 추가해도 수정 불필요
}
"동적화"란: DT에서 슬롯 행이 늘어나면 버튼도 자동으로 늘어나는 구조.
슬롯 추가 = DT에 행 1개 추가가 전부. UI/C++ 수정 없음.
구현 중 OwnedParts(파츠 인벤토리)가 FNSCharacterSaveData(캐릭터별) 안에 있다는 것을 확인했다.
struct FNSCharacterSaveData
{
TArray<FNSPartSaveData> OwnedParts; // ← 캐릭터별로 파츠를 따로 가지고 있었음
TSet<FGameplayTag> UnlockedSlots;
};
이 구조의 문제: 캐릭터 A로 파츠를 구매하면 캐릭터 B에서는 그 파츠가 없다.
원래 의도는 "파츠 인벤토리는 계정 공유"였다.
실제로 OwnedParts는 처음 설계 때 UNSPermanentSaveGame(계정 레벨)에 있었다가,
어느 시점에 캐릭터별로 이사 온 것으로 파악됐다. enum→태그 전환 작업 도중 이 사실이 드러났다.
게임 설계 의도:
OwnedParts가 캐릭터별이면 "파츠 공유" 자체가 불가능해진다.
세이브 구조:
// 변경 전
struct FNSCharacterSaveData
{
TArray<FNSPartSaveData> OwnedParts; // 캐릭터별 (잘못됨)
TSet<FGameplayTag> UnlockedSlots; // 캐릭터별 (유지)
};
// 변경 후
struct FNSCharacterSaveData
{
// OwnedParts 제거됨
TSet<FGameplayTag> UnlockedSlots; // 캐릭터별 (유지)
};
class UNSPermanentSaveGame
{
TArray<FNSPartSaveData> OwnedParts; // 계정 공유로 복귀
};
ProgressionSubsystem 함수 시그니처:
// 변경 전 — CharacterId가 인벤토리 조회에도 쓰임
bool IsPartOwned(FName CharacterId, TSoftObjectPtr<UNSPartDefinition>, ENSPartRarity) const;
const TArray<FNSPartSaveData>& GetOwnedParts(FName CharacterId) const;
// 변경 후 — 인벤토리는 계정 풀, CharacterId 불필요
bool IsPartOwned(TSoftObjectPtr<UNSPartDefinition>, ENSPartRarity) const;
const TArray<FNSPartSaveData>& GetOwnedParts() const;
PurchasePart는 CharacterId를 유지했다. "이 캐릭터의 슬롯이 해금됐는가"를 검사하기 때문이다. 구매는 캐릭터 슬롯 게이트를 통과해야 하지만, 구매된 파츠는 계정 풀에 쌓인다.
NSPlayerController UploadLocalProgress:
// 변경 전 — 장착 파츠를 캐릭터 인벤토리에서 찾음
const FNSPartSaveData* Owned = CharacterSlot->OwnedParts.FindByPredicate(...);
// 변경 후 — 장착 파츠를 계정 인벤토리에서 찾음
const FNSPartSaveData* Owned = PermanentSave->OwnedParts.FindByPredicate(...);
| 데이터 | 저장 위치 | 이유 |
|---|---|---|
OwnedParts (파츠 인벤토리) | UNSPermanentSaveGame (계정) | 계정 공유. 어느 캐릭터로 구매해도 전체 접근 가능 |
UnlockedSlots (슬롯 해금) | FNSCharacterSaveData (캐릭터) | 캐릭터마다 독립 슬롯 투자 |
EquippedPartDefinition/Rarity (장착 참조) | FNSCharacterSaveData (캐릭터) | 어떤 파츠를 끼고 있는지는 캐릭터별로 다름 |
1. Config/DefaultGameplayTags.ini에 Part.Slot.Wing 태그 1줄 추가
2. DT_PartSlot에 행 1개 추가 (SlotTag=Part.Slot.Wing, UnlockCost=500, ...)
3. DT_PartDefinition에 해당 슬롯 파츠 행 추가
완료 — C++ 수정/빌드 없음
| 파일 | 변경 내용 |
|---|---|
Data/Part/NSPartTypes.h | ENSPartSlot enum 제거, 모든 슬롯 필드 → FGameplayTag |
Progression/Save/NSPermanentSaveGame.h | OwnedParts → 계정 레벨로 이동, FNSCharacterSaveData에서 제거 |
Core/.../NSDataSubsystem.h/.cpp | 슬롯 캐시 키 타입 ENSPartSlot → FGameplayTag |
Core/.../NSProgressionSubsystem.h/.cpp | IsPartOwned·GetOwnedParts에서 CharacterId 제거, 계정 풀 조회로 변경 |
Progression/Part/NSPartEquipComponent.h/.cpp | 델리게이트·함수 인자 → FGameplayTag |
Character/Component/NSPartVisualComponent.h/.cpp | 하드코딩 순회 → GetAllSlotRows() DT 순회 |
UI/Part/NSPartPanelWidget.h/.cpp | 명명 버튼 3개 제거 → 동적 생성 구조 (SlotButtonMap) |
UI/Interaction/NSPartEquipWidget.cpp | IsPartOwned 호출에서 CharacterId 인자 제거 |
Core/PlayerController/NSPlayerController.cpp | 장착 파츠 조회: CharacterSlot->OwnedParts → PermanentSave->OwnedParts |
enum은 집합이 고정될 때 좋다. "나중에 늘어날 수 있다"는 가능성이 조금이라도 있으면 태그+DT가 낫다.
전환 비용(모든 참조 수정)이 크지만, 한 번 전환하면 이후 확장 비용이 0에 가까워진다.
OwnedParts가 계정 공유인지 캐릭터별인지는 게임 설계 의도의 문제다.
이걸 코드 구현 중에 발견한 건 설계 문서에 명시되지 않았기 때문이다.
세이브 데이터 설계 시 모든 필드에 "계정 단위 / 캐릭터 단위"를 명시하는 습관이 필요하다.
"파츠를 살 수 있는가"를 검사할 때 "이 캐릭터의 슬롯이 해금됐는가"가 필요하다.
구매 게이트는 캐릭터별, 구매된 파츠의 저장은 계정 공유.
책임이 두 층에 걸쳐 있어서 파라미터가 남아있는 것이 맞다.
슬롯 버튼을 UMG에 하드코딩하면 슬롯 추가 때 UI도 수정해야 한다.
SlotButtonTemplate(버튼 1개짜리 클래스)을 에디터에서 지정하고,
런타임에 DT를 읽어서 스폰하는 패턴이 DT 기반 설계의 완결이다.