image

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


Итак с начала создадим свой тип файла (подробней можно посмотреть вот эту статью). В классе AssetAction переопределяем функцию OpenAssetEditor.
void FMyObjectAssetAction::OpenAssetEditor(const TArray<UObject*>& InObjects, TSharedPtr<class IToolkitHost> EditWithinLevelEditor)
{
	const EToolkitMode::Type Mode = EditWithinLevelEditor.IsValid() ? EToolkitMode::WorldCentric : EToolkitMode::Standalone;
	for (auto ObjIt = InObjects.CreateConstIterator(); ObjIt; ++ObjIt)
	{
		UMyObject* PropData = Cast<UMyObject>(*ObjIt);
		if (PropData)
		{
			TSharedRef<FCustAssetEditor> NewCustEditor(new FCustAssetEditor());
			
			NewCustEditor->InitCustAssetEditor(Mode, EditWithinLevelEditor, PropData);
		}
	}
}


Теперь если мы попытаемся открыть этот файл будет открыто не привычное окно, а то окно, которое мы определим в классе FCustAssetEditor.
class FCustAssetEditor : public FAssetEditorToolkit, public FNotifyHook
{
public:
	
	~FCustAssetEditor();
	
	// IToolkit interface
	virtual void RegisterTabSpawners(const TSharedRef<class FTabManager>& TabManager) override;
	virtual void UnregisterTabSpawners(const TSharedRef<class FTabManager>& 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<class UEdGraphNode*>& 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<class UObject*>& NewSelection);

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

	void ShowMessage();


	TSharedRef<class SGraphEditor> CreateGraphEditorWidget(UEdGraph* InGraph);
	TSharedPtr<SGraphEditor> GraphEditor;
	TSharedPtr<FUICommandList> GraphEditorCommands;
	TSharedPtr<IDetailsView> PropertyEditor;
	UMyObject* PropBeingEdited;
	TSharedRef<SDockTab> SpawnTab_Viewport(const FSpawnTabArgs& Args);
	TSharedRef<SDockTab> SpawnTab_Details(const FSpawnTabArgs& Args);

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

Самым важным для нас методом этого класса явлется InitCustAssetEditor. Сначала этот метод создает новый редактор о чем ниже, потом он, создает две новые пустые вкладки:
const TSharedRef<FTabManager::FLayout> 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<class FTabManager>& 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<SDockTab> FCustAssetEditor::SpawnTab_Viewport(const FSpawnTabArgs& Args)
{

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

}

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

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

Панель свойств нам подойдет стандартная, а вот bluprin редактор мы создадим свой. Создается он в методе CreateGraphEditorWidget.
TSharedRef<SGraphEditor> 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<SGraphEditor> _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<TSharedPtr<FEdGraphSchemaAction> > Actions;

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

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


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

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

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

	TSharedPtr<FNodePropertyObserver> 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<SGraphPin>& 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<SHorizontalBox> OutputPinBox;
	FLinearColor BackgroundColor;
	TSharedPtr<SOverlay> 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<FGraphPanelNodeFactory> 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<class SGraphNode> FGraphPanelNodeFactory_Custom::CreateNode(UEdGraphNode* Node) const
{
	if (URootNode* RootNode = Cast<URootNode>(Node))
	{
		TSharedPtr<SGraphNode_Root> SNode = SNew(SGraphNode_Root, RootNode);
		RootNode->PropertyObserver = SNode;
		return SNode;
	}
	else if (UBranchNode* BranchNode = Cast<UBranchNode>(Node))
	{
		TSharedPtr<SGraphNode_Brunch> SNode = SNew(SGraphNode_Brunch, BranchNode);
		BranchNode->PropertyObserver = SNode;
		return SNode;
	}
	else if (URuleNode* RuleNode = Cast<URuleNode>(Node))
	{
		TSharedPtr<SGraphNode_Rule> SNode = SNew(SGraphNode_Rule, RuleNode);
		RuleNode->PropertyObserver = SNode;
		return SNode;
	}
	else if (USwitcherNode* SwitcherNode = Cast<USwitcherNode>(Node))
	{
		TSharedPtr<SGraphNode_Switcher> 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->CreateNodesMesh(GetWorld(), ActorTag, RandomStream, Roots[i].Location, 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

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

P.S После того как я написал статью, я попробовал собрать игру и она не собралась. Чтобы игру можно было собрать надо в файле CustomNods.h внести следующие исправления:
class UICUSTOM_API UCustomNodeBase : public UEdGraphNode
{
	GENERATED_BODY()
public:
	
	virtual TArray<UCustomNodeBase*> GetChildNodes(FRandomStream& RandomStream);
	virtual void CreateNodesMesh(UWorld* World, FName ActorTag, FRandomStream& RandomStream, FVector AbsLocation, FRotator AbsRotation);
#if WITH_EDITORONLY_DATA
	virtual void PostEditChangeProperty(struct FPropertyChangedEvent& e) override;
#endif //WITH_EDITORONLY_DATA
	TSharedPtr<FNodePropertyObserver> PropertyObserver;
		
};

То есть мы должны исключить все функции кроме GetChildNodes и CreateNodesMesh из класса ноды при помощи оператора #if WITH_EDITORONLY_DATA. В остальных нодах надо сделать тоже самое.

И соответственно CustomNods.cpp:

TArray<UCustomNodeBase*> UCustomNodeBase::GetChildNodes(FRandomStream& RandomStream)
{
	TArray<UCustomNodeBase*> ChildNodes;
	return ChildNodes;
}
void UCustomNodeBase::CreateNodesMesh(UWorld* World, FName ActorTag, FRandomStream& RandomStream, FVector AbsLocation, FRotator AbsRotation)
{
	TArray<UCustomNodeBase*>ChailNodes = GetChildNodes(RandomStream);

	for (int i = 0; i < ChailNodes.Num(); i++)
	{
		ChailNodes[i]->CreateNodesMesh(World, ActorTag, RandomStream, AbsLocation, AbsRotation);
	}
}
#if WITH_EDITORONLY_DATA
void UCustomNodeBase::PostEditChangeProperty(struct FPropertyChangedEvent& e)
{
	if (PropertyObserver.IsValid())
	{
		FName PropertyName = (e.Property != NULL) ? e.Property->GetFName() : NAME_None;
		PropertyObserver->OnPropertyChanged(this, PropertyName);
	}

	Super::PostEditChangeProperty(e);
}
#endif //WITH_EDITORONLY_DATA


Если вы уже скачали файл проекта пожалуйста перекачайте его заново.

P.P.S Продолжение