PART 9 - 강의 4/4

에디터 확장

커스텀 에디터 도구, Property Customization, Asset 에디터 개발

01

에디터 확장의 종류

언리얼 에디터를 커스터마이징하는 다양한 방법

주요 확장 포인트

Detail Customization

Details 패널의 프로퍼티 표시 방식 커스터마이징

Menu/Toolbar Extension

메뉴와 툴바에 커스텀 버튼 및 항목 추가

Custom Asset Editor

에셋 타입별 전용 에디터 윈도우 생성

Editor Utility Widget

UMG 기반 에디터 도구 윈도우

에디터 모듈 Build.cs

MyEditorModule.Build.cs using UnrealBuildTool; public class MyEditorModule : ModuleRules { public MyEditorModule(ReadOnlyTargetRules Target) : base(Target) { PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs; PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "MyRuntimeModule" // 런타임 모듈 }); PrivateDependencyModuleNames.AddRange(new string[] { // 필수 에디터 모듈 "UnrealEd", "Slate", "SlateCore", "EditorStyle", "InputCore", // 프로퍼티 커스터마이징 "PropertyEditor", "DetailCustomizations", // 에셋 관련 "AssetTools", "ContentBrowser", "AssetRegistry", // 레벨 에디터 "LevelEditor", "EditorWidgets" }); } }
02

Detail Customization

Details 패널의 프로퍼티 표시를 커스터마이징

커스텀 타입을 위한 Detail 패널

FMyItemDetailsCustomization.h #pragma once #include "CoreMinimal.h" #include "IDetailCustomization.h" class IDetailLayoutBuilder; /** * UMyItemAsset의 Details 패널 커스터마이징 */ class FMyItemDetailsCustomization : public IDetailCustomization { public: /** 팩토리 함수 */ static TSharedRef<IDetailCustomization> MakeInstance(); /** IDetailCustomization 인터페이스 */ virtual void CustomizeDetails(IDetailLayoutBuilder& DetailBuilder) override; private: // 편집 중인 객체 참조 TWeakObjectPtr<class UMyItemAsset> EditingItem; // UI 이벤트 핸들러 FReply OnGenerateIdClicked(); void OnItemTypeChanged(TSharedPtr<FString> NewValue, ESelectInfo::Type SelectInfo); };

Detail Customization 구현

FMyItemDetailsCustomization.cpp #include "FMyItemDetailsCustomization.h" #include "DetailLayoutBuilder.h" #include "DetailCategoryBuilder.h" #include "DetailWidgetRow.h" #include "Widgets/Input/SButton.h" #include "Widgets/Text/STextBlock.h" #include "MyItemAsset.h" TSharedRef<IDetailCustomization> FMyItemDetailsCustomization::MakeInstance() { return MakeShareable(new FMyItemDetailsCustomization); } void FMyItemDetailsCustomization::CustomizeDetails(IDetailLayoutBuilder& DetailBuilder) { // 편집 중인 객체 가져오기 TArray<TWeakObjectPtr<UObject>> ObjectsBeingCustomized; DetailBuilder.GetObjectsBeingCustomized(ObjectsBeingCustomized); if (ObjectsBeingCustomized.Num() > 0) { EditingItem = Cast<UMyItemAsset>(ObjectsBeingCustomized[0].Get()); } // "Item Info" 카테고리 수정 IDetailCategoryBuilder& ItemCategory = DetailBuilder.EditCategory("Item Info", FText::GetEmpty(), ECategoryPriority::Important); // 기존 프로퍼티 위에 커스텀 위젯 추가 ItemCategory.AddCustomRow(LOCTEXT("ItemIdRow", "Item ID")) .NameContent() [ SNew(STextBlock) .Text(LOCTEXT("ItemIdLabel", "Item ID")) .Font(IDetailLayoutBuilder::GetDetailFont()) ] .ValueContent() .MaxDesiredWidth(250.0f) [ SNew(SHorizontalBox) + SHorizontalBox::Slot() .FillWidth(1.0f) [ SNew(SEditableTextBox) .Text_Lambda([this]() { return EditingItem.IsValid() ? FText::FromString(EditingItem->ItemId.ToString()) : FText::GetEmpty(); }) .IsReadOnly(true) ] + SHorizontalBox::Slot() .AutoWidth() .Padding(4.0f, 0.0f) [ SNew(SButton) .Text(LOCTEXT("GenerateBtn", "Generate")) .OnClicked(this, &FMyItemDetailsCustomization::OnGenerateIdClicked) ] ]; // 특정 프로퍼티 숨기기 DetailBuilder.HideProperty("InternalData"); // 프로퍼티 순서 변경 - 중요한 것을 상단으로 TSharedRef<IPropertyHandle> NameProperty = DetailBuilder.GetProperty(GET_MEMBER_NAME_CHECKED(UMyItemAsset, DisplayName)); ItemCategory.AddProperty(NameProperty); } FReply FMyItemDetailsCustomization::OnGenerateIdClicked() { if (EditingItem.IsValid()) { // 고유 ID 생성 EditingItem->ItemId = FName(*FGuid::NewGuid().ToString()); EditingItem->MarkPackageDirty(); } return FReply::Handled(); }

모듈에서 등록

Module Registration void FMyEditorModule::StartupModule() { // PropertyEditor 모듈 가져오기 FPropertyEditorModule& PropertyModule = FModuleManager::LoadModuleChecked<FPropertyEditorModule>("PropertyEditor"); // Detail Customization 등록 PropertyModule.RegisterCustomClassLayout( "MyItemAsset", // 클래스 이름 (U 접두사 제외) FOnGetDetailCustomizationInstance::CreateStatic( &FMyItemDetailsCustomization::MakeInstance ) ); // 구조체 커스터마이징도 가능 PropertyModule.RegisterCustomPropertyTypeLayout( "MyCustomStruct", FOnGetPropertyTypeCustomizationInstance::CreateStatic( &FMyStructCustomization::MakeInstance ) ); } void FMyEditorModule::ShutdownModule() { if (FModuleManager::Get().IsModuleLoaded("PropertyEditor")) { FPropertyEditorModule& PropertyModule = FModuleManager::GetModuleChecked<FPropertyEditorModule>("PropertyEditor"); PropertyModule.UnregisterCustomClassLayout("MyItemAsset"); PropertyModule.UnregisterCustomPropertyTypeLayout("MyCustomStruct"); } }
03

메뉴 및 툴바 확장

레벨 에디터에 커스텀 메뉴와 툴바 버튼 추가

툴바 버튼 추가

Toolbar Extension #include "LevelEditor.h" #include "Framework/MultiBox/MultiBoxBuilder.h" #include "Styling/SlateStyleRegistry.h" void FMyEditorModule::RegisterToolbarExtension() { // 커맨드 리스트 생성 PluginCommands = MakeShareable(new FUICommandList); // 커맨드 매핑 PluginCommands->MapAction( FMyEditorCommands::Get().OpenMyTool, FExecuteAction::CreateRaw(this, &FMyEditorModule::OnToolbarButtonClicked), FCanExecuteAction() ); // 툴바 확장 TSharedPtr<FExtender> ToolbarExtender = MakeShareable(new FExtender); ToolbarExtender->AddToolBarExtension( "Settings", // 확장 포인트 EExtensionHook::After, PluginCommands, FToolBarExtensionDelegate::CreateRaw(this, &FMyEditorModule::AddToolbarButton) ); // 레벨 에디터에 등록 FLevelEditorModule& LevelEditorModule = FModuleManager::LoadModuleChecked<FLevelEditorModule>("LevelEditor"); LevelEditorModule.GetToolBarExtensibilityManager()->AddExtender(ToolbarExtender); } void FMyEditorModule::AddToolbarButton(FToolBarBuilder& Builder) { Builder.AddToolBarButton( FMyEditorCommands::Get().OpenMyTool, NAME_None, LOCTEXT("MyToolLabel", "My Tool"), LOCTEXT("MyToolTooltip", "Opens My Custom Tool"), FSlateIcon(FMyEditorStyle::GetStyleSetName(), "MyTool.Icon") ); } void FMyEditorModule::OnToolbarButtonClicked() { // 커스텀 탭 열기 FGlobalTabmanager::Get()->TryInvokeTab(FName("MyToolTab")); }

커맨드 정의

FMyEditorCommands.h #pragma once #include "Framework/Commands/Commands.h" class FMyEditorCommands : public TCommands<FMyEditorCommands> { public: FMyEditorCommands() : TCommands<FMyEditorCommands>( TEXT("MyEditorCommands"), NSLOCTEXT("Contexts", "MyEditor", "My Editor"), NAME_None, FMyEditorStyle::GetStyleSetName()) {} // 커맨드 등록 virtual void RegisterCommands() override; // 커맨드 정의 TSharedPtr<FUICommandInfo> OpenMyTool; TSharedPtr<FUICommandInfo> RefreshData; }; // cpp 파일 void FMyEditorCommands::RegisterCommands() { UI_COMMAND(OpenMyTool, "Open Tool", "Open the custom editor tool", EUserInterfaceActionType::Button, FInputChord(EKeys::T, EModifierKey::Control | EModifierKey::Shift)); UI_COMMAND(RefreshData, "Refresh", "Refresh data", EUserInterfaceActionType::Button, FInputChord(EKeys::F5)); }

메뉴 확장

Menu Extension void FMyEditorModule::RegisterMenuExtension() { // 메뉴 확장자 생성 TSharedPtr<FExtender> MenuExtender = MakeShareable(new FExtender); MenuExtender->AddMenuExtension( "WindowLayout", // Window 메뉴 내 위치 EExtensionHook::After, PluginCommands, FMenuExtensionDelegate::CreateRaw(this, &FMyEditorModule::AddMenuEntries) ); FLevelEditorModule& LevelEditorModule = FModuleManager::LoadModuleChecked<FLevelEditorModule>("LevelEditor"); LevelEditorModule.GetMenuExtensibilityManager()->AddExtender(MenuExtender); } void FMyEditorModule::AddMenuEntries(FMenuBuilder& MenuBuilder) { MenuBuilder.BeginSection("MyToolsSection", LOCTEXT("MyTools", "My Tools")); { // 서브메뉴 추가 MenuBuilder.AddSubMenu( LOCTEXT("MyToolsSubMenu", "My Tools"), LOCTEXT("MyToolsSubMenuTooltip", "Custom tool options"), FNewMenuDelegate::CreateRaw(this, &FMyEditorModule::CreateSubMenu) ); } MenuBuilder.EndSection(); } void FMyEditorModule::CreateSubMenu(FMenuBuilder& MenuBuilder) { MenuBuilder.AddMenuEntry(FMyEditorCommands::Get().OpenMyTool); MenuBuilder.AddMenuEntry(FMyEditorCommands::Get().RefreshData); MenuBuilder.AddSeparator(); MenuBuilder.AddMenuEntry( LOCTEXT("Settings", "Settings..."), LOCTEXT("SettingsTooltip", "Open tool settings"), FSlateIcon(), FUIAction(FExecuteAction::CreateRaw(this, &FMyEditorModule::OpenSettings)) ); }
04

커스텀 에디터 탭

독립적인 도구 윈도우 생성

탭 스포너 등록

Tab Registration void FMyEditorModule::RegisterTabs() { // 전역 탭 매니저에 탭 스포너 등록 FGlobalTabmanager::Get()->RegisterNomadTabSpawner( FName("MyToolTab"), FOnSpawnTab::CreateRaw(this, &FMyEditorModule::SpawnMyToolTab)) .SetDisplayName(LOCTEXT("MyToolTabTitle", "My Tool")) .SetMenuType(ETabSpawnerMenuType::Hidden) .SetIcon(FSlateIcon(FMyEditorStyle::GetStyleSetName(), "MyTool.TabIcon")); } TSharedRef<SDockTab> FMyEditorModule::SpawnMyToolTab(const FSpawnTabArgs& Args) { return SNew(SDockTab) .TabRole(ETabRole::NomadTab) [ SNew(SMyToolWidget) ]; } void FMyEditorModule::UnregisterTabs() { FGlobalTabmanager::Get()->UnregisterNomadTabSpawner(FName("MyToolTab")); }

커스텀 Slate 위젯

SMyToolWidget.h #pragma once #include "Widgets/SCompoundWidget.h" class SMyToolWidget : public SCompoundWidget { public: SLATE_BEGIN_ARGS(SMyToolWidget) {} SLATE_END_ARGS() void Construct(const FArguments& InArgs); private: // UI 상태 TSharedPtr<SListView<TSharedPtr<FString>>> ItemListView; TArray<TSharedPtr<FString>> ItemList; // UI 이벤트 FReply OnRefreshClicked(); FReply OnExportClicked(); TSharedRef<ITableRow> OnGenerateRow(TSharedPtr<FString> Item, const TSharedRef<STableViewBase>& OwnerTable); };

Slate 위젯 구현

SMyToolWidget.cpp #include "SMyToolWidget.h" #include "Widgets/Layout/SBox.h" #include "Widgets/Layout/SScrollBox.h" #include "Widgets/Input/SButton.h" #include "Widgets/Input/SSearchBox.h" void SMyToolWidget::Construct(const FArguments& InArgs) { // 테스트 데이터 ItemList.Add(MakeShareable(new FString(TEXT("Item 1")))); ItemList.Add(MakeShareable(new FString(TEXT("Item 2")))); ItemList.Add(MakeShareable(new FString(TEXT("Item 3")))); ChildSlot [ SNew(SVerticalBox) // 툴바 영역 + SVerticalBox::Slot() .AutoHeight() .Padding(4.0f) [ SNew(SHorizontalBox) + SHorizontalBox::Slot() .AutoWidth() [ SNew(SButton) .Text(LOCTEXT("RefreshBtn", "Refresh")) .OnClicked(this, &SMyToolWidget::OnRefreshClicked) ] + SHorizontalBox::Slot() .AutoWidth() .Padding(4.0f, 0.0f) [ SNew(SButton) .Text(LOCTEXT("ExportBtn", "Export")) .OnClicked(this, &SMyToolWidget::OnExportClicked) ] + SHorizontalBox::Slot() .FillWidth(1.0f) .Padding(8.0f, 0.0f) [ SNew(SSearchBox) .OnTextChanged_Lambda([this](const FText& Text) { // 필터링 로직 }) ] ] // 리스트 영역 + SVerticalBox::Slot() .FillHeight(1.0f) [ SNew(SBorder) .BorderImage(FEditorStyle::GetBrush("ToolPanel.GroupBorder")) .Padding(4.0f) [ SAssignNew(ItemListView, SListView<TSharedPtr<FString>>) .ItemHeight(24.0f) .ListItemsSource(&ItemList) .OnGenerateRow(this, &SMyToolWidget::OnGenerateRow) ] ] // 상태바 + SVerticalBox::Slot() .AutoHeight() .Padding(4.0f) [ SNew(STextBlock) .Text_Lambda([this]() { return FText::Format( LOCTEXT("StatusFormat", "Total: {0} items"), FText::AsNumber(ItemList.Num())); }) ] ]; } FReply SMyToolWidget::OnRefreshClicked() { // 데이터 새로고침 ItemListView->RequestListRefresh(); return FReply::Handled(); } TSharedRef<ITableRow> SMyToolWidget::OnGenerateRow( TSharedPtr<FString> Item, const TSharedRef<STableViewBase>& OwnerTable) { return SNew(STableRow<TSharedPtr<FString>>, OwnerTable) [ SNew(STextBlock) .Text(FText::FromString(*Item)) ]; }
05

커스텀 에셋 타입

Content Browser에서 커스텀 에셋 생성 및 관리

에셋 타입 액션

FMyItemAssetActions.h #pragma once #include "AssetTypeActions_Base.h" class FMyItemAssetActions : public FAssetTypeActions_Base { public: // 에셋 이름 virtual FText GetName() const override { return LOCTEXT("ItemAssetName", "Item Asset"); } // 에셋 색상 (Content Browser 표시) virtual FColor GetTypeColor() const override { return FColor(255, 165, 0); // 오렌지색 } // 지원 클래스 virtual UClass* GetSupportedClass() const override { return UMyItemAsset::StaticClass(); } // 카테고리 virtual uint32 GetCategories() override { return EAssetTypeCategories::Gameplay; } // 에셋 더블클릭 시 동작 virtual void OpenAssetEditor( const TArray<UObject*>& InObjects, TSharedPtr<IToolkitHost> EditWithinLevelEditor) override { // 기본 Details 패널 사용 FSimpleAssetEditor::CreateEditor( EToolkitMode::Standalone, EditWithinLevelEditor, InObjects); } // 우클릭 메뉴 확장 virtual void GetActions( const TArray<UObject*>& InObjects, FMenuBuilder& MenuBuilder) override { auto Items = GetTypedWeakObjectPtrs<UMyItemAsset>(InObjects); MenuBuilder.AddMenuEntry( LOCTEXT("ValidateItem", "Validate"), LOCTEXT("ValidateItemTooltip", "Validate item data"), FSlateIcon(), FUIAction( FExecuteAction::CreateSP(this, &FMyItemAssetActions::ValidateItems, Items) ) ); } private: void ValidateItems(TArray<TWeakObjectPtr<UMyItemAsset>> Items); };

에셋 팩토리

UMyItemAssetFactory.h #pragma once #include "Factories/Factory.h" #include "MyItemAssetFactory.generated.h" UCLASS() class UMyItemAssetFactory : public UFactory { GENERATED_BODY() public: UMyItemAssetFactory(); // 새 에셋 생성 virtual UObject* FactoryCreateNew( UClass* InClass, UObject* InParent, FName InName, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) override; // 생성 가능 여부 virtual bool CanCreateNew() const override { return true; } }; // cpp UMyItemAssetFactory::UMyItemAssetFactory() { SupportedClass = UMyItemAsset::StaticClass(); bCreateNew = true; bEditAfterNew = true; } UObject* UMyItemAssetFactory::FactoryCreateNew( UClass* InClass, UObject* InParent, FName InName, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) { UMyItemAsset* NewAsset = NewObject<UMyItemAsset>( InParent, InClass, InName, Flags); // 기본값 설정 NewAsset->ItemId = FName(*FGuid::NewGuid().ToString()); return NewAsset; }

모듈에서 등록

Asset Registration void FMyEditorModule::RegisterAssetTypeActions() { IAssetTools& AssetTools = FModuleManager::LoadModuleChecked<FAssetToolsModule>("AssetTools").Get(); // 에셋 타입 액션 등록 TSharedRef<IAssetTypeActions> ItemActions = MakeShareable(new FMyItemAssetActions()); AssetTools.RegisterAssetTypeActions(ItemActions); RegisteredAssetTypeActions.Add(ItemActions); // 커스텀 카테고리 (선택적) MyAssetCategory = AssetTools.RegisterAdvancedAssetCategory( FName(TEXT("MyGame")), LOCTEXT("MyGameCategory", "My Game")); } void FMyEditorModule::UnregisterAssetTypeActions() { if (FModuleManager::Get().IsModuleLoaded("AssetTools")) { IAssetTools& AssetTools = FModuleManager::GetModuleChecked<FAssetToolsModule>("AssetTools").Get(); for (auto& Action : RegisteredAssetTypeActions) { AssetTools.UnregisterAssetTypeActions(Action); } } RegisteredAssetTypeActions.Empty(); }
SUMMARY

핵심 요약

  • Detail Customization으로 Details 패널의 프로퍼티 표시를 커스터마이징
  • FExtender를 사용하여 메뉴와 툴바에 커스텀 항목 추가
  • FUICommandListTCommands로 단축키와 명령 정의
  • SDockTab과 Slate 위젯으로 독립 도구 윈도우 생성
  • FAssetTypeActionsUFactory로 커스텀 에셋 타입 지원
다음 파트 예고

Part 10에서는 Automation Testing, 로깅 시스템, Visual Logger, DrawDebug, 메모리 프로파일링 등 테스트 및 디버깅 기법을 학습합니다.

PRACTICE

도전 과제

배운 내용을 직접 실습해보세요

실습 1: 커스텀 에디터 패널 생성

FTabManager를 사용하여 RPG 아이템 에디터 패널을 만드세요. Slate UI로 아이템 데이터베이스를 표시하는 리스트 뷰와 상세 정보 패널을 구현하세요.

실습 2: 커스텀 디테일 패널 확장

IDetailCustomization을 구현하여 RPG 캐릭터의 Details Panel에 커스텀 UI를 추가하세요. 스탯 합계 표시, 장비 프리뷰, 빠른 스킬 설정 버튼을 추가하세요.

심화 과제: 에디터 유틸리티 위젯 구현

UEditorUtilityWidget(Blutility)을 활용하여 레벨 디자이너를 위한 RPG 배치 도구를 구현하세요. 몬스터 스폰 포인트 자동 배치, 아이템 드롭 테이블 편집기, 퀘스트 플로우 비주얼라이저를 만드세요.