ProjectFT의 허브 터미널에는 플레이어가 퀘스트를 확인하고 수락하거나 완료할 수 있는 퀘스트 UI가 있다.
초기 퀘스트 UI는 일반적인 게임의 퀘스트 목록처럼 구성했다.
왼쪽: 퀘스트 목록
오른쪽: 퀘스트 상세 정보
하단: 수락 버튼과 완료 버튼
기능적으로는 문제가 없었지만, 허브 터미널이 컴퓨터 형태의 UI로 바뀌면서 기존 퀘스트 목록이 전체 디자인과 어울리지 않았다.
또한 퀘스트를 “NPC에게 직접 받는 임무”가 아니라 “터미널로 전달받은 의뢰”처럼 표현하고 싶었다.
그래서 퀘스트 UI를 메일 클라이언트와 비슷한 구조로 변경했다.
메일 목록
= 퀘스트 목록
보낸 사람
= 퀘스트 의뢰인
메일 제목
= 퀘스트 이름
메일 본문
= 퀘스트 설명과 목표
첨부 정보
= 필요 아이템과 보상
메일 액션
= 수락 또는 완료
이번 글에서는 퀘스트 상태와 ViewModel, ListObject, Widget의 역할을 분리하면서 메일 형태의 UI를 구성한 과정을 정리한다.
기존 퀘스트 UI의 문제
초기 퀘스트 UI에는 상태별 목록과 여러 액션 버튼이 존재했다.
- 수락 가능한 퀘스트 목록
- 진행 중인 퀘스트 목록
- 완료된 퀘스트 목록
- 수락 버튼
- 완료 버튼
- 별도의 퀘스트 상세 패널
퀘스트 상태와 버튼이 일대일로 대응하기 때문에 구현은 직관적이었다.
하지만 화면에 표시해야 하는 요소가 많았다.
Available Tab
Active Tab
Completed Tab
Accept Button
Complete Button
1. 탭이 너무 세분화된다
퀘스트 상태는 시스템 내부적으로 다음 네 가지로 나뉜다.
UENUM(BlueprintType)
enum class EFTQuestStateType : uint8
{
Locked,
Available,
Active,
Completed
};
하지만 사용자에게 Available과 Active를 반드시 서로 다른 탭으로 보여줘야 하는 것은 아니다.
메일 UI 관점에서는 둘 다 아직 처리해야 할 메일이다.
Available
= 아직 수락하지 않은 새 의뢰
Active
= 수락했지만 아직 완료하지 않은 의뢰
Completed
= 처리가 끝난 의뢰
2. 수락과 완료 버튼이 동시에 보인다
선택한 퀘스트 상태에 따라 실제로 사용할 수 있는 버튼은 하나뿐인데, 수락 버튼과 완료 버튼을 모두 배치하면 화면이 복잡해진다.
수락 가능한 퀘스트
→ 수락 버튼만 필요
진행 중인 퀘스트
→ 조건 충족 시 완료 버튼만 필요
완료된 퀘스트
→ 액션 버튼 불필요
3. 목록이 단순한 이름 나열에 그친다
퀘스트 이름만 표시하면 어떤 의뢰인지 목록에서 구분하기 어렵다.
메일 UI처럼 보이려면 다음 정보가 필요했다.
- 보낸 사람
- 메일 제목
- 짧은 목표 요약
- 보상 요약
- 읽거나 선택한 상태
- 수락 여부와 완료 가능 여부
4. EntryWidget이 너무 많은 정보를 처리한다
목록 Entry에서 퀘스트의 모든 상세 정보를 보여주려고 하면 한 행이 지나치게 커진다.
반대로 목록에는 이름만 보여주면 메일 UI라는 느낌이 약해진다.
따라서 목록 Entry와 오른쪽 상세 패널의 역할을 나눌 필요가 있었다.
메일 UI를 선택한 이유
ProjectFT의 허브 터미널에는 상점, 중고거래, 퀘스트 앱이 들어 있다.
퀘스트를 메일 앱처럼 표현하면 터미널이라는 콘셉트와 자연스럽게 연결된다.
Quest Data
→ 의뢰 메일
Quest Sender
→ 메일 발신자
Quest Name
→ 메일 제목
Description
→ 메일 본문
Objective Lines
→ 해야 할 일
Reward
→ 의뢰 보상
또한 메일 UI는 퀘스트 상태를 사용자가 이해하기 쉬운 방식으로 표현할 수 있다.
진행 중
= 아직 처리해야 하는 메일
완료
= 처리가 끝난 메일
내부 상태는 Available, Active, Completed로 구분하지만, 화면에서는 두 개의 탭으로 단순화했다.
전체 구조
퀘스트 UI의 주요 객체는 다음과 같다.
UFTObjectiveSubsystem
- 실제 퀘스트 상태
- 수락 및 완료 규칙
- 조건 검사와 보상 지급
UFTQuestViewModel
- 상태별 퀘스트 목록 구성
- 선택한 퀘스트 관리
- 단일 액션 버튼의 동작 결정
- UI용 아이템 목록 생성
- 인벤토리 및 퀘스트 변경 감지
UFTQuestListObject
- 퀘스트 한 건의 UI용 데이터
- 목표 문구 가공
- 보상 요약 생성
- 수락 및 완료 가능 상태 보관
WBP_FTQuestEntryWidget
- 메일 목록 한 행 표시
- 발신자, 제목, 미리보기 표시
UFTHubQuestPanelWidget
- ViewModel 연결
- Blueprint에 변경 알림 전달
WBP_FTHubQuestPanelWidget
- 탭, 목록, 상세 패널, 액션 버튼 표시
퀘스트 데이터에 메일 정보 추가
기존 FTQuestStruct에는 퀘스트 이름과 설명만 있었다.
메일 형태의 UI를 만들기 위해 SenderName과 ObjectiveLines를 추가했다.
USTRUCT(BlueprintType)
struct PROJECTFT_API FTQuestStruct
: public FTableRowBase
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadOnly)
FName QuestID;
UPROPERTY(EditAnywhere, BlueprintReadOnly)
FText QuestName;
UPROPERTY(EditAnywhere, BlueprintReadOnly)
FText SenderName;
UPROPERTY(EditAnywhere, BlueprintReadOnly)
FText Description;
UPROPERTY(EditAnywhere, BlueprintReadOnly)
TArray<FText> ObjectiveLines;
UPROPERTY(EditAnywhere, BlueprintReadOnly)
TArray<FTCraftIngredientStruct>
RequiredItems;
UPROPERTY(EditAnywhere, BlueprintReadOnly)
TArray<FFTQuestConditionStruct>
EventConditions;
UPROPERTY(EditAnywhere, BlueprintReadOnly)
TArray<FTCraftIngredientStruct>
RewardItems;
UPROPERTY(EditAnywhere, BlueprintReadOnly)
int32 CurrencyReward = 0;
};
각 필드는 UI에서 다음과 같이 사용한다.
| SenderName | 보낸 사람 |
| QuestName | 메일 제목 |
| Description | 본문 |
| ObjectiveLines | 목표 목록 |
| RequiredItems | 제출 아이템 |
| RewardItems | 아이템 보상 |
| CurrencyReward | 화폐 보상 |
Description과 ObjectiveLines 분리
Description과 ObjectiveLines는 비슷해 보이지만 역할이 다르다.
Description
퀘스트의 배경과 상황을 설명한다.
마트의 냉장 진열대에 물이 부족합니다.
레이드에서 물을 확보한 뒤 안전하게 돌아와 주세요.
ObjectiveLines
플레이어가 실제로 수행해야 하는 목표를 간단하게 보여준다.
- 물 3개 확보
- 레이드에서 탈출
- 허브 터미널에 제출
데이터를 분리하면 상세 패널을 다음처럼 구성할 수 있다.
[메일 본문]
마트의 냉장 진열대에 물이 부족합니다.
레이드에서 물을 확보한 뒤 안전하게 돌아와 주세요.
[요청 사항]
- 물 3개 확보
- 레이드에서 탈출
- 허브 터미널에 제출
두 개의 탭으로 단순화하기
화면에는 진행 중과 완료 두 개의 탭만 둔다.
진행 중 탭
현재 구현에서 진행 중 탭은 Available과 Active 퀘스트를 함께 보여준다.
if (CurrentQuestFilter ==
EFTQuestStateType::Active)
{
ObjectiveSubsystem->GetQuestListByState(
EFTQuestStateType::Available,
Quests
);
TArray<FTQuestStruct> ActiveQuests;
ObjectiveSubsystem->GetQuestListByState(
EFTQuestStateType::Active,
ActiveQuests
);
Quests.Append(ActiveQuests);
}
즉, UI에서 사용하는 Active 필터는 시스템의 Active 상태만 의미하지 않는다.
진행 중 탭
├─ Available
└─ Active
아직 처리할 일이 남아 있는 퀘스트를 한곳에 보여주는 메일함 역할이다.
완료 탭
완료 탭에는 Completed 상태만 표시한다.
ObjectiveSubsystem->GetQuestListByState(
EFTQuestStateType::Completed,
Quests
);
완료 탭
└─ Completed
탭 카운트 계산
진행 중 탭의 숫자도 Available과 Active를 합산한다.
int32 UFTQuestViewModel::
GetActiveQuestCount() const
{
if (!ObjectiveSubsystem)
{
return 0;
}
TArray<FTQuestStruct> AvailableQuests;
ObjectiveSubsystem->GetQuestListByState(
EFTQuestStateType::Available,
AvailableQuests
);
TArray<FTQuestStruct> ActiveQuests;
ObjectiveSubsystem->GetQuestListByState(
EFTQuestStateType::Active,
ActiveQuests
);
return AvailableQuests.Num()
+ ActiveQuests.Num();
}
완료 카운트는 완료된 퀘스트만 센다.
int32 UFTQuestViewModel::
GetCompletedQuestCount() const
{
TArray<FTQuestStruct> CompletedQuests;
ObjectiveSubsystem->GetQuestListByState(
EFTQuestStateType::Completed,
CompletedQuests
);
return CompletedQuests.Num();
}
화면에서는 다음과 같이 표현할 수 있다.
진행 중 5
완료 12
탭 변경
ViewModel은 현재 선택된 필터를 관리한다.
EFTQuestStateType CurrentQuestFilter =
EFTQuestStateType::Active;
탭이 바뀌면 기존 선택을 제거하고 목록을 다시 만든다.
void UFTQuestViewModel::SetQuestFilter(
const EFTQuestStateType NewQuestFilter
)
{
if (CurrentQuestFilter ==
NewQuestFilter)
{
return;
}
CurrentQuestFilter =
NewQuestFilter;
ClearSelection();
RefreshAll();
}
Widget은 어떤 퀘스트 상태를 가져와야 하는지 직접 판단하지 않는다.
Widget
→ 진행 중 탭 클릭
→ ViewModel.SetQuestFilter(Active)
Widget
→ 완료 탭 클릭
→ ViewModel.SetQuestFilter(Completed)
수락과 완료 버튼을 하나로 통합하기
기존에는 수락 버튼과 완료 버튼을 각각 배치했다.
BTN_Accept
BTN_Complete
하지만 선택한 퀘스트에서 실제로 가능한 행동은 한 가지뿐이다.
따라서 하나의 공용 액션 버튼으로 통합했다.
BTN_QuestAction
버튼의 기능은 ViewModel이 현재 선택한 퀘스트 상태를 확인해 결정한다.
bool UFTQuestViewModel::
ExecuteSelectedQuestAction()
{
if (CanAcceptSelectedQuest())
{
return AcceptSelectedQuest();
}
if (CanCompleteSelectedQuest())
{
return CompleteSelectedQuest();
}
return false;
}
Widget은 버튼이 수락인지 완료인지 직접 판단하지 않는다.
Widget
→ 공용 버튼 클릭
→ ViewModel.ExecuteSelectedQuestAction()
수락 가능한 상태 판단
선택한 퀘스트가 Available 상태라면 수락할 수 있다.
bool UFTQuestViewModel::
CanAcceptSelectedQuest() const
{
const FTQuestStruct* Quest =
GetSelectedQuest();
return Quest
&& ObjectiveSubsystem
&& ObjectiveSubsystem
->IsQuestAvailable(
Quest->QuestID
);
}
이 상태에서 공용 버튼은 다음처럼 표현할 수 있다.
버튼 문구: 수락
버튼 활성화: true
완료 가능한 상태 판단
퀘스트가 Active 상태라고 해서 항상 완료할 수 있는 것은 아니다.
필요 아이템과 이벤트 조건, 보상 지급 가능 여부를 모두 확인해야 한다.
bool UFTQuestViewModel::
CanCompleteSelectedQuest() const
{
const FTQuestStruct* Quest =
GetSelectedQuest();
return Quest
&& ObjectiveSubsystem
&& ObjectiveSubsystem
->IsQuestActive(
Quest->QuestID
)
&& ObjectiveSubsystem
->CanCompleteQuest(
*Quest,
PlayerInventory
);
}
완료 가능한 상태라면 공용 버튼은 다음처럼 표시한다.
버튼 문구: 완료
버튼 활성화: true
조건을 아직 충족하지 못했다면 다음처럼 처리할 수 있다.
버튼 문구: 진행 중
버튼 활성화: false
완료된 퀘스트에서는 액션 버튼을 숨기거나 비활성화한다.
공용 버튼의 상태표
| Available | 수락 가능 | 수락 | 퀘스트 수락 |
| Active | 완료 조건 미충족 | 진행 중 | 비활성 |
| Active | 완료 조건 충족 | 완료 | 퀘스트 제출 |
| Completed | 완료됨 | 없음 | 버튼 숨김 또는 비활성 |
| 선택 없음 | 없음 | 없음 | 버튼 숨김 |
ViewModel은 버튼 실행 가능 여부를 하나의 함수로 제공한다.
bool UFTQuestViewModel::
CanExecuteSelectedQuestAction() const
{
return CanAcceptSelectedQuest()
|| CanCompleteSelectedQuest();
}
퀘스트 수락 처리
공용 버튼이 수락 동작을 수행하면 ObjectiveSubsystem에 수락을 요청한다.
bool UFTQuestViewModel::
AcceptSelectedQuest()
{
const FTQuestStruct* Quest =
GetSelectedQuest();
if (!Quest || !ObjectiveSubsystem)
{
return false;
}
const bool bAccepted =
ObjectiveSubsystem->AcceptQuest(
Quest->QuestID
);
if (!bAccepted)
{
return false;
}
CurrentQuestFilter =
EFTQuestStateType::Active;
RefreshAll();
return true;
}
퀘스트 수락 후에도 같은 진행 중 탭을 유지한다.
퀘스트 상태만 Available에서 Active로 변경된다.
수락 전
진행 중 탭 / Available
수락 후
진행 중 탭 / Active
퀘스트 완료 처리
선택한 퀘스트가 완료 가능한 상태라면 TryCompleteQuest()를 호출한다.
bool UFTQuestViewModel::
CompleteSelectedQuest()
{
const FTQuestStruct* Quest =
GetSelectedQuest();
if (!Quest || !ObjectiveSubsystem)
{
return false;
}
const bool bCompleted =
ObjectiveSubsystem->TryCompleteQuest(
Quest->QuestID,
PlayerInventory
);
if (!bCompleted)
{
return false;
}
ClearSelection();
RefreshAll();
return true;
}
완료된 퀘스트는 진행 중 목록에서 사라진다.
완료 탭으로 이동하면 다시 확인할 수 있다.
진행 중 목록
→ 완료 버튼
→ TryCompleteQuest
→ Completed 상태
→ 진행 중 목록에서 제거
→ 완료 목록에 표시
거래 중 중복 Refresh 방지
퀘스트를 수락하거나 완료하면 여러 상태가 한 번에 변한다.
- 퀘스트 상태 변경
- 필요 아이템 소비
- 보상 아이템 추가
- 화폐 보상 추가
- Objective Delegate 발생
- Inventory Delegate 발생
각 Delegate가 즉시 RefreshAll()을 호출하면 한 번의 트랜잭션 동안 목록이 여러 차례 갱신될 수 있다.
이를 방지하기 위해 ViewModel은 bTransactionInProgress를 사용한다.
bTransactionInProgress = true;
const bool bCompleted =
ObjectiveSubsystem->TryCompleteQuest(
Quest->QuestID,
PlayerInventory
);
bTransactionInProgress = false;
Delegate Handler에서는 트랜잭션 진행 여부를 확인한다.
void UFTQuestViewModel::
HandleInventoryChanged()
{
if (!bTransactionInProgress)
{
RefreshAll();
}
}
실제 액션이 끝난 뒤 ViewModel이 한 번만 명시적으로 갱신한다.
QuestListObject의 역할
원본 FTQuestStruct를 ListView에 직접 넣지 않고 UFTQuestListObject로 변환한다.
UFTQuestListObject* QuestObject =
NewObject<UFTQuestListObject>(this);
QuestObject->Initialize(
Quest,
ObjectiveSubsystem->CanCompleteQuest(
Quest,
PlayerInventory
),
ObjectiveSubsystem->IsQuestActive(
Quest.QuestID
)
);
QuestObjects.Add(QuestObject);
ListObject에는 원본 데이터와 UI 상태가 함께 들어간다.
FTQuestStruct
+
bCanComplete
+
bAccepted
+
가공된 목표 문구
+
가공된 보상 문구
=
UFTQuestListObject
목표 문구 가공
ObjectiveLines는 배열 형태로 저장되어 있다.
ListObject에서는 UI가 사용하기 편하도록 줄바꿈된 하나의 FText로 만든다.
FString ObjectiveText;
for (const FText& ObjectiveLine
: Quest.ObjectiveLines)
{
if (ObjectiveLine.IsEmpty())
{
continue;
}
if (!ObjectiveText.IsEmpty())
{
ObjectiveText +=
LINE_TERMINATOR;
}
ObjectiveText +=
FString::Printf(
TEXT("- %s"),
*ObjectiveLine.ToString()
);
}
결과는 다음과 같다.
- 물 3개 확보
- 레이드에서 탈출
- 터미널에 제출
가공된 결과를 ObjectiveLinesText에 저장한다.
ObjectiveLinesText =
ObjectiveText.IsEmpty()
? FText::GetEmpty()
: FText::FromString(
ObjectiveText
);
목표 요약 Fallback
ObjectiveLines가 비어 있다면 퀘스트 설명을 목표 요약으로 사용한다.
ObjectiveSummary =
ObjectiveLinesText.IsEmpty()
? Quest.Description
: ObjectiveLinesText;
따라서 모든 퀘스트가 반드시 별도의 ObjectiveLines를 가질 필요는 없다.
ObjectiveLines 존재
→ 목표 문구 사용
ObjectiveLines 없음
→ Description 사용
이 Fallback을 통해 기존 퀘스트 데이터와도 호환할 수 있다.
보상 문구 가공
ListObject는 화폐 보상과 아이템 보상을 하나의 요약으로 만든다.
FString RewardText;
if (Quest.CurrencyReward > 0)
{
RewardText = FString::Printf(
TEXT("%d Coin"),
Quest.CurrencyReward
);
}
아이템 보상도 표시 이름과 수량으로 변환한다.
100 Coin, 물 x2, 설탕 x1
이 결과는 목록 미리보기나 상세 패널의 보상 영역에서 재사용할 수 있다.
발신자 정보
ListObject는 원본 Quest의 발신자 정보를 제공한다.
UFUNCTION(BlueprintPure)
FText GetSenderName() const
{
return Quest.SenderName;
}
메일 목록에서는 다음처럼 표시할 수 있다.
[허브 관리팀]
물자 확보 요청
[정비 담당자]
작업대 부품 수집
발신자 이름이 추가되면서 같은 종류의 퀘스트도 서로 다른 의뢰처럼 표현할 수 있게 되었다.
목록 EntryWidget의 역할
현재 C++에는 별도의 UFTQuestEntryWidget 클래스가 없고, WBP_FTQuestEntryWidget이 ListObject를 받아 표시하는 구조다.
EntryWidget은 메일 목록의 한 행만 담당한다.
권장 표시 정보는 다음과 같다.
- 발신자
- 퀘스트 이름
- 목표 요약
- 보상 요약
- 수락 여부
- 완료 가능 여부
- 선택 상태
┌────────────────────────────┐
│ 허브 관리팀 │
│ 물자 확보 요청 │
│ 물 3개 확보, 레이드 탈출 │
│ 보상: 100 Coin │
└────────────────────────────┘
EntryWidget은 퀘스트 수락이나 완료를 직접 처리하지 않는다.
EntryWidget
→ 클릭된 ListObject 전달
Quest Panel
→ ViewModel.SelectQuestObject()
ViewModel
→ 선택 상태와 상세 데이터 갱신
EntryWidget에 로직을 넣지 않는 이유
목록 Entry는 ListView에서 재생성되거나 재사용될 수 있다.
EntryWidget이 직접 ObjectiveSubsystem을 참조하고 퀘스트 상태를 변경하면 다음 문제가 생긴다.
- 재사용되는 Entry에 이전 상태가 남을 수 있다.
- 목록과 상세 패널의 선택 상태가 어긋날 수 있다.
- 여러 Entry가 같은 퀘스트 로직을 중복으로 가진다.
- 수락 및 완료 후 목록 갱신이 복잡해진다.
따라서 Entry는 전달받은 ListObject를 표시하는 데 집중한다.
EntryWidget
= 목록 표현
ListObject
= 한 행에 필요한 데이터
ViewModel
= 선택과 액션
ObjectiveSubsystem
= 실제 퀘스트 상태
상세 패널의 역할
오른쪽 상세 패널은 현재 선택한 퀘스트 전체 내용을 보여준다.
표시할 수 있는 정보는 다음과 같다.
- 발신자
- 퀘스트 이름
- 본문
- 목표 문구
- 필요 아이템
- 이벤트 조건
- 아이템 보상
- 화폐 보상
- 현재 퀘스트 상태
- 공용 액션 버튼
┌───────────────────────────────┐
│ From: 허브 관리팀 │
│ Subject: 물자 확보 요청 │
├───────────────────────────────┤
│ 마트의 물자가 부족합니다. │
│ 필요한 물품을 확보해 주세요. │
│ │
│ 요청 사항 │
│ - 물 3개 확보 │
│ - 레이드에서 탈출 │
│ │
│ 보상 │
│ 100 Coin │
│ │
│ [수락] │
└───────────────────────────────┘
목록 Entry는 빠르게 훑어보는 정보를 제공하고, 상세 패널은 실제 행동을 결정하는 정보를 제공한다.
퀘스트 선택 처리
사용자가 목록에서 퀘스트를 선택하면 ViewModel에 ListObject를 전달한다.
void UFTQuestViewModel::
SelectQuestObject(UObject* ItemObject)
{
UFTQuestListObject*
NewSelectedQuestObject =
Cast<UFTQuestListObject>(
ItemObject
);
if (SelectedQuestObject ==
NewSelectedQuestObject)
{
return;
}
SelectedQuestObject =
NewSelectedQuestObject;
RefreshSelectedQuestItems();
NotifyChanged();
}
같은 항목을 다시 선택한 경우에는 불필요한 갱신을 하지 않는다.
선택이 변경되면 필요 아이템과 보상 아이템 목록을 다시 만든다.
필요 아이템을 UI용 객체로 변환하기
필요 아이템은 UFTItemTileListObject로 변환한다.
for (const FTCraftIngredientStruct& RequiredItem
: Quest->RequiredItems)
{
UFTItemTileListObject* ItemObject =
NewObject<UFTItemTileListObject>(
this
);
ItemObject->InitializeIngredient(
RequiredItem
);
ItemObject->SetShowSelectionCheckBox(
false
);
RequiredItemObjects.Add(
ItemObject
);
}
Quest Panel은 ViewModel의 RequiredItemObjects를 TileView에 전달한다.
퀘스트 상세 화면의 아이템은 선택 대상이 아니므로 체크박스를 숨긴다.
보상 아이템 목록
보상 아이템도 동일한 UFTItemTileListObject를 사용한다.
for (const FTCraftIngredientStruct& RewardItem
: Quest->RewardItems)
{
UFTItemTileListObject* ItemObject =
NewObject<UFTItemTileListObject>(
this
);
ItemObject->InitializeIngredient(
RewardItem
);
ItemObject->SetShowSelectionCheckBox(
false
);
RewardItemObjects.Add(
ItemObject
);
}
창고, 제작, 상점에서 사용하던 아이템 Entry 구조를 퀘스트 UI에서도 재사용할 수 있다.
아이템 목표가 없는 퀘스트 처리
모든 퀘스트가 아이템 목표를 가지는 것은 아니다.
예를 들어 다음 퀘스트는 이벤트 조건만 필요하다.
보안요원에게 발각되기
레이드에서 탈출하기
상점에서 아이템 구매하기
매대 훼손하기
이런 퀘스트의 RequiredItems는 비어 있다.
ViewModel은 선택한 퀘스트에 아이템 목표가 있는지 제공한다.
bool UFTQuestViewModel::
HasSelectedQuestRequiredItems() const
{
const FTQuestStruct* Quest =
GetSelectedQuest();
return Quest &&
!Quest->RequiredItems.IsEmpty();
}
Blueprint에서는 이 값을 이용해 필요 아이템 영역을 숨길 수 있다.
HasSelectedQuestRequiredItems == true
→ Required Items 제목 표시
→ TileView 표시
HasSelectedQuestRequiredItems == false
→ Required Items 제목 숨김
→ TileView 숨김
단순히 빈 TileView만 남겨두는 것보다 영역 전체를 Collapsed 처리하는 것이 좋다.
아이템 목표가 없어도 목표 문구는 표시하기
아이템 목표가 없다고 해서 퀘스트 목표가 없는 것은 아니다.
이벤트형 퀘스트는 ObjectiveLines를 사용해 목표를 표시한다.
RequiredItems
= 비어 있음
EventConditions
- Event.Raid.Escaped x1
ObjectiveLines
- 레이드에서 탈출
UI에서는 아이템 영역을 숨기고 목표 문구만 보여준다.
요청 사항
- 레이드에서 탈출
데이터 구조와 화면 구조를 분리했기 때문에 아이템형 퀘스트와 이벤트형 퀘스트를 같은 상세 패널에서 처리할 수 있다.
선택 상태 유지
ViewModel이 목록을 갱신할 때마다 선택이 사라지면 사용성이 떨어진다.
따라서 갱신 전 선택된 QuestID를 저장하고, 새 목록에서 같은 ID를 찾는다.
void UFTQuestViewModel::RefreshAll()
{
const FName PreviousQuestID =
SelectedQuestObject
? SelectedQuestObject
->GetQuest()
.QuestID
: NAME_None;
RefreshQuestList();
RestoreSelection(PreviousQuestID);
RefreshSelectedQuestItems();
NotifyChanged();
}
같은 퀘스트가 목록에 남아 있다면 선택을 복원한다.
if (QuestObject &&
QuestObject->GetQuest().QuestID ==
PreviousQuestID)
{
SelectedQuestObject = QuestObject;
return;
}
기존 선택이 사라졌다면 첫 번째 항목을 자동으로 선택한다.
SelectedQuestObject =
QuestObjects.IsEmpty()
? nullptr
: Cast<UFTQuestListObject>(
QuestObjects[0]
);
퀘스트 목록 정렬
목록은 퀘스트 이름을 기준으로 정렬하고, 이름이 같으면 QuestID를 사용한다.
Quests.Sort(
[](const FTQuestStruct& Left,
const FTQuestStruct& Right)
{
const int32 NameComparison =
Left.QuestName
.ToString()
.Compare(
Right.QuestName.ToString(),
ESearchCase::IgnoreCase
);
return NameComparison == 0
? Left.QuestID.LexicalLess(
Right.QuestID
)
: NameComparison < 0;
}
);
정렬 기준이 없으면 DataTable이나 TSet에서 가져온 순서에 따라 목록이 달라질 수 있다.
메일 목록의 표시 순서를 안정적으로 유지하기 위해 명시적인 정렬을 적용했다.
ViewModel 변경 알림
UFTHubQuestPanelWidget은 ViewModel의 OnChanged Delegate를 구독한다.
ViewModel->OnChanged.AddDynamic(
this,
&UFTHubQuestPanelWidget::
RefreshFromViewModel
);
ViewModel이 갱신되면 C++ Widget은 Blueprint Event를 호출한다.
void UFTHubQuestPanelWidget::
RefreshFromViewModel()
{
if (ViewModel)
{
BP_OnQuestViewModelChanged(
ViewModel
);
}
}
Blueprint에서는 이 이벤트를 받아 다음 요소를 갱신한다.
- 탭 카운트
- 퀘스트 목록
- 선택된 메일
- 발신자
- 제목
- 본문
- 목표 문구
- 필요 아이템
- 보상
- 액션 버튼 문구와 활성화 상태
C++ Panel을 얇게 유지하기
현재 UFTHubQuestPanelWidget에는 구체적인 BindWidget 필드가 없다.
C++ Widget의 역할은 다음 정도로 제한했다.
- ViewModel 찾기 또는 생성
- ViewModel 초기화
- ViewModel Delegate 구독
- Blueprint에 변경 알림 전달
- 종료 시 Delegate 해제
UFTHubQuestPanelWidget
= ViewModel 연결 계층
WBP_FTHubQuestPanelWidget
= 실제 화면 구성과 표현
UI 레이아웃과 디자인을 변경하더라도 C++ 로직을 수정할 가능성이 줄어든다.
EntryWidget과 상세 패널의 역할 분리
최종 역할은 다음과 같이 정리했다.
Quest EntryWidget
목적
= 메일 목록을 빠르게 훑어보기
표시 정보
- 발신자
- 제목
- 짧은 목표 요약
- 보상 요약
- 상태
Quest Detail Panel
목적
= 선택한 의뢰를 자세히 읽고 행동 결정
표시 정보
- 전체 본문
- 전체 목표
- 필요 아이템
- 보상 아이템
- 화폐 보상
- 수락 및 완료 버튼
Quest ViewModel
목적
= 목록과 상세 화면에 필요한 데이터 가공
담당
- 탭 필터
- 선택 상태
- 액션 결정
- ListObject 생성
- 아이템 ListObject 생성
- 상태 변경 감지
ObjectiveSubsystem
목적
= 실제 퀘스트 규칙 처리
담당
- 상태 관리
- 수락
- 진행도
- 완료 가능 여부
- 필요 아이템 소비
- 보상 지급
최종 UI 처리 흐름
1. 플레이어가 터미널에서
퀘스트 앱을 연다.
2. Quest Panel이
Quest ViewModel을 초기화한다.
3. ViewModel이 ObjectiveSubsystem에서
Available과 Active 퀘스트를 가져온다.
4. 각 퀘스트를
UFTQuestListObject로 변환한다.
5. Blueprint가 ListObject를
메일 EntryWidget에 표시한다.
6. 사용자가 메일을 선택한다.
7. ViewModel이 선택된 퀘스트의
필요 아이템과 보상 목록을 만든다.
8. 상세 패널이 발신자, 제목,
본문, 목표와 보상을 표시한다.
9. ViewModel이 퀘스트 상태에 따라
공용 버튼의 동작을 결정한다.
10. 사용자가 공용 버튼을 누른다.
11. Available이면 수락하고,
완료 가능한 Active면 제출한다.
12. ObjectiveSubsystem과 인벤토리의
변경 알림이 발생한다.
13. ViewModel이 목록과 상세 화면을
다시 갱신한다.
최종 구조
| FTQuestStruct | 퀘스트 원본 데이터 |
| UFTObjectiveSubsystem | 실제 퀘스트 상태와 규칙 |
| UFTQuestViewModel | 목록, 선택, 액션 상태 관리 |
| UFTQuestListObject | 메일 한 건의 UI용 데이터 |
| WBP_FTQuestEntryWidget | 메일 목록 한 행 표시 |
| UFTHubQuestPanelWidget | C++ ViewModel 연결 |
| WBP_FTHubQuestPanelWidget | 메일 UI 화면 구성 |
| UFTItemTileListObject | 필요 및 보상 아이템 데이터 |
리팩터링하면서 배운 점
이번 작업에서 가장 크게 배운 점은 시스템 내부 상태와 사용자에게 보여줄 분류가 반드시 같을 필요는 없다는 것이었다.
시스템에서는 퀘스트를 다음처럼 구분한다.
Locked
Available
Active
Completed
하지만 UI에서는 사용자가 처리해야 할 항목인지 여부가 더 중요했다.
진행 중
= Available + Active
완료
= Completed
수락 버튼과 완료 버튼도 시스템 기능만 생각하면 별개지만, 사용자 관점에서는 선택한 메일에 대해 실행할 수 있는 하나의 행동이다.
선택된 상태에 맞는 액션 하나
이 관점으로 UI를 설계하면서 화면 요소를 줄이고 퀘스트 상태 판단은 ViewModel에 모을 수 있었다.
또한 퀘스트 데이터에 SenderName과 ObjectiveLines를 추가하면서 원본 게임 데이터를 UI 요구에 맞게 어떻게 확장해야 하는지도 배울 수 있었다.
이후 개선할 점
현재 메일형 퀘스트 UI에서도 개선할 부분이 있다.
- 읽음과 읽지 않음 상태
- 새로운 메일 알림
- 수락 가능한 퀘스트와 진행 중 퀘스트의 시각적 구분
- 발신자 프로필 이미지
- 퀘스트 중요도와 카테고리
- 마감 시간
- 검색과 정렬
- 퀘스트 고정 기능
- 보상 수령 연출
- 완료 퀘스트 보관 및 삭제
- 다음 퀘스트 연결 표시
- 목표별 현재 진행 수량 표시
- ListObject에 명시적인 QuestState 저장
- 액션 버튼 문구를 ViewModel에서 직접 제공
- 선택 상태를 QuestID로 관리
- 현지화를 고려한 보상 문구 생성
현재 ViewModel은 CanAcceptSelectedQuest()와 CanCompleteSelectedQuest()를 제공하지만 버튼 문구 자체는 Blueprint가 결정한다.
다음처럼 액션 상태를 하나의 Enum으로 제공하면 분기를 더 명확하게 만들 수 있다.
UENUM(BlueprintType)
enum class EFTQuestActionType : uint8
{
None,
Accept,
InProgress,
Complete,
Completed
};
EFTQuestActionType
UFTQuestViewModel::
GetSelectedQuestActionType() const
{
if (CanAcceptSelectedQuest())
{
return EFTQuestActionType::Accept;
}
if (CanCompleteSelectedQuest())
{
return EFTQuestActionType::Complete;
}
if (GetSelectedQuestState() ==
EFTQuestStateType::Active)
{
return EFTQuestActionType::InProgress;
}
if (GetSelectedQuestState() ==
EFTQuestStateType::Completed)
{
return EFTQuestActionType::Completed;
}
return EFTQuestActionType::None;
}
Blueprint에서는 이 값만 보고 버튼 문구, 활성화, Visibility를 결정할 수 있다.
마무리
이번 작업을 통해 퀘스트 UI의 역할을 다음과 같이 정리했다.
ObjectiveSubsystem은 실제 퀘스트 상태를 관리한다.
Quest ViewModel은 퀘스트를
메일 UI에 맞는 데이터로 변환한다.
Quest ListObject는 목록 한 행에
필요한 정보와 상태를 가진다.
EntryWidget은 메일 목록을 표시한다.
상세 패널은 선택한 퀘스트의
전체 정보와 공용 액션 버튼을 보여준다.
진행 중 탭에는 수락 가능한 퀘스트와 활성 퀘스트를 함께 표시하고, 완료된 퀘스트는 별도의 완료 탭으로 분리했다.
수락과 완료 버튼은 하나의 공용 액션 버튼으로 통합하고, 선택한 퀘스트의 상태에 따라 ViewModel이 동작을 결정하도록 만들었다.
또한 발신자, 본문, 목표 문구, 보상 요약을 분리하면서 일반적인 퀘스트 목록을 허브 터미널에 어울리는 메일 클라이언트 형태로 표현할 수 있었다.
'TIL' 카테고리의 다른 글
| [UE5] ProjectFT#17 프로젝트 마무리(데이터 기반 거점 시스템 구현 — 퀘스트부터 제작, 창고, 레벨 이동까지) (0) | 2026.07.28 |
|---|---|
| [UE5] ProjectFT #16 퀘스트 보상과 데이터 구조 확장 (0) | 2026.07.27 |
| [UE5] ProjectFT #14 Gameplay Message를 이용한 시스템 간 통신 (0) | 2026.07.23 |
| [UE5] ProjectFT #13 이벤트 기반 퀘스트 진행 시스템 (0) | 2026.07.22 |
| [UE5] ProjectFT #12 허브 경제의 인벤토리 통합 규칙 (0) | 2026.07.21 |