2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-10-08

こんにちは、ユーゴです。今回の記事は、イワケンラボのブログリレー企画で書いた記事となります。

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

本記事でわかること

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

想定読者

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

ポイント

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

なので、派生クラス「MyNode」「MyEdge」を作成して「uid」を設置し、その参照からグラフの参照を復元すれば良い。
node1.png
と、考えていました。しかし、1つのノードにポートが3つ4つと増えたら...?inputが2つ以上、outputが2つ以上となると、どこに繋げばいいかわかりません。なので、ポートにuidを振ります
新ノードエディタ.png

また、ノード主体でデータを考えると、「ノードAはB,Cへ」「ノードBはAから、C,Dへ」「つまりリストで管理して...」となると、参照が複雑で復元が大変です。重複もしそうですね。
node2.png

なので、エッジ(ノードを繋ぐ紐)を主体にして考えます。
エッジで見ると、どんな時でも必ず「始点-終点」の1対1の対応関係になっています。これでリストを作ったり、参照の重複が起きずに済みそうです。
node3.png

実装

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

まず、以下のクラスを用意してください。

ウィンドウ系

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);
        }
    }
}
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;
        }
    }
}
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を振ってみてください。

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);
        }
    }
}
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();
        }
    }
}
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();
        }
    }
}
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の名前も変えましょう)

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

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

あとは右クリックで「Create Node > SampleNode」を押すと、ノードが出てきます。
ポートの○をドラッグして、ノード同士を繋ぎましょう。
スクリーンショット 2022-10-07 23.18.06.png
スクリーンショット 2022-10-07 22.53.19.png
どういうシナリオでこんな結び方になるというのか

ノードのデータは、ツールバーから「Assets > Create > MU5 > ScenarioData」で作成します。
スクリーンショット 2022-10-07 23.23.08.png
名前や階層は適当で。
スクリーンショット 2022-10-07 23.24.55.png
ノードエディタ側でデータを指定します。「None (ScenarioData)」の右の◉をクリックして、「Data」(適当に決めたデータ名)を指定します。
スクリーンショット 2022-10-07 23.26.02.png
適当にノードを組んだら、保存ボタンを押します。(テスト中にここで1度NullReferenceが出たが再現不可能...情報求む)
スクリーンショット 2022-10-07 23.33.54.png
一回ウィンドウを×で閉じて、もう一回開きます。そして、もう一度「Node (ScenarioData)」から「Data」を選択し、「ロード」ボタンを押します。すると、復元できました。
スクリーンショット 2022-10-07 23.34.23.png
スクリーンショット 2022-10-07 23.35.28.png

まとめ

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

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?