프로젝트 초기에는 작업대인 AFTHubWorkbench Actor가 제작 레시피를 보유하고, 재료 검사와 소비, 제작 결과 지급까지 처리하는 구조였다.
기능이 단순할 때는 Actor 하나에서 모든 과정을 확인할 수 있어 편했다. 하지만 창고 시스템과 연동하고, 레시피를 DataTable로 관리하고, 저장 데이터에 따라 레시피를 해금해야 하면서 Workbench Actor의 책임이 계속 커지기 시작했다.
이번 리팩터링에서는 제작 규칙을 UFTCraftingSubsystem으로 이전하고, Workbench Actor는 플레이어가 상호작용할 수 있는 월드 진입점으로만 남기도록 구조를 변경했다.
기존 Workbench Actor 구조의 한계
초기 Workbench Actor는 다음과 같은 역할을 담당했다.
- 플레이어와의 상호작용
- 제작 UI 실행
- 제작 레시피 보관
- 제작 가능 여부 확인
- 플레이어 인벤토리 조회
- 창고 인벤토리 조회
- 재료 소비
- 제작 결과 지급
기능을 빠르게 구현하기에는 편했지만, 프로젝트가 커지면서 몇 가지 문제가 발생했다.
1. 제작 기능이 월드 Actor에 종속된다
Workbench Actor가 제작 규칙을 가지고 있으면 제작 기능을 사용하기 위해 항상 월드에 배치된 Actor를 찾아야 한다.
Crafting UI
↓
Workbench Actor
↓
레시피 조회 및 제작 실행
이 구조에서는 UI 테스트나 다른 시스템에서 제작 기능을 사용하려 해도 Workbench Actor가 필요하다.
제작이라는 게임 규칙이 월드에 배치된 Actor의 존재 여부에 영향을 받게 되는 것이다.
2. 레시피 데이터와 제작 상태가 섞인다
레시피는 기획 데이터다.
반면 현재 플레이어가 제작할 수 있는지, 어떤 레시피가 해금되었는지는 런타임 상태다.
이 두 가지를 Workbench Actor 안에서 함께 관리하면 다음 정보가 한 클래스에 섞이게 된다.
- 어떤 재료가 필요한가?
- 어떤 결과물이 생성되는가?
- 플레이어가 재료를 가지고 있는가?
- 창고에 부족한 재료가 있는가?
- 해당 레시피가 해금되었는가?
- 제작 후 재료를 어디서 차감할 것인가?
3. UI가 제작 규칙을 알게 된다
Workbench Actor의 역할을 줄이지 않으면 제작 UI가 Actor의 함수를 직접 호출하거나, 심하면 Widget 안에서 재료 수량을 직접 검사하게 된다.
Widget
→ 플레이어 인벤토리 검사
→ 창고 인벤토리 검사
→ 재료 제거
→ 결과 아이템 추가
이렇게 되면 UI가 단순한 화면 표시 계층이 아니라 제작 시스템 일부가 된다.
4. 실패 처리와 데이터 복구가 어렵다
제작은 여러 재료를 순서대로 소비한다.
예를 들어 다음 레시피가 있다고 가정해 보자.
물 x2
설탕 x1
→ 음료 x1
물을 제거한 뒤 설탕 제거에 실패하면 이미 제거한 물을 다시 복구해야 한다.
재료는 모두 제거했지만 결과 아이템 지급에 실패하는 경우도 고려해야 한다.
이러한 복구 로직이 Actor나 Widget 여러 곳에 흩어지면 아이템이 사라지거나 복제되는 문제가 발생할 수 있다.
리팩터링 목표
이번 제작 시스템 리팩터링에서는 각 객체의 책임을 다음과 같이 분리했다.
AFTHubWorkbench
- 월드에 배치되는 작업대
- 플레이어 상호작용 처리
- 제작 UI 실행 요청
- 플레이어 및 창고 인벤토리 전달
UFTCraftingSubsystem
- 레시피 DataTable 로드
- 레시피 검색과 목록 조회
- 레시피 해금 여부 확인
- 제작 가능 여부 판단
- 재료 소비 계획 생성
- 재료 소비와 실패 복구
- 제작 결과 지급
UFTCraftingViewModel
- 레시피를 UI용 객체로 변환
- 제작 가능한 레시피 필터링
- 레시피 검색
- 선택한 레시피 상태 관리
- 필요 재료와 보유 수량 표시
- 제작 명령 전달
UFTHubCraftWidget
- 버튼 및 목록 입력 처리
- ViewModel 데이터를 화면에 표시
전체적인 처리 흐름은 다음과 같다.
플레이어
↓ 상호작용
AFTHubWorkbench
↓ UI 실행 요청
UFTUIManagerSubsystem
↓ ViewModel 초기화
UFTCraftingViewModel
↓ 레시피 및 제작 요청
UFTCraftingSubsystem
↓ 데이터 변경
UFTInventoryComponent
CraftingSubsystem으로 제작 책임 이전
제작 기능의 중심은 UFTCraftingSubsystem이 담당한다.
UCLASS()
class PROJECTFT_API UFTCraftingSubsystem
: public UGameInstanceSubsystem
{
GENERATED_BODY()
public:
virtual void Initialize(
FSubsystemCollectionBase& Collection
) override;
bool FindRecipe(
FName RecipeID,
FTCraftRecipeStruct& OutRecipe
) const;
void GetCraftRecipes(
TArray<FTCraftRecipeStruct>& OutRecipes
) const;
bool IsRecipeUnlocked(FName RecipeID) const;
int32 GetCombinedItemCount(
const UFTInventoryComponent* PlayerInventory,
const UFTInventoryComponent* StorageInventory,
FName ItemID
) const;
bool CanCraftRecipe(
const FTCraftRecipeStruct& Recipe,
const UFTInventoryComponent* PlayerInventory,
const UFTInventoryComponent* StorageInventory
) const;
bool TryCraftRecipe(
FName RecipeID,
UFTInventoryComponent* PlayerInventory,
UFTInventoryComponent* StorageInventory
);
};
Subsystem은 특정 Workbench Actor를 직접 보관하지 않는다.
대신 제작 가능 여부를 판단하거나 제작을 실행하는 시점에 필요한 InventoryComponent를 전달받는다.
CraftingSubsystem
+ PlayerInventory
+ StorageInventory
+ RecipeID
= 제작 실행
이 구조를 사용하면 CraftingSubsystem이 월드 Actor에 종속되지 않는다.
Workbench는 제작 기능을 소유하는 객체가 아니라 제작 화면에 접근하게 해주는 Actor가 된다.
Workbench Actor의 역할 축소
리팩터링 이후 AFTHubWorkbench는 Tick을 사용하지 않는 얇은 Actor가 되었다.
AFTHubWorkbench::AFTHubWorkbench()
: HubStorage(nullptr)
{
PrimaryActorTick.bCanEverTick = false;
}
플레이어가 상호작용하면 제작 UI를 여는 요청만 전달한다.
bool AFTHubWorkbench::Interact_Implementation(
AActor* Interactor
)
{
OpenCraftWidget(Interactor);
return true;
}
제작 UI를 열 때는 플레이어 인벤토리와 창고 인벤토리를 UIManager에 전달한다.
void AFTHubWorkbench::OpenCraftWidget(AActor* Interactor)
{
if (UFTUIManagerSubsystem* UIManager =
FTHubActorUtils::GetUIManager(this))
{
UIManager->ShowCrafting(
FTHubActorUtils::FindPlayerInventory(
this,
Interactor
),
GetStorageInventory()
);
}
}
창고 인벤토리도 Workbench가 직접 관리하지 않고 AFTHubStorage에서 가져온다.
UFTInventoryComponent*
AFTHubWorkbench::GetStorageInventory() const
{
return HubStorage
? HubStorage->GetStorageInventory()
: nullptr;
}
이제 Workbench Actor는 다음 정보만 알고 있다.
- 플레이어가 작업대와 상호작용했다.
- 제작 UI를 열어야 한다.
- 플레이어 인벤토리와 창고 인벤토리를 전달해야 한다.
실제 제작 규칙은 전혀 알지 않는다.
CraftRecipe DataTable 기반 레시피 관리
제작 레시피는 FTCraftRecipeStruct 구조체를 사용한다.
USTRUCT(BlueprintType)
struct FTCraftRecipeStruct : public FTableRowBase
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, BlueprintReadWrite)
FName RecipeID;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
TArray<FTCraftIngredientStruct> RequiredItems;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
FName ResultItemID;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
int32 ResultCount = 1;
};
레시피는 크게 네 가지 정보를 가진다.
| RecipeID | 레시피를 식별하는 ID |
| RequiredItems | 제작에 필요한 재료 목록 |
| ResultItemID | 제작 결과 아이템 ID |
| ResultCount | 제작 결과 수량 |
필요 재료는 별도의 FTCraftIngredientStruct 배열로 관리한다.
RecipeID: Recipe_SugarWater
RequiredItems:
- Water x2
- Sugar x1
ResultItemID: SugarWater
ResultCount: 1
레시피를 Actor 생성자에 하드코딩하지 않고 DataTable로 분리하면서 다음과 같은 장점을 얻었다.
- C++ 재컴파일 없이 레시피 수정 가능
- 새로운 레시피 행 추가 가능
- 기획 데이터와 런타임 로직 분리
- Google Sheets 같은 외부 데이터와 연동 가능
- 모든 작업대가 같은 레시피 원본 사용
- 잘못된 테스트 레시피가 Actor마다 남는 문제 방지
GameDataAsset을 통한 DataTable 로드
CraftingSubsystem은 DataTable 경로를 직접 하드코딩하지 않는다.
프로젝트의 공통 설정을 관리하는 UFTGameDataAsset에서 CraftRecipe DataTable의 Soft Reference를 가져온다.
UPROPERTY(EditAnywhere)
TSoftObjectPtr<UDataTable> CraftRecipeDataTable;
Subsystem이 초기화될 때 레시피 데이터를 로드한다.
void UFTCraftingSubsystem::Initialize(
FSubsystemCollectionBase& Collection
)
{
Super::Initialize(Collection);
EnsureRecipeDataLoaded();
}
실제 로딩은 EnsureRecipeDataLoaded()에서 처리한다.
void UFTCraftingSubsystem::EnsureRecipeDataLoaded() const
{
if (CraftRecipeDataTable)
{
return;
}
const UFTGameDataAsset* GameData =
UFTAssetManager::Get().GetGameData();
if (!GameData)
{
return;
}
CraftRecipeDataTable =
UFTAssetManager::GetAsset(
GameData->CraftRecipeDataTable
);
}
이 구조를 사용하면 CraftingSubsystem은 구체적인 에셋 경로를 몰라도 된다.
CraftingSubsystem
↓
GameDataAsset
↓
CraftRecipeDataTable Soft Reference
↓
AssetManager를 통한 로드
레시피 테이블 위치가 변경되더라도 GameDataAsset 설정만 수정하면 된다.
레시피 조회
특정 레시피는 RecipeID를 사용해 DataTable에서 찾는다.
bool UFTCraftingSubsystem::FindRecipe(
const FName RecipeID,
FTCraftRecipeStruct& OutRecipe
) const
{
EnsureRecipeDataLoaded();
if (!CraftRecipeDataTable || RecipeID.IsNone())
{
return false;
}
const FTCraftRecipeStruct* Recipe =
CraftRecipeDataTable
->FindRow<FTCraftRecipeStruct>(
RecipeID,
TEXT("FindRecipe")
);
if (!Recipe)
{
return false;
}
if (!IsRecipeUnlocked(RecipeID))
{
return false;
}
OutRecipe = *Recipe;
return true;
}
단순히 DataTable에 존재하는지만 확인하지 않고 해당 레시피가 현재 해금된 상태인지도 검사한다.
전체 레시피 목록을 가져올 때도 해금된 레시피만 반환한다.
void UFTCraftingSubsystem::GetCraftRecipes(
TArray<FTCraftRecipeStruct>& OutRecipes
) const
{
OutRecipes.Reset();
EnsureRecipeDataLoaded();
if (!CraftRecipeDataTable)
{
return;
}
TArray<FTCraftRecipeStruct*> RecipeRows;
CraftRecipeDataTable
->GetAllRows<FTCraftRecipeStruct>(
TEXT("GetCraftRecipes"),
RecipeRows
);
for (const FTCraftRecipeStruct* Recipe : RecipeRows)
{
if (Recipe &&
IsRecipeUnlocked(Recipe->RecipeID))
{
OutRecipes.Add(*Recipe);
}
}
}
레시피 해금 상태 관리
레시피 데이터가 DataTable에 존재한다고 해서 모든 레시피를 바로 사용할 수 있는 것은 아니다.
현재 프로젝트에서는 SaveGame의 UnlockedRecipes 목록을 확인해 레시피 해금 여부를 판단한다.
bool UFTCraftingSubsystem::IsRecipeUnlocked(
const FName RecipeID
) const
{
if (RecipeID.IsNone())
{
return false;
}
const UFTGameInstance* FTGameInstance =
Cast<UFTGameInstance>(GetGameInstance());
const UFTSaveGame* SaveGame =
FTGameInstance
? FTGameInstance->CurrentSaveData
: nullptr;
if (SaveGame &&
!SaveGame->UnlockedRecipes.IsEmpty())
{
return SaveGame
->UnlockedRecipes
.Contains(RecipeID);
}
const UFTGameDataAsset* GameData =
UFTAssetManager::Get().GetGameData();
return GameData &&
GameData->bUnlockAllRecipesWhenSaveListEmpty;
}
저장 데이터에 해금 목록이 있다면 해당 목록을 기준으로 판단한다.
목록이 비어 있을 때 모든 레시피를 임시 해금할지는 GameDataAsset 설정으로 결정한다.
이를 통해 개발 단계에서는 모든 레시피를 테스트하고, 실제 게임에서는 SaveGame의 진행 상태에 따라 레시피를 제한할 수 있다.
제작 가능 여부를 판단하는 과정
제작 가능 여부는 CanCraftRecipe()에서 판단한다.
검사 순서는 다음과 같다.
- 레시피가 해금되어 있는가?
- 플레이어 또는 창고 인벤토리가 존재하는가?
- 재료 ID와 수량이 유효한가?
- 중복된 재료를 하나의 필요 수량으로 합산한다.
- 플레이어와 창고가 가진 수량을 합산한다.
- 모든 재료가 충분한지 확인한다.
bool UFTCraftingSubsystem::CanCraftRecipe(
const FTCraftRecipeStruct& Recipe,
const UFTInventoryComponent* PlayerInventory,
const UFTInventoryComponent* StorageInventory
) const
{
if (!IsRecipeUnlocked(Recipe.RecipeID) ||
(!PlayerInventory && !StorageInventory))
{
return false;
}
TMap<FName, int32> RequiredCounts;
for (const FTCraftIngredientStruct& Ingredient
: Recipe.RequiredItems)
{
if (Ingredient.ItemID.IsNone() ||
Ingredient.Count <= 0)
{
return false;
}
RequiredCounts.FindOrAdd(
Ingredient.ItemID
) += Ingredient.Count;
}
for (const TPair<FName, int32>& RequiredItem
: RequiredCounts)
{
const int32 OwnedCount =
GetCombinedItemCount(
PlayerInventory,
StorageInventory,
RequiredItem.Key
);
if (OwnedCount < RequiredItem.Value)
{
return false;
}
}
return true;
}
중복 재료를 합산하는 이유
DataTable에 같은 재료가 두 번 들어갈 가능성도 고려해야 한다.
Water x1
Water x2
Sugar x1
이를 각각 검사하면 Water x2만 가지고 있어도 두 조건을 개별적으로 통과할 수 있다.
하지만 실제 필요한 수량은 Water x3이다.
따라서 TMap<FName, int32>를 사용해 같은 ItemID의 수량을 합산한다.
TMap<FName, int32> RequiredCounts;
for (const FTCraftIngredientStruct& Ingredient
: Recipe.RequiredItems)
{
RequiredCounts.FindOrAdd(
Ingredient.ItemID
) += Ingredient.Count;
}
결과는 다음과 같이 정리된다.
Water → 3
Sugar → 1
이렇게 정규화한 뒤 실제 보유 수량과 비교해야 정확한 제작 가능 여부를 판단할 수 있다.
플레이어와 창고의 재료를 함께 검사하기
ProjectFT의 허브 경제 규칙에서는 플레이어 인벤토리와 창고 인벤토리를 함께 사용한다.
int32 UFTCraftingSubsystem::GetCombinedItemCount(
const UFTInventoryComponent* PlayerInventory,
const UFTInventoryComponent* StorageInventory,
const FName ItemID
) const
{
if (ItemID.IsNone())
{
return 0;
}
const int32 PlayerCount =
PlayerInventory
? PlayerInventory->GetItemQuantity(ItemID)
: 0;
const int32 StorageCount =
StorageInventory &&
StorageInventory != PlayerInventory
? StorageInventory->GetItemQuantity(ItemID)
: 0;
return PlayerCount + StorageCount;
}
예를 들어 물이 5개 필요한 레시피가 있다고 가정해 보자.
플레이어 인벤토리: 물 2개
창고 인벤토리: 물 3개
필요 수량: 물 5개
두 인벤토리의 수량을 합치면 총 5개이므로 제작할 수 있다.
같은 InventoryComponent가 두 번 전달됐을 때 수량을 중복 합산하지 않도록 다음 조건도 추가했다.
StorageInventory != PlayerInventory
재료 검사와 재료 소비를 분리한 이유
CanCraftRecipe()는 제작 가능 여부만 판단한다.
이 함수에서는 실제 인벤토리 데이터를 변경하지 않는다.
CanCraftRecipe
- 읽기 전용 검사
- UI 버튼 활성화에 사용
- 레시피 필터링에 사용
- 실제 아이템 수량은 변경하지 않음
실제 재료 소비와 결과 지급은 TryCraftRecipe()에서 처리한다.
TryCraftRecipe
- 레시피 재검증
- 소비 계획 생성
- 재료 제거
- 결과 지급
- 실패 시 복구
검사와 실행을 분리한 이유는 UI에서 제작 가능 여부를 여러 번 확인할 수 있기 때문이다.
예를 들어 다음 상황에서 CanCraftRecipe()가 호출된다.
- 레시피 목록을 처음 표시할 때
- 제작 가능한 레시피만 필터링할 때
- 플레이어가 레시피를 선택할 때
- 인벤토리 수량이 변경될 때
- 제작 버튼 활성화 상태를 갱신할 때
이 과정에서 실제 재료가 소비되면 안 된다.
제작 실행 전 다시 검증하기
UI에서 이미 제작 가능 상태로 표시했더라도, 제작 버튼을 누르는 시점에는 다시 검증해야 한다.
UI 표시 이후 다른 시스템에서 아이템을 사용할 수 있기 때문이다.
bool UFTCraftingSubsystem::TryCraftRecipe(
const FName RecipeID,
UFTInventoryComponent* PlayerInventory,
UFTInventoryComponent* StorageInventory
)
{
FTCraftRecipeStruct Recipe;
if (!PlayerInventory ||
!FindRecipe(RecipeID, Recipe) ||
Recipe.ResultItemID.IsNone() ||
Recipe.ResultCount <= 0 ||
!CanCraftRecipe(
Recipe,
PlayerInventory,
StorageInventory
))
{
return false;
}
// 재료 소비 및 결과 지급
}
제작 버튼이 활성화되어 있다는 사실만 믿고 실행하지 않는다.
Subsystem은 실행 직전에 다음 항목을 다시 검사한다.
- 플레이어 인벤토리 존재 여부
- 레시피 존재 여부
- 레시피 해금 여부
- 결과 아이템 ID
- 결과 아이템 수량
- 현재 재료 수량
재료 소비 계획 만들기
재료를 바로 제거하지 않고 먼저 어떤 인벤토리에서 몇 개를 소비할지 계획한다.
struct FFTCraftConsumption
{
TObjectPtr<UFTInventoryComponent> Inventory = nullptr;
FName ItemID = NAME_None;
int32 Count = 0;
};
ProjectFT에서는 플레이어 인벤토리를 먼저 소비하고, 부족한 수량을 창고에서 소비한다.
int32 RemainingCount =
RequiredCounts.FindChecked(ItemID);
const int32 RemoveFromPlayer =
FMath::Min(
PlayerInventory->GetItemQuantity(ItemID),
RemainingCount
);
if (RemoveFromPlayer > 0)
{
ConsumptionPlan.Add({
PlayerInventory,
ItemID,
RemoveFromPlayer
});
RemainingCount -= RemoveFromPlayer;
}
if (RemainingCount > 0 &&
StorageInventory &&
StorageInventory != PlayerInventory)
{
ConsumptionPlan.Add({
StorageInventory,
ItemID,
RemainingCount
});
}
예를 들어 물 5개가 필요할 때 플레이어가 2개, 창고가 3개를 가지고 있다면 다음 계획이 만들어진다.
ConsumptionPlan
1. PlayerInventory에서 Water 2개 제거
2. StorageInventory에서 Water 3개 제거
이 단계에서는 아직 실제 아이템을 제거하지 않는다.
먼저 전체 소비 계획을 완성한 다음 순서대로 적용한다.
실제 재료 소비와 실패 복구
소비 계획이 만들어지면 각 InventoryComponent에서 아이템을 제거한다.
TArray<FFTCraftConsumption> AppliedConsumptions;
for (const FFTCraftConsumption& Consumption
: ConsumptionPlan)
{
if (!Consumption.Inventory->RemoveItem(
Consumption.ItemID,
Consumption.Count
))
{
RollbackConsumptions(
AppliedConsumptions
);
return false;
}
AppliedConsumptions.Add(Consumption);
}
이미 적용된 소비 내역은 AppliedConsumptions에 기록한다.
중간에 하나라도 실패하면 앞에서 제거한 재료를 다시 추가한다.
bool RollbackConsumptions(
const TArray<FFTCraftConsumption>& AppliedConsumptions
)
{
bool bRollbackSucceeded = true;
for (int32 Index =
AppliedConsumptions.Num() - 1;
Index >= 0;
--Index)
{
const FFTCraftConsumption& Consumption =
AppliedConsumptions[Index];
if (!Consumption.Inventory ||
!Consumption.Inventory->AddItem(
Consumption.ItemID,
Consumption.Count
))
{
bRollbackSucceeded = false;
}
}
return bRollbackSucceeded;
}
복구는 적용 순서의 반대 방향으로 진행한다.
적용 순서
A → B → C
복구 순서
C → B → A
현재 InventoryComponent의 처리에서는 순서가 크게 중요하지 않을 수 있지만, 나중에 각 소비 과정에 추가 상태가 생길 가능성을 고려하면 역순 복구가 더 안전하다.
제작 결과 지급
모든 재료를 성공적으로 소비한 뒤 결과 아이템을 플레이어 인벤토리에 지급한다.
if (!PlayerInventory->AddItem(
Recipe.ResultItemID,
Recipe.ResultCount
))
{
RollbackConsumptions(
AppliedConsumptions
);
return false;
}
ProjectFT에서는 제작 재료를 플레이어와 창고에서 함께 사용할 수 있지만, 제작 결과는 플레이어 인벤토리에만 지급한다.
재료 확인:
플레이어 인벤토리 + 창고 인벤토리
재료 소비:
플레이어 인벤토리 우선
→ 부족한 수량은 창고에서 소비
제작 결과:
플레이어 인벤토리에 지급
결과 아이템 추가에 실패하면 이미 소비한 모든 재료를 복구한다.
따라서 제작 성공 조건은 다음과 같다.
모든 재료 소비 성공
+
결과 아이템 지급 성공
=
제작 성공
Crafting ViewModel의 역할
UFTCraftingViewModel은 제작 규칙을 직접 구현하지 않는다.
CraftingSubsystem의 데이터를 UI가 사용하기 좋은 형태로 변환하고, 사용자의 입력을 Subsystem에 전달한다.
초기화할 때 다음 참조를 전달받는다.
void UFTCraftingViewModel::Initialize(
UFTCraftingSubsystem* InCraftingSubsystem,
UFTInventoryComponent* InPlayerInventory,
UFTInventoryComponent* InStorageInventory
)
{
CraftingSubsystem = InCraftingSubsystem;
PlayerInventory = InPlayerInventory;
StorageInventory = InStorageInventory;
BindInventoryDelegates();
RefreshAll();
}
ViewModel이 관리하는 주요 UI 상태는 다음과 같다.
- 표시할 레시피 목록
- 제작 가능한 레시피만 보기
- 레시피 검색어
- 선택한 레시피
- 선택한 레시피의 결과 아이템
- 필요 재료 목록
- 플레이어와 창고의 재료 보유 수량
- 제작 버튼 활성화 여부
- 결과 아이템 아이콘
- 결과 수량
레시피를 ListObject로 변환하기
DataTable의 원본 구조체를 ListView에 직접 넣지 않고 UFTCraftRecipeListObject로 변환한다.
for (const FTCraftRecipeStruct& Recipe : Recipes)
{
const bool bRecipeCanCraft =
CraftingSubsystem->CanCraftRecipe(
Recipe,
PlayerInventory,
StorageInventory
);
if (!ShouldShowRecipe(
Recipe,
bRecipeCanCraft
))
{
continue;
}
UFTCraftRecipeListObject* RecipeObject =
NewObject<UFTCraftRecipeListObject>(this);
RecipeObject->Initialize(
Recipe,
bRecipeCanCraft
);
RecipeObjects.Add(RecipeObject);
}
ListObject에는 레시피 데이터와 현재 제작 가능 여부가 들어간다.
FTCraftRecipeStruct
+
bCanCraft
=
UFTCraftRecipeListObject
EntryWidget은 ListObject를 전달받아 다음 상태를 표시할 수 있다.
- 레시피 이름
- 결과 아이템
- 제작 가능 여부
- 제작 가능/불가능 색상
- 선택 상태
제작 가능한 레시피 필터링
ViewModel은 제작 가능한 레시피만 보여주는 필터를 제공한다.
bool UFTCraftingViewModel::ShouldShowRecipe(
const FTCraftRecipeStruct& Recipe,
const bool bRecipeCanCraft
) const
{
if (bCraftableOnly && !bRecipeCanCraft)
{
return false;
}
// 검색어 검사
return true;
}
사용자가 필터를 변경하면 ViewModel이 레시피 목록을 다시 만든다.
void UFTCraftingViewModel::SetCraftableOnly(
const bool bInCraftableOnly
)
{
bCraftableOnly = bInCraftableOnly;
RefreshRecipes();
NotifyChanged();
}
Widget은 어떤 레시피를 숨겨야 하는지 직접 판단하지 않는다.
레시피 검색
검색어는 RecipeID와 ResultItemID를 대상으로 검사한다.
const FString SearchString =
SearchText
.ToString()
.TrimStartAndEnd();
if (SearchString.IsEmpty())
{
return true;
}
return Recipe.RecipeID
.ToString()
.Contains(
SearchString,
ESearchCase::IgnoreCase
)
|| Recipe.ResultItemID
.ToString()
.Contains(
SearchString,
ESearchCase::IgnoreCase
);
검색어가 변경되면 레시피 목록을 다시 갱신한다.
void UFTCraftingViewModel::SetSearchText(
const FText& InSearchText
)
{
SearchText = InSearchText;
RefreshRecipes();
NotifyChanged();
}
이 기능 역시 Widget이 문자열을 직접 비교하지 않고 ViewModel에 검색 조건만 전달하는 구조다.
선택한 레시피의 UI 상태 가공
사용자가 레시피를 선택하면 ViewModel은 선택한 레시피의 상세 정보를 만든다.
void UFTCraftingViewModel::SelectRecipeObject(
UObject* RecipeObject
)
{
SelectedRecipeObject =
Cast<UFTCraftRecipeListObject>(
RecipeObject
);
SelectedRecipe =
SelectedRecipeObject
? SelectedRecipeObject
->GetRecipe()
.RecipeID
: NAME_None;
RefreshSelectedRecipeDetails();
NotifyChanged();
}
ViewModel은 결과 아이템의 DataAsset을 찾아 다음 정보를 UI용으로 가공한다.
- 아이템 이름
- 아이템 설명
- 아이템 아이콘
- 결과 수량
- 필요 재료
- 현재 보유 수량
- 제작 가능 여부
필요 재료는 현재 보유 수량과 함께 문자열로 만든다.
RequiredItems += FString::Printf(
TEXT("%s %d / %d"),
*Ingredient.ItemID.ToString(),
GetOwnedIngredientCount(
Ingredient.ItemID
),
Ingredient.Count
);
화면에는 다음과 같은 형태로 표시할 수 있다.
Water 2 / 3
Sugar 1 / 1
보유 수량은 플레이어와 창고의 수량을 합산한다.
int32 UFTCraftingViewModel::GetOwnedIngredientCount(
const FName ItemID
) const
{
return CraftingSubsystem
? CraftingSubsystem->GetCombinedItemCount(
PlayerInventory,
StorageInventory,
ItemID
)
: 0;
}
필요 재료 목록도 ListObject로 변환하기
필요 재료 목록 역시 원본 구조체를 Widget에 직접 전달하지 않는다.
void UFTCraftingViewModel::
RefreshRequiredItemObjects()
{
RequiredItemObjects.Reset();
if (!SelectedRecipeObject)
{
return;
}
for (const FTCraftIngredientStruct& Ingredient
: SelectedRecipeObject
->GetRecipe()
.RequiredItems)
{
UFTItemTileListObject* ItemObject =
NewObject<UFTItemTileListObject>(this);
ItemObject->InitializeIngredient(
Ingredient,
GetOwnedIngredientCount(
Ingredient.ItemID
)
);
RequiredItemObjects.Add(ItemObject);
}
}
이 ListObject에는 다음 정보가 들어간다.
- 재료 아이템 ID
- 필요 수량
- 현재 보유 수량
- 아이템 아이콘
- UI 표시 상태
이를 통해 제작 UI와 창고 UI에서 같은 아이템 Entry 구조를 재사용할 수 있다.
ViewModel에서 제작 요청하기
Widget의 제작 버튼을 누르면 ViewModel의 CraftSelectedRecipe()를 호출한다.
bool UFTCraftingViewModel::CraftSelectedRecipe()
{
if (!CraftingSubsystem ||
!PlayerInventory ||
!SelectedRecipeObject)
{
return false;
}
const FName RecipeID =
SelectedRecipeObject
->GetRecipe()
.RecipeID;
if (!CraftingSubsystem->TryCraftRecipe(
RecipeID,
PlayerInventory,
StorageInventory
))
{
return false;
}
RefreshAll();
return true;
}
ViewModel은 선택된 RecipeID와 InventoryComponent를 전달할 뿐이다.
실제 재료 검사, 소비, 복구, 결과 지급은 CraftingSubsystem이 담당한다.
Widget
→ “제작 버튼이 눌렸다”
ViewModel
→ “현재 선택된 레시피를 제작해 달라”
CraftingSubsystem
→ 레시피 검증
→ 재료 검사
→ 소비 계획 생성
→ 재료 소비
→ 결과 지급
→ 실패 시 복구
인벤토리 변경 시 제작 UI 갱신
재료가 변하면 제작 가능 여부도 달라진다.
따라서 ViewModel은 플레이어와 창고 InventoryComponent의 OnInventoryChanged Delegate를 구독한다.
void UFTCraftingViewModel::BindInventoryDelegates()
{
if (PlayerInventory)
{
PlayerInventory
->OnInventoryChanged
.AddDynamic(
this,
&UFTCraftingViewModel::
HandleInventoryChanged
);
}
if (StorageInventory &&
StorageInventory != PlayerInventory)
{
StorageInventory
->OnInventoryChanged
.AddDynamic(
this,
&UFTCraftingViewModel::
HandleInventoryChanged
);
}
}
인벤토리가 변경되면 전체 제작 상태를 다시 계산한다.
void UFTCraftingViewModel::HandleInventoryChanged()
{
RefreshAll();
}
void UFTCraftingViewModel::RefreshAll()
{
RefreshStorageItems();
RefreshRecipes();
NotifyChanged();
}
이 과정을 통해 다음 상태가 자동으로 갱신된다.
- 창고 아이템 목록
- 레시피별 제작 가능 여부
- 제작 가능한 레시피 필터 결과
- 필요 재료의 현재 보유 수량
- 제작 버튼 활성화 여부
- 선택한 레시피의 상세 정보
Widget이 직접 인벤토리를 다시 검사할 필요가 없다.
최종 제작 흐름
최종적인 제작 처리 흐름은 다음과 같다.
1. 플레이어가 Workbench와 상호작용한다.
2. Workbench가 UIManager에 제작 UI 실행을 요청한다.
3. UIManager가 Crafting ViewModel을 초기화한다.
4. ViewModel이 CraftingSubsystem에서
해금된 레시피 목록을 가져온다.
5. CraftingSubsystem이 플레이어와 창고의
재료 수량을 합산해 제작 가능 여부를 계산한다.
6. ViewModel이 레시피와 제작 가능 상태를
ListObject로 변환한다.
7. 사용자가 레시피를 선택하고
제작 버튼을 누른다.
8. ViewModel이 CraftingSubsystem에
제작을 요청한다.
9. CraftingSubsystem이 실행 직전
레시피와 재료를 다시 검증한다.
10. 플레이어 인벤토리 우선으로
재료 소비 계획을 만든다.
11. 재료를 순서대로 소비한다.
12. 중간에 실패하면 소비한 재료를 복구한다.
13. 모든 재료 소비가 성공하면
결과 아이템을 플레이어에게 지급한다.
14. InventoryChanged Delegate가 발생한다.
15. ViewModel이 레시피와 UI 상태를 갱신한다.
최종 구조
| AFTHubWorkbench | 작업대 상호작용과 UI 실행 |
| UFTCraftingSubsystem | 레시피 조회, 제작 검사 및 실행 |
| UDataTable | 제작 레시피 원본 데이터 |
| UFTGameDataAsset | 레시피 DataTable 참조 설정 |
| UFTInventoryComponent | 실제 아이템 데이터 저장과 변경 |
| UFTCraftingViewModel | 제작 UI 상태 가공과 제작 명령 전달 |
| UFTCraftRecipeListObject | ListView용 레시피 데이터 |
| UFTHubCraftWidget | 화면 표시와 사용자 입력 |
| UFTUIManagerSubsystem | 제작 Widget과 ViewModel 생명주기 관리 |
리팩터링하면서 배운 점
이번 작업을 통해 가장 크게 배운 점은 제작 가능 여부 확인과 실제 제작 실행은 반드시 분리해야 한다는 것이었다.
제작 가능 여부는 UI에서 자주 호출되는 읽기 전용 기능이다.
반면 제작 실행은 실제 데이터를 변경하며, 중간 실패 시 복구까지 고려해야 하는 기능이다.
두 기능을 분리하면서 다음과 같은 구조가 만들어졌다.
CanCraftRecipe
= 상태 확인
TryCraftRecipe
= 데이터 변경
또한 제작에 필요한 재료를 바로 제거하지 않고 소비 계획을 먼저 만든 뒤 적용하면서, 여러 인벤토리를 사용하는 제작 로직을 더 안전하게 처리할 수 있었다.
DataTable은 레시피 원본을 관리하고, SaveGame은 해금 상태를 관리하며, Subsystem은 실제 제작 규칙을 담당한다.
DataTable
= 무엇을 만들 수 있는가?
SaveGame
= 어떤 레시피가 해금되었는가?
InventoryComponent
= 현재 어떤 재료를 가지고 있는가?
CraftingSubsystem
= 지금 제작할 수 있는가?
제작하면 데이터를 어떻게 변경할 것인가?
각 데이터와 로직의 역할을 분리하면서 Workbench Actor와 Widget의 책임도 자연스럽게 줄어들었다.
이후 개선할 점
현재 제작 구조에서도 추가로 개선할 부분이 있다.
- 제작 시간 시스템 적용
- 제작 진행도와 취소 기능
- 여러 개 제작하기
- 결과 아이템의 무게 및 인벤토리 용량 검사
- 제작 실패 사유를 구체적인 결과 타입으로 반환
- 레시피 Tier 데이터의 실제 적용
- 제작 결과에 확률이나 품질 적용
- 레시피 해금 이벤트와 UI 실시간 연동
- 제작 대기열 시스템
- 서버 권한 기반 제작 처리
- 저장 중 제작 상태 복원
현재 TryCraftRecipe()는 성공 여부를 bool로 반환한다.
하지만 UI에서 정확한 실패 원인을 보여주려면 결과 타입을 세분화할 필요가 있다.
enum class EFTCraftResult : uint8
{
Success,
RecipeNotFound,
RecipeLocked,
InvalidRecipe,
NotEnoughMaterials,
InventoryFull,
MaterialConsumptionFailed,
RewardFailed,
RollbackFailed
};
이 결과를 ViewModel까지 전달하면 다음과 같은 메시지를 표시할 수 있다.
재료가 부족합니다.
아직 해금되지 않은 레시피입니다.
인벤토리에 공간이 부족합니다.
제작 처리 중 오류가 발생했습니다.
마무리
이번 제작 시스템 리팩터링을 통해 제작 기능의 책임을 다음과 같이 정리했다.
Workbench Actor는 상호작용 진입점
CraftingSubsystem은 제작 규칙
DataTable은 레시피 원본
SaveGame은 해금 상태
InventoryComponent는 실제 아이템 데이터
ViewModel은 UI용 제작 상태
Widget은 화면 표시와 입력
처음에는 Workbench Actor에서 제작 로직을 분리하는 작업이 단순한 코드 이동처럼 보였다.
하지만 실제로는 레시피 데이터, 해금 상태, 인벤토리 데이터, UI 상태, 제작 규칙의 경계를 나누는 작업이었다.
특히 재료 검사와 재료 소비를 분리하고, 소비 계획과 Rollback을 도입하면서 제작 도중 일부 작업만 성공해 데이터가 어긋나는 문제를 줄일 수 있었다.
Subsystem을 사용한 이유도 어디서든 쉽게 접근하기 위해서만은 아니다. 제작이라는 공통 게임 규칙을 특정 월드 Actor에서 분리하고, UI나 Actor가 바뀌어도 같은 제작 로직을 재사용하기 위한 설계 선택이었다.
'TIL' 카테고리의 다른 글
| [UE5] ProjectFT #11 중고거래 게시글 자동 생성 시스템 (0) | 2026.07.20 |
|---|---|
| [UE5] ProjectFT #10 상점 시스템을 데이터 중심 구조로 변경하기 (0) | 2026.07.16 |
| [UE5] ProjectFT #8 창고 시스템을 Subsystem으로 분리하기 (0) | 2026.07.14 |
| [UE5] ProjectFT#7 AI 협업 환경 구축 (0) | 2026.07.13 |
| [UE5] ProjectFT#6 컴퓨터 UI 구조 설계 (0) | 2026.07.10 |