Sparta Unreal 부트캠프 84일차

정찬호·2026년 4월 1일

[UE5] 언리얼 플러그인에 ToDoList 기능 추가하기 — Phase 3 Part A 트러블슈팅


목표

Claude(AI 툴)의 도움을 받으며 MyNBCLog 플러그인의 에디터 모듈(MySpartaLogEditor)에 Slate 기반 ToDoList 패널을 추가하고, UToDoListAsset(.uasset)을 에디터 패널에서 직접 읽고 편집하는 기능을 구현합니다.

완성된 패널의 구조는 다음과 같습니다.

[ToDo List 패널]
─────────────────────────────
 ToDoList 에셋: [에셋 선택 ▼]
─────────────────────────────
 ☑ 첫 번째 할 일
 ☐ 두 번째 할 일
 ☐ 세 번째 할 일
─────────────────────────────
 [+ 항목 추가]
─────────────────────────────

Part A 진행 결과물

Slate 관련 부분은 공부가 부족하여 전적으로 Claude에 의존하고 있습니다. Claude가 작성해준 코드를 보며 이런 것이 있구나라면서 눈에 익히고 있습니다.

MySpartaLogEditor.h

#pragma once

#include "CoreMinimal.h"
#include "Modules/ModuleInterface.h"
#include "ToDoListAsset.h"

class FMySpartaLogEditorModule : public IModuleInterface
{
public:
    virtual void StartupModule() override;
    virtual void ShutdownModule() override;

private:
    void RegisterMenus();
    void OpenToDoListTab();
    TSharedRef<SDockTab> OnSpawnToDoTab(const FSpawnTabArgs& Args);

    void RefreshList();
    FReply OnAddItemClicked();

    TSharedRef<ITableRow> OnGenerateRow(
        TSharedPtr<int32> ItemIndex,
        const TSharedRef<STableViewBase>& OwnerTable
    );

private:
    TObjectPtr<UToDoListAsset> CurrentAsset = nullptr;
    TArray<TSharedPtr<int32>> ListIndices;
    TSharedPtr<SListView<TSharedPtr<int32>>> ListView;
};

MySpartaLogEditor.cpp

#include "MySpartaLogEditor.h"
#include "Modules/ModuleManager.h"
#include "ToolMenus.h"
#include "Framework/Docking/TabManager.h"
#include "Widgets/Docking/SDockTab.h"
#include "Widgets/Text/STextBlock.h"
#include "Widgets/Input/SButton.h"
#include "Widgets/Input/SCheckBox.h"
#include "Widgets/Views/SListView.h"
#include "Widgets/Layout/SSeparator.h"
#include "PropertyCustomizationHelpers.h"
#include "ToDoListAsset.h"

static const FName ToDoTabName("ToDoListTab");

void FMySpartaLogEditorModule::StartupModule()
{
    FGlobalTabmanager::Get()->RegisterNomadTabSpawner(
        ToDoTabName,
        FOnSpawnTab::CreateRaw(this, &FMySpartaLogEditorModule::OnSpawnToDoTab)
    )
    .SetDisplayName(FText::FromString("ToDo List"))
    .SetMenuType(ETabSpawnerMenuType::Hidden);

    UToolMenus::RegisterStartupCallback(
        FSimpleMulticastDelegate::FDelegate::CreateRaw(
            this, &FMySpartaLogEditorModule::RegisterMenus
        )
    );
}

void FMySpartaLogEditorModule::ShutdownModule()
{
    FGlobalTabmanager::Get()->UnregisterNomadTabSpawner(ToDoTabName);
    UToolMenus::UnRegisterStartupCallback(this);
    UToolMenus::UnregisterOwner(this);
}

void FMySpartaLogEditorModule::RegisterMenus()
{
    FToolMenuOwnerScoped OwnerScoped(this);

    UToolMenu* Menu = UToolMenus::Get()->ExtendMenu("LevelEditor.MainMenu.Tools");
    FToolMenuSection& Section = Menu->FindOrAddSection("MySpartaTools");
    Section.Label = FText::FromString("Sparta Tools");

    Section.AddMenuEntry(
        "OpenToDoList",
        FText::FromString("Open ToDo List"),
        FText::FromString("Opens the ToDo List panel"),
        FSlateIcon(),
        FUIAction(FExecuteAction::CreateRaw(
            this, &FMySpartaLogEditorModule::OpenToDoListTab
        ))
    );
}

void FMySpartaLogEditorModule::OpenToDoListTab()
{
    FGlobalTabmanager::Get()->TryInvokeTab(ToDoTabName);
}

TSharedRef<SDockTab> FMySpartaLogEditorModule::OnSpawnToDoTab(const FSpawnTabArgs& Args)
{
    return SNew(SDockTab)
        .TabRole(ETabRole::NomadTab)
        [
            SNew(SVerticalBox)

            + SVerticalBox::Slot()
            .AutoHeight()
            .Padding(8.f)
            [
                SNew(SHorizontalBox)

                + SHorizontalBox::Slot()
                .AutoWidth()
                .VAlign(VAlign_Center)
                .Padding(FMargin(0.f, 0.f, 8.f, 0.f))
                [
                    SNew(STextBlock)
                    .Text(FText::FromString("ToDoList 에셋:"))
                ]

                + SHorizontalBox::Slot()
                .FillWidth(1.f)
                [
                    SNew(SObjectPropertyEntryBox)
                    .AllowedClass(UToDoListAsset::StaticClass())
                    .ObjectPath_Lambda([this]()
                    {
                        return CurrentAsset
                            ? CurrentAsset->GetPathName()
                            : FString();
                    })
                    .OnObjectChanged_Lambda([this](const FAssetData& AssetData)
                    {
                        CurrentAsset = Cast<UToDoListAsset>(AssetData.GetAsset());
                        RefreshList();
                    })
                ]
            ]

            + SVerticalBox::Slot()
            .AutoHeight()
            [ SNew(SSeparator) ]

            + SVerticalBox::Slot()
            .FillHeight(1.f)
            .Padding(8.f)
            [
                SAssignNew(ListView, SListView<TSharedPtr<int32>>)
                .ListItemsSource(&ListIndices)
                .OnGenerateRow(SListView<TSharedPtr<int32>>::FOnGenerateRow::CreateRaw(
                    this, &FMySpartaLogEditorModule::OnGenerateRow))
                .SelectionMode(ESelectionMode::None)
            ]

            + SVerticalBox::Slot()
            .AutoHeight()
            [ SNew(SSeparator) ]

            + SVerticalBox::Slot()
            .AutoHeight()
            .Padding(8.f)
            [
                SNew(SButton)
                .Text(FText::FromString("+ 항목 추가"))
                .OnClicked(FOnClicked::CreateRaw(
                    this, &FMySpartaLogEditorModule::OnAddItemClicked))
            ]
        ];
}

void FMySpartaLogEditorModule::RefreshList()
{
    ListIndices.Empty();

    if (CurrentAsset)
    {
        for (int32 i = 0; i < CurrentAsset->Items.Num(); ++i)
        {
            ListIndices.Add(MakeShared<int32>(i));
        }
    }

    if (ListView.IsValid())
    {
        ListView->RequestListRefresh();
    }
}

FReply FMySpartaLogEditorModule::OnAddItemClicked()
{
    if (!CurrentAsset) return FReply::Handled();

    FToDoItem NewItem;
    NewItem.Text = FString::Printf(TEXT("새 항목: %d"), CurrentAsset->Items.Num() + 1);
    NewItem.bCompleted = false;
    CurrentAsset->Items.Add(NewItem);
    CurrentAsset->MarkPackageDirty();
    RefreshList();

    return FReply::Handled();
}

TSharedRef<ITableRow> FMySpartaLogEditorModule::OnGenerateRow(
    TSharedPtr<int32> ItemIndex,
    const TSharedRef<STableViewBase>& OwnerTable)
{
    if (!ItemIndex.IsValid() || !CurrentAsset)
        return SNew(STableRow<TSharedPtr<int32>>, OwnerTable);

    const int32 Index = *ItemIndex;

    if (!CurrentAsset->Items.IsValidIndex(Index))
        return SNew(STableRow<TSharedPtr<int32>>, OwnerTable);

    UToDoListAsset* Asset = CurrentAsset;

    return SNew(STableRow<TSharedPtr<int32>>, OwnerTable)
        .Padding(FMargin(4.f, 2.f))
        [
            SNew(SHorizontalBox)

            + SHorizontalBox::Slot()
            .AutoWidth()
            .VAlign(VAlign_Center)
            [
                SNew(SCheckBox)
                .IsChecked_Lambda([Asset, Index]()
                {
                    if (Asset && Asset->Items.IsValidIndex(Index))
                        return Asset->Items[Index].bCompleted
                            ? ECheckBoxState::Checked
                            : ECheckBoxState::Unchecked;
                    return ECheckBoxState::Unchecked;
                })
                .OnCheckStateChanged_Lambda([Asset, Index](ECheckBoxState NewState)
                {
                    if (Asset && Asset->Items.IsValidIndex(Index))
                    {
                        Asset->Items[Index].bCompleted = (NewState == ECheckBoxState::Checked);
                        Asset->MarkPackageDirty();
                    }
                })
            ]

            + SHorizontalBox::Slot()
            .VAlign(VAlign_Center)
            .Padding(FMargin(6.f, 0.f))
            [
                SNew(STextBlock)
                .Text_Lambda([Asset, Index]()
                {
                    if (Asset && Asset->Items.IsValidIndex(Index))
                        return FText::FromString(Asset->Items[Index].Text);
                    return FText::GetEmpty();
                })
            ]
        ];
}

IMPLEMENT_MODULE(FMySpartaLogEditorModule, MySpartaLogEditor);

MySpartaLogEditor.Build.cs

using UnrealBuildTool;

public class MySpartaLogEditor : ModuleRules
{
    public MySpartaLogEditor(ReadOnlyTargetRules Target) : base(Target)
    {
        PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;

        PublicIncludePaths.Add(ModuleDirectory);

        PublicDependencyModuleNames.AddRange(new string[]
        {
            "Core",
            "CoreUObject",
            "Engine",
            "UnrealEd",
            "Slate",
            "SlateCore",
            "EditorStyle",
            "ToolMenus",
            "MySpartaLog",
            "EditorFramework",
            "AppFramework",
            "ApplicationCore",
            "PropertyEditor",
            "InputCore",
        });
    }
}

트러블슈팅

1. LNK2019 — Slate 심볼을 찾을 수 없음 (92개)

증상

LNK2019: FCurveSequence, SVerticalBox, SBoxPanel, EVisibility ...
UnrealEditor-MySpartaLogEditor.dll: 92 unresolved externals

원인

컴파일(.cpp.obj)은 성공했지만 링크 단계에서 실패했습니다. 헤더(include)만 있으면 컴파일은 통과하지만, 실제 구현이 담긴 모듈을 Build.cs에 명시하지 않으면 링커가 구현체를 찾지 못한다고 합니다.

언리얼 엔진은 모듈이 매우 잘게 쪼개져 있습니다.

모듈담당
Slate위젯 고수준 클래스 (헤더/인터페이스)
SlateCoreSWidget, FSlotBase, EVisibility 등 기반 타입
AppFramework위젯 구현체 상당수
ApplicationCoreFSlateIcon 등 기반 타입
EditorFrameworkSDockTab, TabManager 등 에디터 탭 시스템

해결

Build.cs에 누락된 모듈들을 추가했습니다. 특히 SlateCore가 핵심이었다. SlateSlateCore는 반드시 함께 명시해야 한다고 합니다.

"EditorFramework",
"AppFramework",
"ApplicationCore",
"SlateCore",   // ← 핵심

2. C2679 — SListView의 ListItemsSource 타입 불일치

증상

Error C2679: binary '=': no operator found
SListView<FToDoItemPtr>::FArguments::ListItemsSource
    with OptionalType = FToDoItemPtr  // NotThreadSafe
    could be TSharedPtr<FToDoItem, ESPMode::ThreadSafe>

원인

처음에 FToDoItemTSharedPtr로 감싸서 SListView에 넘기려 했는데, 구조체를 TSharedPtr로 직접 감싸는 방식은 SListView 내부의 타입 요구와 충돌이 발생했습니다.

해결

TSharedPtr<FToDoItem> 대신 TSharedPtr<int32> 인덱스 방식으로 변경했습니다. SListView에는 인덱스 번호만 넘기고, 실제 데이터는 CurrentAsset->Items[Index]로 직접 접근합니다.

// 변경 전
TArray<TSharedPtr<FToDoItem>> ListItems;

// 변경 후
TArray<TSharedPtr<int32>> ListIndices;  // 인덱스 배열

이 방식은 타입 충돌도 없고 데이터 복사도 없어서 체크박스를 클릭하면 원본 에셋이 즉시 수정된다고 합니다.


3. C2039 — 'AsShared' is not a member

증상

Error C2039: 'AsShared': is not a member of 'FMySpartaLogEditorModule'
.OnGenerateRow(this, &FMySpartaLogEditorModule::OnGenerateRow)
.OnClicked(this, &FMySpartaLogEditorModule::OnAddItemClicked)

원인

Slate 이벤트에 this와 멤버 함수 포인터를 직접 넘기면 내부적으로 CreateSP()가 호출된다고 합니다. CreateSP()TSharedFromThis를 상속한 클래스에서만 사용 가능한데, FMySpartaLogEditorModuleIModuleInterface만 상속하는 일반 C++ 클래스라 AsShared()가 없어서 에러가 난는 것이라 합니다.

해결

this를 직접 넘기는 대신 CreateRaw()를 명시적으로 사용해야 합니다.

// 변경 전
.OnGenerateRow(this, &FMySpartaLogEditorModule::OnGenerateRow)
.OnClicked(this, &FMySpartaLogEditorModule::OnAddItemClicked)

// 변경 후
.OnGenerateRow(SListView<TSharedPtr<int32>>::FOnGenerateRow::CreateRaw(
    this, &FMySpartaLogEditorModule::OnGenerateRow))
.OnClicked(FOnClicked::CreateRaw(
    this, &FMySpartaLogEditorModule::OnAddItemClicked))
바인딩 함수사용 대상
CreateRaw일반 C++ 클래스 (IModuleInterface 등)
CreateSPTSharedFromThis를 상속한 클래스
CreateUObjectUObject를 상속한 클래스

규칙: FMySpartaLogEditorModule처럼 일반 C++ 클래스에서 Slate 이벤트에 this를 넘길 때는 항상 CreateRaw를 명시해야 합니다.


4. C2653 — 'FOnGenerateRow': 클래스 또는 네임스페이스 이름이 아님

증상

Error C2653: 'FOnGenerateRow': 클래스 또는 네임스페이스 이름이 아닙니다.
FOnGenerateRow::CreateRaw(this, ...)

원인

FOnGenerateRow는 전역 타입이 아니라 SListView 안에 정의된 타입이라 합니다. 단독으로 사용할 수가 없습니다.

해결

전체 경로를 명시한다.

// 변경 전
FOnGenerateRow::CreateRaw(this, ...)

// 변경 후
SListView<TSharedPtr<int32>>::FOnGenerateRow::CreateRaw(this, ...)

5. LNK2019 — EKeys 심볼을 찾을 수 없음 (8개)

증상

LNK2019: EKeys::LeftMouseButton
LNK2019: EKeys::RightMouseButton
LNK2019: EKeys::SpaceBar, PageUp, PageDown, End, Home, A
UnrealEditor-MySpartaLogEditor.dll: 8 unresolved externals

원인

SListView가 키보드/마우스 입력 처리를 위해 EKeys를 사용하는데, EKeysInputCore 모듈에 있습니다. Build.cs에 명시되지 않아 링크에 실패했습니다.

해결

Build.csInputCore를 추가했습니다.

"InputCore",   // ← 추가

핵심 개념 정리

컴파일 에러 vs 링크 에러

  • 컴파일 에러(C****) → 헤더(#include)가 없거나 타입이 맞지 않는 문제
  • 링크 에러(LNK****) → 헤더는 찾았지만 실제 구현(.dll/.lib)을 Build.cs에 명시하지 않은 문제

언리얼에서 링크 에러가 나면 먼저 Build.csPublicDependencyModuleNames를 의심해야 합니다.

MarkPackageDirty()

에셋을 코드로 수정했을 때 반드시 호출해야 하는 함수입니다. 호출하지 않으면 변경사항이 메모리에만 존재하고 에디터의 저장 버튼도 활성화되지 않습니다.

인덱스 기반 리스트

SListView에 구조체를 직접 넘기는 대신 인덱스(int32)를 넘기면 타입 충돌 없이 원본 데이터에 직접 접근할 수 있었습니다.

Phase3 - Part A 이해하기

  1. 데이터 구조
// 멤버 변수들
TObjectPtr<UToDoListAsset> CurrentAsset = nullptr;
TArray<TSharedPtr<int32>> ListIndices;
TSharedPtr<SListView<TSharedPtr<int32>>> ListView;

CurrentAsset 은 지금 패널에서 편집 중인 .uasset 파일입니다. 에셋 피커에서 고른 파일이 여기 저장됩니다.

ListIndices 가 핵심인데, 처음엔 왜 FToDoItem을 직접 사용하다가 트러블 슈팅 2번의 "구조체를 TSharedPtr로 직접 감싸는 방식은 SListView 내부의 타입 요구와 충돌이 발생" 때문에 인덱스를 사용하는 방식으로 변경했습니다.

CurrentAsset->Items = [ {할일A}, {할일B}, {할일C} ]
                           ↑         ↑         ↑
ListIndices        = [    0,        1,        2    ]

SListView에 실제 데이터를 넘기는 게 아니라 인덱스(번호표)만 넘기고, 화면을 그릴 때 그 번호로 원본 데이터를 직접 읽는 방식입니다. 데이터를 복사하지 않아서 체크박스를 바꾸면 바로 원본 에셋이 수정됩니다.

ListView 는 리스트 위젯 자체를 가리키는 포인터입니다. RefreshList()에서 ListView->RequestListRefresh()를 호출해야 하기 때문에 멤버로 보관해야 한다고 합니다.

2. 데이터 흐름 - "언제 무슨 일이 일어나나"

에셋 선택 시

.OnObjectChanged_Lambda([this](const FAssetData& AssetData)
{
    CurrentAsset = Cast<UToDoListAsset>(AssetData.GetAsset());
    RefreshList();
})

에셋 피커에서 파일을 고르면 위의 람다가 호출이 됩니다. CurrentAsset에 선택한 에셋을 저장하고 RefreshList()를 호출합니다.

RefreshList

void FMySpartaLogEditorModule::RefreshList()
{
    ListIndices.Empty();                          // 기존 인덱스 전부 지움

    if (CurrentAsset)
    {
        for (int32 i = 0; i < CurrentAsset->Items.Num(); ++i)
        {
            ListIndices.Add(MakeShared<int32>(i)); // 0, 1, 2, ... 추가
        }
    }

    if (ListView.IsValid())
    {
        ListView->RequestListRefresh();            // 화면 다시 그리기 요청
    }
}

Items가 3개면 ListIndices{0, 1, 2}가 만들집니다. RequestListRefresh()SListView에게 "데이터가 바뀌었으니 OnGenerateRow를 다시 호출해서 화면을 다시 그려라"라고 알리는 함수라고 합니다.

+ 항목 추가 버튼

FReply FMySpartaLogEditorModule::OnAddItemClicked()
{
    FToDoItem NewItem;
    NewItem.Text = FString::Printf(TEXT("새 항목: %d"), CurrentAsset->Items.Num() + 1);
    NewItem.bCompleted = false;
    CurrentAsset->Items.Add(NewItem);   // 원본 에셋에 추가
    CurrentAsset->MarkPackageDirty();   // "저장 필요" 표시
    RefreshList();                       // 화면 갱신
    return FReply::Handled();
}

MarkPackageDirty()가 중요합니다. 이걸 호출해야 언리얼 에디터가 이 에셋이 수정됬다는 걸 알게 되고, 에디터 상단에 저장 버튼이 활성화된다고 합니다. 안 하면 변경사항이 메모리에만 존재하고 파일에 안 써진다고 합니다.

3. 화면 구성 - "어떻게 보여주나"

OnGenerateRow — 각 행을 그리는 함수
SListViewListIndices를 순회하면서 항목마다 이 함수를 호출하고 있습니다.

TSharedRef<ITableRow> FMySpartaLogEditorModule::OnGenerateRow(
    TSharedPtr<int32> ItemIndex,          // SListView가 넘겨주는 인덱스
    const TSharedRef<STableViewBase>& OwnerTable)
{
    const int32 Index = *ItemIndex;       // TSharedPtr에서 실제 숫자 꺼냄
    UToDoListAsset* Asset = CurrentAsset; // 람다 캡처용 로컬 변수

    return SNew(STableRow<TSharedPtr<int32>>, OwnerTable)
    [
        SNew(SHorizontalBox)

        // 체크박스
        + SHorizontalBox::Slot()
        [
            SNew(SCheckBox)
            .IsChecked_Lambda([Asset, Index]()     // 읽기: 현재 상태 반환
            {
                return Asset->Items[Index].bCompleted
                    ? ECheckBoxState::Checked
                    : ECheckBoxState::Unchecked;
            })
            .OnCheckStateChanged_Lambda([Asset, Index](ECheckBoxState NewState) // 쓰기: 변경 시 반영
            {
                Asset->Items[Index].bCompleted = (NewState == ECheckBoxState::Checked);
                Asset->MarkPackageDirty();
            })
        ]

        // 텍스트
        + SHorizontalBox::Slot()
        [
            SNew(STextBlock)
            .Text_Lambda([Asset, Index]()          // Items[Index].Text를 읽어서 표시
            {
                return FText::FromString(Asset->Items[Index].Text);
            })
        ]
    ];
}

여기서 람다 캡처 [Asset, Index]가 중요하다고 합니다. 각 행이 생성될 때 그 행이 몇 번째 항목인지(Index)와 어느 에셋인지(Asset)를 람다 안에 복사해서 기억시킵니다. 그래서 체크박스를 클릭하면 정확히 Items[Index]만 수정할 수 있게 되는 겁니다.

전체 흐름 한눈에 보기

에셋 피커에서 선택
        │
        ▼
  CurrentAsset 저장
        │
        ▼
  RefreshList() 호출
        │
        ├─ ListIndices = [0, 1, 2, ...]  ← 인덱스 번호표 생성
        │
        └─ RequestListRefresh()
                │
                ▼
        OnGenerateRow() × N번 호출      ← 각 행 UI 생성
                │
                └─ 체크박스/텍스트에 람다 등록
                        │
                        └─ 클릭 시 Asset->Items[Index] 직접 수정
                                  + MarkPackageDirty()

팀프로젝트 Vigilant 시작

이번 팀 프로젝트에서 Mission 구현을 담당하게 되었습니다. 오늘 하루종일 시간을 들인 것 치고는 만족스러운 성과는 얻지 못했습니다.

1. Mission에 사용할 태그 등록

팀장을 맡으신 형백님께서 태그를 등록할 클래스와 틀을 작성해 주셨습니다. 덕분에 편하게 태그를 등록하고 불러와 사용할 수 있게 되었습니다.

VGGameplayTags.h

namespace VigilantMissionTags
{
	// 미션 상태
	UE_DECLARE_GAMEPLAY_TAG_EXTERN(MissionInactive);
	UE_DECLARE_GAMEPLAY_TAG_EXTERN(MissionActive);
	UE_DECLARE_GAMEPLAY_TAG_EXTERN(MissionCompleted);
	// 미션 타입
	UE_DECLARE_GAMEPLAY_TAG_EXTERN(PuzzleMission);
	UE_DECLARE_GAMEPLAY_TAG_EXTERN(CooperativeMission);
	UE_DECLARE_GAMEPLAY_TAG_EXTERN(CombatMission);
	// 미션 기믹 상태
	UE_DECLARE_GAMEPLAY_TAG_EXTERN(GimmickInative);
	UE_DECLARE_GAMEPLAY_TAG_EXTERN(GimmickActive);
	UE_DECLARE_GAMEPLAY_TAG_EXTERN(GimmickCompleted);
	// 미션 기믹 타입
	UE_DECLARE_GAMEPLAY_TAG_EXTERN(PressureGimmick);
	UE_DECLARE_GAMEPLAY_TAG_EXTERN(StatueGimmick);
	UE_DECLARE_GAMEPLAY_TAG_EXTERN(LeverGimmick);
}

VGGameplayTags.cpp

UE_DEFINE_GAMEPLAY_TAG(VigilantMissionTags::MissionInactive, "Mission.State.Inactive");
UE_DEFINE_GAMEPLAY_TAG(VigilantMissionTags::MissionActive, "Mission.State.Active");
UE_DEFINE_GAMEPLAY_TAG(VigilantMissionTags::MissionCompleted, "Mission.State.Completed");
UE_DEFINE_GAMEPLAY_TAG(VigilantMissionTags::PuzzleMission, "Mission.Type.Puzzle");
UE_DEFINE_GAMEPLAY_TAG(VigilantMissionTags::CooperativeMission, "Mission.Type.Cooperative");
UE_DEFINE_GAMEPLAY_TAG(VigilantMissionTags::CombatMission, "Mission.Type.Combat");
UE_DEFINE_GAMEPLAY_TAG(VigilantMissionTags::GimmickInative, "Mission.Gimmick.State.Inactive");
UE_DEFINE_GAMEPLAY_TAG(VigilantMissionTags::GimmickActive, "Mission.Gimmick.State.Active");
UE_DEFINE_GAMEPLAY_TAG(VigilantMissionTags::GimmickCompleted, "Mission.Gimmick.State.Completed");
UE_DEFINE_GAMEPLAY_TAG(VigilantMissionTags::PressureGimmick, "Mission.Gimmick.Type.Pressure");
UE_DEFINE_GAMEPLAY_TAG(VigilantMissionTags::StatueGimmick, "Mission.Gimmick.Type.Statue");
UE_DEFINE_GAMEPLAY_TAG(VigilantMissionTags::LeverGimmick, "Mission.Gimmick.Type.Lever");

UE_DECLARE_GAMEPLAY_TAG_EXTERN

  • 게임 플레이 테그를 전역 변수처럼 사용할 수 있게 해주는 매크로 입니다.
  • 이름 대로 선언(DECLARE)이라 cpp 파일에도 정의를 해주어야 합니다.

UE_DEFINE_GAMEPLAY_TAG

  • 게임 플레이 태그를 정의해주는 매크로입니다.
  • 첫번째 변수는 C++에서 사용할 게임 플레이 테그명입니다.
  • 두번째 변수는에디터에서 사용할 게임플레이 태그명입니다.

2. Mission 전용 WorldSubsystem 클래스 및 MissionBase 클래스 작성

구현중 델리게이트 중복 선언 문제가 발생했습니다.
FOnMissionStateChanged를 양쪽에 선언해 버린겁니다.

DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnMissionCompleted, int32, MissionID);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnMissionStateChanged, int32, MissionID, FGameplayTag, NewStateTag);

원인
DECLAREDYNAMIC_MULTICAST_DELEGATE* 매크로는 UHT가 전처리하여 전역 타입을 생성합니다.
같은 프로젝트 내에서 동일한 이름으로 두 번 선언하면 타입 충돌이 발생하게 됩니다.

해결 원칙
델리게이트는 의미상 가장 밀접한 클래스의 헤더에 한 곳에만 선언해야 합니다.
다른 클래스에서 해당 타입을 사용해야 할 경우, 선언이 있는 헤더를 #include한다고 합니다.

FOnMissionStateChanged는 Mission의 상태 변화를 나타내는 델리게이트이니 Mission 그 자체인 MissionBase에 남겨두기로 했습니다.

현재 완전히 수정된 것은 아니라 문제가 남아있는 상태입니다.

3. Mission에 사용될 오브젝트이 부모 클래스 작성

플레이어가 상호작용 시 획득하지 않고 제자리에서 정해진 상호작용 로직을 수행하는 VGMissionGimmickBase, 플레이어가 획득하고 옮기는 VGMissionItemBase 클래스의 스켈레톤 코드를 작성했습니다.

이제 상호작용 관련 로직을 구현할 차례입니다.

Misson 상태 변화에 따른 이벤트 흐름

AVGMissionBase::SetMissionState()
    └── OnMissionStateChanged.Broadcast()
            └── UVGMissionSubsystem이 구독
                    └── UVGMissionSubsystem::OnMissionStateChanged.Broadcast()
                            └── UI/HUD가 구독하여 갱신

AVGMissionBase::NotifyMissionCompleted()
    └── UVGMissionSubsystem::OnMissionCompleted()
            └── CompletedMissions에 추가
            └── 전체 완료 시 OnAllMissionCompleted.Broadcast()
                    └── AVGGameMode가 구독하여 페이즈 전환
profile
게임 개발 지망생입니다.

0개의 댓글