こんにちは、ユーゴです。今回の記事は、イワケンラボのブログリレー企画で書いた記事となります。
【追記】
2022/10/10
挙動に問題はありませんでしたが、黄色エラーが1箇所出ていて目障りだったので、プログラムを直しました。
明らかに使っていないメソッドが記入されていたので、消しました。
本記事でわかること
ノードエディタで作成したグラフを保存・復元する方法
メリットは、他サイトの方法(1対1)と違い、複雑なノード関係(多対多)もセーブ・ロードできる。
想定読者
エディタ拡張がよくわかる人
なんなら、ノードエディタまでは作れたが、セーブ/ロードあたりで悩んでるような人
ポイント
座標はNodeクラスにある「Rect worldBound」「Rect localBound」あたりを使えばいい。
しかし、通常のエディタ拡張から参照データを保存するのはちょっとむずいかも...
なので、派生クラス「MyNode」「MyEdge」を作成して「uid」を設置し、その参照からグラフの参照を復元すれば良い。
と、考えていました。しかし、1つのノードにポートが3つ4つと増えたら...?inputが2つ以上、outputが2つ以上となると、どこに繋げばいいかわかりません。なので、ポートにuidを振ります。
また、ノード主体でデータを考えると、「ノードAはB,Cへ」「ノードBはAから、C,Dへ」「つまりリストで管理して...」となると、参照が複雑で復元が大変です。重複もしそうですね。
なので、エッジ(ノードを繋ぐ紐)を主体にして考えます。
エッジで見ると、どんな時でも必ず「始点-終点」の1対1の対応関係になっています。これでリストを作ったり、参照の重複が起きずに済みそうです。
実装
エディタ拡張を実装します。今回は、以下の記事を参考にさせていただきました。
・GraphView完全理解した(2019年末版)
・今PCが手元にないなら絶対に見ないで下さい。記事が優良すぎて、ほぼ100%その場でGraphViewしてしまいます
余裕がある方は、本記事より実装について詳細に書かれているので、ぜひ元記事を読んでみてください。特に、今回初めてノードエディタに挑戦される方は、どちらか片方でもいいので先に実装してみましょう。
まず、以下のクラスを用意してください。
ウィンドウ系
using System.Linq;
using UnityEngine;
using UnityEngine.UIElements;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEditor.Experimental.GraphView;
namespace MU5Editor.NodeEditor
{
public class GraphWindow : EditorWindow
{
MyGraphView graphView;
ObjectField objectField;
public ScenarioData scenarioData { get { return (ScenarioData)objectField.value; } }
//ーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーー
[MenuItem("MU5Editor/Node Editor")]
public static void Open()
{
GetWindow<GraphWindow>("Node Editor");
}
//ーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーー
void OnEnable()
{
Toolbar toolbar = new Toolbar();
toolbar.style.flexDirection = FlexDirection.Row;
objectField = new ObjectField();
objectField.objectType = typeof(ScenarioData);
Button loadBtn = new Button(LoadData) { text = "ロード" };
Button saveBtn = new Button(SaveData) { text = "保存" };
toolbar.Add(objectField);
toolbar.Add(loadBtn);
toolbar.Add(saveBtn);
rootVisualElement.Add(toolbar);
graphView = new MyGraphView(this)
{
style = { flexGrow = 1 }
};
rootVisualElement.Add(graphView);
}
void LoadData()
{
if (scenarioData == null) return;
graphView.DeleteAllElements();
foreach (var nodeData in scenarioData.nodeData_list)
{
graphView.LoadNodeData(nodeData);
}
foreach (var edgeData in scenarioData.edgeData_list)
{
graphView.LoadEdgeData(edgeData);
}
Debug.Log($"ロード完了");
}
void SaveData()
{
if (scenarioData == null) return;
scenarioData.nodeData_list.Clear();
scenarioData.edgeData_list.Clear();
foreach (var graphElement in graphView.graphElements)
{
if (graphElement is MU5Node) SaveData_Node(graphElement);
else if (graphElement is Edge) SaveData_Edge(graphElement);
else Debug.LogWarning($"Find a non-surported graphElement type: {graphElement.GetType()}");
}
EditorUtility.SetDirty(objectField.value);
AssetDatabase.SaveAssets();
Debug.Log($"保存完了");
}
void SaveData_Node(GraphElement _graphElement)
{
MU5Node node = _graphElement as MU5Node;
NodeData nodeData = new NodeData()
{
uid = node.uid,
nodeType_str = node.GetType().ToString(),
localBound = node.localBound
};
scenarioData.nodeData_list.Add(nodeData);
}
void SaveData_Edge(GraphElement _graphElement)
{
Edge edge = _graphElement as Edge;
Port inputPort = edge.input;
Port outputPort = edge.output;
MU5Node inputNode = edge.input.node as MU5Node;
MU5Node outputNode = edge.output.node as MU5Node;
string uid_inputPort_target = inputNode.port_dict.FirstOrDefault(x => x.Value.Equals(inputPort)).Key;
string uid_outputPort_target = outputNode.port_dict.FirstOrDefault(x => x.Value.Equals(outputPort)).Key;
EdgeData edgeData = new EdgeData()
{
uid_outputNode = outputNode.uid,
uid_outputPort = uid_outputPort_target,
uid_inputNode = inputNode.uid,
uid_inputPort = uid_inputPort_target
};
scenarioData.edgeData_list.Add(edgeData);
}
}
}
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UIElements;
using UnityEditor.Experimental.GraphView;
namespace MU5Editor.NodeEditor
{
public class MyGraphView : GraphView
{
public EntryNode entryNode;
public ExitNode exitNode;
public GraphWindow graphWindow;
//ーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーー
public MyGraphView(GraphWindow graphWindow) : base()
{
SetupZoom(ContentZoomer.DefaultMinScale, ContentZoomer.DefaultMaxScale);
Insert(0, new GridBackground());
this.AddManipulator(new SelectionDragger());
this.AddManipulator(new ContentDragger());
this.AddManipulator(new RectangleSelector());
SearchWindowProvider searchWindowProvider = ScriptableObject.CreateInstance(typeof(SearchWindowProvider)) as SearchWindowProvider;
searchWindowProvider.Initialize(this, graphWindow);
nodeCreationRequest += context =>
{
SearchWindow.Open(new SearchWindowContext(context.screenMousePosition), searchWindowProvider);
};
CreateBasicNodes();
}
//ーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーー
public void CreateBasicNodes()
{
entryNode = new EntryNode();
entryNode.SetPosition(new Rect(0, 0, 0, 0));
AddElement(entryNode);
exitNode = new ExitNode();
exitNode.SetPosition(new Rect(580, 0, 0, 0));
AddElement(exitNode);
}
public override List<Port> GetCompatiblePorts(Port startAnchor, NodeAdapter nodeAdapter)
{
var compatiblePorts = new List<Port>();
foreach (var port in ports.ToList())
{
if (startAnchor.node == port.node ||
startAnchor.direction == port.direction ||
startAnchor.portType != port.portType)
{
continue;
}
compatiblePorts.Add(port);
}
return compatiblePorts;
}
public void DeleteAllElements()
{
foreach (var element in graphElements)
{
RemoveElement(element);
}
}
public void LoadNodeData(NodeData nodeData)
{
MU5Node node = (MU5Node)Activator.CreateInstance(nodeData.nodeType);
node.LoadData(nodeData);
AddElement(node);
}
public void LoadEdgeData(EdgeData edgeData)
{
Edge edge = new Edge();
MU5Node outputNode = GetMU5NodeByUid(edgeData.uid_outputNode);
MU5Node inputNode = GetMU5NodeByUid(edgeData.uid_inputNode);
edge.output = outputNode.port_dict[edgeData.uid_outputPort];
outputNode.port_dict[edgeData.uid_outputPort].Connect(edge);
edge.input = inputNode.port_dict[edgeData.uid_inputPort];
inputNode.port_dict[edgeData.uid_inputPort].Connect(edge);
AddElement(edge);
}
public MU5Node GetMU5NodeByUid(string _uid)
{
MU5Node node = null;
foreach (var graphElement in graphElements)
{
MU5Node _node = graphElement as MU5Node;
if (_node == null) continue;
if (_node.uid != _uid) continue;
node = _node;
break;
}
return node;
}
}
}
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UIElements;
using UnityEditor.Experimental.GraphView;
namespace MU5Editor.NodeEditor
{
public class SearchWindowProvider : ScriptableObject, ISearchWindowProvider
{
private MyGraphView graphView;
private GraphWindow graphWindow;
public void Initialize(MyGraphView graphView, GraphWindow graphWindow)
{
this.graphView = graphView;
this.graphWindow = graphWindow;
}
List<SearchTreeEntry> ISearchWindowProvider.CreateSearchTree(SearchWindowContext context)
{
var entries = new List<SearchTreeEntry>();
entries.Add(new SearchTreeGroupEntry(new GUIContent("Create Node")));
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
foreach (var type in assembly.GetTypes())
{
if (type.IsClass && !type.IsAbstract && (type.IsSubclassOf(typeof(MU5Node)))
&& type != typeof(EntryNode) && type != typeof(ExitNode))
{
entries.Add(new SearchTreeEntry(new GUIContent(type.Name)) { level = 1, userData = type });
}
}
}
return entries;
}
bool ISearchWindowProvider.OnSelectEntry(SearchTreeEntry searchTreeEntry, SearchWindowContext context)
{
var type = searchTreeEntry.userData as System.Type;
var node = Activator.CreateInstance(type) as MU5Node;
var worldMousePosition = graphWindow.rootVisualElement.ChangeCoordinatesTo(graphWindow.rootVisualElement.parent, context.screenMousePosition - graphWindow.position.position);
var localMousePosition = graphView.contentViewContainer.WorldToLocal(worldMousePosition);
node.SetPosition(new Rect(localMousePosition, new Vector2(100, 100)));
graphView.AddElement(node);
return true;
}
}
}
ノード系
ここで小言を挟むと、Portクラスはどうも継承を前提とした作りになっていません。
しかし、ポートに関して「フィールド名変更」「順番変更」「増減」があったときにも、データが破損しないという要件は満たしたいです。
そのため、MyPortとかは作らず、Node側にDictionaryで管理したいと思います。つまりuidはkeyとなります。今回は一定のルールでuidを振っていますが、運用に合わせてuidを振ってみてください。
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UIElements;
using UnityEditor.Experimental.GraphView;
namespace MU5Editor.NodeEditor
{
public class MU5Node : Node
{
public string uid = string.Empty;
public Dictionary<string, Port> port_dict;
public Label uidLabel = new Label();
//<Methods>ーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーー
public void Init_UID()
{
if (uid != string.Empty) return;
uid = MU5Utility.GenerateUID();
viewDataKey = uid;
}
public void LoadData(NodeData nodeData)
{
uid = nodeData.uid;
SetPosition(nodeData.localBound);
}
}
}
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UIElements;
using UnityEditor.Experimental.GraphView;
namespace MU5Editor.NodeEditor
{
public class EntryNode : MU5Node
{
//<Variables>ーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーー
Port outputPort;
void Init_Port()
{
outputPort = Port.Create<Edge>(Orientation.Horizontal, Direction.Output, Port.Capacity.Multi, typeof(Port));
outputPort.portName = "output";
outputContainer.Add(outputPort);
port_dict = new Dictionary<string, Port>(){
{ "500100",outputPort}
};
}
//<Constructor>ーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーー
public EntryNode() : base()
{
title = "Entry";
titleContainer.style.backgroundColor = new StyleColor() { value = new Color(0, 0.6f, 0) };
capabilities -= Capabilities.Deletable;
Init_UID();
Init_Port();
}
}
}
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UIElements;
using UnityEditor.Experimental.GraphView;
namespace MU5Editor.NodeEditor
{
public class ExitNode : MU5Node
{
//<Variables>ーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーー
Port inputPort;
void Init_Port()
{
inputPort = Port.Create<Edge>(Orientation.Horizontal, Direction.Input, Port.Capacity.Multi, typeof(Port));
inputPort.portName = "input";
inputContainer.Add(inputPort);
port_dict = new Dictionary<string, Port>(){
{"000100",inputPort}
};
}
//<Constructor>ーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーー
public ExitNode() : base()
{
title = "Exit";
titleContainer.style.backgroundColor = new StyleColor() { value = new Color(0.6f, 0, 0) };
capabilities -= Capabilities.Deletable;
Init_UID();
Init_Port();
}
}
}
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UIElements;
using UnityEditor.UIElements;
using UnityEditor.Experimental.GraphView;
namespace MU5Editor.NodeEditor
{
public class SampleNode : MU5Node
{
//<Variables>ーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーー
Port inputPort1;
Port inputPort2;
Port outputPort1;
Port outputPort2;
Port outputPort3;
void Init_Port()
{
inputPort1 = Port.Create<Edge>(Orientation.Horizontal, Direction.Input, Port.Capacity.Multi, typeof(Port));
inputPort1.portName = "in1";
inputContainer.Add(inputPort1);
inputPort2 = Port.Create<Edge>(Orientation.Horizontal, Direction.Input, Port.Capacity.Multi, typeof(Port));
inputPort2.portName = "in2";
inputContainer.Add(inputPort2);
outputPort1 = Port.Create<Edge>(Orientation.Horizontal, Direction.Output, Port.Capacity.Multi, typeof(Port));
outputPort1.portName = "out1";
outputContainer.Add(outputPort1);
outputPort2 = Port.Create<Edge>(Orientation.Horizontal, Direction.Output, Port.Capacity.Multi, typeof(Port));
outputPort2.portName = "out2";
outputContainer.Add(outputPort2);
outputPort3 = Port.Create<Edge>(Orientation.Horizontal, Direction.Output, Port.Capacity.Multi, typeof(Port));
outputPort3.portName = "out3";
outputContainer.Add(outputPort3);
port_dict = new Dictionary<string, Port>(){
{"000100",inputPort1},
{"000200",inputPort2},
{"500100",outputPort1},
{"500200",outputPort2},
{"500300",outputPort3}
};
}
//<Constructor>ーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーー
public SampleNode() : base()
{
title = "サンプル";
Init_UID();
Init_Port();
}
}
}
データ系
ノベルゲーム向けの開発を考えていたので、シナリオデータというクラス名になっています。汎用的に、NodeGraphDataとかしてもいいと思います。(その場合他のクラスで使ってたScenarioDataの名前も変えましょう)
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor.Experimental.GraphView;
namespace MU5Editor.NodeEditor
{
[CreateAssetMenu(menuName = "MU5/ScenarioData")]
public class ScenarioData : ScriptableObject
{
public List<NodeData> nodeData_list;
public List<EdgeData> edgeData_list;
}
//ーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーー
[Serializable]
public class NodeData
{
public string uid;
public string nodeType_str;
public Type nodeType { get { return Type.GetType(nodeType_str); } }
public Rect localBound;
}
//ーーーーーーーーーーーーーーーーーーーーー
[Serializable]
public class EdgeData
{
public string uid_outputNode;
public string uid_outputPort;
public string uid_inputNode;
public string uid_inputPort;
}
}
その他
今回だけなら作り分ける必要もなかったと思いますが、繰り返し説明するとノベルゲーム向けのノードエディタ開発だったので、Utility的なクラスが欲しいな...と思い、uid生成はこちらに入れました。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace MU5Editor
{
public class MU5Utility
{
public static string GenerateUID()
{
char[] characters = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
'a','b','c','d','e','f','g','h','i','j','k','l','m',
'n','o','p','q','r','s','t','u','v','w','x','y','z' };
string uid = string.Empty;
int digit = 16;
for (int i = 0; i < digit; i++)
{
int num = Random.Range(0, characters.Length);
uid += characters[num];
}
return uid;
}
}
}
ひとまずこれで、使えるか試します。
まず、ツールバーから「MU5Editor > Node Editor」を選択します。
あとは右クリックで「Create Node > SampleNode」を押すと、ノードが出てきます。
ポートの○をドラッグして、ノード同士を繋ぎましょう。
どういうシナリオでこんな結び方になるというのか
ノードのデータは、ツールバーから「Assets > Create > MU5 > ScenarioData」で作成します。
名前や階層は適当で。
ノードエディタ側でデータを指定します。「None (ScenarioData)」の右の◉をクリックして、「Data」(適当に決めたデータ名)を指定します。
適当にノードを組んだら、保存ボタンを押します。(テスト中にここで1度NullReferenceが出たが再現不可能...情報求む)
一回ウィンドウを×で閉じて、もう一回開きます。そして、もう一度「Node (ScenarioData)」から「Data」を選択し、「ロード」ボタンを押します。すると、復元できました。
まとめ
いかがだったでしょうか。今回は、ノードエディタの実装から復元までを駆け足で紹介しました。今回のように、上級者向けの内容から、Unity初心者向けの内容まで、幅広く紹介していきます。
気に入っていただけましたら、評価・フォローよろしくお願いいたします。