TIL

[UE5] ProjectFT #13 이벤트 기반 퀘스트 진행 시스템

think95592 2026. 7. 22. 20:47

ProjectFT의 퀘스트에는 여러 종류의 목표가 존재한다.

  • 특정 아이템 획득
  • 아이템 사용
  • 제작 완료
  • 상점 구매 및 판매
  • 매대 훼손 또는 파괴
  • 절도 완료
  • NPC에게 발각
  • 보안요원 호출
  • 레이드 진입
  • 레이드 탈출

초기에는 퀘스트 진행도를 UI가 직접 확인하거나, 일정 시간마다 현재 상태를 검사하는 방식을 고려했다.

하지만 퀘스트 종류가 늘어나면서 이런 방식은 시스템 간 의존성을 크게 만들었다.

이번 작업에서는 UGameplayMessageSubsystem과 UFTObjectiveSubsystem을 중심으로 게임에서 발생한 사건을 메시지로 전달하고, 활성 퀘스트와 일치하는 조건만 갱신하는 이벤트 기반 구조를 만들었다.

현재 구조에서는 아이템 목표와 이벤트 목표를 서로 다르게 관리한다.

아이템 목표
= 현재 플레이어와 창고의 실제 보유 수량으로 계산

이벤트 목표
= GameplayMessage를 수신해 누적 진행도로 기록

퀘스트 진행률은 두 값을 합산해 계산하고, 화면은 상태 변화 알림을 받은 시점에 필요한 데이터를 다시 조회한다.


Tick으로 퀘스트를 검사하면 안 되는 이유

가장 단순한 방법은 ObjectiveSubsystem이나 Widget의 Tick에서 퀘스트 조건을 계속 확인하는 것이다.

void UQuestWidget::NativeTick(...)
{
    if (PlayerInventory->GetItemQuantity(ItemID)
        >= RequiredCount)
    {
        // 퀘스트 완료
    }
}

구현은 간단하지만 여러 문제가 생긴다.

1. 변화가 없어도 계속 검사한다

아이템을 획득하지 않았는데도 매 프레임 인벤토리 수량을 확인한다.

60 FPS
× 퀘스트 개수
× 조건 개수
× 플레이 시간

퀘스트가 많아질수록 의미 없는 검사가 계속 쌓인다.

2. 이벤트성 조건을 표현하기 어렵다

“보안요원에게 한 번 발각되기”, “아이템을 세 번 제작하기”, “레이드에서 탈출하기” 같은 조건은 현재 상태만 확인해서는 처리하기 어렵다.

현재 보안요원이 플레이어를 보고 있는가?

이 값만으로는 과거에 몇 번 발각됐는지 알 수 없다.

이벤트가 발생한 순간을 기록해야 한다.

3. UI가 게임 규칙을 알게 된다

Widget이 퀘스트 조건을 직접 검사하면 UI가 다음 내용을 알아야 한다.

  • 활성 퀘스트 목록
  • 필요 아이템
  • 이벤트 조건
  • 플레이어 인벤토리
  • 창고 인벤토리
  • 탈출 상태
  • 완료 조건

UI는 화면 표시를 담당해야 하는데 퀘스트 시스템 일부가 되어버린다.

4. Widget이 닫혀 있으면 진행도가 갱신되지 않는다

UI가 퀘스트 진행을 관리하면 해당 Widget이 생성되지 않았거나 화면에서 제거된 동안에는 이벤트를 놓칠 수 있다.

퀘스트 진행은 UI 존재 여부와 관계없이 동작해야 한다.


Polling 대신 이벤트 사용하기

퀘스트 시스템이 모든 객체의 상태를 계속 확인하는 대신, 사건이 발생한 객체가 메시지를 발행하도록 구성했다.

아이템 획득
→ Event.Item.PickedUp

제작 성공
→ Event.Craft.Completed

상점 구매
→ Event.Shop.Purchased

절도 완료
→ Event.Steal.Completed

레이드 탈출
→ Event.Raid.Escaped

ObjectiveSubsystem은 자신에게 필요한 메시지만 구독한다.

사건 발생 객체
→ GameplayMessage 발행
→ ObjectiveSubsystem 수신
→ 활성 퀘스트 조건 확인
→ 진행도 변경
→ UI 갱신 알림

퀘스트 시스템이 아이템 Actor, 상점, 작업대, GameFlowSubsystem을 직접 참조할 필요가 없다.


UFTObjectiveSubsystem의 역할

퀘스트의 런타임 상태는 UFTObjectiveSubsystem이 관리한다.

주요 책임은 다음과 같다.

  • 퀘스트 DataTable 참조
  • 수락 가능한 퀘스트 관리
  • 활성 퀘스트 관리
  • 완료된 퀘스트 관리
  • 이벤트 진행도 저장
  • 아이템 목표 진행도 계산
  • 퀘스트 수락 및 제출
  • 보상 지급
  • 다음 퀘스트 해금
  • 퀘스트 상태 저장 및 복원
  • 진행도 변경 메시지 발행

런타임 퀘스트 상태는 ID 집합으로 관리한다.

TSet<FName> AvailableQuestIDs;
TSet<FName> ActiveQuestIDs;
TSet<FName> CompletedQuestIDs;

이벤트 조건의 누적 진행도는 퀘스트별 배열로 관리한다.

TMap<FName, TArray<int32>>
    EventConditionProgressByQuest;

예를 들어 하나의 퀘스트에 이벤트 조건이 두 개 있다면 다음과 같이 저장할 수 있다.

Quest_A

EventConditions
[0] 아이템 제작 3회
[1] 레이드 탈출 1회

Progress
[0] 2
[1] 0

이벤트 메시지 구독

ObjectiveSubsystem은 초기화할 때 퀘스트에서 사용할 메시지를 구독한다.

void UFTObjectiveSubsystem::Initialize(
    FSubsystemCollectionBase& Collection
)
{
    Super::Initialize(Collection);

    UGameplayMessageSubsystem& MessageSubsystem =
        UGameplayMessageSubsystem::Get(this);

    ObjectiveListenerHandles.Add(
        MessageSubsystem.RegisterListener(
            TAG_FT_Event_RaidEscaped,
            this,
            &ThisClass::HandleRaidEscapedMessage
        )
    );

    ObjectiveListenerHandles.Add(
        MessageSubsystem.RegisterListener(
            TAG_FT_Event_ItemConsumed,
            this,
            &ThisClass::HandleQuestMessage
        )
    );

    ObjectiveListenerHandles.Add(
        MessageSubsystem.RegisterListener(
            TAG_FT_Event_CraftCompleted,
            this,
            &ThisClass::
                HandleCountedItemQuestMessage
        )
    );

    ObjectiveListenerHandles.Add(
        MessageSubsystem.RegisterListener(
            TAG_FT_Event_ShopPurchased,
            this,
            &ThisClass::
                HandleCountedItemQuestMessage
        )
    );
}

현재는 다음과 같은 이벤트를 퀘스트 조건으로 사용할 수 있다.

  • 레이드 시작, 성공, 실패
  • 아이템 사용
  • 제작 완료
  • 상점 구매와 판매
  • 허브 컴퓨터 접근
  • 매대 훼손과 파괴
  • 절도 완료
  • 플레이어 사망
  • NPC 발견과 신고
  • 보안요원 호출
  • 플레이어 체포와 탈출
  • 보안요원 추격 시작과 종료
  • 보안요원 배치와 복귀

리스너 해제

Subsystem이 종료될 때 등록한 리스너를 해제한다.

void UFTObjectiveSubsystem::Deinitialize()
{
    UGameplayMessageSubsystem& MessageSubsystem =
        UGameplayMessageSubsystem::Get(this);

    for (FGameplayMessageListenerHandle& Handle
        : ObjectiveListenerHandles)
    {
        if (Handle.IsValid())
        {
            MessageSubsystem
                .UnregisterListener(Handle);
        }
    }

    ObjectiveListenerHandles.Reset();

    Super::Deinitialize();
}

메시지 리스너를 해제하지 않으면 종료된 객체를 호출하거나 중복으로 이벤트를 받는 문제가 발생할 수 있다.

등록과 해제는 항상 하나의 생명주기로 관리해야 한다.


퀘스트 조건 구조체

GameplayMessage 기반 퀘스트 조건은 FFTQuestConditionStruct로 정의했다.

USTRUCT(BlueprintType)
struct PROJECTFT_API FFTQuestConditionStruct
{
    GENERATED_BODY()

    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    FGameplayTag EventTag;

    UPROPERTY(
        EditAnywhere,
        BlueprintReadOnly,
        meta = (ClampMin = "1")
    )
    int32 RequiredCount = 1;

    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    FName ItemID = NAME_None;

    bool IsValid() const
    {
        return EventTag.IsValid()
            && RequiredCount > 0;
    }
};

각 필드의 의미는 다음과 같다.

필드역할
EventTag 어떤 사건을 추적할지 결정
RequiredCount 몇 번 발생해야 하는지 결정
ItemID 특정 아이템 이벤트만 추적할 때 사용

ItemID가 비어 있으면 해당 태그의 모든 이벤트를 진행도에 반영한다.

EventTag: Event.Craft.Completed
ItemID: None
RequiredCount: 3

→ 어떤 아이템이든 제작 성공 3회

특정 ItemID가 들어 있으면 해당 아이템의 이벤트만 반영한다.

EventTag: Event.Craft.Completed
ItemID: ID_Food_Water
RequiredCount: 3

→ Water 제작 성공만 3회

퀘스트 데이터 구조

퀘스트는 아이템 목표와 이벤트 목표를 모두 가질 수 있다.

USTRUCT(BlueprintType)
struct FTQuestStruct : public FTableRowBase
{
    GENERATED_BODY()

    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    FName QuestID;

    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    TArray<FTCraftIngredientStruct>
        RequiredItems;

    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    TArray<FFTQuestConditionStruct>
        EventConditions;

    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    TArray<FTCraftIngredientStruct>
        RewardItems;

    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    int32 CurrencyReward = 0;
};

두 목표는 서로 다른 의미를 가진다.

RequiredItems
= 퀘스트 제출 시 실제로 보유해야 하는 아이템

EventConditions
= 플레이 중 특정 사건이 발생했는지 기록하는 조건

예를 들어 “설탕 3개를 획득하고 탈출하기”는 다음과 같이 표현할 수 있다.

RequiredItems
- Sugar x3

EventConditions
- Event.Raid.Escaped x1

아이템 획득 메시지 발행

월드 아이템과 상호작용하면 Item Actor는 인벤토리를 직접 수정하지 않고 Event.Item.PickedUp 메시지를 발행한다.

bool AFTItemActor::Interact_Implementation(
    AActor* Interactor
)
{
    if (!ItemData)
    {
        return false;
    }

    UGameplayMessageSubsystem& MessageSubsystem =
        UGameplayMessageSubsystem::Get(this);

    FFTMessagePayloadStruct Payload;
    Payload.ItemId =
        ItemData->ItemData.ItemId;
    Payload.InstigatorActor = Interactor;
    Payload.TargetActor = this;
    Payload.Value = 1.0f;

    MessageSubsystem.BroadcastMessage(
        TAG_FT_Event_ItemPickedUp,
        Payload
    );

    DestroyItem();
    return true;
}

메시지 Payload에는 다음 정보가 포함된다.

USTRUCT(BlueprintType)
struct FFTMessagePayloadStruct
{
    GENERATED_BODY()

    TObjectPtr<AActor> InstigatorActor;
    TObjectPtr<AActor> TargetActor;
    float Value = 0.0f;
    FName ItemId = NAME_None;
    FName QuestId = NAME_None;
};

아이템 획득 메시지에서는 각 값을 다음과 같이 사용한다.

InstigatorActor
= 아이템을 획득한 플레이어

TargetActor
= 획득 대상 Item Actor

ItemId
= 획득한 아이템 ID

Value
= 획득 수량

InventoryComponent가 획득 메시지를 처리한다

UFTInventoryComponent는 Event.Item.PickedUp을 구독한다.

PickedUpListenerHandle =
    MessageSubsystem.RegisterListener<
        FFTMessagePayloadStruct
    >(
        TAG_FT_Event_ItemPickedUp,
        this,
        &UFTInventoryComponent::
            HandleItemPickedUpMessage
    );

메시지의 획득 주체가 자신의 Owner인지 확인하고 아이템을 추가한다.

void UFTInventoryComponent::
HandleItemPickedUpMessage(
    FGameplayTag Channel,
    const FFTMessagePayloadStruct& Payload
)
{
    AActor* Owner = GetOwner();

    if (Payload.TargetActor == Owner ||
        Payload.InstigatorActor == Owner ||
        (
            Payload.TargetActor == nullptr &&
            Payload.InstigatorActor == nullptr
        ))
    {
        const int32 QuantityToAdd =
            FMath::Max(
                1,
                static_cast<int32>(
                    Payload.Value
                )
            );

        AddItem(
            Payload.ItemId,
            QuantityToAdd
        );
    }
}

아이템 획득 흐름은 다음과 같다.

Item Actor
→ Event.Item.PickedUp 발행
→ Player InventoryComponent 수신
→ AddItem 실행
→ OnInventoryChanged 발생

Item Actor와 InventoryComponent가 직접 참조로 연결되지 않아도 메시지를 통해 획득 처리를 수행할 수 있다.


아이템 획득과 퀘스트 진행도 연결

초기 이벤트 기반 설계에서는 ObjectiveSubsystem이 Event.Item.PickedUp을 직접 구독하고 획득 횟수를 누적하는 방식도 고려했다.

하지만 현재 구조에서는 아이템 목표를 별도의 누적 숫자로 저장하지 않는다.

대신 플레이어와 창고가 현재 보유한 실제 수량을 기준으로 진행도를 계산한다.

int32 UFTObjectiveSubsystem::
GetQuestItemProgressTotal(
    const FTQuestStruct& Quest,
    UFTInventoryComponent* PlayerInventory
) const
{
    int32 ItemProgressTotal = 0;

    for (const FTCraftIngredientStruct& RequiredItem
        : Quest.RequiredItems)
    {
        const int32 RequiredCount =
            FMath::Max(
                0,
                RequiredItem.Count
            );

        if (!RequiredItem.ItemID.IsNone() &&
            RequiredCount > 0)
        {
            ItemProgressTotal += FMath::Min(
                GetCombinedItemCount(
                    PlayerInventory,
                    RequiredItem.ItemID
                ),
                RequiredCount
            );
        }
    }

    return ItemProgressTotal;
}

현재의 아이템 진행 흐름은 다음과 같다.

Event.Item.PickedUp
→ InventoryComponent에 아이템 추가
→ OnInventoryChanged 발생
→ 퀘스트 UI가 진행도를 다시 조회
→ ObjectiveSubsystem이 현재 보유 수량 계산

이 방식은 아이템을 획득했다가 사용하거나 버린 경우에도 현재 제출 가능한 수량을 정확하게 보여준다.


획득 누적과 현재 보유 수량의 차이

두 방식은 서로 다른 의미를 가진다.

획득 누적 방식

물 3개 획득
→ 진행도 3/3

이후 물 3개 사용
→ 진행도는 계속 3/3

“아이템을 획득한 경험”이 목표라면 이 방식이 적합하다.

현재 보유 수량 방식

물 3개 획득
→ 진행도 3/3

이후 물 2개 사용
→ 진행도 1/3

“아이템을 가져와 제출하는 것”이 목표라면 이 방식이 적합하다.

ProjectFT의 RequiredItems는 제출용 아이템의 의미를 가지므로 현재 보유 수량으로 계산한다.

정말로 “아이템을 획득한 횟수”를 추적해야 한다면 Event.Item.PickedUp을 EventConditions에 등록해 별도의 누적 목표로 관리할 수 있다.


활성 퀘스트만 추적하기

이벤트가 발생했다고 해서 모든 퀘스트 데이터를 검사하거나 변경하면 안 된다.

ApplyQuestEvent()는 ActiveQuestIDs만 순회한다.

void UFTObjectiveSubsystem::ApplyQuestEvent(
    FGameplayTag EventTag,
    FName ItemID,
    int32 Count
)
{
    if (!EventTag.IsValid() ||
        Count <= 0)
    {
        return;
    }

    for (const FName& QuestID
        : ActiveQuestIDs)
    {
        const FTQuestStruct* Quest =
            FindQuestByID(QuestID);

        if (!Quest ||
            Quest->EventConditions.IsEmpty())
        {
            continue;
        }

        // 조건 검사
    }
}

수락하지 않은 퀘스트나 이미 완료된 퀘스트는 진행하지 않는다.

Available
→ 아직 수락하지 않음
→ 이벤트 추적 안 함

Active
→ 진행 중
→ 이벤트 추적

Completed
→ 완료됨
→ 이벤트 추적 안 함

필요한 이벤트만 추적하기

활성 퀘스트 안에서도 현재 발생한 태그와 일치하는 조건만 갱신한다.

if (!Condition.IsValid() ||
    Condition.EventTag != EventTag)
{
    continue;
}

특정 아이템 필터가 있다면 ItemID까지 비교한다.

if (!Condition.ItemID.IsNone() &&
    Condition.ItemID != ItemID)
{
    continue;
}

예를 들어 다음 퀘스트가 활성 상태라고 가정해 보자.

Quest_A
Event.Craft.Completed
ItemID: Water
RequiredCount: 3

각 이벤트의 처리 결과는 다음과 같다.

Water 제작
→ 태그 일치
→ ItemID 일치
→ 진행도 증가

Sugar 제작
→ 태그 일치
→ ItemID 불일치
→ 무시

Water 구매
→ 태그 불일치
→ 무시

모든 메시지가 모든 퀘스트 진행도를 변경하지 않도록 조건을 좁혀준다.


이벤트 횟수 누적

조건이 일치하면 현재 진행도에 Count를 더한다.

const int32 PreviousProgress =
    ProgressValues[ConditionIndex];

ProgressValues[ConditionIndex] =
    FMath::Min(
        PreviousProgress + Count,
        Condition.RequiredCount
    );

필요 수량을 초과하지 않도록 최대값을 제한한다.

필요 횟수: 3
현재 진행도: 2
이번 이벤트 Count: 5

2 + 5 = 7
Min(7, 3) = 3

최종 진행도: 3/3

실제로 값이 변경된 경우에만 알림을 보낸다.

bProgressChanged |=
    ProgressValues[ConditionIndex]
        != PreviousProgress;

if (bProgressChanged)
{
    BroadcastQuestProgressChanged(
        QuestID
    );
}

이미 완료된 조건에 같은 이벤트가 다시 들어와도 불필요한 UI 갱신 메시지를 발생시키지 않는다.


수량이 포함된 이벤트

제작이나 상점 거래에서는 한 번의 요청으로 여러 아이템을 처리할 수 있다.

이 경우 Payload의 Value를 진행 수량으로 사용한다.

void UFTObjectiveSubsystem::
HandleCountedItemQuestMessage(
    FGameplayTag Channel,
    const FFTMessagePayloadStruct& Payload
)
{
    const int32 ItemCount =
        FMath::Max(
            1,
            FMath::RoundToInt(
                Payload.Value
            )
        );

    ApplyQuestEvent(
        Channel,
        Payload.ItemId,
        ItemCount
    );
}

예를 들어 물을 3개 제작했다면 한 번의 메시지로 진행도를 3 증가시킬 수 있다.

Channel: Event.Craft.Completed
ItemId: Water
Value: 3

전체 퀘스트 진행률 계산

퀘스트 전체 진행률은 아이템 목표와 이벤트 목표를 합산해 계산한다.

float UFTObjectiveSubsystem::GetQuestProgress(
    FName QuestID
) const
{
    const FTQuestStruct* Quest =
        FindQuestByID(QuestID);

    if (!Quest)
    {
        return 0.0f;
    }

    const int32 RequiredTotal =
        GetQuestRequiredTotal(*Quest)
        + GetQuestEventRequiredTotal(*Quest);

    if (RequiredTotal <= 0)
    {
        return 1.0f;
    }

    const int32 ProgressTotal =
        GetQuestItemProgressTotal(
            *Quest,
            ResolvePlayerInventory()
        )
        + GetQuestEventProgressTotal(*Quest);

    return FMath::Clamp(
        static_cast<float>(ProgressTotal)
            / static_cast<float>(
                RequiredTotal
            ),
        0.0f,
        1.0f
    );
}

예를 들어 다음 퀘스트가 있다고 가정해 보자.

RequiredItems
- Sugar x3

EventConditions
- Event.Raid.Escaped x1

전체 필요 수량은 4다.

설탕 3개 보유, 아직 탈출 전
→ 아이템 진행 3
→ 이벤트 진행 0
→ 3 / 4
→ 75%

설탕 3개 보유, 탈출 완료
→ 아이템 진행 3
→ 이벤트 진행 1
→ 4 / 4
→ 100%

Event.Objective.ProgressChanged 설계

퀘스트 진행도가 변경되면 ObjectiveSubsystem은 두 가지 알림을 보낸다.

void UFTObjectiveSubsystem::
BroadcastQuestProgressChanged(
    FName QuestID
)
{
    OnQuestStateChanged.Broadcast(QuestID);

    UGameplayMessageSubsystem& MessageSubsystem =
        UGameplayMessageSubsystem::Get(this);

    FFTMessagePayloadStruct Payload;
    Payload.QuestId = QuestID;
    Payload.Value =
        GetQuestProgress(QuestID);

    MessageSubsystem.BroadcastMessage(
        TAG_FT_Event_ObjectiveProgressChanged,
        Payload
    );
}

로컬 Delegate

OnQuestStateChanged.Broadcast(QuestID);

ObjectiveSubsystem을 직접 참조하고 있는 ViewModel 등이 사용할 수 있다.

GameplayMessage

Event.Objective.ProgressChanged

ObjectiveSubsystem을 직접 참조하지 않는 HUD나 UIManager가 사용할 수 있다.

Payload에는 변경된 퀘스트와 전체 진행률이 들어간다.

QuestId
= 진행도가 변경된 퀘스트 ID

Value
= 0.0~1.0 범위의 전체 진행률

왜 UI에 직접 접근하지 않는가

ObjectiveSubsystem에서 다음과 같이 Widget을 직접 찾을 수도 있다.

QuestWidget->Refresh();

하지만 이 방식은 ObjectiveSubsystem이 UI 클래스와 생성 상태를 알아야 한다.

이벤트 방식에서는 ObjectiveSubsystem이 메시지만 발행한다.

ObjectiveSubsystem
→ “Quest_A 진행도가 0.75로 변경됐다”

HUD
→ 메시지를 받고 화면 갱신

Quest Widget
→ 메시지를 받고 목록 갱신

UIManager
→ 메시지를 받고 HUD ViewModel 갱신

새로운 UI가 추가되어도 ObjectiveSubsystem 코드를 수정할 필요가 없다.


HUD에서 진행도 메시지 사용하기

UIManagerSubsystem은 Event.Objective.ProgressChanged를 구독한다.

UIMessageListenerHandles.Add(
    MessageSubsystem.RegisterListener(
        TAG_FT_Event_ObjectiveProgressChanged,
        this,
        &ThisClass::
            HandleObjectiveProgressChanged
    )
);

메시지를 받으면 진행률을 퍼센트로 변환해 HUD ViewModel에 전달한다.

void UFTUIManagerSubsystem::
HandleObjectiveProgressChanged(
    FGameplayTag Channel,
    const FFTMessagePayloadStruct& Payload
)
{
    if (!HUDViewModel)
    {
        return;
    }

    const int32 ProgressPercent =
        FMath::RoundToInt(
            FMath::Clamp(
                Payload.Value,
                0.0f,
                1.0f
            ) * 100.0f
        );

    HUDViewModel->SetObjectiveText(
        FText::FromString(
            FString::Printf(
                TEXT("Quest %s %d%%"),
                *Payload.QuestId.ToString(),
                ProgressPercent
            )
        )
    );
}

ObjectiveSubsystem은 HUD의 구조를 모른다.

HUD도 퀘스트 진행도를 매 프레임 검사하지 않는다.


Quest Widget의 갱신 방식

Quest Widget도 진행도 변경 메시지를 구독한다.

QuestProgressListenerHandle =
    MessageSubsystem.RegisterListener(
        TAG_FT_Event_ObjectiveProgressChanged,
        this,
        &ThisClass::
            HandleQuestProgressChanged
    );

메시지를 받으면 현재 활성 퀘스트 목록과 진행도를 다시 가져온다.

void UFTQuestListWidget::
HandleQuestProgressChanged(
    FGameplayTag Channel,
    const FFTMessagePayloadStruct& Payload
)
{
    RefreshQuestList();
}

UI는 Payload만으로 모든 화면을 직접 만들지 않는다.

메시지는 “상태가 변경됐다”는 사실을 전달하고, 실제 화면 데이터는 ObjectiveSubsystem에서 다시 조회한다.

알림 메시지
= 무엇인가 변경됐다

ObjectiveSubsystem 조회
= 현재 정확한 상태가 무엇인가

아이템 목표는 Inventory Delegate로 갱신한다

현재 아이템 목표는 저장된 누적값이 아니라 실제 보유 수량으로 계산한다.

따라서 Quest Widget은 플레이어와 창고 InventoryComponent의 OnInventoryChanged에도 바인딩한다.

if (BoundPlayerInventory)
{
    BoundPlayerInventory
        ->OnInventoryChanged
        .AddUniqueDynamic(
            this,
            &ThisClass::
                HandleInventoryChanged
        );
}

if (BoundStorageInventory &&
    BoundStorageInventory
        != BoundPlayerInventory)
{
    BoundStorageInventory
        ->OnInventoryChanged
        .AddUniqueDynamic(
            this,
            &ThisClass::
                HandleInventoryChanged
        );
}

인벤토리 수량이 변경되면 퀘스트 목록을 다시 그린다.

void UFTQuestListWidget::
HandleInventoryChanged()
{
    RefreshQuestList();
}

현재 구조의 알림 경로는 두 가지다.

이벤트 조건 변경
→ Event.Objective.ProgressChanged
→ 퀘스트 UI 갱신

아이템 보유 수량 변경
→ OnInventoryChanged
→ 퀘스트 UI 갱신

둘 모두 매 프레임 검사하지 않고 실제 상태가 변경된 시점에만 화면을 갱신한다.


아이템 획득과 완료 조건 분리

아이템을 모두 획득했다고 해서 퀘스트를 바로 완료 처리하지 않는다.

아이템 목표 충족
≠ 퀘스트 완료

아이템 목표 충족은 완료 가능 조건 중 하나일 뿐이다.

실제 완료 가능 여부는 다음 항목을 모두 확인한다.

bool UFTObjectiveSubsystem::CanCompleteQuest(
    const FTQuestStruct& Quest,
    UFTInventoryComponent* PlayerInventory
) const
{
    if (!AreQuestEventConditionsCompleted(
        Quest
    ))
    {
        return false;
    }

    for (const FTCraftIngredientStruct& RequiredItem
        : Quest.RequiredItems)
    {
        if (GetCombinedItemCount(
                PlayerInventory,
                RequiredItem.ItemID
            ) < RequiredItem.Count)
        {
            return false;
        }
    }

    return CanGrantQuestRewards(
        Quest,
        PlayerInventory
    );
}

퀘스트 완료를 위해 다음 조건이 필요하다.

  • 퀘스트가 활성 상태
  • 모든 이벤트 조건 충족
  • 모든 필요 아이템 보유
  • 보상을 플레이어 인벤토리에 지급 가능
  • 플레이어가 터미널에서 완료를 요청

아이템 획득 후 탈출해야 완료되는 구조

“아이템을 획득하고 레이드에서 탈출하기” 퀘스트는 아이템 목표와 탈출 이벤트를 함께 가진다.

RequiredItems
- QuestItem x3

EventConditions
- Event.Raid.Escaped x1

레이드 탈출 시 GameFlowSubsystem이 다음 메시지를 발행한다.

Event.Raid.Escaped

ObjectiveSubsystem은 이 이벤트를 별도 Handler로 받는다.

void UFTObjectiveSubsystem::
HandleRaidEscapedMessage(
    FGameplayTag Channel,
    const FFTMessagePayloadStruct& Payload
)
{
    ApplyQuestEvent(Channel);
}

중요한 점은 탈출 메시지를 받았다고 즉시 퀘스트를 완료하지 않는다는 것이다.

Event.Raid.Escaped 수신
→ 탈출 조건 진행도만 기록
→ 보상 지급하지 않음
→ 퀘스트 완료 상태로 변경하지 않음

실제 완료는 플레이어가 허브로 돌아와 터미널에서 퀘스트를 제출할 때 수행한다.

레이드에서 아이템 획득
→ 인벤토리에 아이템 추가
→ 아이템 목표 충족

레이드 탈출
→ Event.Raid.Escaped
→ 탈출 조건 충족

허브 터미널에서 완료 버튼
→ TryCompleteQuest
→ 조건 재검사
→ 필요 아이템 소비
→ 보상 지급
→ 완료 상태 기록

탈출 전에 아이템만 획득한 경우

다음 상태를 가정해 보자.

필요 아이템: 3개
현재 보유: 3개
탈출 조건: 0/1

아이템 목표는 충족했지만 이벤트 조건이 남아 있다.

아이템 진행도: 3/3
탈출 진행도: 0/1
전체 진행도: 3/4
완료 가능: false

아이템을 모두 획득한 순간 퀘스트를 완료하지 않기 때문에, 레이드에서 실패하거나 사망한 상황과 성공적인 탈출을 구분할 수 있다.


탈출에 성공한 경우

탈출 메시지를 받으면 해당 조건이 갱신된다.

아이템 진행도: 3/3
탈출 진행도: 1/1
전체 진행도: 4/4
완료 가능: true

그래도 바로 보상을 지급하지 않는다.

허브 터미널에서 완료 버튼을 눌러야 TryCompleteQuest()가 실행된다.

이를 통해 다음 역할을 분리했다.

레이드
= 조건 달성

허브 터미널
= 퀘스트 제출과 보상 수령

퀘스트 완료 메시지

퀘스트 제출과 보상 지급이 모두 성공하면 완료 상태를 기록한다.

CompletedQuestIDs.Add(QuestID);
AvailableQuestIDs.Remove(QuestID);
ActiveQuestIDs.Remove(QuestID);

다음 퀘스트가 있다면 해금한다.

for (const FName& NextQuestID
    : Quest->NextQuestIDs)
{
    UnlockQuest(NextQuestID);
}

마지막으로 완료 메시지를 발행한다.

FFTMessagePayloadStruct Payload;
Payload.QuestId = QuestID;
Payload.Value = 1.0f;

MessageSubsystem.BroadcastMessage(
    TAG_FT_Event_ObjectiveCompleted,
    Payload
);
Event.Objective.Completed

HUD와 Quest Widget은 이 메시지를 받아 완료 상태를 반영한다.


최종 이벤트 흐름

아이템 획득부터 퀘스트 완료까지의 전체 흐름은 다음과 같다.

1. 플레이어가 Item Actor와 상호작용한다.

2. Item Actor가 Event.Item.PickedUp을 발행한다.

3. InventoryComponent가 메시지를 수신한다.

4. 플레이어 인벤토리에 아이템을 추가한다.

5. OnInventoryChanged가 발생한다.

6. Quest Widget이 ObjectiveSubsystem에
   현재 아이템 진행도를 다시 요청한다.

7. 플레이어가 탈출 지점에 도달한다.

8. GameFlowSubsystem이
   Event.Raid.Escaped를 발행한다.

9. ObjectiveSubsystem이 활성 퀘스트 중
   탈출 조건을 가진 퀘스트만 갱신한다.

10. ObjectiveSubsystem이
    Event.Objective.ProgressChanged를 발행한다.

11. HUD와 Quest Widget이 진행도를 갱신한다.

12. 플레이어가 허브 터미널에서
    퀘스트 완료를 요청한다.

13. ObjectiveSubsystem이 아이템,
    이벤트 조건, 보상 공간을 재검사한다.

14. 필요 아이템을 소비한다.

15. 보상을 플레이어 인벤토리에 지급한다.

16. 퀘스트를 완료 상태로 변경한다.

17. Event.Objective.Completed를 발행한다.

최종 구조

객체역할
UFTObjectiveSubsystem 퀘스트 상태와 이벤트 진행도 관리
UGameplayMessageSubsystem 시스템 사이의 사건 전달
FTQuestStruct 퀘스트 원본 데이터
FFTQuestConditionStruct 이벤트 기반 목표 정의
FFTMessagePayloadStruct 이벤트의 아이템, 수량, 대상 전달
UFTInventoryComponent 획득 메시지 처리와 아이템 보관
UFTGameFlowSubsystem 레이드 탈출 이벤트 발행
UFTQuestViewModel 터미널 퀘스트 UI 상태 가공
UFTQuestListWidget HUD 퀘스트 목록 표시
UFTUIManagerSubsystem 진행도 메시지를 HUD ViewModel에 연결

리팩터링하면서 배운 점

이번 작업에서 가장 크게 배운 점은 상태와 사건을 구분해야 한다는 것이었다.

상태
= 지금 아이템을 몇 개 가지고 있는가?

사건
= 제작을 몇 번 했는가?
= 레이드에서 탈출했는가?
= 보안요원에게 발각됐는가?

아이템 제출형 목표는 현재 보유 수량이 중요하기 때문에 상태를 조회한다.

반면 탈출이나 제작 횟수는 과거 사건이 중요하기 때문에 이벤트가 발생한 순간 진행도를 누적한다.

RequiredItems
= 상태 기반 목표

EventConditions
= 사건 기반 목표

모든 목표를 무조건 메시지 누적으로 처리하거나, 모든 목표를 현재 상태 조회로 처리하는 것보다 목표의 의미에 따라 방식을 나누는 것이 더 정확했다.

또한 퀘스트 진행과 완료를 분리하면서 “아이템을 얻기만 하면 완료”되는 문제를 해결할 수 있었다.

목표 달성
→ 완료 가능한 상태

터미널 제출
→ 실제 아이템 소비
→ 보상 지급
→ 완료 확정

이후 개선할 점

현재 구조에서도 개선할 부분이 있다.

  • Event.Item.PickedUp을 사용하는 순수 획득 횟수 퀘스트 추가
  • 아이템 상태 변경 시 ObjectiveSubsystem도 통합 진행 알림 발행
  • HUD와 Widget의 알림 경로 통일
  • 플레이어와 창고 Delegate 바인딩을 ViewModel로 이동
  • 퀘스트별 진행도 변경 이유를 Payload에 추가
  • 복수 플레이어 환경에서 Instigator 필터 강화
  • 동일 이벤트의 중복 발행 방지
  • 퀘스트 조건별 선택적 저장
  • 이벤트 조건에 맵, NPC, Actor 필터 추가
  • 연속 성공, 제한 시간, 실패 조건 지원
  • 메시지 Payload 타입을 이벤트 종류별로 분리
  • 진행률 단순 합산 대신 조건별 가중치 적용
  • 자동 테스트를 통한 이벤트 순서 검증

진행도 변경 원인까지 전달하려면 Payload를 확장할 수 있다.

USTRUCT()
struct FFTObjectiveProgressPayload
{
    GENERATED_BODY()

    FName QuestID;
    FGameplayTag SourceEvent;
    FName ItemID;
    int32 CurrentCount;
    int32 RequiredCount;
    float NormalizedProgress;
};

이 정보를 이용하면 HUD에서 단순한 전체 퍼센트 대신 다음과 같은 알림을 표시할 수 있다.

설탕 획득 2 / 3
제작 완료 1 / 2
레이드 탈출 완료

마무리

이번 리팩터링을 통해 퀘스트 진행 구조를 다음과 같이 정리했다.

게임 객체는 사건을 메시지로 발행한다.

InventoryComponent는 아이템 획득 메시지를
실제 인벤토리 상태로 반영한다.

ObjectiveSubsystem은 활성 퀘스트의
이벤트 조건만 누적한다.

아이템 목표는 현재 보유 수량으로 계산한다.

진행도가 변경되면 UI에 알림을 보낸다.

목표 달성과 실제 완료 처리는 분리한다.

아이템 획득 후 탈출하고,
허브에서 제출해야 퀘스트가 완료된다.

Tick이나 Widget에서 퀘스트 조건을 계속 검사하지 않고, 실제 사건과 상태 변화가 발생했을 때만 필요한 시스템을 갱신하도록 만들었다.

그 결과 아이템, 제작, 상점, NPC, 보안 AI, GameFlow처럼 서로 다른 시스템을 ObjectiveSubsystem과 직접 결합하지 않고도 하나의 퀘스트 조건 구조로 연결할 수 있었다.