Создание системы расстановки объектов по уровню при помощи редактора bluprint

image

Здравствуйте, меня зовут Дмитрий. Я занимаюсь созданием компьютерных игр на Unreal Engine в качестве хобби. Для своего проекта я разрабатываю продцедурно генерируемый уровень. Мой алгоритм расставляет в определенно порядке точки в пространстве (которые я называю корни «roots»), после чего к этим точкам я прикрепляю меши. Но тут возникает проблема в том, что нужно с начала прикрепить меш потом откомпилировать проект и лиш после этого можно увидеть как она встала. Естественно постоянно бегать из окна редактора в окно VS очень долго. И я подумал что можно было-бы для этого использовать редактор bluprint, тем более мне попался на глаза плагин Dungeon architect, в котором расстановка объектов по уровню реализована через bluprint. Собственно здесь я расскажу о создании подобной системы скриншот из которой изображен на первом рисунке.

Итак с начала создадим свой тип файла (подробней можно посмотреть вот эту статью). В классе AssetAction переопределяем функцию OpenAssetEditor.

void FMyObjectAssetAction::OpenAssetEditor(const TArray& InObjects, TSharedPtr EditWithinLevelEditor)
{
        const EToolkitMode::Type Mode = EditWithinLevelEditor.IsValid() ? EToolkitMode::WorldCentric : EToolkitMode::Standalone;
        for (auto ObjIt = InObjects.CreateConstIterator(); ObjIt; ++ObjIt)
        {
                UMyObject* PropData = Cast(*ObjIt);
                if (PropData)
                {
                        TSharedRef NewCustEditor(new FCustAssetEditor());
                        
                        NewCustEditor->InitCustAssetEditor(Mode, EditWithinLevelEditor, PropData);
                }
        }
}

Теперь если мы попытаемся открыть этот файл будет открыто не привычное окно, а то окно, которое мы определим в классе FCustAssetEditor.

class FCustAssetEditor : public FAssetEditorToolkit, public FNotifyHook
{
public:
        
        ~FCustAssetEditor();
        
        // IToolkit interface
        virtual void RegisterTabSpawners(const TSharedRef& TabManager) override;
        virtual void UnregisterTabSpawners(const TSharedRef& TabManager) override;
        // FAssetEditorToolkit
        virtual FName GetToolkitFName() const override;
        virtual FText GetBaseToolkitName() const override;
        virtual FLinearColor GetWorldCentricTabColorScale() const override;
        virtual FString GetWorldCentricTabPrefix() const override;
        void InitCustAssetEditor(const EToolkitMode::Type Mode, const TSharedPtr< class IToolkitHost >& InitToolkitHost, UMyObject* PropData);
        int N;
protected:

        
        void OnGraphChanged(const FEdGraphEditAction& Action);
        void SelectAllNodes();
        bool CanSelectAllNodes() const;
        void DeleteSelectedNodes();
        bool CanDeleteNode(class UEdGraphNode* Node);
        bool CanDeleteNodes() const;
        void DeleteNodes(const TArray& NodesToDelete);
        void CopySelectedNodes();
        bool CanCopyNodes() const;
        void PasteNodes();
        void PasteNodesHere(const FVector2D& Location);
        bool CanPasteNodes() const;
        void CutSelectedNodes();
        bool CanCutNodes() const;
        void DuplicateNodes();
        bool CanDuplicateNodes() const;
        void DeleteSelectedDuplicatableNodes();
        
        /** Called when the selection changes in the GraphEditor */
        void OnSelectedNodesChanged(const TSet& NewSelection);

        /** Called when a node is double clicked */
        void OnNodeDoubleClicked(class UEdGraphNode* Node);

        void ShowMessage();


        TSharedRef CreateGraphEditorWidget(UEdGraph* InGraph);
        TSharedPtr GraphEditor;
        TSharedPtr GraphEditorCommands;
        TSharedPtr PropertyEditor;
        UMyObject* PropBeingEdited;
        TSharedRef SpawnTab_Viewport(const FSpawnTabArgs& Args);
        TSharedRef SpawnTab_Details(const FSpawnTabArgs& Args);

        FDelegateHandle OnGraphChangedDelegateHandle;
        
        TSharedPtr ToolbarExtender;
        TSharedPtr MyToolBarCommands;
        bool bGraphStateChanged;
        void AddToolbarExtension(FToolBarBuilder &builder);
};


Самым важным для нас методом этого класса явлется InitCustAssetEditor. Сначала этот метод создает новый редактор о чем ниже, потом он, создает две новые пустые вкладки:

const TSharedRef StandaloneDefaultLayout = FTabManager::NewLayout("CustomEditor_Layout")
                ->AddArea
                (
                        FTabManager::NewPrimaryArea()
                        ->SetOrientation(Orient_Vertical)
                        ->Split
                        (
                                FTabManager::NewStack()
                                ->SetSizeCoefficient(0.1f)
                                ->SetHideTabWell(true)
                                ->AddTab(GetToolbarTabId(), ETabState::OpenedTab)
                        )
                        ->Split
                        (
                                FTabManager::NewSplitter()
                                ->SetOrientation(Orient_Horizontal)
                                ->SetSizeCoefficient(0.2f)
                                ->Split
                                (

                                        FTabManager::NewStack()
                                        ->SetSizeCoefficient(0.8f)
                                        ->SetHideTabWell(true)
                                        ->AddTab(FCustomEditorTabs::ViewportID, ETabState::OpenedTab)

                                )
                                ->Split
                                (
                                        FTabManager::NewStack()
                                        ->SetSizeCoefficient(0.2f)
                                        ->SetHideTabWell(true)
                                        ->AddTab(FCustomEditorTabs::DetailsID, ETabState::OpenedTab)
                                )


                        )

                );


Одна из этих вкладок будет вкладкой нашего блюпринт редактора, а вторая нужна для отображения свойств нодов. Собственно вкладки созданы нужно их чем-то заполнить. Заполняет вкладки содержимым метод RegisterTabSpawners

void FCustAssetEditor::RegisterTabSpawners(const TSharedRef& TabManager)
{
        WorkspaceMenuCategory = TabManager->AddLocalWorkspaceMenuCategory(FText::FromString("Custom Editor"));
        auto WorkspaceMenuCategoryRef = WorkspaceMenuCategory.ToSharedRef();

        FAssetEditorToolkit::RegisterTabSpawners(TabManager);

        TabManager->RegisterTabSpawner(FCustomEditorTabs::ViewportID, FOnSpawnTab::CreateSP(this, &FCustAssetEditor::SpawnTab_Viewport))
                .SetDisplayName(FText::FromString("Viewport"))
                .SetGroup(WorkspaceMenuCategoryRef)
                .SetIcon(FSlateIcon(FEditorStyle::GetStyleSetName(), "LevelEditor.Tabs.Viewports"));

        TabManager->RegisterTabSpawner(FCustomEditorTabs::DetailsID, FOnSpawnTab::CreateSP(this, &FCustAssetEditor::SpawnTab_Details))
                .SetDisplayName(FText::FromString("Details"))
                .SetGroup(WorkspaceMenuCategoryRef)
                .SetIcon(FSlateIcon(FEditorStyle::GetStyleSetName(), "LevelEditor.Tabs.Details"));
}

TSharedRef FCustAssetEditor::SpawnTab_Viewport(const FSpawnTabArgs& Args)
{

        return SNew(SDockTab)
                .Label(FText::FromString("Mesh Graph"))
                .TabColorScale(GetTabColorScale())
                [
                        GraphEditor.ToSharedRef()
                ];

}

TSharedRef FCustAssetEditor::SpawnTab_Details(const FSpawnTabArgs& Args)
{
        FPropertyEditorModule& PropertyEditorModule = FModuleManager::GetModuleChecked("PropertyEditor");
        const FDetailsViewArgs DetailsViewArgs(false, false, true, FDetailsViewArgs::HideNameArea, true, this);
        TSharedRef PropertyEditorRef = PropertyEditorModule.CreateDetailView(DetailsViewArgs);
        PropertyEditor = PropertyEditorRef;

        // Spawn the tab
        return SNew(SDockTab)
                .Label(FText::FromString("Details"))
                [
                        PropertyEditorRef
                ];
}


Панель свойств нам подойдет стандартная, а вот bluprin редактор мы создадим свой. Создается он в методе CreateGraphEditorWidget.

TSharedRef FCustAssetEditor::CreateGraphEditorWidget(UEdGraph* InGraph)
{
        // Create the appearance info
        FGraphAppearanceInfo AppearanceInfo;
        AppearanceInfo.CornerText = FText::FromString("Mesh tree Editor");

        GraphEditorCommands = MakeShareable(new FUICommandList);
        {
                GraphEditorCommands->MapAction(FGenericCommands::Get().SelectAll,
                        FExecuteAction::CreateSP(this, &FCustAssetEditor::SelectAllNodes),
                        FCanExecuteAction::CreateSP(this, &FCustAssetEditor::CanSelectAllNodes)
                        );

                GraphEditorCommands->MapAction(FGenericCommands::Get().Delete,
                        FExecuteAction::CreateSP(this, &FCustAssetEditor::DeleteSelectedNodes),
                        FCanExecuteAction::CreateSP(this, &FCustAssetEditor::CanDeleteNodes)
                        );

                GraphEditorCommands->MapAction(FGenericCommands::Get().Copy,
                        FExecuteAction::CreateSP(this, &FCustAssetEditor::CopySelectedNodes),
                        FCanExecuteAction::CreateSP(this, &FCustAssetEditor::CanCopyNodes)
                        );

                GraphEditorCommands->MapAction(FGenericCommands::Get().Paste,
                        FExecuteAction::CreateSP(this, &FCustAssetEditor::PasteNodes),
                        FCanExecuteAction::CreateSP(this, &FCustAssetEditor::CanPasteNodes)
                        );

                GraphEditorCommands->MapAction(FGenericCommands::Get().Cut,
                        FExecuteAction::CreateSP(this, &FCustAssetEditor::CutSelectedNodes),
                        FCanExecuteAction::CreateSP(this, &FCustAssetEditor::CanCutNodes)
                        );

                GraphEditorCommands->MapAction(FGenericCommands::Get().Duplicate,
                        FExecuteAction::CreateSP(this, &FCustAssetEditor::DuplicateNodes),
                        FCanExecuteAction::CreateSP(this, &FCustAssetEditor::CanDuplicateNodes)
                        );
                

        }

        SGraphEditor::FGraphEditorEvents InEvents;
        InEvents.OnSelectionChanged = SGraphEditor::FOnSelectionChanged::CreateSP(this, &FCustAssetEditor::OnSelectedNodesChanged);
        InEvents.OnNodeDoubleClicked = FSingleNodeEvent::CreateSP(this, &FCustAssetEditor::OnNodeDoubleClicked);

        TSharedRef _GraphEditor = SNew(SGraphEditor)
                .AdditionalCommands(GraphEditorCommands)
                .Appearance(AppearanceInfo)
                .GraphToEdit(InGraph)
                .GraphEvents(InEvents)
                ;
        return _GraphEditor;
}


Здесь с начала определяются действия и события на которые будет реагировать наш редактор, а потом собственно создается виджет редактора. Наиболее интересным параметром является .GraphToEdit (InGraph) он передает указатель на класс UEdGraphSchema_CustomEditor

UCLASS()
class UEdGraphSchema_CustomEditor : public UEdGraphSchema
{
        GENERATED_UCLASS_BODY()
        // Begin EdGraphSchema interface
        virtual void GetGraphContextActions(FGraphContextMenuBuilder& ContextMenuBuilder) const override;
        virtual void GetContextMenuActions(const UEdGraph* CurrentGraph, const UEdGraphNode* InGraphNode, const UEdGraphPin* InGraphPin, FMenuBuilder* MenuBuilder, bool bIsDebugging) const override;
        virtual const FPinConnectionResponse CanCreateConnection(const UEdGraphPin* A, const UEdGraphPin* B) const override;
        virtual class FConnectionDrawingPolicy* CreateConnectionDrawingPolicy(int32 InBackLayerID, int32 InFrontLayerID, float InZoomFactor, const FSlateRect& InClippingRect, class FSlateWindowElementList& InDrawElements, class UEdGraph* InGraphObj) const override;
        virtual FLinearColor GetPinTypeColor(const FEdGraphPinType& PinType) const override;
        virtual bool ShouldHidePinDefaultValue(UEdGraphPin* Pin) const override;
        // End EdGraphSchema interface
};


Этот класс определяет такие вещи как пункты контекстного меню редактора, определяет как будут соединятся между собой ноды и т.д. Для нас самое главное это возможность создания собственных нод. Это делается в методе GetGraphContextActions.

void UEdGraphSchema_CustomEditor::GetGraphContextActions(FGraphContextMenuBuilder& ContextMenuBuilder) const
{
        FFormatNamedArguments Args;
        const FName AttrName("Attributes");
        Args.Add(TEXT("Attribute"), FText::FromName(AttrName));
        const UEdGraphPin* FromPin = ContextMenuBuilder.FromPin;
        const UEdGraph* Graph = ContextMenuBuilder.CurrentGraph;
        TArray > Actions;

        CustomSchemaUtils::AddAction(TEXT("Add Root Node"), TEXT("Add root node to the prop graph"), Actions, ContextMenuBuilder.OwnerOfTemporaries);
        CustomSchemaUtils::AddAction(TEXT("Add Brunch Node"), TEXT("Add brunch node to the prop graph"), Actions, ContextMenuBuilder.OwnerOfTemporaries);
        CustomSchemaUtils::AddAction(TEXT("Add Rule Node"), TEXT("Add ruleh node to the prop graph"), Actions, ContextMenuBuilder.OwnerOfTemporaries);
        CustomSchemaUtils::AddAction(TEXT("Add Switch Node"), TEXT("Add switch node to the prop graph"), Actions, ContextMenuBuilder.OwnerOfTemporaries);

        for (TSharedPtr Action : Actions)
        {
                ContextMenuBuilder.AddAction(Action);
        }
}

Как вы видете пока что я создал только четыре ноды итак по списку:
1)Нода URootNode является отображением элемента корень на графе. URootNode также как и элементы типа корень имеют тип.
2)Нода UBranchNode эта нода размещает на уровне статик меш (пока только меши, но можно легко создать ноды и для других элементов обстановки или персонажей)
3)Нода URuleNode эта нода может быть либо открыта либо закрыта в зависимости от заданного условия. Условие естественно задаются в bluprint.
4)Нода USwitcherNode эта нода имеет один вход и два выхода в зависимости от условия может открывать либо правый выход либо левый.

Пока только четыре ноды, но если у вас есть идеи можете написать их в комментарии. Давайте посмотрим как они устроены. (Для экономии места я приведу здесь только базовый для них класс, исходники можно скачать по ссылке в конце статьи)

UCLASS()

class UICUSTOM_API UCustomNodeBase : public UEdGraphNode
{
        GENERATED_BODY()
public:
        
        virtual TArray GetChildNodes(FRandomStream& RandomStream);
        virtual void CreateNodesMesh(UWorld* World, FName ActorTag, FRandomStream& RandomStream, FVector AbsLocation, FRotator AbsRotation);
        virtual void PostEditChangeProperty(struct FPropertyChangedEvent& e) override;

        TSharedPtr PropertyObserver;
        FVector Location;
        FRotator Rotation;
        
};

Здесь мы видим метод GetChildNodes в котором нода передает массив объектов присоединенных к её выходам. И метод CreateNodesMesh в котором нода создает меш или не создает, а просто передает дальше значения AbsLocation и AbsRotation. Метод PostEditChangeProperty как вы наверно догадались выполняется когда кто-то меняет свойства ноды.

Но как вы наверно заметили ноды на заглавном рисунке отличаются по внешнему виду от тех, которые мы привыкли видеть. Как же этого добиться. Для этого нужно создать для каждой ноды класс наследник SGraphNode. Как и в прошлый раз здесь я приведу только базовый класс.

class  SGraphNode_CustomNodeBase : public SGraphNode, public FNodePropertyObserver
{
public:
        SLATE_BEGIN_ARGS(SGraphNode_CustomNodeBase) { }
        SLATE_END_ARGS()

        /** Constructs this widget with InArgs */
        void Construct(const FArguments& InArgs, UCustomNodeBase* InNode);

        // SGraphNode interface
        virtual void UpdateGraphNode() override;
        virtual void CreatePinWidgets() override;
        virtual void AddPin(const TSharedRef& PinToAdd) override;
        virtual void CreateNodeWidget();
        // End of SGraphNode interface

        // FPropertyObserver interface
        virtual void OnPropertyChanged(UEdGraphNode* Sender, const FName& PropertyName) override;
        // End of FPropertyObserver interface

protected:
        UCustomNodeBase* NodeBace;
        virtual FSlateColor GetBorderBackgroundColor() const;
        virtual const FSlateBrush* GetNameIcon() const;
        TSharedPtr OutputPinBox;
        FLinearColor BackgroundColor;
        TSharedPtr NodeWiget;
};

Наследование класса FNodePropertyObserver нужено исключительно для метода OnPropertyChanged. Самым важным методом является метод UpdateGraphNode именно в нем и создается виджет который мы видим на экране, Остальные методы вызываются из него для создания определенных частей этого виждета.

Прошу не путать класс SGraphNode с классом UEdGraphNode. SGraphNode определяет исключительно внешний вид ноды, в то время как класс UEdGraphNode определяет свойства самой ноды.

Но даже сейчас если запустить проект ноды будут иметь прежний вид. Чтобы изменения внешнего вида вступили в силу, нужно их зарегистрировать. Где это сделать? Конечно же при старте модуля:

void FUICustomEditorModule::StartupModule()
{
        //Registrate asset actions for MyObject
        FMyObjectAssetAction::RegistrateCustomPartAssetType();

        //Registrate detail pannel costamization for TestActor
        FMyClassDetails::RegestrateCostumization();

        // Register custom graph nodes
        TSharedPtr GraphPanelNodeFactory = MakeShareable(new FGraphPanelNodeFactory_Custom);
        FEdGraphUtilities::RegisterVisualNodeFactory(GraphPanelNodeFactory);

        //Registrate ToolBarCommand for costom graph
        FToolBarCommandsCommands::Register();

        //Create pool for icon wich show on costom nodes
        FCustomEditorThumbnailPool::Create();
}


Хочу заметить что также здесь создается хранилище, для хранения иконок которые будут отображаться на нодах UBranchNode. Регестрация нодов происходит в методе CreateNode класса FGraphPanelNodeFactory_Custom.

TSharedPtr FGraphPanelNodeFactory_Custom::CreateNode(UEdGraphNode* Node) const
{
        if (URootNode* RootNode = Cast(Node))
        {
                TSharedPtr SNode = SNew(SGraphNode_Root, RootNode);
                RootNode->PropertyObserver = SNode;
                return SNode;
        }
        else if (UBranchNode* BranchNode = Cast(Node))
        {
                TSharedPtr SNode = SNew(SGraphNode_Brunch, BranchNode);
                BranchNode->PropertyObserver = SNode;
                return SNode;
        }
        else if (URuleNode* RuleNode = Cast(Node))
        {
                TSharedPtr SNode = SNew(SGraphNode_Rule, RuleNode);
                RuleNode->PropertyObserver = SNode;
                return SNode;
        }
        else if (USwitcherNode* SwitcherNode = Cast(Node))
        {
                TSharedPtr SNode = SNew(SGraphNode_Switcher, SwitcherNode);
                SwitcherNode->PropertyObserver = SNode;
                return SNode;
        }
        return NULL;
}

Генерация осуществляется в классе TestActor.

bool  ATestAct::GenerateMeshes()
{
        FRandomStream  RandomStream = FRandomStream(10);
        
        
        if (!MyObject)
        {
                return false;
        }
        for (int i = 0; i < Roots.Num(); i++)
        {
                URootNode* RootBuf;

                RootBuf = MyObject->FindRootFromType(Roots[i].RootType);
                if (RootBuf)
                {
                        
                        RootBuf->Location = Roots[i].Location;
                        RootBuf->CreateNodesMesh(GetWorld(), ActorTag, RandomStream, FVector(0, 0, 0), FRotator(0, 0, 0));
                }
        }
        return true;
}


Здесь мы переберем в цикле все объекты root, каждый из них характерезуется координатой в пространстве и типом. Получив этот объект мы ищем в графе ноду URootNode c таким же типом. Найдя её передаем ей начальные координаты и запускаем метод CreateNodesMesh который пройдет по цепочки через весь граф. Делаем это пока все объекты root не будут обработаны.

Собственно вот и все. Для дальнейшего ознакомления рекомендую смотреть исходники.

Проект с исходным кодом здесь

А я пока расскажу вам как же работает это хозяйство. Генерация осуществляется в объекте TestActor, с начала надо в ручную задать положения и типы объектов root (а что вы хотели проект учебный).
image
После этого выбираем в свойствах файл MyObject, в котором мы должны построить граф, определяющий какие меши будут созданы.

Итак как-же задать правило для ноды rule и switcher. Для этого нажимаем плюсик в свойства чтобы создать новый блюпринт.
image
Но он оказывается пустым что-же делать дальше? Нужно нажать Override NodeBool.
image
Теперь можно или открыть или закрыть ноду.
image
Все аналогично и для switchera. У ноды Brunch есть такое же правило для задания координаты и поворота. Кроме того она имеет выход, это значит если к ней прикрепить другую Brunch то она в качестве привязки будет использовать координату предыдущей.

Осталось только нажать кнопку Generate Meshes на панели свойств TestActor, и наслаждаться результатом.
image

Надеюсь вам понравилась эта статья. Она оказалась намного длинней чем раньше, боялся что не допишу до конца.

© Habrahabr.ru