Claude(AI 툴)의 도움을 받으며 MyNBCLog 플러그인의 에디터 모듈(MySpartaLogEditor)에 Slate 기반 ToDoList 패널을 추가하고, UToDoListAsset(.uasset)을 에디터 패널에서 직접 읽고 편집하는 기능을 구현합니다.
완성된 패널의 구조는 다음과 같습니다.
[ToDo List 패널]
─────────────────────────────
ToDoList 에셋: [에셋 선택 ▼]
─────────────────────────────
☑ 첫 번째 할 일
☐ 두 번째 할 일
☐ 세 번째 할 일
─────────────────────────────
[+ 항목 추가]
─────────────────────────────
Slate 관련 부분은 공부가 부족하여 전적으로 Claude에 의존하고 있습니다. Claude가 작성해준 코드를 보며 이런 것이 있구나라면서 눈에 익히고 있습니다.
#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;
};
#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);
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",
});
}
}
증상
LNK2019: FCurveSequence, SVerticalBox, SBoxPanel, EVisibility ...
UnrealEditor-MySpartaLogEditor.dll: 92 unresolved externals
원인
컴파일(.cpp → .obj)은 성공했지만 링크 단계에서 실패했습니다. 헤더(include)만 있으면 컴파일은 통과하지만, 실제 구현이 담긴 모듈을 Build.cs에 명시하지 않으면 링커가 구현체를 찾지 못한다고 합니다.
언리얼 엔진은 모듈이 매우 잘게 쪼개져 있습니다.
| 모듈 | 담당 |
|---|---|
Slate | 위젯 고수준 클래스 (헤더/인터페이스) |
SlateCore | SWidget, FSlotBase, EVisibility 등 기반 타입 |
AppFramework | 위젯 구현체 상당수 |
ApplicationCore | FSlateIcon 등 기반 타입 |
EditorFramework | SDockTab, TabManager 등 에디터 탭 시스템 |
해결
Build.cs에 누락된 모듈들을 추가했습니다. 특히 SlateCore가 핵심이었다. Slate와 SlateCore는 반드시 함께 명시해야 한다고 합니다.
"EditorFramework",
"AppFramework",
"ApplicationCore",
"SlateCore", // ← 핵심
증상
Error C2679: binary '=': no operator found
SListView<FToDoItemPtr>::FArguments::ListItemsSource
with OptionalType = FToDoItemPtr // NotThreadSafe
could be TSharedPtr<FToDoItem, ESPMode::ThreadSafe>
원인
처음에 FToDoItem을 TSharedPtr로 감싸서 SListView에 넘기려 했는데, 구조체를 TSharedPtr로 직접 감싸는 방식은 SListView 내부의 타입 요구와 충돌이 발생했습니다.
해결
TSharedPtr<FToDoItem> 대신 TSharedPtr<int32> 인덱스 방식으로 변경했습니다. SListView에는 인덱스 번호만 넘기고, 실제 데이터는 CurrentAsset->Items[Index]로 직접 접근합니다.
// 변경 전
TArray<TSharedPtr<FToDoItem>> ListItems;
// 변경 후
TArray<TSharedPtr<int32>> ListIndices; // 인덱스 배열
이 방식은 타입 충돌도 없고 데이터 복사도 없어서 체크박스를 클릭하면 원본 에셋이 즉시 수정된다고 합니다.
증상
Error C2039: 'AsShared': is not a member of 'FMySpartaLogEditorModule'
.OnGenerateRow(this, &FMySpartaLogEditorModule::OnGenerateRow)
.OnClicked(this, &FMySpartaLogEditorModule::OnAddItemClicked)
원인
Slate 이벤트에 this와 멤버 함수 포인터를 직접 넘기면 내부적으로 CreateSP()가 호출된다고 합니다. CreateSP()는 TSharedFromThis를 상속한 클래스에서만 사용 가능한데, FMySpartaLogEditorModule은 IModuleInterface만 상속하는 일반 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 등) |
CreateSP | TSharedFromThis를 상속한 클래스 |
CreateUObject | UObject를 상속한 클래스 |
규칙:
FMySpartaLogEditorModule처럼 일반 C++ 클래스에서 Slate 이벤트에this를 넘길 때는 항상CreateRaw를 명시해야 합니다.
증상
Error C2653: 'FOnGenerateRow': 클래스 또는 네임스페이스 이름이 아닙니다.
FOnGenerateRow::CreateRaw(this, ...)
원인
FOnGenerateRow는 전역 타입이 아니라 SListView 안에 정의된 타입이라 합니다. 단독으로 사용할 수가 없습니다.
해결
전체 경로를 명시한다.
// 변경 전
FOnGenerateRow::CreateRaw(this, ...)
// 변경 후
SListView<TSharedPtr<int32>>::FOnGenerateRow::CreateRaw(this, ...)
증상
LNK2019: EKeys::LeftMouseButton
LNK2019: EKeys::RightMouseButton
LNK2019: EKeys::SpaceBar, PageUp, PageDown, End, Home, A
UnrealEditor-MySpartaLogEditor.dll: 8 unresolved externals
원인
SListView가 키보드/마우스 입력 처리를 위해 EKeys를 사용하는데, EKeys는 InputCore 모듈에 있습니다. Build.cs에 명시되지 않아 링크에 실패했습니다.
해결
Build.cs에 InputCore를 추가했습니다.
"InputCore", // ← 추가
컴파일 에러 vs 링크 에러
C****) → 헤더(#include)가 없거나 타입이 맞지 않는 문제LNK****) → 헤더는 찾았지만 실제 구현(.dll/.lib)을 Build.cs에 명시하지 않은 문제언리얼에서 링크 에러가 나면 먼저 Build.cs의 PublicDependencyModuleNames를 의심해야 합니다.
MarkPackageDirty()
에셋을 코드로 수정했을 때 반드시 호출해야 하는 함수입니다. 호출하지 않으면 변경사항이 메모리에만 존재하고 에디터의 저장 버튼도 활성화되지 않습니다.
인덱스 기반 리스트
SListView에 구조체를 직접 넘기는 대신 인덱스(int32)를 넘기면 타입 충돌 없이 원본 데이터에 직접 접근할 수 있었습니다.
// 멤버 변수들
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()를 호출해야 하기 때문에 멤버로 보관해야 한다고 합니다.
에셋 선택 시
.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()가 중요합니다. 이걸 호출해야 언리얼 에디터가 이 에셋이 수정됬다는 걸 알게 되고, 에디터 상단에 저장 버튼이 활성화된다고 합니다. 안 하면 변경사항이 메모리에만 존재하고 파일에 안 써진다고 합니다.
OnGenerateRow — 각 행을 그리는 함수
SListView는 ListIndices를 순회하면서 항목마다 이 함수를 호출하고 있습니다.
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()
이번 팀 프로젝트에서 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");
구현중 델리게이트 중복 선언 문제가 발생했습니다.
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에 남겨두기로 했습니다.
현재 완전히 수정된 것은 아니라 문제가 남아있는 상태입니다.
플레이어가 상호작용 시 획득하지 않고 제자리에서 정해진 상호작용 로직을 수행하는 VGMissionGimmickBase, 플레이어가 획득하고 옮기는 VGMissionItemBase 클래스의 스켈레톤 코드를 작성했습니다.
이제 상호작용 관련 로직을 구현할 차례입니다.
AVGMissionBase::SetMissionState()
└── OnMissionStateChanged.Broadcast()
└── UVGMissionSubsystem이 구독
└── UVGMissionSubsystem::OnMissionStateChanged.Broadcast()
└── UI/HUD가 구독하여 갱신
AVGMissionBase::NotifyMissionCompleted()
└── UVGMissionSubsystem::OnMissionCompleted()
└── CompletedMissions에 추가
└── 전체 완료 시 OnAllMissionCompleted.Broadcast()
└── AVGGameMode가 구독하여 페이즈 전환