Pull to refresh

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

Reading time 13 min
Views 12K
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 Продолжение
Tags:
Hubs:
+6
Comments 0
Comments Leave a comment

Articles