hirukoTimeのUnity備忘録

Unityでゲームを作成してます。

【Unity】GraphViewで遊んでみた。【第三回】

はじめに

この記事は第三回になります。
執筆日は20222/11/14になります。
Unityのバージョンは2021.3.1f1を使用しています。
第一回:Windowの作成方法
第二回:Nodeの作成方法

自己紹介

活動名はHiruko、もしくはHirukoTimeで活動してます。
個人でSteamでゲームを販売したり、定期的にゲームジャムなどに参加をしています。

今回のお題

グラフの保存機能を作る。

GraphViewに保存に関するものは無いので頑張って実装する。
まずは保存用のボタンを作成する。

ファイル構成

  • Editor
    • Node
      • BaseNode.cs
      • FloatNode
    • TestWindow.cs
    • TestGraphView.cs
    • SearchMenu.cs
  • Resources
    • UXMLファイル
    • USSファイル
  • Runtime
    • GraphData.cs

ブログ用でアクセサーは適当にpublic。
見た目の基本方針としては、ShaderGraphっぽくする。

アセットのダブルクリックでGraphが開く。 開いた時に自動ロード 保存と新規保存。

GraphData.cs
    using System;
    using System.Collections.Generic;
    using UnityEngine;

    public class GraphData : ScriptableObject
    {
        public List<NodeData> Nodes = new List<NodeData>();
        public List<EdgeData> Edges = new List<EdgeData>();

        public void Clear()
        {
            nodeDates.Clear();
            edgeDates.Clear();
        }
    }

    [Serializable]
    public class NodeData
    {
        public string guid;
        public string typeName;
        public string parameterJson;
        public Rect position;
        
        public NodeData(string targetGuid,string typeFullName,string paramJson,Rect pos)
        {
            guid = targetGuid;
            typeName = typeFullName;
            parameterJson = paramJson;
            position = pos;
        }
    }

    [Serializable]
    public class EdgeData
    {
        public string from;
        public string to;
        
        public EdgeData(string fromGuid, string toGuid)
        {
            from = fromGuid;
            to = toGuid;
        }
    }
TestWindow.cs
    public class TestWindow : GraphViewEditorWindow
    {
        [SerializeField] private GraphData data;
        private TestGraphView _graphView;
        
        private const string DefaultFileName = "NewGraphData";
        private const string Extension = "asset";
        
        //Attributeはなくして、アセットファイルから開くようにする。
        private static void ShowWindow(GraphData data)
        {
            if (data == null) return;
            
            //呼び出した段階でOnEnableが走る
            var window = GetWindow<TestWindow>(data.name);
                
            window.Initialize(data);
                
            window.Show();
        }

        //アセットを開く(ダブルクリックする)処理。
        [OnOpenAsset(0)]
        public static bool OnOpenAsset(int instanceId, int line)
        {
            var asset = EditorUtility.InstanceIDToObject(instanceId) as GraphData;
        
            if (asset == null) return false;
        
            ShowWindow(asset);
            return true;
        }
        
        //タイミング的にOnEnableの後になる。
        private void Initialize(GraphData graphData)
        {
            data = graphData;
            OnLoad();
        }
        
        private void OnEnable()
        {
            _graphView = new TestGraphView();
            _graphView.InitializeMenuWindow(this);
            rootVisualElement.Add(_graphView);

            var toolbar = new Toolbar();
            toolbar.styleSheets.Add(Resources.Load<StyleSheet>("TestToolBarStyle"));
            var saveButton = new ToolbarButton(OnSave){text = "Save",name = "save-button"};
            var saveAsButton = new ToolbarButton(CreateFile){text = "SaveAs",name = "save-as-button"};
            
            toolbar.Add(saveButton);
            toolbar.Add(saveAsButton);
            
            rootVisualElement.Add(toolbar);
            
            //ここのタイミングはPlayなどでリロードが入った場合に再読み込みする。
            OnLoad();
        }

        //このタイミングでSaveしないと編集内容が破棄されて保存前のデータがリロードされる。
        //今回は何もしてないが、ダイアログなどだして、注意メッセージ後に保存でもいいかもしれない
        private void OnDisable()
        {
            OnSave();
        }

        private void CreateFile()
        {
            var path 
                = EditorUtility
                    .SaveFilePanelInProject("Save Graph Data", DefaultFileName , Extension,"");

            //Panelをキャンセルした時は空文字列になる。
            if (string.IsNullOrEmpty(path)) return;

            var fileName = System.IO.Path.GetFileNameWithoutExtension(path);
            var newAsset = CreateInstance<GraphData>();
            newAsset.name = fileName;
            
            data = newAsset;
            
            OnSave();
            
            var oldAsset = AssetDatabase.LoadAssetAtPath<GraphData>(path);
            if (oldAsset == null) {
                AssetDatabase.CreateAsset(newAsset, path);
            }
            else {
                EditorUtility.CopySerializedIfDifferent(newAsset, oldAsset);
                AssetDatabase.SaveAssets();
            }

        }
        
        private void OnSave()
        {
            if (data.IsUnityNull()) return;
            
            data.Clear();
            
            foreach (var edge in _graphView.edges)
            {
                var to = edge.input.node as BaseNode;
                var from = edge.output.node as BaseNode;

                if (from == null || to == null) continue;
                
                var edgeData = new EdgeData(from.Guid, to.Guid);
                
                data.Edges.Add(edgeData);
            }
            
            foreach (var node in _graphView.nodes)
            {
                if (!(node is BaseNode n)) continue;
                
                var nodeData = 
                    new NodeData(
                        n.Guid,
                        n.GetType().FullName,
                        n.ToJson(), 
                        n.GetPosition());
                
                data.Nodes.Add(nodeData);
            }
        }

        private void OnLoad()
        {
            if (data.IsUnityNull()) return;

            _graphView.nodes.ForEach(x => x.RemoveFromHierarchy());
            _graphView.edges.ForEach(x => x.RemoveFromHierarchy());
            
            foreach (var n in data.Nodes)
            {
                var t = Type.GetType(n.typeName);
                
                if (t == null) continue;
                
                var instance = Activator.CreateInstance(t) as BaseNode;
                
                if (instance == null) continue;
                
                _graphView.AddElement(instance);
                instance.Initialize(n.guid,n.parameterJson,n.position);
            }

            var nodes = _graphView.nodes;
            
            foreach (var e in data.Edges)
            {
                var from = nodes.Select(x => x as BaseNode).FirstOrDefault(x => x?.Guid == e.from);
                var to = nodes.Select(x => x as BaseNode).FirstOrDefault(x => x?.Guid == e.to);

                if (from == null || to == null) continue;

                var input = to.inputContainer.Children().FirstOrDefault(x => x is Port) as Port;
                var output = from.outputContainer.Children().FirstOrDefault(x => x is Port) as Port;

                if (input == null || output == null) continue;
                
                var edge = new Edge() {input = input, output = output};
                edge.input.Connect(edge);
                edge.output.Connect(edge);
                _graphView.Add(edge);
            }
        }
    }
TestToolBarStyle.uss
.unity-toolbar-button{
    -unity-text-align: middle-center;
    margin: 0px 5px;
}
BaseNode.cs
  
public abstract class BaseNode : Node
{
    public string Guid { get; protected set; }
    protected BaseNode() : base() {}
    protected BaseNode(string uxmlPath) : base(uxmlPath){}

    public virtual void Initialize(string guid,string json,Rect pos)
    {
        Guid = guid;
        //JsonUtility.FromJson(json);
        SetPosition(pos);
    }

    public abstract string ToJson();
}
FloatNode.cs
    public class FloatNode : BaseNode
    {
        public FloatField Value { get; private set; }
    
        public FloatNode() : base()
        {
            Guid = System.Guid.NewGuid().ToString("N");
        
            UseDefaultStyling();
            base.title = "Float";

            var input 
                = base.InstantiatePort(Orientation.Horizontal, Direction.Input, Port.Capacity.Multi,typeof(float));

            input.portName = "X(1)";

            inputContainer.Add(input);
        
            var output 
                = base.InstantiatePort(Orientation.Horizontal, Direction.Output, Port.Capacity.Multi,typeof(float));

            output.portName = "Out(1)";
        
            outputContainer.Add(output);

            Value = new FloatField(){name = "Value",label = "Value"};
            extensionContainer.Add(Value);
        
            base.ToggleCollapse();
            base.ToggleCollapse();
        }

        public override string ToJson()
        {
            return JsonUtility.ToJson(Value);
        }

        public override void Initialize(string guid, string json, Rect pos)
        {
            Guid = guid;
            var field = JsonUtility.FromJson<FloatField>(json);
            Value.value = field.value;
            SetPosition(pos);
        }
    }

テスト動画(GIF)


一応、保存はできているが、不安が残る実装。
他にいい方法がないか検討。
変更箇所が多いので記載漏れがないか不安。

今後の予定

  • ブラックボード
  • ミニマップ
  • 他何か思いついたら