Unity ノードエディタを保存・復元する

こんにちは、ユーゴです。今回の記事は、[イワケンラボ](https://iwakenlab.jp/)のブログリレー企画で書いた記事となります。

【追記】
2022/10/10
挙動に問題はありませんでしたが、黄色エラーが1箇所出ていて目障りだったので、プログラムを直しました。
明らかに使っていないメソッドが記入されていたので、消しました。

# 本記事でわかること
ノードエディタで作成したグラフを保存・復元する方法
メリットは、他サイトの方法(1対1)と違い、複雑なノード関係(多対多)もセーブ・ロードできる。

# 想定読者
エディタ拡張がよくわかる人
なんなら、ノードエディタまでは作れたが、セーブ/ロードあたりで悩んでるような人

# ポイント
座標はNodeクラスにある「Rect worldBound」「Rect localBound」あたりを使えばいい。
しかし、通常のエディタ拡張から参照データを保存するのはちょっとむずいかも...

~~なので、派生クラス「MyNode」「MyEdge」を作成して「uid」を設置し、その参照からグラフの参照を復元すれば良い。~~
![node1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2607452/b41b955f-4a80-457b-ba20-5edb6bdbbcac.png)
と、考えていました。しかし、1つのノードにポートが3つ4つと増えたら...？inputが2つ以上、outputが2つ以上となると、どこに繋げばいいかわかりません。なので、**ポートにuidを振ります**。
![新ノードエディタ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2607452/c544cd74-c512-aa0b-d7e4-6c60ca7eef95.png)



また、ノード主体でデータを考えると、「ノードAはB,Cへ」「ノードBはAから、C,Dへ」「つまりリストで管理して...」となると、参照が複雑で復元が大変です。重複もしそうですね。
![node2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2607452/48e7a8d7-88dd-24cc-90a9-567aa0ee4134.png)

なので、**エッジ**(ノードを繋ぐ紐)を主体にして考えます。
エッジで見ると、どんな時でも必ず「始点-終点」の1対1の対応関係になっています。これでリストを作ったり、参照の重複が起きずに済みそうです。
![node3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2607452/4a4257f3-8ff8-7696-caf8-b85a43355b7e.png)

# 実装
エディタ拡張を実装します。今回は、以下の記事を参考にさせていただきました。
・[GraphView完全理解した(2019年末版)](https://qiita.com/ma_sh/items/7627a6151e849f5a0ede)
・[今PCが手元にないなら絶対に見ないで下さい。記事が優良すぎて、ほぼ100％その場でGraphViewしてしまいます](https://qiita.com/dwl/items/dd14f5f2a187084d317b)
余裕がある方は、本記事より実装について詳細に書かれているので、ぜひ元記事を読んでみてください。特に、今回初めてノードエディタに挑戦される方は、どちらか片方でもいいので先に実装してみましょう。

まず、以下のクラスを用意してください。
### ウィンドウ系
```cs:GraphWindow.cs
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);
        }
    }
}
```

```cs:MyGraphView.cs
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;
        }
    }
}
```

```cs:SearchWindowProvider.cs
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を振ってみてください。

```cs:MU5Node.cs
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);
        }
    }
}
```

```cs:EntryNode.cs
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();
        }
    }
}
```

```cs:ExitNode.cs
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();
        }
    }
}
```

```cs:SampleNode.cs
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の名前も変えましょう)

```cs:ScenarioData.cs
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生成はこちらに入れました。

```cs:
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」を選択します。
![スクリーンショット 2022-10-07 23.12.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2607452/5459d2b2-f754-958d-adf3-f11ec3a19703.png)

あとは右クリックで「Create Node > SampleNode」を押すと、ノードが出てきます。
ポートの○をドラッグして、ノード同士を繋ぎましょう。
![スクリーンショット 2022-10-07 23.18.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2607452/98bb5c72-8162-d5b1-6fe9-5571d3e04933.png)
![スクリーンショット 2022-10-07 22.53.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2607452/0709a268-29c6-c73a-dea1-59f616fc9dab.png)
~~どういうシナリオでこんな結び方になるというのか~~

ノードのデータは、ツールバーから「Assets > Create > MU5 > ScenarioData」で作成します。
![スクリーンショット 2022-10-07 23.23.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2607452/d7a12b0e-4596-4788-d0dd-03589c1e37ce.png)
名前や階層は適当で。
![スクリーンショット 2022-10-07 23.24.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2607452/7cc85c03-07ee-8724-1297-4df21f68b9f6.png)
ノードエディタ側でデータを指定します。「None (ScenarioData)」の右の◉をクリックして、「Data」(適当に決めたデータ名)を指定します。
![スクリーンショット 2022-10-07 23.26.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2607452/a14f6bae-d050-e925-95d9-b9aa4df412d6.png)
適当にノードを組んだら、保存ボタンを押します。(テスト中にここで1度NullReferenceが出たが再現不可能...情報求む)
![スクリーンショット 2022-10-07 23.33.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2607452/75c2203d-11bb-3a44-e58f-c7c4d8c2de67.png)
一回ウィンドウを×で閉じて、もう一回開きます。そして、もう一度「Node (ScenarioData)」から「Data」を選択し、「ロード」ボタンを押します。すると、復元できました。
![スクリーンショット 2022-10-07 23.34.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2607452/4346aa17-d244-aeb8-c89a-a14fa6d0db70.png)
![スクリーンショット 2022-10-07 23.35.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2607452/185eb5c6-672b-04ec-0418-551b404c2d4c.png)

# まとめ
いかがだったでしょうか。今回は、ノードエディタの実装から復元までを駆け足で紹介しました。今回のように、上級者向けの内容から、Unity初心者向けの内容まで、幅広く紹介していきます。
気に入っていただけましたら、評価・フォローよろしくお願いいたします。