深入Unreal藍圖開發:自定義藍圖節點(中)

通過本系列文章上篇的介紹,我們已經可以創建一個“沒什麼用”的藍圖節點了。要想讓它有用,關鍵還是上篇中說的典型應用場景:動態添加Pin,這篇博客就來解決這個問題。

目標

和上篇一樣,我還將通過一個儘量簡單的節點,來說明"可動態添加Pin的藍圖節點"的實現過程,讓大家儘量聚焦在“藍圖自定義節點”這個主題上。

設想這樣一個節點:Say Something,把輸入的N個字符串連接起來,然後打印輸出。也就是說,這個節點的輸入Pin是可以動態添加的。我們將在上篇的那個工程基礎上實現這個自定義節點。最終實現的效果如下圖所示:
在這裏插入圖片描述
下面我們還是來仔細的過一遍實現步驟吧!

創建Blueprint Graph節點類型

首先,我們還是需要創建一個class UK2Node的派生類,這個過程在上篇中已經詳細說過了,照單炒菜,很容易就創建了下圖這樣一個空的自定義節點,這裏就不贅述了。不清楚的話,可以返回去在照着上篇做就好了。
在這裏插入圖片描述

創建自定義的節點Widget

我們要動態增加Pin的話,需要在節點上顯示一個"加號按鈕",點擊之後增加一個“input pin”。這就不能使用默認的Blueprint Graph Node Widget了,需要對其進行擴展。這個擴展的思路和前面一樣,也是找到特定的基類,重載其虛函數即可,這個基類就是class SGraphNodeK2Base。我們要重載的兩個核心的函數是:

  1. CreateInputSideAddButton(),創建我們需要的添加輸入Pin的按鈕;
  2. OnAddPin(),響應這個按鈕的操作;

來看一下最簡化的代碼吧:
SGraphNodeSaySomething.h

class SGraphNodeSaySomething : public SGraphNodeK2Base
{
public:
	SLATE_BEGIN_ARGS(SGraphNodeSaySomething){}
	SLATE_END_ARGS()

	void Construct(const FArguments& InArgs, UBPNode_SaySomething* InNode);
protected:
	virtual void CreateInputSideAddButton(TSharedPtr<SVerticalBox> InputBox) override;
	virtual FReply OnAddPin() override;
};

SGraphNodeSaySomething.cpp

void SGraphNodeSaySomething::Construct(const FArguments& InArgs, UBPNode_SaySomething* InNode)
{
	this->GraphNode = InNode;
	this->SetCursor( EMouseCursor::CardinalCross );
	this->UpdateGraphNode();
}

void SGraphNodeSaySomething::CreateInputSideAddButton(TSharedPtr<SVerticalBox> InputBox)
{
	FText Tmp = FText::FromString(TEXT("Add word"));
	TSharedRef<SWidget> AddPinButton = AddPinButtonContent(Tmp, Tmp);

	FMargin AddPinPadding = Settings->GetInputPinPadding();
	AddPinPadding.Top += 6.0f;

	InputBox->AddSlot()
	.AutoHeight()
	.VAlign(VAlign_Center)
	.Padding(AddPinPadding)
	[
		AddPinButton
	];
}

FReply SGraphNodeSaySomething::OnAddPin()
{ }

如果你接觸過Unreal Slate的話,上面這個Slate Widget的代碼很容易看懂啦,如果你沒有玩過Slate。。。。Slate是虛幻自己的一套 Immediate Mode UI framework,建議先過一下官方文檔

最後,因爲這個基類:SGraphNodeK2Base,屬於GraphEditor模塊,所以要修改MyBlueprintNodeEditor.Build.cs,把它添加到PrivateDependencyModuleNames:

PrivateDependencyModuleNames.AddRange(new string[] {
            "UnrealEd",
            "GraphEditor",
            "BlueprintGraph",
            "KismetCompiler",
            "MyBlueprintNode"
        });

擴展藍圖編輯器的節點Widget

OK,上面我們已經創建了兩個類,分別是:

  1. class UBPNode_SaySomething : public UK2Node
  2. class SGraphNodeSaySomething : public SGraphNodeK2Base

下面我們就需要讓藍圖編輯器知道:創建UBPNode_SaySomething對象的時候,需要使用SGraphNodeSaySomething這個Widget。

添加自定義Node Widget的兩種方式(參見引擎源碼class FNodeFactory):

  1. 重載UEdGraphNode::CreateVisualWidget()函數,例如:
TSharedPtr<SGraphNode> UNiagaraNode::CreateVisualWidget() 
{
	return SNew(SNiagaraGraphNode, this);
}
  1. 使用 class FEdGraphUtilities 註冊 class FGraphPanelNodeFactory對象,例如:
void FBehaviorTreeEditorModule::StartupModule()
{
	GraphPanelNodeFactory_BehaviorTree = MakeShareable( new FGraphPanelNodeFactory_BehaviorTree() );
	FEdGraphUtilities::RegisterVisualNodeFactory(GraphPanelNodeFactory_BehaviorTree);
}

在這裏,我們使用第一種方式,也就是在class UBPNode_SaySomething中重載父類的虛函數CreateVisualWidget()。

TSharedPtr<SGraphNode> UBPNode_SaySomething::CreateVisualWidget() {
	return SNew(SGraphNodeSaySomething, this);
}

完成上述代碼之後,運行藍圖編輯器,添加Say Something節點,就可以看到這個Widget了:
在這裏插入圖片描述

動態增加輸入參數變量

當用戶點擊“Add Word +”按鈕時,SGraphNodeSaySomething::OnAddPin()會被調用,下面是它的實現代碼:

FReply SGraphNodeSaySomething::OnAddPin()
{
	UBPNode_SaySomething* BPNode = CastChecked<UBPNode_SaySomething>(GraphNode);

	const FScopedTransaction Transaction(NSLOCTEXT("Kismet", "AddArgumentPin", "Add Argument Pin"));
	BPNode->Modify();

	BPNode->AddPinToNode();
	FBlueprintEditorUtils::MarkBlueprintAsModified(BPNode->GetBlueprint());

	UpdateGraphNode();
	GraphNode->GetGraph()->NotifyGraphChanged();

	return FReply::Handled();
}

上面這段代碼主要是響應用戶的UI操作,添加Pin的核心操作,還是放在UBPNode_SaySomething::AddPinToNode()這個函數裏面去實現的:

void UBPNode_SaySomething::AddPinToNode()
{
	TMap<FString, FStringFormatArg> FormatArgs= {
			{TEXT("Count"), ArgPinNames.Num()}
	};
	FName NewPinName(*FString::Format(TEXT("Word {Count}"), FormatArgs));
	ArgPinNames.Add(NewPinName);

	CreatePin(EGPD_Input, UEdGraphSchema_K2::PC_String, NewPinName);
}

現在我們就可以在藍圖編輯器裏面操作添加輸入Pin了 :
在這裏插入圖片描述

動態刪除Pin

如果用戶想要刪除某個輸入變量Pin,他需要在那個Pin上點擊鼠標右鍵,呼出Context Menu,選擇“刪除”菜單項將其移除。下面我們就看看這個操作是如何實現的。

在這裏插入圖片描述

我們可以通過重載void UEdGraphNode::GetContextMenuActions(const FGraphNodeContextMenuBuilder& Context) const來定製Context Menu。

void UBPNode_SaySomething::GetContextMenuActions(const FGraphNodeContextMenuBuilder & Context) const
{
	Super::GetContextMenuActions(Context);

	if (Context.bIsDebugging)
		return;

	Context.MenuBuilder->BeginSection("UBPNode_SaySomething", FText::FromString(TEXT("Say Something")));

	if (Context.Pin != nullptr)
	{
		if (Context.Pin->Direction == EGPD_Input && Context.Pin->ParentPin == nullptr)
		{
			Context.MenuBuilder->AddMenuEntry(
				FText::FromString(TEXT("Remove Word")),
				FText::FromString(TEXT("Remove Word from input")),
				FSlateIcon(),
				FUIAction(
					FExecuteAction::CreateUObject(this, &UBPNode_SaySomething::RemoveInputPin, const_cast<UEdGraphPin*>(Context.Pin))
				)
			);
		}
	}// end of if

	Context.MenuBuilder->EndSection();
}

這個函數的實現很直白啦,就是操作MenuBuilder,添加菜單項,並綁定UIAction到成員函數UBPNode_SaySomething::RemoveInputPin,接下來就是實現這個函數了。

void UBPNode_SaySomething::RemoveInputPin(UEdGraphPin * Pin)
{
	FScopedTransaction Transaction(FText::FromString("SaySomething_RemoveInputPin"));
	Modify();

	ArgPinNames.Remove(Pin->GetFName());

	RemovePin(Pin);
	FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(GetBlueprint());
}

也很簡單,就是直接調用父類的RemovePin(),並同步處理一下自己內部的狀態變量就好了。

實現這個藍圖節點的編譯

通過前面的步驟,藍圖編輯器的擴展就全部完成了,接下來就是最後一步了,通過擴展藍圖編譯過程來實現這個節點的實際功能。

我們延續上篇的思路來實現這個節點的功能,也就是重載UK2Node::ExpandNode()函數。

核心的問題是如何把當前的所有的輸入的Pin組合起來? 答案很簡單,把所有輸入的Pin做成一個TArray<>,這樣就可以傳入到一個UFunction來調用。

首先我們在 class UMyBlueprintFunctionLibrary 中添加一個函數:

UCLASS()
class MYBLUEPRINTNODE_API UMyBlueprintFunctionLibrary : public UBlueprintFunctionLibrary
{
	GENERATED_BODY()

public:
	UFUNCTION(BlueprintCallable, meta = (BlueprintInternalUseOnly = "true"))
		static void SaySomething_Internal(const TArray<FString>& InWords);
};

然後,仍然與上篇相同,使用一個 class UK2Node_CallFunction 節點實例對象來調用這個UFunction,不同的是,我們需要使用一個 class UK2Node_MakeArray 節點的實例來把收集所有的動態生成的輸入Pin。下面是實現的代碼:


void UBPNode_SaySomething::ExpandNode(FKismetCompilerContext & CompilerContext, UEdGraph * SourceGraph) {
	Super::ExpandNode(CompilerContext, SourceGraph);

	UEdGraphPin* ExecPin = GetExecPin();
	UEdGraphPin* ThenPin = GetThenPin();
	if (ExecPin && ThenPin) {

		// create a CallFunction node
		FName MyFunctionName = GET_FUNCTION_NAME_CHECKED(UMyBlueprintFunctionLibrary, SaySomething_Internal);

		UK2Node_CallFunction* CallFuncNode = CompilerContext.SpawnIntermediateNode<UK2Node_CallFunction>(this, SourceGraph);
		CallFuncNode->FunctionReference.SetExternalMember(MyFunctionName, UBPNode_SaySomething::StaticClass());
		CallFuncNode->AllocateDefaultPins();

		// move exec pins
		CompilerContext.MovePinLinksToIntermediate(*ExecPin, *(CallFuncNode->GetExecPin()));
		CompilerContext.MovePinLinksToIntermediate(*ThenPin, *(CallFuncNode->GetThenPin()));

		// create a "Make Array" node to compile all args
		UK2Node_MakeArray* MakeArrayNode = CompilerContext.SpawnIntermediateNode<UK2Node_MakeArray>(this, SourceGraph);
		MakeArrayNode->AllocateDefaultPins();

		// Connect Make Array output to function arg
		UEdGraphPin* ArrayOut = MakeArrayNode->GetOutputPin();
		UEdGraphPin* FuncArgPin = CallFuncNode->FindPinChecked(TEXT("InWords"));
		ArrayOut->MakeLinkTo(FuncArgPin);
		
		// This will set the "Make Array" node's type, only works if one pin is connected.
		MakeArrayNode->PinConnectionListChanged(ArrayOut);

		// connect all arg pin to Make Array input
		for (int32 i = 0; i < ArgPinNames.Num(); i++) {

			// Make Array node has one input by default
			if (i > 0)
				MakeArrayNode->AddInputPin();

			// find the input pin on the "Make Array" node by index.
			const FString PinName = FString::Printf(TEXT("[%d]"), i);
			UEdGraphPin* ArrayInputPin = MakeArrayNode->FindPinChecked(PinName);

			// move input word to array 
			UEdGraphPin* MyInputPin = FindPinChecked(ArgPinNames[i], EGPD_Input);
			CompilerContext.MovePinLinksToIntermediate(*MyInputPin, *ArrayInputPin);
		}// end of for
	}

	// break any links to the expanded node
	BreakAllNodeLinks();
}

核心步驟來講解一下:

  1. 創建了一個class UK2Node_CallFunction的實例,然後把自身節點的兩端的Exec Pin重定向到這個Node的兩端;
  2. 使用“函數參數名稱”找到UK2Node_CallFunction節點的輸入Pin,把它連接到一個新建的UK2Node_MakeArray的節點實例上;
  3. 把自己所有的輸入變量Pin重定向到UK2Node_MakeArray的輸入上(需要爲它動態添加新的Pin);

結束語

今天涉及到的class稍微有點多,我整理了一個UML靜態結構圖,看看這幾個classes直接的關係以及它們所在的模塊。完整源代碼仍然是在我的GitHub:https://github.com/neil3d/UnrealCookBook/tree/master/MyBlueprintNode
在這裏插入圖片描述
至此,通過派生class UK2Node和class SGraphNodeK2Base來擴展Blueprint Graph Editor,我們可以自己定義藍圖節點,以及編輯器中的Node Widget,可以添加按鈕,以及其他任何你想要做的東西。通過這個定製化的Node Widget,可以實現編輯時對Blueprint Graph Node的交互控制。至此,我們已經掌握了最強大的藍圖節點的擴展方法。動態添加Pin這個問題說明白之後,下篇將寫什麼呢?先賣個關子,且待下回分解吧~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章