20191221のC#に関する記事は9件です。

C#手遊び(PostgreSQL 9と10で動作が違う?)

この記事、何?

お仕事でC#からPostgreSQLに値を更新していて「?」があったので投稿。
解決はしてない。
冬休みの宿題、のつもりで、再現用のコードをしたためました、という話。

概要

Newtonsoft.Jsonを使ってPostgreSQLにjson型で値を落としているところ。
PostgreSQLの9で検証済みのコードを10につなげてみたら動かなかったという話。
で、原因がDBに格納されている値が異なる、という話。
続きは詳細で。

(その前に)環境

Windows 10 Pro x64 (Windows 8 Pro x64も検証済み。同様)
PostgreSQL 9.6.15 (x64)
PostgreSQL 10.10 (x64)
Visual Studio 2019 Community 16.4.2
.NET Framework 4.7.2
Npgsql.4.1.1

詳細

もういきなりコード載せちゃう。

using Newtonsoft.Json;

        public static void TestCode(int id)
        {
            // テーブルはこれ
            // create table test (id integer, json json);

            string jsonValue;
            string jsonString;

            // jsonValue = "{\"x\": 3}";
            jsonValue = "\"{\\\"x\\\": 3}\"";
            jsonString = "{\"id\":1, \"json\":" + jsonValue + "}";

            string hostname = "localhost";

            string connectionString = $"Server={hostname};Port={id};User Id=postgres;Password=manager;Database=postgres;";
            using (var conn = new Npgsql.NpgsqlConnection(connectionString))
            {
                conn.Open();
                string sql;
                sql = $@"
                    INSERT INTO
                        test (id, json)
                    SELECT
                        id, json
                    FROM
                        json_populate_record(null::test, @para)
                    ";
                using (var cmd = new Npgsql.NpgsqlCommand(sql, conn))
                {
                    cmd.Parameters.Add("para", NpgsqlTypes.NpgsqlDbType.Json);
                    cmd.Parameters["para"].Value = jsonString;
                    cmd.ExecuteNonQuery();
                }
                conn.Close();
            }
        }


PostgreSQLのjson型のカラムにjsonを格納したくて、上記のようなコードを書いてみた。
で、9では意図した動きをしていたんだけど、10にしたら違った。

こんな。

9にInsertした場合

9.png

10にInsertした場合

10.png

なんか・・・サクラエディタとかで正規表現のチェックを間違えたかのようなリアクションが。。。

感想

この投稿するために「json_populate_record」ってQiitaをググったらこんな記事が・・・

■PostgreSQL 9.3 の JSON サポートについて(長いよッ)
https://qiita.com/kumazo/items/483f47360f8b61a9fbb9

これを読めばいい気がしてきた。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

初心者がUnityでゲームを制作する過程[その1]

はじめに

タイトル通りプログラミングを学び始めて数ヶ月ですが、ゲームを作ってみようと思ったのでその過程を投稿してみようと思いました。なので、これがQiitaでの初投稿になります。そのため稚拙な部分など多々ありますがそこはご指摘いただけると幸いです。またこの投稿が、同じように手を出して見ようと思っている人たちの助けとなれればと思います。

現在のスキル

C#である程度かけるので、UnityでもC#を使用していきます。
また、Unityの基本的な知識を身に付けるために映像コンテンツを使用しました。

使用した教材:ドットインストール
Unity ゲーム開発入門:インディーゲームクリエイターが教えるマリオのようなゲームを作成する方法

ゲーム内容

今回はモノポリーのような桃鉄のようなものを作っていきたいと思います。
ゲーム盤に駅を配置し、サイコロの出た目にそって電車が駅を回っていくことを想定しています

では次の回から制作していきます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Unity】ゼロから作るノードベースエディター

概要

目標は、UnityのUIElementsによってノードベースエディターを作ることです。
タイトルの「ゼロから」が意味するところは、「UIElements」を知らないところから、という意味です。
そのため、UIElemtnsに関する前提知識は必要ありません。

Unityのバージョンは2019.1.3f1です。
プロジェクトはGitHubに挙げておきます。
https://github.com/saragai/GraphEditor

追記:バージョン2019.2.16f1でこのエディタを使用したところ、エッジの選択ができなくなっていました。

背景

Unity2019からUIElementsというUIツールが入りました。
現在はエディタ拡張にしか使えませんが、将来的にはゲーム内部のUIにも使えるようになるそうです。

最近の機能でいえば、ShaderGraphのようなGUIツールもUIElementで作られています。

shader_graph_sample.jpg
[画像は引用:https://unity.com/ja/shader-graph]

これはGraphViewというノードベースエディタによって作られていて、GraphViewを使えばShaderGraphのようなヴィジュアルツールを作成できます。
[参照:GraphView完全理解した(2019年末版)]

さて、本記事の目標はGraphViewのようなのツールを作ることです。

いやGraphView使えばいいじゃん、と思った方は鋭いです。実用に耐えるものを作るなら、使った方がよいと思います。
さらに、本記事はUnityが公開しているGraphViewの実装を大いに参考にしているので、GraphViewを使うならすべて無視できる内容です。

とはいえ、内部でどんなことをすると上記画像のようなエディタ拡張ができるのか、気になる方も多いのではと思います。
その理解の一助となればと思います。

注)この記事は手順を細かく解説したり、あえて不具合のある例を付けたりしているので、冗長な部分が多々あります。

実装

公式ドキュメントを見ながら実装していきます。
https://docs.unity3d.com/2019.1/Documentation/Manual/UIElements.html
https://docs.unity3d.com/2019.3/Documentation/ScriptReference/UIElements.VisualElement.html

0. 挨拶

エディタ拡張用のスクリプトは必ず Assets/[どこでもいい]/Editor というディレクトリの下に置きます。ビルド時にビルド対象から外すためです。
というわけで、Assets/Scripts/Editor にC#ファイルを作って GraphEditor と名付けます。

Graphとは、頂点と辺からなるデータ構造を示す用語で、ビヘイビアツリーや有限オートマトンもGraphの一種です。ビヘイビアツリーの場合はアクションやデコレータが頂点、それぞれがどのようにつながっているかが辺に対応します。ひとまずの目標は、このグラフを可視化できるようにすることです。

とはいえ、まだUIElementsと初めましてなので、まずは挨拶から始めます。

// GraphEditor.cs
using UnityEngine;
using UnityEditor;
using UnityEngine.UIElements;

public class GraphEditor : EditorWindow
{
    [MenuItem("Window/GraphEditor")]  // Unityのメニュー/Window/GraphEditorから呼び出せるように
    public static void ShowWindow()
    {
        GraphEditor graphEditor = CreateInstance<GraphEditor>();  // ウィンドウを作成。
        graphEditor.Show();  // ウィンドウを表示
        graphEditor.titleContent = new GUIContent("Graph Editor");  // Windowの名前の設定
    }

    public void OnEnable()
    {
        VisualElement root = this.rootVisualElement;
        root.Add(new Label("Hello, World!"));
    }
}

Unity公式ブログのはじめの例を参考にしました。

ウィンドウが作成されたときに呼ばれるOnEnable()で、はじめてUIElementsと対面します。
見たところ、UIElementsはウィンドウの大元にあるrootVisualElementにどんどん要素を追加していく方式なんですね。
rootVisualElementはVisualElementクラスで、LabelもVisualElementクラスを継承しています。

さあ、メニューからWindow/GraphEditorを選択すると以下のようなウィンドウが表示されます。

NodeEditor-0.PNG

こんにちは!
ひとまず、挨拶は終わりました。

1. ノードを表示する

Inspectorのように、行儀よく上から下へ情報を追加していくUIであれば、あとは色を変えてみたり、ボタンを追加してみたり、水平に並べてみたりすればいいのですが、ノードベースエディタを作ろうとしているのでそれだけでは不十分です。
四角形を自由自在に動かせなければいけません。

ドキュメントには、UIElementの構造の説明として、このような図がありました。
visualtree-hierarchy.png
[画像は引用:https://docs.unity3d.com/ja/2019.3/Manual/UIE-VisualTree.html]
まずは、このred containerのような四角形を出したいですね。

というわけでいろいろ試してみます。

1.1 表示場所を指定する

ドキュメントによるとVisualElementはそれぞれlayoutなるメンバを持っていて、layout.positionやlayout.transformによって親に対する位置が決まるようです。実際に試してみましょう。

// GraphEditor.cs
using UnityEngine;
using UnityEditor;
using UnityEngine.UIElements;

public class GraphEditor : EditorWindow
{
    [MenuItem("Window/GraphEditor")]
    public static void ShowWindow()
    {
        GraphEditor graphEditor = CreateInstance<GraphEditor>();
        graphEditor.Show();
        graphEditor.titleContent = new GUIContent("Graph Editor");
    }

    public void OnEnable()
    {
        VisualElement root = this.rootVisualElement;
        root.Add(new Label("Hello, World!"));

        root.Add(new NodeElement("One", Color.red, new Vector2(100, 50)));
    }
}
// NodeElement.cs
using UnityEngine;
using UnityEngine.UIElements;

public class NodeElement : VisualElement
{
    public NodeElement (Node node,string name, Color color, Vector2 pos)
    {
        style.backgroundColor = new StyleColor(color);
        transform.position = pos;

        Add(new Label(name));
    }
}

先ほどと違うのは、NodeElementクラスですね。
NodeはGraphの頂点のことで、有限オートマトンでいうと状態に対応します。

このNodeElementのコンストラクタに色と位置を渡して、内部でstyle.backgroundClorとtransform.positionを設定します。
それをrootにAddして、どのように表示されるかを見てみます。

以下、結果です。
NodeEditor-1.PNG
お!
表示位置が唐突な場所になっていますね。
右にずっと伸びていますが、まだ幅を指定していないからでしょう。

もう一つ追加してみましょう。

// GraphEditorクラス
    public void OnEnable()
    {
        VisualElement root = this.rootVisualElement;
        root.Add(new Label("Hello, World!"));

        root.Add(new NodeElement("One", Color.red, new Vector2(100, 50)));
        root.Add(new NodeElement("Two", Color.yellow, new Vector2(200, 50)));
    }

NodeEditor-2.PNG
……あれ?
同じY座標を指定したのに二つのノードは重なっていません。

本当は、
NodeEditor-3.PNG
このようになって欲しかったのです。
ちなみに、この時点で上の図のようにするには以下を書きました。

// GraphEditorクラス
    public void OnEnable()
    {
        VisualElement root = this.rootVisualElement;
        root.Add(new Label("Hello, World!"));

        root.Add(new NodeElement("One", Color.red, new Vector2(100, 50)));
        root.Add(new NodeElement("Two", Color.yellow, new Vector2(200, 32)));  // y座標を変更
    }

どうやら18ピクセルだけ勝手に下にずらされていたようです。困ります。いい感じに自動レイアウトしてくれるという親切心だとは思うのですが、私が今作りたいのはヴィジュアルツールなので、上下左右に自在に動かしたいのです。

探すと別のドキュメントにありました。

Set the position property to absolute to place an element relative to its parent position rectangle. In this case, it does not affect the layout of its siblings or parent.

positionプロパティをabsoluteにすれば兄弟(=siblings)や親の影響を受けないよとあります。
positionプロパティってなんだと思いましたが、VisualStudioの予測変換機能を駆使して見つけました。

NodeElementのコンストラクタを以下のように書き換えます

// NodeElementクラス
    public NodeElement (Node node,string name, Color color, Vector2 pos)
    {
        style.backgroundColor = new StyleColor(color);
        style.position = Position.Absolute;  // 追加。これがposition propertyらしい
        transform.position = pos;

        Add(new Label(name));
    }

すると、

// GraphEditorクラス
    public void OnEnable()
    {
        VisualElement root = this.rootVisualElement;
        root.Add(new Label("Hello, World!"));

        root.Add(new NodeElement("One", Color.red, new Vector2(100, 50)));
        root.Add(new NodeElement("Two", Color.yellow, new Vector2(200, 50)));
    }

は以下のようになります。
NodeEditor-4.PNG

ちゃんと同じ高さになりました!
なぜか横方向の帯も消えています。

これで位置を自由に指定できるようになりました。

1.2 大きさを指定する

次は四角形の大きさを指定します。位置指定はラベルで実験したので勝手に大きさを合わせてくれていましたが、自由に幅や高さを指定したいです。
このような見た目に関する部分はだいたいVisualElement.styleにまとまっているようで、以下のように指定します。

// NodeElementクラス
    public NodeElement (Node node,string name, Color color, Vector2 pos)
    {
        style.backgroundColor = new StyleColor(color);
        style.position = Position.Absolute;
        style.height = 50,
        style.width = 100,
        transform.position = pos;

        Add(new Label(name));
    }

すると以下のようになります。
NodeEditor-5.PNG

初めに言った、red containerのようになったと思います。
visualtree-hierarchy.png

2. ノードを動かす

次は表示した四角形をインタラクティブに移動させます。
ヴィジュアルツールでは、見やすいように位置を動かすことは大事です。

挙動としては、
1. 四角形を左クリックして選択
2. そのままドラッグすると一緒に動く
3. ドロップで現在の位置に固定
というのを想定しています。

2.1 まずは試してみる

これらはどれもマウスの挙動に対しての反応なので、マウスイベントに対するコールバックとして実装します。
探すと公式ドキュメントにThe Event Systemという項がありました。
いろいろと重要そうなことが書いてある気がしますが、今はとりあえずイベントを取りたいのでその中のResponding to Eventsを見てみます。
どうやら、VisualElement.RegisterCallback()によってコールバックを登録できるみたいですね。

マウスに関するイベントはそれぞれ、
1. MouseDownEvent
2. MouseMoveEvent
3. MouseUpEvent
でとることができそうです。

NodeElementクラスを以下のように書き換えます。

// NodeElementクラス
    public NodeElement (string name, Color color, Vector2 pos)
    {
        style.backgroundColor = new StyleColor(color);
        style.position = Position.Absolute;
        style.height = 50;
        style.width = 100;

        transform.position = pos;

        Add(new Label(name));

        bool focus = false;

        RegisterCallback((MouseDownEvent evt) =>
        {
            if (evt.button == 0)  // 左クリック
            {
                focus = true;  // 選択
            }
        });

        RegisterCallback((MouseUpEvent evt) =>
        {
            focus = false;  // 選択を解除
        });

        RegisterCallback((MouseMoveEvent evt) =>
        {
            if (focus)
            {
                transform.position += (Vector3)evt.mouseDelta;  // マウスが動いた分だけノードを動かす
            }
        });
    }

すると、以下のような挙動になります。
NodeEditor-7.gif
動きましたが、少し使いにくそうな動きです。
まず、赤いノードが黄色のノードの下にあるせいで、赤を動かしている途中にカーソルが黄色の上に来ると、赤が動かなくなってしまいます。
さらにそのあと右クリックをやめても選択が解除されておらず、赤が勝手に動いてしまいます。これは、MouseUpEventが赤いノードに対して呼ばれていないことが問題のようです。

改善策は、
1. 選択したノードは最前面に来てほしい
2. カーソルがノードの外に出たときにも、マウスイベントは呼ばれてほしい
の二つです。

2.2 VisualElementの表示順を変える

ドキュメントのThe Visual Treeの項目に、Drawing orderの項があります。

Drawing order
The elements in the visual tree are drawn in the following order:
- parents are drawn before their children
- children are drawn according to their sibling list

The only way to change their drawing order is to reorder VisualElementobjects in their parents.

描画順を変えるには親オブジェクトが持つVisualElementを並び替えないといけないようです。
それ以上の情報がないのでVisualElementのスクリプトリファレンスを見てみます。その中のメソッドでそれらしいものがないかを探すと……ありました。

BringToFront()というメソッドで、親の子供リストの中の最後尾へ自分を持っていくものです。
これをMouseDownEventのコールバックに追加します。

// NodeElementクラス
        RegisterCallback((MouseDownEvent evt) =>
        {
            if (evt.button == 0)
            {
                focus = true;
                BringToFront();  // 自分を最前面に持ってくる
            }
        });

実行結果は以下です。
NodeEditor-8.gif
クリックしたものが最前面へきているのがわかります。
しかし、動画後半のように、マウスを勢いよく動かすとノードがついてこられないことがわかります。

2.3 マウスイベントをキャプチャする

マウスを勢いよく動かしたとき、カーソルがノードの外に出るのでMouseLeaveEventが呼ばれるはずです。その時にPositionを更新してドラッグ中は常にノードがカーソルの下にあるようにすればよい、と初めは思っていました。
ですが、それだと勢いよく動かした直後にマウスクリックを解除した場合に、MouseUpEventが選択中のノードに対して呼ばれないようなのです。
イベントの呼ばれる順序にかかわる部分で、丁寧に対応してもバグの温床になりそうです。

いい方法はないかなとドキュメントを読んでいると、よさそうなものを見つけました。
Dispatching Eventsの中のCapture the mouseという項です。

VisualElementはCaptureMouse()を呼ぶことによって、カーソルが自身の上にないときでもマウスイベントを自分のみに送ってくれるようになるということで、まさにマウスをキャプチャしています。
キャプチャすると、マウスが自分の上にあるかどうかを気にしなくてよくなるので、安心して使えそうです。

ということで、MouseDown時にキャプチャし、MouseUp時に解放するように書き換えてみます。

// NodeElementクラス
        RegisterCallback((MouseDownEvent evt) =>
        {
            if (evt.button == 0)
            {
                focus = true;
                BringToFront();
                CaptureMouse();  // マウスイベントをキャプチャ
            }
        });

        RegisterCallback((MouseUpEvent evt) =>
        {
            ReleaseMouse();  // キャプチャを解放
            focus = false;
        });

        RegisterCallback((MouseCaptureOutEvent evt) =>
        {
            m_Focus = false;  // キャプチャが外れたときはドラッグを終了する
        }

MouseCaptureOutEventは他のVisualElementなどによってキャプチャを奪われたときに呼ばれる関数です。

実行結果は以下になります。
NodeEditor-9.gif
無事に意図した動きになりました。

2.4 ノードを動かすコードをManipulatorによって分離する

この後もノードには様々な機能が追加される予定ですので、コードが煩雑にならないためにも、ノードを動かす部分を分離してしまいたいです。
どうしようか悩んでいましたが、UIElementsにはManipulatorという仕組みがあることを見つけました。
Manipulatorを使うことで、「ノードを動かす」のような操作を追加するコードをきれいに分離して書くことができます。

NodeDraggerというクラスを作成します。

// NodeDragger.cs
using UnityEngine;
using UnityEngine.UIElements;

public class NodeDragger : MouseManipulator
{
    private bool m_Focus;

    public NodeDragger()
    {
        // 左クリックで有効化する
        activators.Add(new ManipulatorActivationFilter { button = MouseButton.LeftMouse });
    }

    /// Manipulatorにターゲットがセットされたときに呼ばれる
    protected override void RegisterCallbacksOnTarget()
    {
        m_Focus = false;

        target.RegisterCallback<MouseDownEvent>(OnMouseDown);
        target.RegisterCallback<MouseUpEvent>(OnMouseUp);
        target.RegisterCallback<MouseMoveEvent>(OnMouseMove);
        target.RegisterCallback<MouseCaptureOutEvent>(OnMouseCaptureOut);
    }

    /// Manipulatorのターゲットが変わる直前に呼ばれる
    protected override void UnregisterCallbacksFromTarget()
    {
        target.UnregisterCallback<MouseDownEvent>(OnMouseDown);
        target.UnregisterCallback<MouseUpEvent>(OnMouseUp);
        target.UnregisterCallback<MouseMoveEvent>(OnMouseMove);
        target.UnregisterCallback<MouseCaptureOutEvent>(OnMouseCaptureOut);
    }

    protected void OnMouseDown(MouseDownEvent evt)
    {
        // 設定した有効化条件をみたすか (= 左クリックか)
        if (CanStartManipulation(evt))
        {
            m_Focus = true;
            target.BringToFront();
            target.CaptureMouse();
        }
    }

    protected void OnMouseUp(MouseUpEvent evt)
    {
        // CanStartManipulation()で条件を満たしたActivationのボタン条件と、
        // このイベントを発火させているボタンが同じか
        // (= 左クリックを離したときか)
        if (CanStopManipulation(evt))
        {
            target.ReleaseMouse();
            m_Focus = false;
        }
    }

    protected void OnMouseCaptureOut(MouseCaptureOutEvent evt)
    {
        m_Focus = false;
    }

    protected void OnMouseMove(MouseMoveEvent evt)
    {
        if (m_Focus)
        {
            target.transform.position += (Vector3)evt.mouseDelta;
        }
    }
}

RegisterCallBacksOnTarget()UnregisterCallbacksFromTarget()Manipulatorクラスの関数で、イベントのコールバックの登録・解除を担っています。
activatorsCanStartManipulation()CanStopManipulation()Manipulatorクラスを継承するMouseManipulatorクラスの関数で、マウスのボタンの管理がしやすくなっています。
細かいことはコード中のコメントに記載しました。

このManipulatorを使用するには、対象のVisualElementを設定しなければいけません。

// NodeElementクラス
    public NodeElement (string name, Color color, Vector2 pos)
    {
        style.backgroundColor = new StyleColor(color);
        style.position = Position.Absolute;
        style.height = 50;
        style.width = 100;

        transform.position = pos;

        Add(new Label(name));

        AddManipulator(new NodeDragger());  // 操作の追加が一行で済む
    }

AddManipulatorという関数によって対象のVisualElementを設定しています。
実はこのコードは以下のようにもかけます。

        new NodeDragger(){target = container};

内部の実装を見ると、AddManipulatorではIManipulator.targetプロパティに自身をセットしているだけでした。
そしてsetter内で、セットする前に既存のtargetがあればUnregisterCallbacksFromTarget()を呼び、そのあと新規のターゲットをセットしてからRegisterCallbacksOnTarget()を呼びます。

[参照:https://github.com/Unity-Technologies/UnityCsReference/blob/2019.1/Modules/UIElements/Manipulators.cs]
[参照:https://github.com/Unity-Technologies/UnityCsReference/blob/2019.1/Modules/UIElements/MouseManipulator.cs]

3. ノードを追加する

これまではテストのためにノードの数は2つで固定されていましたが、自在に追加できなければグラフエディタとはとても呼べません。
想定している挙動は、

  1. 右クリックでメニューが出てくる
  2. 「Add Node」を選択する
  3. 右クリックした場所にノードが生成される

です。

......実は前章の最後あたりで、このままのペースで書いていると時間がいくらあっても足りないと思い、先に実装してから記事を書くことにしました。
ですので、これからの説明は少しスムーズに(悪く言えば飛躍気味に)なるかもしれません。ご了承ください。

3.1 メニューを表示する

2.4節で見たようなManipulatorと同じように、この挙動も操作としてまとめることができそうです。
というか、こんなみんなが欲しそうな機能が公式に用意されていないはずがありません。
案の定、存在しました。例によって公式ドキュメントです。

コードを載せます。

// GraphEditorクラス
    public void OnEnable()
    {
        VisualElement root = this.rootVisualElement;

        root.Add(new NodeElement("One", Color.red, new Vector2(100, 50)));
        root.Add(new NodeElement("Two", Color.yellow, new Vector2(200, 50)));

        root.AddManipulator(new ContextualMenuManipulator(OnContextMenuPopulate));
    }

    void OnContextMenuPopulate(ContextualMenuPopulateEvent evt)
    {
        // 項目を追加
        evt.menu.AppendAction(
            "Add Node",  // 項目名
            AddEdgeMenuAction,  // 選択時の挙動
            DropdownMenuAction.AlwaysEnabled  // 選択可能かどうか
            );
    }

    void AddEdgeMenuAction(DropdownMenuAction menuAction)
    {
        Debug.Log("Add Node");
    }
}

さあ、どうでしょうか。
NodeEditor-10.gif
上手く動いていません。

期待していた挙動は、「背景を左クリックしたときはメニューが開いて、ノードを左クリックしたときは何も起こらない」です。でも、これでは逆ですね。
イベント発行についてのドキュメントを見てみます。
NodeEditor-11.PNG
[図は引用:https://docs.unity3d.com/2019.1/Documentation/Manual/UIE-Events-Dispatching.html]

イベントは root -> target -> root と呼ばれるみたいですね。イベント受け取りについてのドキュメントには、デフォルトではTargetフェイズとBubbleUpフェイズにイベントが登録されるともあります。

とにかく、思い当たるのは、ルートに登録したコールバックがノード経由で伝わっているということです。
いろいろ試してみてわかったのは、ルートではデフォルトでpickingModePickingMode.Ignoreに設定されているということでした。

リファレンスによると、マウスのクリック時にターゲットを決める際、その位置にある一番上のVisualElementを取ってきているらしいのですが、このpickingModePickingMode.Ignoreに設定されていた場合は候補から外す、という挙動になるようです。

実際、このようにすると動きます。

// GraphEditorクラス

    public void OnEnable()
    {
        VisualElement root = this.rootVisualElement;

        root.Add(new NodeElement("One", Color.red, new Vector2(100, 50)));
        root.Add(new NodeElement("Two", Color.yellow, new Vector2(200, 50)));

        root.pickingMode = PickingMode.Position;  // ピッキングモード変更

        root.AddManipulator(new ContextualMenuManipulator(OnContextMenuPopulate));
    }

NodeEditor-12.gif
でも、ルートをむやみに変更するのはよくないですね。
そこで、ルートの一つ下に一枚挟むことにします。今の作りでは、EditorWindowとVisualElementが不可分になってしまっていましたが、それを分離可能にするという意味合いもあります。

さあ、いよいよもってGraphViewに近づいてきました。
分離自体はすぐにできます。

// GraphEditor.cs
using UnityEngine;
using UnityEditor;
using UnityEngine.UIElements;

public class GraphEditor : EditorWindow
{
    [MenuItem("Window/GraphEditor")]
    public static void ShowWindow()
    {
        GraphEditor graphEditor = CreateInstance<GraphEditor>();
        graphEditor.Show();
        graphEditor.titleContent = new GUIContent("Graph Editor");
    }

    GraphEditorElement m_GraphEditorElement;

    public void OnEnable()
    {
        VisualElement root = this.rootVisualElement;

        m_GraphEditorElement = new GraphEditorElement();
        root.Add(m_GraphEditorElement);
    }
}

// GraphEditorElement.cs
using UnityEngine;
using UnityEngine.UIElements;
using System.Collections.Generic;

public class GraphEditorElement: VisualElement
{
    public GraphEditorElement()
    {
        style.flexGrow = 1;  // サイズを画面いっぱいに広げる
        style.overflow = Overflow.Hidden;  // ウィンドウの枠からはみ出ないようにする

        Add(new NodeElement("One", Color.red, new Vector2(100, 50)));
        Add(new NodeElement("Two", Color.yellow, new Vector2(200, 50)));

        this.AddManipulator(new ContextualMenuManipulator(OnContextMenuPopulate));
    }

    void OnContextMenuPopulate(ContextualMenuPopulateEvent evt)
    {
        evt.menu.AppendAction(
            "Add Node",
            AddNodeMenuAction,
            DropdownMenuAction.AlwaysEnabled
            );
    }

    void AddNodeMenuAction(DropdownMenuAction menuAction)
    {
        Debug.Log("Add Node");
    }
}

二つのスタイルを適用しました。
style.flexGrow = 1; によってGraphEditorElementのサイズが画面いっぱいに広がり、クリックイベントを拾う背景の役割を果たしてくれます。
style.overflow = Overflow.Hidden; は親の領域からはみ出た部分を表示しないようにします。ノードを動かすとウィンドウの枠からはみ出したりしていましたが、これでもう心配はいりません。

挙動はこのようになります。
NodeEditor-13.gif
まだノードの上で右クリックしたときもAdd Nodeメニューが出てしまいます。
これはノードに対しても何かを設定する必要がありそうですね。

後でノードを左クリックしたときにエッジを追加する挙動を実装します。そのとき考えましょう。

とにかくメニューは出たということで、次へ進んでいきます。

3.2 ノードを生成する

Add Nodeというログを出していた部分を少し変更すると、新しいノードが生成できます。

// GraphEditorElementクラス
    void AddNodeMenuAction(DropdownMenuAction menuAction)
    {
        Vector2 mousePosition = menuAction.eventInfo.localMousePosition;  // マウス位置はeventInfoの中にあります

        Add(new NodeElement("add", Color.green, mousePosition));
    }

挙動です。
NodeEditor-14.gif
これで、表示の上では新しいノードを生成できました。

4. ノードを永続化する

3章で生成したノードはGraphEditorウィンドウを開きなおしたりすると消えてしまいます。
Unityで何らかのデータを保存しておくには、どこかのファイルにシリアライズしておく必要があります。

「シリアライズとはXXXである」と一言で言えたらいいのですが、短く上手く説明できる気がしません。
脱線になってしまってもよくないので、気になる方は「Unity シリアライズ」などで検索してみてください。

4.1 グラフ構造とは

方針としては、グラフを再現するのに最低限必要なものを用意します。
冒頭でも少し触れましたが、ここでグラフの定義を明確にしておきます。

グラフには大きく分けて二種類あります。
無向グラフと有向グラフです。
graph_sample.jpg
[図は引用:https://qiita.com/drken/items/4a7869c5e304883f539b]

エッジ、つまり辺に向きがあるかないかの差があります。
ゲームで使う場合、ロジックを表現するためのグラフはほとんどが有向グラフなのではと思います。

ビヘイビアツリー: ノードはアクション、エッジは遷移
有限オートマトン: ノードは状態、エッジは遷移

ということで、作成中のグラフエディタも、有向グラフを表せるものを作りたいと思います。
余談ですが、無向グラフは有効グラフの矢印と逆向きに同じ矢印を付けると実現することができます。

4.2 シリアライズ用のクラスを作る

構造としては、
グラフアセット:ノードリストを持つ
ノード:エッジリストを持つ
エッジ:つながるノードを持つ

として、グラフアセットをアセットとして新規作成できるようにしようと思います。
実装は以下のようにします。

// GraphAsset.cs
using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(fileName ="graph.asset", menuName ="Graph Asset")]
public class GraphAsset : ScriptableObject
{
    public List<SerializableNode> nodes = new List<SerializableNode>();
}

[System.Serializable]
public class SerializableNode
{
    public Vector2 position;
    public List<SerializableEdge> edges = new List<SerializableEdge>();
}

[System.Serializable]
public class SerializableEdge
{
    public SerializableNode toNode;
}

ScriptableObject[CreateAssetMenu]を付けることで、Unityのプロジェクトなどで右クリックをしたときにメニューから生成できるようになります。
また、[System.Serializable]アトリビュートによって、指定したクラスをシリアライズ可能にしています。

早速、グラフアセットを作ってみました。
NodeEditor-15.gif
すると、このようなエラーが出ます。

Serialization depth limit 7 exceeded at 'SerializableEdge.toNode'. There may be an object composition cycle in one or more of your serialized classes.

Serialization hierarchy:
8: SerializableEdge.toNode
7: SerializableNode.edges
6: SerializableEdge.toNode
5: SerializableNode.edges
4: SerializableEdge.toNode
3: SerializableNode.edges
2: SerializableEdge.toNode
1: SerializableNode.edges
0: GraphAsset.nodes

UnityEditor.InspectorWindow:RedrawFromNative()

これはつまり、こういうことです。
NodeEditor-16.gif

そう、ノードがシリアライズしようとするエッジが、さらにノードをシリアライズしようとして、循環が発生しているのです。
シリアライズの仕組みとして、クラスの参照をそのまま保存することはできません。

では、どうするかというと、ノードのIDを保存しておくことにします。
UnityのGUIDみたいに大きなIDを振ってもいいのですが、振るのとか対応付けとかが面倒そうです。
そこで、ここではGraphAssetが持っているノードリストの何番目にあるか、というのをIDとしようと思います。

SerializableEdgeだけ以下のように直します。

// GraphAsset.cs
[System.Serializable]
public class SerializableEdge
{
    public int toId;
}

これでワーニングは出なくなります。

4.3 アセットとエディタを対応付ける

どのアセットを表示・編集するかを決めるために、エディタにアセットの情報を持たせなければいけません。
実際にエディタを使うときのことを考えると、アセットからエディタが開けて、その際にそのアセットについて編集するようにできたらいいですね。

というわけで要件としては、
1. GraphAssetをダブルクリックするとエディタが開く
2. どこかのGraphEditorElementクラスにGraphAssetクラスを渡す
です。

// GraphAsset.cs
using UnityEngine;
using UnityEditor;
using UnityEditor.Callbacks;  // OnOpenAssetアトリビュートのために追加
using UnityEngine.UIElements;
using System.Collections.Generic;

public class GraphEditor : EditorWindow
{
    [MenuItem("Window/GraphEditor")]
    public static void ShowWindow()
    {
        GraphEditor graphEditor = CreateInstance<GraphEditor>();
        graphEditor.Show();
        graphEditor.titleContent = new GUIContent("Graph Editor");

        if(Selection.activeObject is GraphAsset graphAsset)
        {
            graphEditor.Initialize(graphAsset);
        }
    }

    [OnOpenAsset()]  // Unityで何らかのアセットを開いたときに呼ばれるコールバック
    static bool OnOpenAsset(int instanceId, int line)
    {
        if(EditorUtility.InstanceIDToObject(instanceId) is GraphAsset)  // 開いたアセットがGraphAssetかどうか
        {
            ShowWindow();
            return true;
        }

        return false;
    }

    GraphAsset m_GraphAsset;  // メンバ変数として持っておく
    GraphEditorElement m_GraphEditorElement;

    public void OnEnable()
    {
        // ShowWindow()を通らないような時(スクリプトのコンパイル後など)
        // のために初期化への導線を付ける
        if (m_GraphAsset != null)
        {
            // 初期化はInitializeに任せる
            Initialize(m_GraphAsset);
        }
    }

    // 初期化
    public void Initialize(GraphAsset graphAsset)
    {
        m_GraphAsset = graphAsset;

        // 以下はもともとOnEnable() で行っていた処理
        // OnEnable() はCreateInstance<GraphEditor>() の際に呼ばれるので、まだgraphAssetが渡されていない
        // 初期化でもgraphAssetを使うことになるのでここに移す
        VisualElement root = this.rootVisualElement;

        m_GraphEditorElement = new GraphEditorElement();
        root.Add(m_GraphEditorElement);
    }
}

これで、GraphAssetファイルをダブルクリックしたときにエディタが開くようになります。
NodeEditor-17.gif

4.4 アセットのデータからノードを表示するようにする

続いて、アセットにある情報からノードを構築、表示したいと思います。
まずはGraphAssetにダミーの情報を手打ちします。
NodeEditor-18.PNG
(100, 50)と(200, 50)の位置、つまり今まで表示してきた赤と黄色の位置、にノードが表示されればOKです。

まず、NodeElementを少し変えます。
色の情報はアセットにはないので省きますし、位置はシリアライズされますからね。

具体的には、生成をSerializableNodeから行うようにします。

// NodeElement.cs

// BackgroundColorがなくなると見えなくなるので、周囲を枠線で囲んだVisualElement、Boxを継承する
public class NodeElement : Box  
{
    public SerializableNode serializableNode;

    public NodeElement (SerializableNode node)  // 引数を変更
    {
        serializableNode = node;  // シリアライズ対象を保存しておく

        style.position = Position.Absolute;
        style.height = 50;
        style.width = 100;

        transform.position = node.position;  // シリアライズされている位置を取る

        this.AddManipulator(new NodeDragger());
    }
}

GraphEditorElementも伴って変更します。

// GraphEditorElement.cs
using UnityEngine;
using UnityEngine.UIElements;
using System.Collections.Generic;

public class GraphEditorElement: VisualElement
{
    GraphAsset m_GraphAsset;  // 渡されたアセットを保存
    List<NodeElement> m_Nodes;  // 作ったノードを入れておく。順序が重要

    public GraphEditorElement(GraphAsset graphAsset)
    {
        m_GraphAsset = graphAsset;

        style.flexGrow = 1;
        style.overflow = Overflow.Hidden;

        this.AddManipulator(new ContextualMenuManipulator(OnContextMenuPopulate));

        m_Nodes = new List<NodeElement>();

        // 順番にノードを生成。この作る際の順番がSerializableEdgeが持つNodeのIDとなる
        foreach(var node in graphAsset.nodes)
        {
            CreateNodeElement(node);
        }
    }

    void CreateNodeElement(SerializableNode node)
    {
        var nodeElement = new NodeElement(node);

        Add(nodeElement);  // GraphEditorElementの子として追加
        m_Nodes.Add(nodeElement);  // 順番を保持するためのリストに追加
    }

/* ... 省略 */

    void AddNodeMenuAction(DropdownMenuAction menuAction)
    {
        Vector2 mousePosition = menuAction.eventInfo.localMousePosition;

        CreateNodeElement(new SerializableNode() { position = mousePosition });  // 追加生成時には仮で新しく作る
    }
}

GraphEditorElementのコンストラクタにGraphAssetを渡すようにしたので、GraphEditorから生成するときに必要です

// GraphEditorクラス
    public void Initialize(GraphAsset graphAsset)
    {
        m_GraphAsset = graphAsset;

        VisualElement root = this.rootVisualElement;

        m_GraphEditorElement = new GraphEditorElement(graphAsset);  // アセットを渡す
        root.Add(m_GraphEditorElement);
    }

以上で、アセットに保持された情報を描画することができました。
NodeEditor-19.gif
書き込みはしていないので、当然開きなおすと追加したノードは消えてしまいます。

4.5 追加作成したノードをアセットに書き込む

前節までできたら、あとはもう少し変えるだけです。

// GraphEditorElementクラス
    private void AddNodeMenuAction(DropdownMenuAction menuAction)
    {
        Vector2 mousePosition = menuAction.eventInfo.localMousePosition;
        var node = new SerializableNode() { position = mousePosition };

        m_GraphAsset.nodes.Add(node);  // アセットに追加する

        CreateNodeElement(node);
    }

これでアセットに書き込まれます。
NodeEditor-20.gif
おっと、動かしたことを記録するのを忘れていました。

// NodeDraggerクラス
    protected void OnMouseUp(MouseUpEvent evt)
    {
        if (CanStopManipulation(evt))
        {
            target.ReleaseMouse();

            if(target is NodeElement node)
            {
                //NodeElementに保存しておいたシリアライズ対象のポジションをいじる
                node.serializableNode.position = target.transform.position;
            }

            m_Focus = false;
        }
    }

動かしてドラッグをやめた瞬間に記録するとよいと思います。
これで動かしたことも保存されるようになりました。
NodeEditor-21.gif

5. エッジを追加する

頂点の表示ができたので、次は辺です。辺は頂点同士を線で結ぶことで表します。
コンテナ的な仕組みでは直線や曲線は引けないように思うので、ここは既存の仕組みで線を引きます。
Handles.Draw系の関数が一番楽かなと思います。
DrawLineDrawBezierなどです。

ちなみにGraphViewでは、エッジ用のメッシュを作って、Graphics.DrawMeshNow()で描画をしていました。

5.1 エッジを表示する

とりあえずダミーでデータを作ってみます。
NodeEditor-22.PNG
Element0のEgesに要素を追加しました。
このまま表示するとこうなります。
NodeEditor-23.PNG
イメージとしては、左上のノードから右下のノードへ繋がっている矢印があればいいなと思います。

VisualElementは初期化字に一度呼べば後は自動で描画してくれていましたが、Handlesで描画をするならウィンドウ更新のたびに呼ぶ必要があります。
EditorWindowの更新といえば、OnGUIです。
ウィンドウの更新のたびにOnGUIが呼ばれますので、そこからGraphEditorElementの描画関数を呼ぶことにします。

ひとまずこのように実装してみます。

// GraphEditorクラス
    private void OnGUI()
    {
        if(m_GraphEditorElement == null)
        {
            return;
        }

        m_GraphEditorElement.DrawEdge();
    }
// GraphEditorElementクラス
    public void DrawEdge()
    {
        for(var i = 0; i < m_GraphAsset.nodes.Count; i++)
        {
            var node = m_GraphAsset.nodes[i];
            foreach(var edge in node.edges)
            {
                DrawEdge(
                    startPos: m_Nodes[i].transform.position,
                    startNorm: new Vector2(0f, 1f),
                    endPos: m_Nodes[edge.toId].transform.position,
                    endNorm: new Vector2(0f, -1f));
            }
        }
    }

    private void DrawEdge(Vector2 startPos, Vector2 startNorm, Vector2 endPos, Vector2 endNorm)
    {
        Handles.color = Color.blue;  // 色指定

        // エッジをベジェ曲線で描画
        Handles.DrawBezier(
            startPos,
            endPos,
            startPos + 50f * startNorm,
            endPos + 50f * endNorm,
            color: Color.blue,
            texture: null,
            width: 2f);

        // 矢印の三角形の描画
        Vector2 arrowAxis = 10f * endNorm;
        Vector2 arrowNorm = 5f * Vector3.Cross(endNorm, Vector3.forward);

        Handles.DrawAAConvexPolygon(endPos,
            endPos + arrowAxis + arrowNorm,
            endPos + arrowAxis - arrowNorm);

        Handles.color = Color.white;  // 色指定をデフォルトに戻す
    }

このようになりました。
NodeEditor-24.gif

ポジションとして単にVisualElement.transform.positionを利用しているので左上隅に始点・終点が来ています。
元ノードは下辺中央から、先ノードの上辺中央につながってほしい気がします。
とはいえ、GraphEditorElementでNodeの形に関する部分を決め打ちで呼んでしまうのはちょっと気持ち悪いので、NodeElementに始点や終点の位置・方向の情報を返す関数を作ろうと思います。

// GraphEditorElementクラス
    public void DrawEdge()
    {
        for(var i = 0; i < m_GraphAsset.nodes.Count; i++)
        {
            var node = m_GraphAsset.nodes[i];
            foreach(var edge in node.edges)
            {
                // ノードに情報を問い合わせる
                DrawEdge(
                    startPos: m_Nodes[i].GetStartPosition(),
                    startNorm: m_Nodes[i].GetStartNorm(),
                    endPos: m_Nodes[edge.toId].GetEndPosition(),
                    endNorm: m_Nodes[edge.toId].GetEndNorm());
            }
        }
    }
// NodeElementクラス

    public Vector2 GetStartPosition()
    {
        return (Vector2)transform.position + new Vector2(style.width.value.value / 2f, style.height.value.value);
    }
    public Vector2 GetEndPosition()
    {
        return (Vector2)transform.position + new Vector2(style.width.value.value / 2f, 0f);
    }
    public Vector2 GetStartNorm()
    {
        return new Vector2(0f, 1f);
    }
    public Vector2 GetEndNorm()
    {
        return new Vector2(0f, -1f);
    }

ちゃんとそれらしい位置から生えました。
NodeEditor-25.gif

また、エッジを描画するだけなら、エッジのVisualElementを作らずにGraphAssetに保存されているSerializableEdgeの値を見ていればよいのですが、エッジの追加・削除・付け替えなど、いずれ必要になるであろう操作がやりにくくなります。

そこで、エッジにもEdgeElementクラスを作ります。

// EdgeElement.cs

using UnityEngine;
using UnityEngine.UIElements;
using UnityEditor;

public class EdgeElement : VisualElement
{
    public SerializableEdge serializableEdge;  // データを持っておく

    public NodeElement From { get; private set; }  // 元ノード
    public NodeElement To { get; private set; }  // 先ノード

    public EdgeElement(SerializableEdge edge, NodeElement from, NodeElement to )
    {
        serializableEdge = edge;
        From = from;
        To = to;
    }

    public void DrawEdge()
    {
        if(From != null && To != null)
        {
            DrawEdge(
                startPos: From.GetStartPosition(),
                startNorm: From.GetStartNorm(),
                endPos: To.GetEndPosition(),
                endNorm: To.GetEndNorm());
        }
    }

    // GraphEditorElementからそのまま移した
    private void DrawEdge(Vector2 startPos, Vector2 startNorm, Vector2 endPos, Vector2 endNorm)
    {
        Handles.color = Color.blue;
        Handles.DrawBezier(
            startPos,
            endPos,
            startPos + 50f * startNorm,
            endPos + 50f * endNorm,
            color: Color.blue,
            texture: null,
            width: 2f);

        Vector2 arrowAxis = 10f * endNorm;
        Vector2 arrowNorm = 5f * Vector3.Cross(endNorm, Vector3.forward);

        Handles.DrawAAConvexPolygon(endPos,
            endPos + arrowAxis + arrowNorm,
            endPos + arrowAxis - arrowNorm);
        Handles.color = Color.white;
    }
}

このクラスもノードと同様に、GraphEditorElementが生成し、GraphEditorElementの子として保持することにします。
ノードが持っていて、ノードの子として生成というのも考えましたが、GraphEditorで一元管理した方が構造が単純になりそうだと思ったのが理由です。

実装はこうです。

// GraphEditorElementクラス

    List<EdgeElement> m_Edges;  // エッジもノードと同じくまとめて保持しておく

    public GraphEditorElement(GraphAsset graphAsset)
    {
        m_GraphAsset = graphAsset;

        style.flexGrow = 1;
        style.overflow = Overflow.Hidden;

        this.AddManipulator(new ContextualMenuManipulator(OnContextMenuPopulate));

        m_Nodes = new List<NodeElement>();

        foreach(var node in graphAsset.nodes)
        {
            CreateNodeElement(node);
        }

        // すべてのノードの生成が終わってからエッジの生成を行う
        // エッジが持っているノードIDからノードを取得するため
        m_Edges = new List<EdgeElement>();

        foreach(var node in m_Nodes)
        {
            foreach(var edge in node.serializableNode.edges)
            {
                CreateEdgeElement(edge, node, m_Nodes);
            }
        }
    }

    // エッジの生成
    public EdgeElement CreateEdgeElement(SerializableEdge edge, NodeElement fromNode, List<NodeElement> nodeElements)
    {
        var edgeElement = new EdgeElement(edge, fromNode, nodeElements[edge.toId]);
        Add(edgeElement);
        m_Edges.Add(edgeElement);

        return edgeElement;
    }

    // GraphEditor.OnGUI() 内で呼ばれる。描画処理をエッジに移したので小さくなった
    public void DrawEdge()
    {
        foreach(var edge in m_Edges)
        {
            edge.DrawEdge();
        }
    }

見た目は先ほどと変わりません。

5.2 エッジを追加できるようにする

あるノードからあるノードにエッジをつけようと思う時、元ノードから先ノードへ線を伸ばしていくようなイメージになると思います。

UnityのGraphViewやUnrealEngineのBluePrintではノードに備わった接続用のポートをクリックしてそのままドラッグすると線が引かれていきます。
NodeEditor-26.gif

UnrealEngineのBehaviourTreeでは、ノードの上下にエッジ接続領域があります。
NodeEditor-27.gif

これらのようなポートや接続領域などはあると便利そうですが、いったんメニューにAdd Edgeを追加するので良いでしょう。
重要なのは、追加中に元ノードからエッジがマウスの位置を追従していることです。
このUIによって、現在エッジ追加操作中であることと、つなげるノードを指定する方法が直感的にわかります。
これは実装したいです。

挙動としては、
1. ノードを右クリックする
2. メニューから「Add Edge」を選択する
3. 元ノードからマウスの位置に向かうエッジ候補ができる
4. 他のノードを左クリックして、エッジの向かい先を確定する

を想定します。
ノードに対する操作なので、ノードにManipulatorを追加します。
エッジをつなぐ操作なので、EdgeConnectorクラスとします。

5.2.1 EdgeConnectorクラスを作る

EdgeConnectorの役割はメニューを出してエッジ追加モードに入ることと、そのあとに別のノードをクリックして実際にノードを接続することの二つあります。
その中でメニューを出す部分はContexturalMenuManipulatorの役割ですので、EdgeConnectorクラスの中でContexturalMenuManipulatorを作成し、それをEdgeConnectorのターゲットノードにAddManipulatorしようと思います。

こうすることで、NodeElementにEdgeConnectorを追加するだけで、エッジ追加の処理をすべてEdgeConnectorクラスに投げることができます。

// NodeElementクラス
    public NodeElement (SerializableNode node)
    {
        /* ... 省略 */

        this.AddManipulator(new NodeDragger());
        this.AddManipulator(new EdgeConnector());  // 追加
    }

そして、EdgeConnectorの内部はひとまずこのようにしておきます。

using UnityEngine;
using UnityEngine.UIElements;

public class EdgeConnector : MouseManipulator
{
    bool m_Active = false;

    ContextualMenuManipulator m_AddEdgeMenu;

    public EdgeConnector()
    {
        // ノードの接続は左クリックで行う
        activators.Add(new ManipulatorActivationFilter() { button = MouseButton.LeftMouse });

        m_Active = false;

        // メニュー選択マニピュレータは作っておくが、この時点ではターゲットが確定していないので、
        // RegisterCallbacksOnTarget()で追加する
        m_AddEdgeMenu = new ContextualMenuManipulator(OnContextualMenuPopulate);
    }

    private void OnContextualMenuPopulate(ContextualMenuPopulateEvent evt)
    {
        if (evt.target is NodeElement node)
        {
            // エッジ追加中に右クリックを押されたときのために、ノードの上かどうかを見る
            if (!node.ContainsPoint(node.WorldToLocal(evt.mousePosition)))
            {
                // イベントを即座に中断
                evt.StopImmediatePropagation();
                return;
            }

            evt.menu.AppendAction(
                "Add Edge",
                (DropdownMenuAction menuItem) =>
                {
                    m_Active = true;

                    Debug.Log("Add Edge");  // ここでエッジ追加モード開始処理を書く

                    target.CaptureMouse();
                },
                DropdownMenuAction.AlwaysEnabled);
        }
    }

    protected override void RegisterCallbacksOnTarget()
    {
        target.RegisterCallback<MouseDownEvent>(OnMouseDown);
        target.RegisterCallback<MouseUpEvent>(OnMouseUp);
        target.RegisterCallback<MouseMoveEvent>(OnMouseMove);
        target.RegisterCallback<MouseCaptureOutEvent>(OnCaptureOut);

        target.AddManipulator(m_AddEdgeMenu);
    }

    protected override void UnregisterCallbacksFromTarget()
    {
        target.RemoveManipulator(m_AddEdgeMenu);

        target.UnregisterCallback<MouseDownEvent>(OnMouseDown);
        target.UnregisterCallback<MouseUpEvent>(OnMouseUp);
        target.UnregisterCallback<MouseMoveEvent>(OnMouseMove);
        target.UnregisterCallback<MouseCaptureOutEvent>(OnCaptureOut);
    }

    protected void OnMouseDown(MouseDownEvent evt)
    {
        if (!CanStartManipulation(evt))
            return;

        // マウス押下では他のイベントが起きてほしくないのでPropagationを中断する
        if (m_Active)
            evt.StopImmediatePropagation();
    }

    protected void OnMouseUp(MouseUpEvent evt)
    {
        if (!CanStopManipulation(evt))
            return;

        if (!m_Active)
            return;

        Debug.Log("Try Connect");  // ここでマウスの下にあるノードにエッジを接続しようとする

        m_Active = false;
        target.ReleaseMouse();
    }

    protected void OnMouseMove(MouseMoveEvent evt)
    {
        if (!m_Active)
            return;

        Debug.Log("move");  // ここで、追加中のエッジの再描画を行う
    }

    private void OnCaptureOut(MouseCaptureOutEvent evt)
    {
        if (!m_Active)
            return;

        m_Active = false;
        target.ReleaseMouse();
    }
}

この時点では、以下のような挙動になります。
NodeEditor-28.gif

5.2.2 エッジ追加のためにエッジ・グラフクラスを整備

次に、EdgeElementクラスに追加中のEdgeを作成するための準備をします。
これまではEdgeには元ノードと先ノードを渡して作成していましたが、追加中には先ノード確定していないので、元ノードと矢印の位置からエッジを描画できるようにします。

// EdgeElementクラス

    Vector2 m_ToPosition;
    public Vector2 ToPosition
    {
        get { return m_ToPosition; }
        set
        {
            m_ToPosition = this.WorldToLocal(value);  // ワールド座標で渡されることを想定
            MarkDirtyRepaint();  // 再描画をリクエスト
        }  
    }

    // 新しいコンストラクタ
    public EdgeElement(NodeElement fromNode, Vector2 toPosition)
    {
        From = fromNode;
        ToPosition = toPosition;
    }

    // つなげるときに呼ぶ
    public void ConnectTo(NodeElement node)
    {
        To = node;
        MarkDirtyRepaint();  // 再描画をリクエスト
    }

    public void DrawEdge()
    {
        if (From != null && To != null)
        {
            DrawEdge(
                startPos: From.GetStartPosition(),
                startNorm: From.GetStartNorm(),
                endPos: To.GetEndPosition(),
                endNorm: To.GetEndNorm());
        }
        else {
            // 追加中の描画用
            if (From != null)
            {
                DrawEdge(
                    startPos: From.GetStartPosition(),
                    startNorm: From.GetStartNorm(),
                    endPos: ToPosition,
                    endNorm: Vector2.zero);
            }
        }
    }

これにより、追加中のEdgeElementをGraphEditorElementのEdgesに追加すれば自動的に描画されるようになったはずです。
ということで、GraphEditorElementにエッジ追加リクエストを投げられるようにします。
ついでに、ノード追加を中断したときのためにエッジ削除関数も作っておきます。

// GraphEditorElementクラス
    public EdgeElement CreateEdgeElement(NodeElement fromNode, Vector2 toPosition)
    {
        var edgeElement = new EdgeElement(fromNode, toPosition);
        Add(edgeElement);
        m_Edges.Add(edgeElement);

        return edgeElement;
    }

    public void RemoveEdgeElement(EdgeElement edge)
    {
        Remove(edge);
        m_Edges.Remove(edge);
    }

5.2.3 エッジ追加の挙動を実装

上で作った関数をEdgeConnectorクラスから呼びます。

// EdgeConnectorクラス

    GraphEditorElement m_Graph;
    EdgeElement m_ConnectingEdge;

    private void OnContextualMenuPopulate(ContextualMenuPopulateEvent evt)
    {
        if (evt.target is NodeElement node)
        {
            evt.menu.AppendAction(
                "Add Edge",
                (DropdownMenuAction menuItem) =>
                {
                    m_Active = true;

                    // 親をたどってGraphEditorElementを取得する
                    m_Graph = target.GetFirstAncestorOfType<GraphEditorElement>();
                    m_ConnectingEdge = m_Graph.CreateEdgeElement(node, menuItem.eventInfo.mousePosition);

                    target.CaptureMouse();
                },
                DropdownMenuAction.AlwaysEnabled);
        }
    }

    /* ... 省略 */

    protected void OnMouseUp(MouseUpEvent evt)
    {
        if (!CanStopManipulation(evt))
            return;

        if (!m_Active)
            return;

        var node = m_Graph.GetDesignatedNode(evt.originalMousePosition);

        if (node == null  // 背景をクリックしたとき
            || node == target  // 自分自身をクリックしたとき
            || m_Graph.ContainsEdge(m_ConnectingEdge.From, node))  // すでにつながっているノード同士をつなげようとしたとき
        {
            m_Graph.RemoveEdgeElement(m_ConnectingEdge);
        }
        else
        {
            m_ConnectingEdge.ConnectTo(node);
        }
        m_Active = false;
        m_ConnectingEdge = null;  // 接続終了
        target.ReleaseMouse();
    }

    protected void OnMouseMove(MouseMoveEvent evt)
    {
        if (!m_Active)
        {
            return;
        }

        m_ConnectingEdge.ToPosition = evt.originalMousePosition;  // 位置更新
    }

    private void OnCaptureOut(MouseCaptureOutEvent evt)
    {
        if (!m_Active)
            return;

        // 中断時の処理
        m_Graph.RemoveEdgeElement(m_ConnectingEdge);
        m_ConnectingEdge = null;

        m_Active = false;
        target.ReleaseMouse();
    }
// GraphEditorElementクラス

    // マウスの位置にあるノードを返す
    public NodeElement GetDesignatedNode(Vector2 position)
    {
        foreach(NodeElement node in m_Nodes)
        {
            if (node.ContainsPoint(node.WorldToLocal(position)))
                return node;
        }

        return null;
    }

    // すでに同じエッジがあるかどうか
    public bool ContainsEdge(NodeElement from, NodeElement to)
    {
        return m_Edges.Exists(edge =>
        {
            return edge.From == from && edge.To == to;
        });
    }

ここまでで、このような挙動になります。
NodeEditor-29.gif

5.2.4 追加したエッジをシリアライズする

今のままではEdgeElementを追加しただけなので、つないだエッジはデータとして残っていません。
ノードのときと同じようにシリアライズする必要があります。

// EdgeConnectorクラス

    protected void OnMouseUp(MouseUpEvent evt)
    {
        /* ... 省略 */
        var node = m_Graph.GetDesignatedNode(evt.originalMousePosition);

        if (node == null
            || node == target
            || m_Graph.ContainsEdge(m_ConnectingEdge.From, node))
        {
            m_Graph.RemoveEdgeElement(m_ConnectingEdge);
        }
        else
        {
            m_ConnectingEdge.ConnectTo(node);
            m_Graph.SerializeEdge(m_ConnectingEdge);  // つないだ時にシリアライズする
        }

        /* ... 省略 */
    }
// GraphEditorElementクラス

    public void SerializeEdge(EdgeElement edge)
    {
        var serializableEdge = new SerializableEdge()
        {
            toId = m_Nodes.IndexOf(edge.To)  // ここで先ノードのIDを数える
        };

        edge.From.serializableNode.edges.Add(serializableEdge);  // 実際に追加
        edge.serializableEdge = serializableEdge;  // EdgeElementに登録しておく
    }

そして、実際に開きなおしてみると、
NodeEditor-30.gif

保存されています。

5.3 エッジを削除できるようにする

エッジの追加ができるようになったので、やはり削除もできなければいけません。

ノードを削除するときと同様に、エッジの削除もコンテキストメニューから行いたいと思います。
しかし、このとき問題があります。
ノードは大きさのあるVisualElementだったため、ContextualManipulatorを付けるとそのままクリックで選択ができました。
しかし、エッジのVisualElementは大きさがありません。

5.3.1 エッジを選択できるようにする

VisualElementをクリックして選択するときの挙動について、ドキュメントに記載がありました。
Event targetのPicking mode and custom shapesの項です。

You can override the VisualElement.ContainsPoint() method to perform custom intersection logic.

このVisualElement.ContainsPoint()は、マウス座標を与えると、その座標と自分が衝突しているかを判定する関数です。
それをオーバーライドして、独自の衝突判定を埋め込むことで、VisualElementRect以外の形に対応させることができます。

実際にベジェ曲線と点との距離を計算するのは面倒なので、近似した線分との距離を計算して、指定距離以内だったら選択したことにしようと思います。

さて、衝突を判定の実装に当たって、ログを出すものが必要です
というわけで最初に、エッジに削除用のコンテキストメニューを作ります。

// EdgeElementクラス

    // 削除用マニピュレータの追加
    public EdgeElement()
    {
        this.AddManipulator(new ContextualMenuManipulator(evt =>
        {
            if (evt.target is EdgeElement)
            {
                evt.menu.AppendAction(
                "Remove Edge",
                (DropdownMenuAction menuItem) =>
                {
                    Debug.Log("Remove Edge");
                },
                DropdownMenuAction.AlwaysEnabled);
            }
        }));
    }

    public EdgeElement(NodeElement fromNode, Vector2 toPosition):this()  // 上のコンストラクタを呼ぶ
    {
        From = fromNode;
        ToPosition = toPosition;
    }

    public EdgeElement(SerializableEdge edge, NodeElement fromNode, NodeElement toNode):this()  // 上のコンストラクタを呼ぶ
    {
        serializableEdge = edge;
        From = fromNode;
        To = toNode;
    }

まず、接続元と接続先が収まるバウンディングボックスと衝突しているかどうかを判定してみます。

// EdgeElementクラス
    public override bool ContainsPoint(Vector2 localPoint)
    {
        if (From == null || To == null)
            return false;

        Vector2 start = From.GetStartPosition();
        Vector2 end = To.GetEndPosition();

        // ノードを覆うRectを作成
        Vector2 rectPos = new Vector2(Mathf.Min(start.x, end.x), Mathf.Min(start.y, end.y));
        Vector2 rectSize = new Vector2(Mathf.Abs(start.x - end.x), Mathf.Abs(start.y - end.y));
        Rect bound = new Rect(rectPos, rectSize);

        if (!bound.Contains(localPoint))
        {
            return false;
        }

        return true;
    }

結果はこうなりました。
NodeEditor-31.gif
確かに、エッジのバウンディングボックスとの当たりを判定できていそうです。

次に、近似線分との距離を計算してみます。
先にバウンディングボックスに入っていないものを弾いているので、端点が一番近い場合などを考えなくて済みます。
つまり、線分ではなく直線と点の距離を考えればよいということです。

// EdgeElementクラス
    readonly float INTERCEPT_WIDHT = 15f;  // エッジと当たる距離

    public override bool ContainsPoint(Vector2 localPoint)
    {
        /* ... 省略 */

        if (!bound.Contains(localPoint))
        {
            return false;
        }

        // 近似線分ab
        Vector2 a = From.GetStartPosition() + 12f * From.GetStartNorm();
        Vector2 b = To.GetEndPosition() + 12f * To.GetEndNorm();

        // 一致した場合はaからの距離
        if (a == b)
        {
            return Vector2.Distance(localPoint, a) < INTERCEPT_WIDHT;
        }

        // 直線abとlocalPointの距離
        float distance = Mathf.Abs(
            (b.y - a.y) * localPoint.x
            - (b.x - a.x) * localPoint.y
            + b.x * a.y - b.y * a.x
            ) / Vector2.Distance(a, b);

        return distance < INTERCEPT_WIDHT;
    }

結果はこうなりました。
NodeEditor-32.gif
...ちょっとずれている気もしますが、まあ、許容範囲でしょう。

5.3.2 エッジデータを削除する

GraphAssetからエッジのデータを消します。
EdgeElementには元ノードの情報が既にありますので、そこから自分のデータが入っているSerializableNodeを取得することができます。
これを消せばよいですね。

// EdgeElementクラス

    public EdgeElement()
    {
        this.AddManipulator(new ContextualMenuManipulator(evt =>
        {
            if (evt.target is EdgeElement)
            {
                evt.menu.AppendAction(
                "Remove Edge",
                (DropdownMenuAction menuItem) =>
                {
                    // 親をたどってGraphEditorElementに削除リクエストを送る
                    var graph = GetFirstAncestorOfType<GraphEditorElement>();
                    graph.RemoveEdgeElement(this);
                },
                DropdownMenuAction.AlwaysEnabled);
            }
        }));
    }
// GraphEditorElementクラス

    public void RemoveEdgeElement(EdgeElement edge)
    {
        // 消すエッジにSerializableEdgeがあれば、それを消す
        if(edge.serializableEdge != null)
        {
            edge.From.serializableNode.edges.Remove(edge.serializableEdge);
        }

        Remove(edge);
        m_Edges.Remove(edge);
    }

NodeEditor-33.gif

無事、削除できています。

6. ノードを削除する

最後に、ノードを削除できるようにしたいと思います。
ノードを削除したときには、
- NodeElementを削除する
- 対応するSerializableNodeを削除する
- そのノードとつながるEdgeElementを削除する
- 対応するSerializableEdgeを削除する
- 他ノードのIDが変わるので、それに応じてSerializableEdgeのIDを振りなおす

のすべてを行う必要があります。

// NodeElementクラス

    public NodeElement (SerializableNode node)
    {
        /* ... 省略 */

        this.AddManipulator(new NodeDragger());
        this.AddManipulator(new EdgeConnector());
        this.AddManipulator(new ContextualMenuManipulator(OnContextualMenuPopulate));  // 削除用マニピュレータ
    }

    private void OnContextualMenuPopulate(ContextualMenuPopulateEvent evt)
    {
        if (evt.target is NodeElement)
        {
            evt.menu.AppendAction(
                "Remove Node",
                RemoveNodeMenuAction,
                DropdownMenuAction.AlwaysEnabled);
        }
    }

    private void RemoveNodeMenuAction(DropdownMenuAction menuAction)
    {
        // 親をたどって削除をリクエスト
        var graph = GetFirstAncestorOfType<GraphEditorElement>();
        graph.RemoveNodeElement(this);
    }
// GraphEditorElementクラス

    public void RemoveNodeElement(NodeElement node)
    {
        m_GraphAsset.nodes.Remove(node.serializableNode);  // アセットから削除

        int id = m_Nodes.IndexOf(node);

        // エッジの削除とID変更
        // m_Edgesに変更が伴うため、降順で行う
        for (int i = m_Edges.Count - 1; i >= 0; i--)
        {
            var edgeElement = m_Edges[i];
            var edge = edgeElement.serializableEdge;

            // 削除されるノードにつながるエッジを削除
            if (edgeElement.To == node || edgeElement.From == node)
            {
                RemoveEdgeElement(edgeElement);
                continue;
            }

            // 変更が生じるIDを持つエッジに対して、IDに修正を加える
            if (edge.toId > id)
                edge.toId--;
        }

        Remove(node);  // VisualElementの子としてのノードを削除
        m_Nodes.Remove(node);  // 順序を保持するためのリストから削除
    }

これでノードを削除できるようになりました。
NodeEditor-35.gif

ウィンドウを開きなおしてもちゃんと構造が保存されています。

結果

NodeEditor-36.gif
ゼロからノードベースエディタを作りました。
現状ではグラフ構造を保存するアセットを作れるだけですが、このノード部分に何か情報を載せると立派なヴィジュアルツールが出来上がります。

おわりに

UIElementの使い方を勉強したいと思ったので、ノードベースエディタを作ってみました。
ドキュメントとリファレンスを読み込むことになり、GraphViewの実装もかなり追ったので勉強になってよかったです。
実をいうと、このGraphEditorを使ってBehaviorTreeを作るところまでやりたかったのですが、エディタを作るだけで相当の時間がかかってしまったので、この記事はここまでにしておきます。

また、ゼロから作るを銘打って、実装する手順通りに事細かく書いてしまったので、やたら長くなってしまいました。
とはいえ、エディタを作るにあたって得た知見をふんだんに盛り込めたのではないかと思います。

ここはもっとこうした方がよい、のような意見があればコメントで教えていただけるとありがたいです。
ご拝読ありがとうございました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Unity UIElements】ゼロから作るノードベースエディター

概要

目標は、UnityのUIElementsによってノードベースエディターを作ることです。
タイトルの「ゼロから」が意味するところは、「UIElements」を知らないところから、という意味です。
そのため、UIElemtnsに関する前提知識は必要ありません。

Unityのバージョンは2019.1.3f1です。
プロジェクトはGitHubに挙げておきます。
https://github.com/saragai/GraphEditor

追記:バージョン2019.2.16f1でこのエディタを使用したところ、エッジの選択ができなくなっていました。

背景

Unity2019からUIElementsというUIツールが入りました。
現在はエディタ拡張にしか使えませんが、将来的にはゲーム内部のUIにも使えるようになるそうです。

最近の機能でいえば、ShaderGraphのようなGUIツールもUIElementで作られています。

shader_graph_sample.jpg
[画像は引用:https://unity.com/ja/shader-graph]

これはGraphViewというノードベースエディタによって作られていて、GraphViewを使えばShaderGraphのようなヴィジュアルツールを作成できます。
[参照:GraphView完全理解した(2019年末版)]

さて、本記事の目標はGraphViewのようなのツールを作ることです。

いやGraphView使えばいいじゃん、と思った方は鋭いです。実用に耐えるものを作るなら、使った方がよいと思います。
さらに、本記事はUnityが公開しているGraphViewの実装を大いに参考にしているので、GraphViewを使うならすべて無視できる内容です。

とはいえ、内部でどんなことをすると上記画像のようなエディタ拡張ができるのか、気になる方も多いのではと思います。
その理解の一助となればと思います。

注)この記事は手順を細かく解説したり、あえて不具合のある例を付けたりしているので、冗長な部分が多々あります。

実装

公式ドキュメントを見ながら実装していきます。
https://docs.unity3d.com/2019.1/Documentation/Manual/UIElements.html
https://docs.unity3d.com/2019.3/Documentation/ScriptReference/UIElements.VisualElement.html

0. 挨拶

エディタ拡張用のスクリプトは必ず Assets/[どこでもいい]/Editor というディレクトリの下に置きます。ビルド時にビルド対象から外すためです。
というわけで、Assets/Scripts/Editor にC#ファイルを作って GraphEditor と名付けます。

Graphとは、頂点と辺からなるデータ構造を示す用語で、ビヘイビアツリーや有限オートマトンもGraphの一種です。ビヘイビアツリーの場合はアクションやデコレータが頂点、それぞれがどのようにつながっているかが辺に対応します。ひとまずの目標は、このグラフを可視化できるようにすることです。

とはいえ、まだUIElementsと初めましてなので、まずは挨拶から始めます。

// GraphEditor.cs
using UnityEngine;
using UnityEditor;
using UnityEngine.UIElements;

public class GraphEditor : EditorWindow
{
    [MenuItem("Window/GraphEditor")]  // Unityのメニュー/Window/GraphEditorから呼び出せるように
    public static void ShowWindow()
    {
        GraphEditor graphEditor = CreateInstance<GraphEditor>();  // ウィンドウを作成。
        graphEditor.Show();  // ウィンドウを表示
        graphEditor.titleContent = new GUIContent("Graph Editor");  // Windowの名前の設定
    }

    public void OnEnable()
    {
        VisualElement root = this.rootVisualElement;
        root.Add(new Label("Hello, World!"));
    }
}

Unity公式ブログのはじめの例を参考にしました。

ウィンドウが作成されたときに呼ばれるOnEnable()で、はじめてUIElementsと対面します。
見たところ、UIElementsはウィンドウの大元にあるrootVisualElementにどんどん要素を追加していく方式なんですね。
rootVisualElementはVisualElementクラスで、LabelもVisualElementクラスを継承しています。

さあ、メニューからWindow/GraphEditorを選択すると以下のようなウィンドウが表示されます。

NodeEditor-0.PNG

こんにちは!
ひとまず、挨拶は終わりました。

1. ノードを表示する

Inspectorのように、行儀よく上から下へ情報を追加していくUIであれば、あとは色を変えてみたり、ボタンを追加してみたり、水平に並べてみたりすればいいのですが、ノードベースエディタを作ろうとしているのでそれだけでは不十分です。
四角形を自由自在に動かせなければいけません。

ドキュメントには、UIElementの構造の説明として、このような図がありました。
visualtree-hierarchy.png
[画像は引用:https://docs.unity3d.com/ja/2019.3/Manual/UIE-VisualTree.html]
まずは、このred containerのような四角形を出したいですね。

というわけでいろいろ試してみます。

1.1 表示場所を指定する

ドキュメントによるとVisualElementはそれぞれlayoutなるメンバを持っていて、layout.positionやlayout.transformによって親に対する位置が決まるようです。実際に試してみましょう。

// GraphEditor.cs
using UnityEngine;
using UnityEditor;
using UnityEngine.UIElements;

public class GraphEditor : EditorWindow
{
    [MenuItem("Window/GraphEditor")]
    public static void ShowWindow()
    {
        GraphEditor graphEditor = CreateInstance<GraphEditor>();
        graphEditor.Show();
        graphEditor.titleContent = new GUIContent("Graph Editor");
    }

    public void OnEnable()
    {
        VisualElement root = this.rootVisualElement;
        root.Add(new Label("Hello, World!"));

        root.Add(new NodeElement("One", Color.red, new Vector2(100, 50)));
    }
}
// NodeElement.cs
using UnityEngine;
using UnityEngine.UIElements;

public class NodeElement : VisualElement
{
    public NodeElement (Node node,string name, Color color, Vector2 pos)
    {
        style.backgroundColor = new StyleColor(color);
        transform.position = pos;

        Add(new Label(name));
    }
}

先ほどと違うのは、NodeElementクラスですね。
NodeはGraphの頂点のことで、有限オートマトンでいうと状態に対応します。

このNodeElementのコンストラクタに色と位置を渡して、内部でstyle.backgroundClorとtransform.positionを設定します。
それをrootにAddして、どのように表示されるかを見てみます。

以下、結果です。
NodeEditor-1.PNG
お!
表示位置が唐突な場所になっていますね。
右にずっと伸びていますが、まだ幅を指定していないからでしょう。

もう一つ追加してみましょう。

// GraphEditorクラス
    public void OnEnable()
    {
        VisualElement root = this.rootVisualElement;
        root.Add(new Label("Hello, World!"));

        root.Add(new NodeElement("One", Color.red, new Vector2(100, 50)));
        root.Add(new NodeElement("Two", Color.yellow, new Vector2(200, 50)));
    }

NodeEditor-2.PNG
……あれ?
同じY座標を指定したのに二つのノードは重なっていません。

本当は、
NodeEditor-3.PNG
このようになって欲しかったのです。
ちなみに、この時点で上の図のようにするには以下を書きました。

// GraphEditorクラス
    public void OnEnable()
    {
        VisualElement root = this.rootVisualElement;
        root.Add(new Label("Hello, World!"));

        root.Add(new NodeElement("One", Color.red, new Vector2(100, 50)));
        root.Add(new NodeElement("Two", Color.yellow, new Vector2(200, 32)));  // y座標を変更
    }

どうやら18ピクセルだけ勝手に下にずらされていたようです。困ります。いい感じに自動レイアウトしてくれるという親切心だとは思うのですが、私が今作りたいのはヴィジュアルツールなので、上下左右に自在に動かしたいのです。

探すと別のドキュメントにありました。

Set the position property to absolute to place an element relative to its parent position rectangle. In this case, it does not affect the layout of its siblings or parent.

positionプロパティをabsoluteにすれば兄弟(=siblings)や親の影響を受けないよとあります。
positionプロパティってなんだと思いましたが、VisualStudioの予測変換機能を駆使して見つけました。

NodeElementのコンストラクタを以下のように書き換えます

// NodeElementクラス
    public NodeElement (Node node,string name, Color color, Vector2 pos)
    {
        style.backgroundColor = new StyleColor(color);
        style.position = Position.Absolute;  // 追加。これがposition propertyらしい
        transform.position = pos;

        Add(new Label(name));
    }

すると、

// GraphEditorクラス
    public void OnEnable()
    {
        VisualElement root = this.rootVisualElement;
        root.Add(new Label("Hello, World!"));

        root.Add(new NodeElement("One", Color.red, new Vector2(100, 50)));
        root.Add(new NodeElement("Two", Color.yellow, new Vector2(200, 50)));
    }

は以下のようになります。
NodeEditor-4.PNG

ちゃんと同じ高さになりました!
なぜか横方向の帯も消えています。

これで位置を自由に指定できるようになりました。

1.2 大きさを指定する

次は四角形の大きさを指定します。位置指定はラベルで実験したので勝手に大きさを合わせてくれていましたが、自由に幅や高さを指定したいです。
このような見た目に関する部分はだいたいVisualElement.styleにまとまっているようで、以下のように指定します。

// NodeElementクラス
    public NodeElement (Node node,string name, Color color, Vector2 pos)
    {
        style.backgroundColor = new StyleColor(color);
        style.position = Position.Absolute;
        style.height = 50,
        style.width = 100,
        transform.position = pos;

        Add(new Label(name));
    }

すると以下のようになります。
NodeEditor-5.PNG

初めに言った、red containerのようになったと思います。
visualtree-hierarchy.png

2. ノードを動かす

次は表示した四角形をインタラクティブに移動させます。
ヴィジュアルツールでは、見やすいように位置を動かすことは大事です。

挙動としては、
1. 四角形を左クリックして選択
2. そのままドラッグすると一緒に動く
3. ドロップで現在の位置に固定
というのを想定しています。

2.1 まずは試してみる

これらはどれもマウスの挙動に対しての反応なので、マウスイベントに対するコールバックとして実装します。
探すと公式ドキュメントにThe Event Systemという項がありました。
いろいろと重要そうなことが書いてある気がしますが、今はとりあえずイベントを取りたいのでその中のResponding to Eventsを見てみます。
どうやら、VisualElement.RegisterCallback()によってコールバックを登録できるみたいですね。

マウスに関するイベントはそれぞれ、
1. MouseDownEvent
2. MouseMoveEvent
3. MouseUpEvent
でとることができそうです。

NodeElementクラスを以下のように書き換えます。

// NodeElementクラス
    public NodeElement (string name, Color color, Vector2 pos)
    {
        style.backgroundColor = new StyleColor(color);
        style.position = Position.Absolute;
        style.height = 50;
        style.width = 100;

        transform.position = pos;

        Add(new Label(name));

        bool focus = false;

        RegisterCallback((MouseDownEvent evt) =>
        {
            if (evt.button == 0)  // 左クリック
            {
                focus = true;  // 選択
            }
        });

        RegisterCallback((MouseUpEvent evt) =>
        {
            focus = false;  // 選択を解除
        });

        RegisterCallback((MouseMoveEvent evt) =>
        {
            if (focus)
            {
                transform.position += (Vector3)evt.mouseDelta;  // マウスが動いた分だけノードを動かす
            }
        });
    }

すると、以下のような挙動になります。
NodeEditor-7.gif
動きましたが、少し使いにくそうな動きです。
まず、赤いノードが黄色のノードの下にあるせいで、赤を動かしている途中にカーソルが黄色の上に来ると、赤が動かなくなってしまいます。
さらにそのあと右クリックをやめても選択が解除されておらず、赤が勝手に動いてしまいます。これは、MouseUpEventが赤いノードに対して呼ばれていないことが問題のようです。

改善策は、
1. 選択したノードは最前面に来てほしい
2. カーソルがノードの外に出たときにも、マウスイベントは呼ばれてほしい
の二つです。

2.2 VisualElementの表示順を変える

ドキュメントのThe Visual Treeの項目に、Drawing orderの項があります。

Drawing order
The elements in the visual tree are drawn in the following order:
- parents are drawn before their children
- children are drawn according to their sibling list

The only way to change their drawing order is to reorder VisualElementobjects in their parents.

描画順を変えるには親オブジェクトが持つVisualElementを並び替えないといけないようです。
それ以上の情報がないのでVisualElementのスクリプトリファレンスを見てみます。その中のメソッドでそれらしいものがないかを探すと……ありました。

BringToFront()というメソッドで、親の子供リストの中の最後尾へ自分を持っていくものです。
これをMouseDownEventのコールバックに追加します。

// NodeElementクラス
        RegisterCallback((MouseDownEvent evt) =>
        {
            if (evt.button == 0)
            {
                focus = true;
                BringToFront();  // 自分を最前面に持ってくる
            }
        });

実行結果は以下です。
NodeEditor-8.gif
クリックしたものが最前面へきているのがわかります。
しかし、動画後半のように、マウスを勢いよく動かすとノードがついてこられないことがわかります。

2.3 マウスイベントをキャプチャする

マウスを勢いよく動かしたとき、カーソルがノードの外に出るのでMouseLeaveEventが呼ばれるはずです。その時にPositionを更新してドラッグ中は常にノードがカーソルの下にあるようにすればよい、と初めは思っていました。
ですが、それだと勢いよく動かした直後にマウスクリックを解除した場合に、MouseUpEventが選択中のノードに対して呼ばれないようなのです。
イベントの呼ばれる順序にかかわる部分で、丁寧に対応してもバグの温床になりそうです。

いい方法はないかなとドキュメントを読んでいると、よさそうなものを見つけました。
Dispatching Eventsの中のCapture the mouseという項です。

VisualElementはCaptureMouse()を呼ぶことによって、カーソルが自身の上にないときでもマウスイベントを自分のみに送ってくれるようになるということで、まさにマウスをキャプチャしています。
キャプチャすると、マウスが自分の上にあるかどうかを気にしなくてよくなるので、安心して使えそうです。

ということで、MouseDown時にキャプチャし、MouseUp時に解放するように書き換えてみます。

// NodeElementクラス
        RegisterCallback((MouseDownEvent evt) =>
        {
            if (evt.button == 0)
            {
                focus = true;
                BringToFront();
                CaptureMouse();  // マウスイベントをキャプチャ
            }
        });

        RegisterCallback((MouseUpEvent evt) =>
        {
            ReleaseMouse();  // キャプチャを解放
            focus = false;
        });

        RegisterCallback((MouseCaptureOutEvent evt) =>
        {
            m_Focus = false;  // キャプチャが外れたときはドラッグを終了する
        }

MouseCaptureOutEventは他のVisualElementなどによってキャプチャを奪われたときに呼ばれる関数です。

実行結果は以下になります。
NodeEditor-9.gif
無事に意図した動きになりました。

2.4 ノードを動かすコードをManipulatorによって分離する

この後もノードには様々な機能が追加される予定ですので、コードが煩雑にならないためにも、ノードを動かす部分を分離してしまいたいです。
どうしようか悩んでいましたが、UIElementsにはManipulatorという仕組みがあることを見つけました。
Manipulatorを使うことで、「ノードを動かす」のような操作を追加するコードをきれいに分離して書くことができます。

NodeDraggerというクラスを作成します。

// NodeDragger.cs
using UnityEngine;
using UnityEngine.UIElements;

public class NodeDragger : MouseManipulator
{
    private bool m_Focus;

    public NodeDragger()
    {
        // 左クリックで有効化する
        activators.Add(new ManipulatorActivationFilter { button = MouseButton.LeftMouse });
    }

    /// Manipulatorにターゲットがセットされたときに呼ばれる
    protected override void RegisterCallbacksOnTarget()
    {
        m_Focus = false;

        target.RegisterCallback<MouseDownEvent>(OnMouseDown);
        target.RegisterCallback<MouseUpEvent>(OnMouseUp);
        target.RegisterCallback<MouseMoveEvent>(OnMouseMove);
        target.RegisterCallback<MouseCaptureOutEvent>(OnMouseCaptureOut);
    }

    /// Manipulatorのターゲットが変わる直前に呼ばれる
    protected override void UnregisterCallbacksFromTarget()
    {
        target.UnregisterCallback<MouseDownEvent>(OnMouseDown);
        target.UnregisterCallback<MouseUpEvent>(OnMouseUp);
        target.UnregisterCallback<MouseMoveEvent>(OnMouseMove);
        target.UnregisterCallback<MouseCaptureOutEvent>(OnMouseCaptureOut);
    }

    protected void OnMouseDown(MouseDownEvent evt)
    {
        // 設定した有効化条件をみたすか (= 左クリックか)
        if (CanStartManipulation(evt))
        {
            m_Focus = true;
            target.BringToFront();
            target.CaptureMouse();
        }
    }

    protected void OnMouseUp(MouseUpEvent evt)
    {
        // CanStartManipulation()で条件を満たしたActivationのボタン条件と、
        // このイベントを発火させているボタンが同じか
        // (= 左クリックを離したときか)
        if (CanStopManipulation(evt))
        {
            target.ReleaseMouse();
            m_Focus = false;
        }
    }

    protected void OnMouseCaptureOut(MouseCaptureOutEvent evt)
    {
        m_Focus = false;
    }

    protected void OnMouseMove(MouseMoveEvent evt)
    {
        if (m_Focus)
        {
            target.transform.position += (Vector3)evt.mouseDelta;
        }
    }
}

RegisterCallBacksOnTarget()UnregisterCallbacksFromTarget()Manipulatorクラスの関数で、イベントのコールバックの登録・解除を担っています。
activatorsCanStartManipulation()CanStopManipulation()Manipulatorクラスを継承するMouseManipulatorクラスの関数で、マウスのボタンの管理がしやすくなっています。
細かいことはコード中のコメントに記載しました。

このManipulatorを使用するには、対象のVisualElementを設定しなければいけません。

// NodeElementクラス
    public NodeElement (string name, Color color, Vector2 pos)
    {
        style.backgroundColor = new StyleColor(color);
        style.position = Position.Absolute;
        style.height = 50;
        style.width = 100;

        transform.position = pos;

        Add(new Label(name));

        AddManipulator(new NodeDragger());  // 操作の追加が一行で済む
    }

AddManipulatorという関数によって対象のVisualElementを設定しています。
実はこのコードは以下のようにもかけます。

        new NodeDragger(){target = container};

内部の実装を見ると、AddManipulatorではIManipulator.targetプロパティに自身をセットしているだけでした。
そしてsetter内で、セットする前に既存のtargetがあればUnregisterCallbacksFromTarget()を呼び、そのあと新規のターゲットをセットしてからRegisterCallbacksOnTarget()を呼びます。

[参照:https://github.com/Unity-Technologies/UnityCsReference/blob/2019.1/Modules/UIElements/Manipulators.cs]
[参照:https://github.com/Unity-Technologies/UnityCsReference/blob/2019.1/Modules/UIElements/MouseManipulator.cs]

3. ノードを追加する

これまではテストのためにノードの数は2つで固定されていましたが、自在に追加できなければグラフエディタとはとても呼べません。
想定している挙動は、

  1. 右クリックでメニューが出てくる
  2. 「Add Node」を選択する
  3. 右クリックした場所にノードが生成される

です。

......実は前章の最後あたりで、このままのペースで書いていると時間がいくらあっても足りないと思い、先に実装してから記事を書くことにしました。
ですので、これからの説明は少しスムーズに(悪く言えば飛躍気味に)なるかもしれません。ご了承ください。

3.1 メニューを表示する

2.4節で見たようなManipulatorと同じように、この挙動も操作としてまとめることができそうです。
というか、こんなみんなが欲しそうな機能が公式に用意されていないはずがありません。
案の定、存在しました。例によって公式ドキュメントです。

コードを載せます。

// GraphEditorクラス
    public void OnEnable()
    {
        VisualElement root = this.rootVisualElement;

        root.Add(new NodeElement("One", Color.red, new Vector2(100, 50)));
        root.Add(new NodeElement("Two", Color.yellow, new Vector2(200, 50)));

        root.AddManipulator(new ContextualMenuManipulator(OnContextMenuPopulate));
    }

    void OnContextMenuPopulate(ContextualMenuPopulateEvent evt)
    {
        // 項目を追加
        evt.menu.AppendAction(
            "Add Node",  // 項目名
            AddEdgeMenuAction,  // 選択時の挙動
            DropdownMenuAction.AlwaysEnabled  // 選択可能かどうか
            );
    }

    void AddEdgeMenuAction(DropdownMenuAction menuAction)
    {
        Debug.Log("Add Node");
    }
}

さあ、どうでしょうか。
NodeEditor-10.gif
上手く動いていません。

期待していた挙動は、「背景を左クリックしたときはメニューが開いて、ノードを左クリックしたときは何も起こらない」です。でも、これでは逆ですね。
イベント発行についてのドキュメントを見てみます。
NodeEditor-11.PNG
[図は引用:https://docs.unity3d.com/2019.1/Documentation/Manual/UIE-Events-Dispatching.html]

イベントは root -> target -> root と呼ばれるみたいですね。イベント受け取りについてのドキュメントには、デフォルトではTargetフェイズとBubbleUpフェイズにイベントが登録されるともあります。

とにかく、思い当たるのは、ルートに登録したコールバックがノード経由で伝わっているということです。
いろいろ試してみてわかったのは、ルートではデフォルトでpickingModePickingMode.Ignoreに設定されているということでした。

リファレンスによると、マウスのクリック時にターゲットを決める際、その位置にある一番上のVisualElementを取ってきているらしいのですが、このpickingModePickingMode.Ignoreに設定されていた場合は候補から外す、という挙動になるようです。

実際、このようにすると動きます。

// GraphEditorクラス

    public void OnEnable()
    {
        VisualElement root = this.rootVisualElement;

        root.Add(new NodeElement("One", Color.red, new Vector2(100, 50)));
        root.Add(new NodeElement("Two", Color.yellow, new Vector2(200, 50)));

        root.pickingMode = PickingMode.Position;  // ピッキングモード変更

        root.AddManipulator(new ContextualMenuManipulator(OnContextMenuPopulate));
    }

NodeEditor-12.gif
でも、ルートをむやみに変更するのはよくないですね。
そこで、ルートの一つ下に一枚挟むことにします。今の作りでは、EditorWindowとVisualElementが不可分になってしまっていましたが、それを分離可能にするという意味合いもあります。

さあ、いよいよもってGraphViewに近づいてきました。
分離自体はすぐにできます。

// GraphEditor.cs
using UnityEngine;
using UnityEditor;
using UnityEngine.UIElements;

public class GraphEditor : EditorWindow
{
    [MenuItem("Window/GraphEditor")]
    public static void ShowWindow()
    {
        GraphEditor graphEditor = CreateInstance<GraphEditor>();
        graphEditor.Show();
        graphEditor.titleContent = new GUIContent("Graph Editor");
    }

    GraphEditorElement m_GraphEditorElement;

    public void OnEnable()
    {
        VisualElement root = this.rootVisualElement;

        m_GraphEditorElement = new GraphEditorElement();
        root.Add(m_GraphEditorElement);
    }
}

// GraphEditorElement.cs
using UnityEngine;
using UnityEngine.UIElements;
using System.Collections.Generic;

public class GraphEditorElement: VisualElement
{
    public GraphEditorElement()
    {
        style.flexGrow = 1;  // サイズを画面いっぱいに広げる
        style.overflow = Overflow.Hidden;  // ウィンドウの枠からはみ出ないようにする

        Add(new NodeElement("One", Color.red, new Vector2(100, 50)));
        Add(new NodeElement("Two", Color.yellow, new Vector2(200, 50)));

        this.AddManipulator(new ContextualMenuManipulator(OnContextMenuPopulate));
    }

    void OnContextMenuPopulate(ContextualMenuPopulateEvent evt)
    {
        evt.menu.AppendAction(
            "Add Node",
            AddNodeMenuAction,
            DropdownMenuAction.AlwaysEnabled
            );
    }

    void AddNodeMenuAction(DropdownMenuAction menuAction)
    {
        Debug.Log("Add Node");
    }
}

二つのスタイルを適用しました。
style.flexGrow = 1; によってGraphEditorElementのサイズが画面いっぱいに広がり、クリックイベントを拾う背景の役割を果たしてくれます。
style.overflow = Overflow.Hidden; は親の領域からはみ出た部分を表示しないようにします。ノードを動かすとウィンドウの枠からはみ出したりしていましたが、これでもう心配はいりません。

挙動はこのようになります。
NodeEditor-13.gif
まだノードの上で右クリックしたときもAdd Nodeメニューが出てしまいます。
これはノードに対しても何かを設定する必要がありそうですね。

後でノードを左クリックしたときにエッジを追加する挙動を実装します。そのとき考えましょう。

とにかくメニューは出たということで、次へ進んでいきます。

3.2 ノードを生成する

Add Nodeというログを出していた部分を少し変更すると、新しいノードが生成できます。

// GraphEditorElementクラス
    void AddNodeMenuAction(DropdownMenuAction menuAction)
    {
        Vector2 mousePosition = menuAction.eventInfo.localMousePosition;  // マウス位置はeventInfoの中にあります

        Add(new NodeElement("add", Color.green, mousePosition));
    }

挙動です。
NodeEditor-14.gif
これで、表示の上では新しいノードを生成できました。

4. ノードを永続化する

3章で生成したノードはGraphEditorウィンドウを開きなおしたりすると消えてしまいます。
Unityで何らかのデータを保存しておくには、どこかのファイルにシリアライズしておく必要があります。

「シリアライズとはXXXである」と一言で言えたらいいのですが、短く上手く説明できる気がしません。
脱線になってしまってもよくないので、気になる方は「Unity シリアライズ」などで検索してみてください。

4.1 グラフ構造とは

方針としては、グラフを再現するのに最低限必要なものを用意します。
冒頭でも少し触れましたが、ここでグラフの定義を明確にしておきます。

グラフには大きく分けて二種類あります。
無向グラフと有向グラフです。
graph_sample.jpg
[図は引用:https://qiita.com/drken/items/4a7869c5e304883f539b]

エッジ、つまり辺に向きがあるかないかの差があります。
ゲームで使う場合、ロジックを表現するためのグラフはほとんどが有向グラフなのではと思います。

ビヘイビアツリー: ノードはアクション、エッジは遷移
有限オートマトン: ノードは状態、エッジは遷移

ということで、作成中のグラフエディタも、有向グラフを表せるものを作りたいと思います。
余談ですが、無向グラフは有効グラフの矢印と逆向きに同じ矢印を付けると実現することができます。

4.2 シリアライズ用のクラスを作る

構造としては、
グラフアセット:ノードリストを持つ
ノード:エッジリストを持つ
エッジ:つながるノードを持つ

として、グラフアセットをアセットとして新規作成できるようにしようと思います。
実装は以下のようにします。

// GraphAsset.cs
using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(fileName ="graph.asset", menuName ="Graph Asset")]
public class GraphAsset : ScriptableObject
{
    public List<SerializableNode> nodes = new List<SerializableNode>();
}

[System.Serializable]
public class SerializableNode
{
    public Vector2 position;
    public List<SerializableEdge> edges = new List<SerializableEdge>();
}

[System.Serializable]
public class SerializableEdge
{
    public SerializableNode toNode;
}

ScriptableObject[CreateAssetMenu]を付けることで、Unityのプロジェクトなどで右クリックをしたときにメニューから生成できるようになります。
また、[System.Serializable]アトリビュートによって、指定したクラスをシリアライズ可能にしています。

早速、グラフアセットを作ってみました。
NodeEditor-15.gif
すると、このようなエラーが出ます。

Serialization depth limit 7 exceeded at 'SerializableEdge.toNode'. There may be an object composition cycle in one or more of your serialized classes.

Serialization hierarchy:
8: SerializableEdge.toNode
7: SerializableNode.edges
6: SerializableEdge.toNode
5: SerializableNode.edges
4: SerializableEdge.toNode
3: SerializableNode.edges
2: SerializableEdge.toNode
1: SerializableNode.edges
0: GraphAsset.nodes

UnityEditor.InspectorWindow:RedrawFromNative()

これはつまり、こういうことです。
NodeEditor-16.gif

そう、ノードがシリアライズしようとするエッジが、さらにノードをシリアライズしようとして、循環が発生しているのです。
シリアライズの仕組みとして、クラスの参照をそのまま保存することはできません。

では、どうするかというと、ノードのIDを保存しておくことにします。
UnityのGUIDみたいに大きなIDを振ってもいいのですが、振るのとか対応付けとかが面倒そうです。
そこで、ここではGraphAssetが持っているノードリストの何番目にあるか、というのをIDとしようと思います。

SerializableEdgeだけ以下のように直します。

// GraphAsset.cs
[System.Serializable]
public class SerializableEdge
{
    public int toId;
}

これでワーニングは出なくなります。

4.3 アセットとエディタを対応付ける

どのアセットを表示・編集するかを決めるために、エディタにアセットの情報を持たせなければいけません。
実際にエディタを使うときのことを考えると、アセットからエディタが開けて、その際にそのアセットについて編集するようにできたらいいですね。

というわけで要件としては、
1. GraphAssetをダブルクリックするとエディタが開く
2. どこかのGraphEditorElementクラスにGraphAssetクラスを渡す
です。

// GraphAsset.cs
using UnityEngine;
using UnityEditor;
using UnityEditor.Callbacks;  // OnOpenAssetアトリビュートのために追加
using UnityEngine.UIElements;
using System.Collections.Generic;

public class GraphEditor : EditorWindow
{
    [MenuItem("Window/GraphEditor")]
    public static void ShowWindow()
    {
        GraphEditor graphEditor = CreateInstance<GraphEditor>();
        graphEditor.Show();
        graphEditor.titleContent = new GUIContent("Graph Editor");

        if(Selection.activeObject is GraphAsset graphAsset)
        {
            graphEditor.Initialize(graphAsset);
        }
    }

    [OnOpenAsset()]  // Unityで何らかのアセットを開いたときに呼ばれるコールバック
    static bool OnOpenAsset(int instanceId, int line)
    {
        if(EditorUtility.InstanceIDToObject(instanceId) is GraphAsset)  // 開いたアセットがGraphAssetかどうか
        {
            ShowWindow();
            return true;
        }

        return false;
    }

    GraphAsset m_GraphAsset;  // メンバ変数として持っておく
    GraphEditorElement m_GraphEditorElement;

    public void OnEnable()
    {
        // ShowWindow()を通らないような時(スクリプトのコンパイル後など)
        // のために初期化への導線を付ける
        if (m_GraphAsset != null)
        {
            // 初期化はInitializeに任せる
            Initialize(m_GraphAsset);
        }
    }

    // 初期化
    public void Initialize(GraphAsset graphAsset)
    {
        m_GraphAsset = graphAsset;

        // 以下はもともとOnEnable() で行っていた処理
        // OnEnable() はCreateInstance<GraphEditor>() の際に呼ばれるので、まだgraphAssetが渡されていない
        // 初期化でもgraphAssetを使うことになるのでここに移す
        VisualElement root = this.rootVisualElement;

        m_GraphEditorElement = new GraphEditorElement();
        root.Add(m_GraphEditorElement);
    }
}

これで、GraphAssetファイルをダブルクリックしたときにエディタが開くようになります。
NodeEditor-17.gif

4.4 アセットのデータからノードを表示するようにする

続いて、アセットにある情報からノードを構築、表示したいと思います。
まずはGraphAssetにダミーの情報を手打ちします。
NodeEditor-18.PNG
(100, 50)と(200, 50)の位置、つまり今まで表示してきた赤と黄色の位置、にノードが表示されればOKです。

まず、NodeElementを少し変えます。
色の情報はアセットにはないので省きますし、位置はシリアライズされますからね。

具体的には、生成をSerializableNodeから行うようにします。

// NodeElement.cs

// BackgroundColorがなくなると見えなくなるので、周囲を枠線で囲んだVisualElement、Boxを継承する
public class NodeElement : Box  
{
    public SerializableNode serializableNode;

    public NodeElement (SerializableNode node)  // 引数を変更
    {
        serializableNode = node;  // シリアライズ対象を保存しておく

        style.position = Position.Absolute;
        style.height = 50;
        style.width = 100;

        transform.position = node.position;  // シリアライズされている位置を取る

        this.AddManipulator(new NodeDragger());
    }
}

GraphEditorElementも伴って変更します。

// GraphEditorElement.cs
using UnityEngine;
using UnityEngine.UIElements;
using System.Collections.Generic;

public class GraphEditorElement: VisualElement
{
    GraphAsset m_GraphAsset;  // 渡されたアセットを保存
    List<NodeElement> m_Nodes;  // 作ったノードを入れておく。順序が重要

    public GraphEditorElement(GraphAsset graphAsset)
    {
        m_GraphAsset = graphAsset;

        style.flexGrow = 1;
        style.overflow = Overflow.Hidden;

        this.AddManipulator(new ContextualMenuManipulator(OnContextMenuPopulate));

        m_Nodes = new List<NodeElement>();

        // 順番にノードを生成。この作る際の順番がSerializableEdgeが持つNodeのIDとなる
        foreach(var node in graphAsset.nodes)
        {
            CreateNodeElement(node);
        }
    }

    void CreateNodeElement(SerializableNode node)
    {
        var nodeElement = new NodeElement(node);

        Add(nodeElement);  // GraphEditorElementの子として追加
        m_Nodes.Add(nodeElement);  // 順番を保持するためのリストに追加
    }

/* ... 省略 */

    void AddNodeMenuAction(DropdownMenuAction menuAction)
    {
        Vector2 mousePosition = menuAction.eventInfo.localMousePosition;

        CreateNodeElement(new SerializableNode() { position = mousePosition });  // 追加生成時には仮で新しく作る
    }
}

GraphEditorElementのコンストラクタにGraphAssetを渡すようにしたので、GraphEditorから生成するときに必要です

// GraphEditorクラス
    public void Initialize(GraphAsset graphAsset)
    {
        m_GraphAsset = graphAsset;

        VisualElement root = this.rootVisualElement;

        m_GraphEditorElement = new GraphEditorElement(graphAsset);  // アセットを渡す
        root.Add(m_GraphEditorElement);
    }

以上で、アセットに保持された情報を描画することができました。
NodeEditor-19.gif
書き込みはしていないので、当然開きなおすと追加したノードは消えてしまいます。

4.5 追加作成したノードをアセットに書き込む

前節までできたら、あとはもう少し変えるだけです。

// GraphEditorElementクラス
    private void AddNodeMenuAction(DropdownMenuAction menuAction)
    {
        Vector2 mousePosition = menuAction.eventInfo.localMousePosition;
        var node = new SerializableNode() { position = mousePosition };

        m_GraphAsset.nodes.Add(node);  // アセットに追加する

        CreateNodeElement(node);
    }

これでアセットに書き込まれます。
NodeEditor-20.gif
おっと、動かしたことを記録するのを忘れていました。

// NodeDraggerクラス
    protected void OnMouseUp(MouseUpEvent evt)
    {
        if (CanStopManipulation(evt))
        {
            target.ReleaseMouse();

            if(target is NodeElement node)
            {
                //NodeElementに保存しておいたシリアライズ対象のポジションをいじる
                node.serializableNode.position = target.transform.position;
            }

            m_Focus = false;
        }
    }

動かしてドラッグをやめた瞬間に記録するとよいと思います。
これで動かしたことも保存されるようになりました。
NodeEditor-21.gif

5. エッジを追加する

頂点の表示ができたので、次は辺です。辺は頂点同士を線で結ぶことで表します。
コンテナ的な仕組みでは直線や曲線は引けないように思うので、ここは既存の仕組みで線を引きます。
Handles.Draw系の関数が一番楽かなと思います。
DrawLineDrawBezierなどです。

ちなみにGraphViewでは、エッジ用のメッシュを作って、Graphics.DrawMeshNow()で描画をしていました。

5.1 エッジを表示する

とりあえずダミーでデータを作ってみます。
NodeEditor-22.PNG
Element0のEgesに要素を追加しました。
このまま表示するとこうなります。
NodeEditor-23.PNG
イメージとしては、左上のノードから右下のノードへ繋がっている矢印があればいいなと思います。

VisualElementは初期化字に一度呼べば後は自動で描画してくれていましたが、Handlesで描画をするならウィンドウ更新のたびに呼ぶ必要があります。
EditorWindowの更新といえば、OnGUIです。
ウィンドウの更新のたびにOnGUIが呼ばれますので、そこからGraphEditorElementの描画関数を呼ぶことにします。

ひとまずこのように実装してみます。

// GraphEditorクラス
    private void OnGUI()
    {
        if(m_GraphEditorElement == null)
        {
            return;
        }

        m_GraphEditorElement.DrawEdge();
    }
// GraphEditorElementクラス
    public void DrawEdge()
    {
        for(var i = 0; i < m_GraphAsset.nodes.Count; i++)
        {
            var node = m_GraphAsset.nodes[i];
            foreach(var edge in node.edges)
            {
                DrawEdge(
                    startPos: m_Nodes[i].transform.position,
                    startNorm: new Vector2(0f, 1f),
                    endPos: m_Nodes[edge.toId].transform.position,
                    endNorm: new Vector2(0f, -1f));
            }
        }
    }

    private void DrawEdge(Vector2 startPos, Vector2 startNorm, Vector2 endPos, Vector2 endNorm)
    {
        Handles.color = Color.blue;  // 色指定

        // エッジをベジェ曲線で描画
        Handles.DrawBezier(
            startPos,
            endPos,
            startPos + 50f * startNorm,
            endPos + 50f * endNorm,
            color: Color.blue,
            texture: null,
            width: 2f);

        // 矢印の三角形の描画
        Vector2 arrowAxis = 10f * endNorm;
        Vector2 arrowNorm = 5f * Vector3.Cross(endNorm, Vector3.forward);

        Handles.DrawAAConvexPolygon(endPos,
            endPos + arrowAxis + arrowNorm,
            endPos + arrowAxis - arrowNorm);

        Handles.color = Color.white;  // 色指定をデフォルトに戻す
    }

このようになりました。
NodeEditor-24.gif

ポジションとして単にVisualElement.transform.positionを利用しているので左上隅に始点・終点が来ています。
元ノードは下辺中央から、先ノードの上辺中央につながってほしい気がします。
とはいえ、GraphEditorElementでNodeの形に関する部分を決め打ちで呼んでしまうのはちょっと気持ち悪いので、NodeElementに始点や終点の位置・方向の情報を返す関数を作ろうと思います。

// GraphEditorElementクラス
    public void DrawEdge()
    {
        for(var i = 0; i < m_GraphAsset.nodes.Count; i++)
        {
            var node = m_GraphAsset.nodes[i];
            foreach(var edge in node.edges)
            {
                // ノードに情報を問い合わせる
                DrawEdge(
                    startPos: m_Nodes[i].GetStartPosition(),
                    startNorm: m_Nodes[i].GetStartNorm(),
                    endPos: m_Nodes[edge.toId].GetEndPosition(),
                    endNorm: m_Nodes[edge.toId].GetEndNorm());
            }
        }
    }
// NodeElementクラス

    public Vector2 GetStartPosition()
    {
        return (Vector2)transform.position + new Vector2(style.width.value.value / 2f, style.height.value.value);
    }
    public Vector2 GetEndPosition()
    {
        return (Vector2)transform.position + new Vector2(style.width.value.value / 2f, 0f);
    }
    public Vector2 GetStartNorm()
    {
        return new Vector2(0f, 1f);
    }
    public Vector2 GetEndNorm()
    {
        return new Vector2(0f, -1f);
    }

ちゃんとそれらしい位置から生えました。
NodeEditor-25.gif

また、エッジを描画するだけなら、エッジのVisualElementを作らずにGraphAssetに保存されているSerializableEdgeの値を見ていればよいのですが、エッジの追加・削除・付け替えなど、いずれ必要になるであろう操作がやりにくくなります。

そこで、エッジにもEdgeElementクラスを作ります。

// EdgeElement.cs

using UnityEngine;
using UnityEngine.UIElements;
using UnityEditor;

public class EdgeElement : VisualElement
{
    public SerializableEdge serializableEdge;  // データを持っておく

    public NodeElement From { get; private set; }  // 元ノード
    public NodeElement To { get; private set; }  // 先ノード

    public EdgeElement(SerializableEdge edge, NodeElement from, NodeElement to )
    {
        serializableEdge = edge;
        From = from;
        To = to;
    }

    public void DrawEdge()
    {
        if(From != null && To != null)
        {
            DrawEdge(
                startPos: From.GetStartPosition(),
                startNorm: From.GetStartNorm(),
                endPos: To.GetEndPosition(),
                endNorm: To.GetEndNorm());
        }
    }

    // GraphEditorElementからそのまま移した
    private void DrawEdge(Vector2 startPos, Vector2 startNorm, Vector2 endPos, Vector2 endNorm)
    {
        Handles.color = Color.blue;
        Handles.DrawBezier(
            startPos,
            endPos,
            startPos + 50f * startNorm,
            endPos + 50f * endNorm,
            color: Color.blue,
            texture: null,
            width: 2f);

        Vector2 arrowAxis = 10f * endNorm;
        Vector2 arrowNorm = 5f * Vector3.Cross(endNorm, Vector3.forward);

        Handles.DrawAAConvexPolygon(endPos,
            endPos + arrowAxis + arrowNorm,
            endPos + arrowAxis - arrowNorm);
        Handles.color = Color.white;
    }
}

このクラスもノードと同様に、GraphEditorElementが生成し、GraphEditorElementの子として保持することにします。
ノードが持っていて、ノードの子として生成というのも考えましたが、GraphEditorで一元管理した方が構造が単純になりそうだと思ったのが理由です。

実装はこうです。

// GraphEditorElementクラス

    List<EdgeElement> m_Edges;  // エッジもノードと同じくまとめて保持しておく

    public GraphEditorElement(GraphAsset graphAsset)
    {
        m_GraphAsset = graphAsset;

        style.flexGrow = 1;
        style.overflow = Overflow.Hidden;

        this.AddManipulator(new ContextualMenuManipulator(OnContextMenuPopulate));

        m_Nodes = new List<NodeElement>();

        foreach(var node in graphAsset.nodes)
        {
            CreateNodeElement(node);
        }

        // すべてのノードの生成が終わってからエッジの生成を行う
        // エッジが持っているノードIDからノードを取得するため
        m_Edges = new List<EdgeElement>();

        foreach(var node in m_Nodes)
        {
            foreach(var edge in node.serializableNode.edges)
            {
                CreateEdgeElement(edge, node, m_Nodes);
            }
        }
    }

    // エッジの生成
    public EdgeElement CreateEdgeElement(SerializableEdge edge, NodeElement fromNode, List<NodeElement> nodeElements)
    {
        var edgeElement = new EdgeElement(edge, fromNode, nodeElements[edge.toId]);
        Add(edgeElement);
        m_Edges.Add(edgeElement);

        return edgeElement;
    }

    // GraphEditor.OnGUI() 内で呼ばれる。描画処理をエッジに移したので小さくなった
    public void DrawEdge()
    {
        foreach(var edge in m_Edges)
        {
            edge.DrawEdge();
        }
    }

見た目は先ほどと変わりません。

5.2 エッジを追加できるようにする

あるノードからあるノードにエッジをつけようと思う時、元ノードから先ノードへ線を伸ばしていくようなイメージになると思います。

UnityのGraphViewやUnrealEngineのBluePrintではノードに備わった接続用のポートをクリックしてそのままドラッグすると線が引かれていきます。
NodeEditor-26.gif

UnrealEngineのBehaviourTreeでは、ノードの上下にエッジ接続領域があります。
NodeEditor-27.gif

これらのようなポートや接続領域などはあると便利そうですが、いったんメニューにAdd Edgeを追加するので良いでしょう。
重要なのは、追加中に元ノードからエッジがマウスの位置を追従していることです。
このUIによって、現在エッジ追加操作中であることと、つなげるノードを指定する方法が直感的にわかります。
これは実装したいです。

挙動としては、
1. ノードを右クリックする
2. メニューから「Add Edge」を選択する
3. 元ノードからマウスの位置に向かうエッジ候補ができる
4. 他のノードを左クリックして、エッジの向かい先を確定する

を想定します。
ノードに対する操作なので、ノードにManipulatorを追加します。
エッジをつなぐ操作なので、EdgeConnectorクラスとします。

5.2.1 EdgeConnectorクラスを作る

EdgeConnectorの役割はメニューを出してエッジ追加モードに入ることと、そのあとに別のノードをクリックして実際にノードを接続することの二つあります。
その中でメニューを出す部分はContexturalMenuManipulatorの役割ですので、EdgeConnectorクラスの中でContexturalMenuManipulatorを作成し、それをEdgeConnectorのターゲットノードにAddManipulatorしようと思います。

こうすることで、NodeElementにEdgeConnectorを追加するだけで、エッジ追加の処理をすべてEdgeConnectorクラスに投げることができます。

// NodeElementクラス
    public NodeElement (SerializableNode node)
    {
        /* ... 省略 */

        this.AddManipulator(new NodeDragger());
        this.AddManipulator(new EdgeConnector());  // 追加
    }

そして、EdgeConnectorの内部はひとまずこのようにしておきます。

using UnityEngine;
using UnityEngine.UIElements;

public class EdgeConnector : MouseManipulator
{
    bool m_Active = false;

    ContextualMenuManipulator m_AddEdgeMenu;

    public EdgeConnector()
    {
        // ノードの接続は左クリックで行う
        activators.Add(new ManipulatorActivationFilter() { button = MouseButton.LeftMouse });

        m_Active = false;

        // メニュー選択マニピュレータは作っておくが、この時点ではターゲットが確定していないので、
        // RegisterCallbacksOnTarget()で追加する
        m_AddEdgeMenu = new ContextualMenuManipulator(OnContextualMenuPopulate);
    }

    private void OnContextualMenuPopulate(ContextualMenuPopulateEvent evt)
    {
        if (evt.target is NodeElement node)
        {
            // エッジ追加中に右クリックを押されたときのために、ノードの上かどうかを見る
            if (!node.ContainsPoint(node.WorldToLocal(evt.mousePosition)))
            {
                // イベントを即座に中断
                evt.StopImmediatePropagation();
                return;
            }

            evt.menu.AppendAction(
                "Add Edge",
                (DropdownMenuAction menuItem) =>
                {
                    m_Active = true;

                    Debug.Log("Add Edge");  // ここでエッジ追加モード開始処理を書く

                    target.CaptureMouse();
                },
                DropdownMenuAction.AlwaysEnabled);
        }
    }

    protected override void RegisterCallbacksOnTarget()
    {
        target.RegisterCallback<MouseDownEvent>(OnMouseDown);
        target.RegisterCallback<MouseUpEvent>(OnMouseUp);
        target.RegisterCallback<MouseMoveEvent>(OnMouseMove);
        target.RegisterCallback<MouseCaptureOutEvent>(OnCaptureOut);

        target.AddManipulator(m_AddEdgeMenu);
    }

    protected override void UnregisterCallbacksFromTarget()
    {
        target.RemoveManipulator(m_AddEdgeMenu);

        target.UnregisterCallback<MouseDownEvent>(OnMouseDown);
        target.UnregisterCallback<MouseUpEvent>(OnMouseUp);
        target.UnregisterCallback<MouseMoveEvent>(OnMouseMove);
        target.UnregisterCallback<MouseCaptureOutEvent>(OnCaptureOut);
    }

    protected void OnMouseDown(MouseDownEvent evt)
    {
        if (!CanStartManipulation(evt))
            return;

        // マウス押下では他のイベントが起きてほしくないのでPropagationを中断する
        if (m_Active)
            evt.StopImmediatePropagation();
    }

    protected void OnMouseUp(MouseUpEvent evt)
    {
        if (!CanStopManipulation(evt))
            return;

        if (!m_Active)
            return;

        Debug.Log("Try Connect");  // ここでマウスの下にあるノードにエッジを接続しようとする

        m_Active = false;
        target.ReleaseMouse();
    }

    protected void OnMouseMove(MouseMoveEvent evt)
    {
        if (!m_Active)
            return;

        Debug.Log("move");  // ここで、追加中のエッジの再描画を行う
    }

    private void OnCaptureOut(MouseCaptureOutEvent evt)
    {
        if (!m_Active)
            return;

        m_Active = false;
        target.ReleaseMouse();
    }
}

この時点では、以下のような挙動になります。
NodeEditor-28.gif

5.2.2 エッジ追加のためにエッジ・グラフクラスを整備

次に、EdgeElementクラスに追加中のEdgeを作成するための準備をします。
これまではEdgeには元ノードと先ノードを渡して作成していましたが、追加中には先ノード確定していないので、元ノードと矢印の位置からエッジを描画できるようにします。

// EdgeElementクラス

    Vector2 m_ToPosition;
    public Vector2 ToPosition
    {
        get { return m_ToPosition; }
        set
        {
            m_ToPosition = this.WorldToLocal(value);  // ワールド座標で渡されることを想定
            MarkDirtyRepaint();  // 再描画をリクエスト
        }  
    }

    // 新しいコンストラクタ
    public EdgeElement(NodeElement fromNode, Vector2 toPosition)
    {
        From = fromNode;
        ToPosition = toPosition;
    }

    // つなげるときに呼ぶ
    public void ConnectTo(NodeElement node)
    {
        To = node;
        MarkDirtyRepaint();  // 再描画をリクエスト
    }

    public void DrawEdge()
    {
        if (From != null && To != null)
        {
            DrawEdge(
                startPos: From.GetStartPosition(),
                startNorm: From.GetStartNorm(),
                endPos: To.GetEndPosition(),
                endNorm: To.GetEndNorm());
        }
        else {
            // 追加中の描画用
            if (From != null)
            {
                DrawEdge(
                    startPos: From.GetStartPosition(),
                    startNorm: From.GetStartNorm(),
                    endPos: ToPosition,
                    endNorm: Vector2.zero);
            }
        }
    }

これにより、追加中のEdgeElementをGraphEditorElementのEdgesに追加すれば自動的に描画されるようになったはずです。
ということで、GraphEditorElementにエッジ追加リクエストを投げられるようにします。
ついでに、ノード追加を中断したときのためにエッジ削除関数も作っておきます。

// GraphEditorElementクラス
    public EdgeElement CreateEdgeElement(NodeElement fromNode, Vector2 toPosition)
    {
        var edgeElement = new EdgeElement(fromNode, toPosition);
        Add(edgeElement);
        m_Edges.Add(edgeElement);

        return edgeElement;
    }

    public void RemoveEdgeElement(EdgeElement edge)
    {
        Remove(edge);
        m_Edges.Remove(edge);
    }

5.2.3 エッジ追加の挙動を実装

上で作った関数をEdgeConnectorクラスから呼びます。

// EdgeConnectorクラス

    GraphEditorElement m_Graph;
    EdgeElement m_ConnectingEdge;

    private void OnContextualMenuPopulate(ContextualMenuPopulateEvent evt)
    {
        if (evt.target is NodeElement node)
        {
            evt.menu.AppendAction(
                "Add Edge",
                (DropdownMenuAction menuItem) =>
                {
                    m_Active = true;

                    // 親をたどってGraphEditorElementを取得する
                    m_Graph = target.GetFirstAncestorOfType<GraphEditorElement>();
                    m_ConnectingEdge = m_Graph.CreateEdgeElement(node, menuItem.eventInfo.mousePosition);

                    target.CaptureMouse();
                },
                DropdownMenuAction.AlwaysEnabled);
        }
    }

    /* ... 省略 */

    protected void OnMouseUp(MouseUpEvent evt)
    {
        if (!CanStopManipulation(evt))
            return;

        if (!m_Active)
            return;

        var node = m_Graph.GetDesignatedNode(evt.originalMousePosition);

        if (node == null  // 背景をクリックしたとき
            || node == target  // 自分自身をクリックしたとき
            || m_Graph.ContainsEdge(m_ConnectingEdge.From, node))  // すでにつながっているノード同士をつなげようとしたとき
        {
            m_Graph.RemoveEdgeElement(m_ConnectingEdge);
        }
        else
        {
            m_ConnectingEdge.ConnectTo(node);
        }
        m_Active = false;
        m_ConnectingEdge = null;  // 接続終了
        target.ReleaseMouse();
    }

    protected void OnMouseMove(MouseMoveEvent evt)
    {
        if (!m_Active)
        {
            return;
        }

        m_ConnectingEdge.ToPosition = evt.originalMousePosition;  // 位置更新
    }

    private void OnCaptureOut(MouseCaptureOutEvent evt)
    {
        if (!m_Active)
            return;

        // 中断時の処理
        m_Graph.RemoveEdgeElement(m_ConnectingEdge);
        m_ConnectingEdge = null;

        m_Active = false;
        target.ReleaseMouse();
    }
// GraphEditorElementクラス

    // マウスの位置にあるノードを返す
    public NodeElement GetDesignatedNode(Vector2 position)
    {
        foreach(NodeElement node in m_Nodes)
        {
            if (node.ContainsPoint(node.WorldToLocal(position)))
                return node;
        }

        return null;
    }

    // すでに同じエッジがあるかどうか
    public bool ContainsEdge(NodeElement from, NodeElement to)
    {
        return m_Edges.Exists(edge =>
        {
            return edge.From == from && edge.To == to;
        });
    }

ここまでで、このような挙動になります。
NodeEditor-29.gif

5.2.4 追加したエッジをシリアライズする

今のままではEdgeElementを追加しただけなので、つないだエッジはデータとして残っていません。
ノードのときと同じようにシリアライズする必要があります。

// EdgeConnectorクラス

    protected void OnMouseUp(MouseUpEvent evt)
    {
        /* ... 省略 */
        var node = m_Graph.GetDesignatedNode(evt.originalMousePosition);

        if (node == null
            || node == target
            || m_Graph.ContainsEdge(m_ConnectingEdge.From, node))
        {
            m_Graph.RemoveEdgeElement(m_ConnectingEdge);
        }
        else
        {
            m_ConnectingEdge.ConnectTo(node);
            m_Graph.SerializeEdge(m_ConnectingEdge);  // つないだ時にシリアライズする
        }

        /* ... 省略 */
    }
// GraphEditorElementクラス

    public void SerializeEdge(EdgeElement edge)
    {
        var serializableEdge = new SerializableEdge()
        {
            toId = m_Nodes.IndexOf(edge.To)  // ここで先ノードのIDを数える
        };

        edge.From.serializableNode.edges.Add(serializableEdge);  // 実際に追加
        edge.serializableEdge = serializableEdge;  // EdgeElementに登録しておく
    }

そして、実際に開きなおしてみると、
NodeEditor-30.gif

保存されています。

5.3 エッジを削除できるようにする

エッジの追加ができるようになったので、やはり削除もできなければいけません。

ノードを削除するときと同様に、エッジの削除もコンテキストメニューから行いたいと思います。
しかし、このとき問題があります。
ノードは大きさのあるVisualElementだったため、ContextualManipulatorを付けるとそのままクリックで選択ができました。
しかし、エッジのVisualElementは大きさがありません。

5.3.1 エッジを選択できるようにする

VisualElementをクリックして選択するときの挙動について、ドキュメントに記載がありました。
Event targetのPicking mode and custom shapesの項です。

You can override the VisualElement.ContainsPoint() method to perform custom intersection logic.

このVisualElement.ContainsPoint()は、マウス座標を与えると、その座標と自分が衝突しているかを判定する関数です。
それをオーバーライドして、独自の衝突判定を埋め込むことで、VisualElementRect以外の形に対応させることができます。

実際にベジェ曲線と点との距離を計算するのは面倒なので、近似した線分との距離を計算して、指定距離以内だったら選択したことにしようと思います。

さて、衝突を判定の実装に当たって、ログを出すものが必要です
というわけで最初に、エッジに削除用のコンテキストメニューを作ります。

// EdgeElementクラス

    // 削除用マニピュレータの追加
    public EdgeElement()
    {
        this.AddManipulator(new ContextualMenuManipulator(evt =>
        {
            if (evt.target is EdgeElement)
            {
                evt.menu.AppendAction(
                "Remove Edge",
                (DropdownMenuAction menuItem) =>
                {
                    Debug.Log("Remove Edge");
                },
                DropdownMenuAction.AlwaysEnabled);
            }
        }));
    }

    public EdgeElement(NodeElement fromNode, Vector2 toPosition):this()  // 上のコンストラクタを呼ぶ
    {
        From = fromNode;
        ToPosition = toPosition;
    }

    public EdgeElement(SerializableEdge edge, NodeElement fromNode, NodeElement toNode):this()  // 上のコンストラクタを呼ぶ
    {
        serializableEdge = edge;
        From = fromNode;
        To = toNode;
    }

まず、接続元と接続先が収まるバウンディングボックスと衝突しているかどうかを判定してみます。

// EdgeElementクラス
    public override bool ContainsPoint(Vector2 localPoint)
    {
        if (From == null || To == null)
            return false;

        Vector2 start = From.GetStartPosition();
        Vector2 end = To.GetEndPosition();

        // ノードを覆うRectを作成
        Vector2 rectPos = new Vector2(Mathf.Min(start.x, end.x), Mathf.Min(start.y, end.y));
        Vector2 rectSize = new Vector2(Mathf.Abs(start.x - end.x), Mathf.Abs(start.y - end.y));
        Rect bound = new Rect(rectPos, rectSize);

        if (!bound.Contains(localPoint))
        {
            return false;
        }

        return true;
    }

結果はこうなりました。
NodeEditor-31.gif
確かに、エッジのバウンディングボックスとの当たりを判定できていそうです。

次に、近似線分との距離を計算してみます。
先にバウンディングボックスに入っていないものを弾いているので、端点が一番近い場合などを考えなくて済みます。
つまり、線分ではなく直線と点の距離を考えればよいということです。

// EdgeElementクラス
    readonly float INTERCEPT_WIDHT = 15f;  // エッジと当たる距離

    public override bool ContainsPoint(Vector2 localPoint)
    {
        /* ... 省略 */

        if (!bound.Contains(localPoint))
        {
            return false;
        }

        // 近似線分ab
        Vector2 a = From.GetStartPosition() + 12f * From.GetStartNorm();
        Vector2 b = To.GetEndPosition() + 12f * To.GetEndNorm();

        // 一致した場合はaからの距離
        if (a == b)
        {
            return Vector2.Distance(localPoint, a) < INTERCEPT_WIDHT;
        }

        // 直線abとlocalPointの距離
        float distance = Mathf.Abs(
            (b.y - a.y) * localPoint.x
            - (b.x - a.x) * localPoint.y
            + b.x * a.y - b.y * a.x
            ) / Vector2.Distance(a, b);

        return distance < INTERCEPT_WIDHT;
    }

結果はこうなりました。
NodeEditor-32.gif
...ちょっとずれている気もしますが、まあ、許容範囲でしょう。

5.3.2 エッジデータを削除する

GraphAssetからエッジのデータを消します。
EdgeElementには元ノードの情報が既にありますので、そこから自分のデータが入っているSerializableNodeを取得することができます。
これを消せばよいですね。

// EdgeElementクラス

    public EdgeElement()
    {
        this.AddManipulator(new ContextualMenuManipulator(evt =>
        {
            if (evt.target is EdgeElement)
            {
                evt.menu.AppendAction(
                "Remove Edge",
                (DropdownMenuAction menuItem) =>
                {
                    // 親をたどってGraphEditorElementに削除リクエストを送る
                    var graph = GetFirstAncestorOfType<GraphEditorElement>();
                    graph.RemoveEdgeElement(this);
                },
                DropdownMenuAction.AlwaysEnabled);
            }
        }));
    }
// GraphEditorElementクラス

    public void RemoveEdgeElement(EdgeElement edge)
    {
        // 消すエッジにSerializableEdgeがあれば、それを消す
        if(edge.serializableEdge != null)
        {
            edge.From.serializableNode.edges.Remove(edge.serializableEdge);
        }

        Remove(edge);
        m_Edges.Remove(edge);
    }

NodeEditor-33.gif

無事、削除できています。

6. ノードを削除する

最後に、ノードを削除できるようにしたいと思います。
ノードを削除したときには、
- NodeElementを削除する
- 対応するSerializableNodeを削除する
- そのノードとつながるEdgeElementを削除する
- 対応するSerializableEdgeを削除する
- 他ノードのIDが変わるので、それに応じてSerializableEdgeのIDを振りなおす

のすべてを行う必要があります。

// NodeElementクラス

    public NodeElement (SerializableNode node)
    {
        /* ... 省略 */

        this.AddManipulator(new NodeDragger());
        this.AddManipulator(new EdgeConnector());
        this.AddManipulator(new ContextualMenuManipulator(OnContextualMenuPopulate));  // 削除用マニピュレータ
    }

    private void OnContextualMenuPopulate(ContextualMenuPopulateEvent evt)
    {
        if (evt.target is NodeElement)
        {
            evt.menu.AppendAction(
                "Remove Node",
                RemoveNodeMenuAction,
                DropdownMenuAction.AlwaysEnabled);
        }
    }

    private void RemoveNodeMenuAction(DropdownMenuAction menuAction)
    {
        // 親をたどって削除をリクエスト
        var graph = GetFirstAncestorOfType<GraphEditorElement>();
        graph.RemoveNodeElement(this);
    }
// GraphEditorElementクラス

    public void RemoveNodeElement(NodeElement node)
    {
        m_GraphAsset.nodes.Remove(node.serializableNode);  // アセットから削除

        int id = m_Nodes.IndexOf(node);

        // エッジの削除とID変更
        // m_Edgesに変更が伴うため、降順で行う
        for (int i = m_Edges.Count - 1; i >= 0; i--)
        {
            var edgeElement = m_Edges[i];
            var edge = edgeElement.serializableEdge;

            // 削除されるノードにつながるエッジを削除
            if (edgeElement.To == node || edgeElement.From == node)
            {
                RemoveEdgeElement(edgeElement);
                continue;
            }

            // 変更が生じるIDを持つエッジに対して、IDに修正を加える
            if (edge.toId > id)
                edge.toId--;
        }

        Remove(node);  // VisualElementの子としてのノードを削除
        m_Nodes.Remove(node);  // 順序を保持するためのリストから削除
    }

これでノードを削除できるようになりました。
NodeEditor-35.gif

ウィンドウを開きなおしてもちゃんと構造が保存されています。

結果

NodeEditor-36.gif
ゼロからノードベースエディタを作りました。
現状ではグラフ構造を保存するアセットを作れるだけですが、このノード部分に何か情報を載せると立派なヴィジュアルツールが出来上がります。

おわりに

UIElementの使い方を勉強したいと思ったので、ノードベースエディタを作ってみました。
ドキュメントとリファレンスを読み込むことになり、GraphViewの実装もかなり追ったので勉強になってよかったです。
実をいうと、このGraphEditorを使ってBehaviorTreeを作るところまでやりたかったのですが、エディタを作るだけで相当の時間がかかってしまったので、この記事はここまでにしておきます。

また、ゼロから作るを銘打って、実装する手順通りに事細かく書いてしまったので、やたら長くなってしまいました。
とはいえ、エディタを作るにあたって得た知見をふんだんに盛り込めたのではないかと思います。

ここはもっとこうした方がよい、のような意見があればコメントで教えていただけるとありがたいです。
ご拝読ありがとうございました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Unity】ゼロから作るノードベースエディター【UIElements】

概要

UnityのUIElementsによってこのようなノードベースエディタを作ります。
NodeEditor-36.gif

タイトルの「ゼロから」は、「UIElements」を知らないところから、という意味です。
そのため、UIElemtnsに関する前提知識は必要ありません。

Unityのバージョンは2019.1.3f1です。
プロジェクトはGitHubに挙げておきます。
https://github.com/saragai/GraphEditor

追記:バージョン2019.2.16f1でこのエディタを使用したところ、エッジの選択ができなくなっていました。

背景

Unity2019からUIElementsというUIツールが入りました。
現在はエディタ拡張にしか使えませんが、将来的にはゲーム内部のUIにも使えるようになるそうです。

最近の機能でいえば、ShaderGraphのようなGUIツールもUIElementで作られています。

shader_graph_sample.jpg
[画像は引用:https://unity.com/ja/shader-graph]

これはGraphViewというノードベースエディタによって作られていて、GraphViewを使えばShaderGraphのようなヴィジュアルツールを作成できます。
[参照:GraphView完全理解した(2019年末版)]

さて、本記事の目標はGraphViewのようなのツールを作ることです。

いやGraphView使えばいいじゃん、と思った方は鋭いです。実用に耐えるものを作るなら、使った方がよいと思います。
さらに、本記事はUnityが公開しているGraphViewの実装を大いに参考にしているので、GraphViewを使うならすべて無視できる内容です。

とはいえ、内部でどんなことをすると上記画像のようなエディタ拡張ができるのか、気になる方も多いのではと思います。
その理解の一助となればと思います。

注)この記事は手順を細かく解説したり、あえて不具合のある例を付けたりしているので、冗長な部分が多々あります。

実装

公式ドキュメントを見ながら実装していきます。
https://docs.unity3d.com/2019.1/Documentation/Manual/UIElements.html
https://docs.unity3d.com/2019.3/Documentation/ScriptReference/UIElements.VisualElement.html

0. 挨拶

エディタ拡張用のスクリプトは必ず Assets/[どこでもいい]/Editor というディレクトリの下に置きます。ビルド時にビルド対象から外すためです。
というわけで、Assets/Scripts/Editor にC#ファイルを作って GraphEditor と名付けます。

Graphとは、頂点と辺からなるデータ構造を示す用語で、ビヘイビアツリーや有限オートマトンもGraphの一種です。ビヘイビアツリーの場合はアクションやデコレータが頂点、それぞれがどのようにつながっているかが辺に対応します。ひとまずの目標は、このグラフを可視化できるようにすることです。

とはいえ、まだUIElementsと初めましてなので、まずは挨拶から始めます。

// GraphEditor.cs
using UnityEngine;
using UnityEditor;
using UnityEngine.UIElements;

public class GraphEditor : EditorWindow
{
    [MenuItem("Window/GraphEditor")]  // Unityのメニュー/Window/GraphEditorから呼び出せるように
    public static void ShowWindow()
    {
        GraphEditor graphEditor = CreateInstance<GraphEditor>();  // ウィンドウを作成。
        graphEditor.Show();  // ウィンドウを表示
        graphEditor.titleContent = new GUIContent("Graph Editor");  // Windowの名前の設定
    }

    public void OnEnable()
    {
        VisualElement root = this.rootVisualElement;
        root.Add(new Label("Hello, World!"));
    }
}

Unity公式ブログのはじめの例を参考にしました。

ウィンドウが作成されたときに呼ばれるOnEnable()で、はじめてUIElementsと対面します。
見たところ、UIElementsはウィンドウの大元にあるrootVisualElementにどんどん要素を追加していく方式なんですね。
rootVisualElementはVisualElementクラスで、LabelもVisualElementクラスを継承しています。

さあ、メニューからWindow/GraphEditorを選択すると以下のようなウィンドウが表示されます。

NodeEditor-0.PNG

こんにちは!
ひとまず、挨拶は終わりました。

1. ノードを表示する

Inspectorのように、行儀よく上から下へ情報を追加していくUIであれば、あとは色を変えてみたり、ボタンを追加してみたり、水平に並べてみたりすればいいのですが、ノードベースエディタを作ろうとしているのでそれだけでは不十分です。
四角形を自由自在に動かせなければいけません。

ドキュメントには、UIElementの構造の説明として、このような図がありました。
visualtree-hierarchy.png
[画像は引用:https://docs.unity3d.com/ja/2019.3/Manual/UIE-VisualTree.html]
まずは、このred containerのような四角形を出したいですね。

というわけでいろいろ試してみます。

1.1 表示場所を指定する

ドキュメントによるとVisualElementはそれぞれlayoutなるメンバを持っていて、layout.positionやlayout.transformによって親に対する位置が決まるようです。実際に試してみましょう。

// GraphEditor.cs
using UnityEngine;
using UnityEditor;
using UnityEngine.UIElements;

public class GraphEditor : EditorWindow
{
    [MenuItem("Window/GraphEditor")]
    public static void ShowWindow()
    {
        GraphEditor graphEditor = CreateInstance<GraphEditor>();
        graphEditor.Show();
        graphEditor.titleContent = new GUIContent("Graph Editor");
    }

    public void OnEnable()
    {
        VisualElement root = this.rootVisualElement;
        root.Add(new Label("Hello, World!"));

        root.Add(new NodeElement("One", Color.red, new Vector2(100, 50)));
    }
}
// NodeElement.cs
using UnityEngine;
using UnityEngine.UIElements;

public class NodeElement : VisualElement
{
    public NodeElement (Node node,string name, Color color, Vector2 pos)
    {
        style.backgroundColor = new StyleColor(color);
        transform.position = pos;

        Add(new Label(name));
    }
}

先ほどと違うのは、NodeElementクラスですね。
NodeはGraphの頂点のことで、有限オートマトンでいうと状態に対応します。

このNodeElementのコンストラクタに色と位置を渡して、内部でstyle.backgroundClorとtransform.positionを設定します。
それをrootにAddして、どのように表示されるかを見てみます。

以下、結果です。
NodeEditor-1.PNG
お!
表示位置が唐突な場所になっていますね。
右にずっと伸びていますが、まだ幅を指定していないからでしょう。

もう一つ追加してみましょう。

// GraphEditorクラス
    public void OnEnable()
    {
        VisualElement root = this.rootVisualElement;
        root.Add(new Label("Hello, World!"));

        root.Add(new NodeElement("One", Color.red, new Vector2(100, 50)));
        root.Add(new NodeElement("Two", Color.yellow, new Vector2(200, 50)));
    }

NodeEditor-2.PNG
……あれ?
同じY座標を指定したのに二つのノードは重なっていません。

本当は、
NodeEditor-3.PNG
このようになって欲しかったのです。
ちなみに、この時点で上の図のようにするには以下を書きました。

// GraphEditorクラス
    public void OnEnable()
    {
        VisualElement root = this.rootVisualElement;
        root.Add(new Label("Hello, World!"));

        root.Add(new NodeElement("One", Color.red, new Vector2(100, 50)));
        root.Add(new NodeElement("Two", Color.yellow, new Vector2(200, 32)));  // y座標を変更
    }

どうやら18ピクセルだけ勝手に下にずらされていたようです。困ります。いい感じに自動レイアウトしてくれるという親切心だとは思うのですが、私が今作りたいのはヴィジュアルツールなので、上下左右に自在に動かしたいのです。

探すと別のドキュメントにありました。

Set the position property to absolute to place an element relative to its parent position rectangle. In this case, it does not affect the layout of its siblings or parent.

positionプロパティをabsoluteにすれば兄弟(=siblings)や親の影響を受けないよとあります。
positionプロパティってなんだと思いましたが、VisualStudioの予測変換機能を駆使して見つけました。

NodeElementのコンストラクタを以下のように書き換えます

// NodeElementクラス
    public NodeElement (Node node,string name, Color color, Vector2 pos)
    {
        style.backgroundColor = new StyleColor(color);
        style.position = Position.Absolute;  // 追加。これがposition propertyらしい
        transform.position = pos;

        Add(new Label(name));
    }

すると、

// GraphEditorクラス
    public void OnEnable()
    {
        VisualElement root = this.rootVisualElement;
        root.Add(new Label("Hello, World!"));

        root.Add(new NodeElement("One", Color.red, new Vector2(100, 50)));
        root.Add(new NodeElement("Two", Color.yellow, new Vector2(200, 50)));
    }

は以下のようになります。
NodeEditor-4.PNG

ちゃんと同じ高さになりました!
なぜか横方向の帯も消えています。

これで位置を自由に指定できるようになりました。

1.2 大きさを指定する

次は四角形の大きさを指定します。位置指定はラベルで実験したので勝手に大きさを合わせてくれていましたが、自由に幅や高さを指定したいです。
このような見た目に関する部分はだいたいVisualElement.styleにまとまっているようで、以下のように指定します。

// NodeElementクラス
    public NodeElement (Node node,string name, Color color, Vector2 pos)
    {
        style.backgroundColor = new StyleColor(color);
        style.position = Position.Absolute;
        style.height = 50,
        style.width = 100,
        transform.position = pos;

        Add(new Label(name));
    }

すると以下のようになります。
NodeEditor-5.PNG

初めに言った、red containerのようになったと思います。
visualtree-hierarchy.png

2. ノードを動かす

次は表示した四角形をインタラクティブに移動させます。
ヴィジュアルツールでは、見やすいように位置を動かすことは大事です。

挙動としては、
1. 四角形を左クリックして選択
2. そのままドラッグすると一緒に動く
3. ドロップで現在の位置に固定
というのを想定しています。

2.1 まずは試してみる

これらはどれもマウスの挙動に対しての反応なので、マウスイベントに対するコールバックとして実装します。
探すと公式ドキュメントにThe Event Systemという項がありました。
いろいろと重要そうなことが書いてある気がしますが、今はとりあえずイベントを取りたいのでその中のResponding to Eventsを見てみます。
どうやら、VisualElement.RegisterCallback()によってコールバックを登録できるみたいですね。

マウスに関するイベントはそれぞれ、
1. MouseDownEvent
2. MouseMoveEvent
3. MouseUpEvent
でとることができそうです。

NodeElementクラスを以下のように書き換えます。

// NodeElementクラス
    public NodeElement (string name, Color color, Vector2 pos)
    {
        style.backgroundColor = new StyleColor(color);
        style.position = Position.Absolute;
        style.height = 50;
        style.width = 100;

        transform.position = pos;

        Add(new Label(name));

        bool focus = false;

        RegisterCallback((MouseDownEvent evt) =>
        {
            if (evt.button == 0)  // 左クリック
            {
                focus = true;  // 選択
            }
        });

        RegisterCallback((MouseUpEvent evt) =>
        {
            focus = false;  // 選択を解除
        });

        RegisterCallback((MouseMoveEvent evt) =>
        {
            if (focus)
            {
                transform.position += (Vector3)evt.mouseDelta;  // マウスが動いた分だけノードを動かす
            }
        });
    }

すると、以下のような挙動になります。
NodeEditor-7.gif
動きましたが、少し使いにくそうな動きです。
まず、赤いノードが黄色のノードの下にあるせいで、赤を動かしている途中にカーソルが黄色の上に来ると、赤が動かなくなってしまいます。
さらにそのあと右クリックをやめても選択が解除されておらず、赤が勝手に動いてしまいます。これは、MouseUpEventが赤いノードに対して呼ばれていないことが問題のようです。

改善策は、
1. 選択したノードは最前面に来てほしい
2. カーソルがノードの外に出たときにも、マウスイベントは呼ばれてほしい
の二つです。

2.2 VisualElementの表示順を変える

ドキュメントのThe Visual Treeの項目に、Drawing orderの項があります。

Drawing order
The elements in the visual tree are drawn in the following order:
- parents are drawn before their children
- children are drawn according to their sibling list

The only way to change their drawing order is to reorder VisualElementobjects in their parents.

描画順を変えるには親オブジェクトが持つVisualElementを並び替えないといけないようです。
それ以上の情報がないのでVisualElementのスクリプトリファレンスを見てみます。その中のメソッドでそれらしいものがないかを探すと……ありました。

BringToFront()というメソッドで、親の子供リストの中の最後尾へ自分を持っていくものです。
これをMouseDownEventのコールバックに追加します。

// NodeElementクラス
        RegisterCallback((MouseDownEvent evt) =>
        {
            if (evt.button == 0)
            {
                focus = true;
                BringToFront();  // 自分を最前面に持ってくる
            }
        });

実行結果は以下です。
NodeEditor-8.gif
クリックしたものが最前面へきているのがわかります。
しかし、動画後半のように、マウスを勢いよく動かすとノードがついてこられないことがわかります。

2.3 マウスイベントをキャプチャする

マウスを勢いよく動かしたとき、カーソルがノードの外に出るのでMouseLeaveEventが呼ばれるはずです。その時にPositionを更新してドラッグ中は常にノードがカーソルの下にあるようにすればよい、と初めは思っていました。
ですが、それだと勢いよく動かした直後にマウスクリックを解除した場合に、MouseUpEventが選択中のノードに対して呼ばれないようなのです。
イベントの呼ばれる順序にかかわる部分で、丁寧に対応してもバグの温床になりそうです。

いい方法はないかなとドキュメントを読んでいると、よさそうなものを見つけました。
Dispatching Eventsの中のCapture the mouseという項です。

VisualElementはCaptureMouse()を呼ぶことによって、カーソルが自身の上にないときでもマウスイベントを自分のみに送ってくれるようになるということで、まさにマウスをキャプチャしています。
キャプチャすると、マウスが自分の上にあるかどうかを気にしなくてよくなるので、安心して使えそうです。

ということで、MouseDown時にキャプチャし、MouseUp時に解放するように書き換えてみます。

// NodeElementクラス
        RegisterCallback((MouseDownEvent evt) =>
        {
            if (evt.button == 0)
            {
                focus = true;
                BringToFront();
                CaptureMouse();  // マウスイベントをキャプチャ
            }
        });

        RegisterCallback((MouseUpEvent evt) =>
        {
            ReleaseMouse();  // キャプチャを解放
            focus = false;
        });

        RegisterCallback((MouseCaptureOutEvent evt) =>
        {
            m_Focus = false;  // キャプチャが外れたときはドラッグを終了する
        }

MouseCaptureOutEventは他のVisualElementなどによってキャプチャを奪われたときに呼ばれる関数です。

実行結果は以下になります。
NodeEditor-9.gif
無事に意図した動きになりました。

2.4 ノードを動かすコードをManipulatorによって分離する

この後もノードには様々な機能が追加される予定ですので、コードが煩雑にならないためにも、ノードを動かす部分を分離してしまいたいです。
どうしようか悩んでいましたが、UIElementsにはManipulatorという仕組みがあることを見つけました。
Manipulatorを使うことで、「ノードを動かす」のような操作を追加するコードをきれいに分離して書くことができます。

NodeDraggerというクラスを作成します。

// NodeDragger.cs
using UnityEngine;
using UnityEngine.UIElements;

public class NodeDragger : MouseManipulator
{
    private bool m_Focus;

    public NodeDragger()
    {
        // 左クリックで有効化する
        activators.Add(new ManipulatorActivationFilter { button = MouseButton.LeftMouse });
    }

    /// Manipulatorにターゲットがセットされたときに呼ばれる
    protected override void RegisterCallbacksOnTarget()
    {
        m_Focus = false;

        target.RegisterCallback<MouseDownEvent>(OnMouseDown);
        target.RegisterCallback<MouseUpEvent>(OnMouseUp);
        target.RegisterCallback<MouseMoveEvent>(OnMouseMove);
        target.RegisterCallback<MouseCaptureOutEvent>(OnMouseCaptureOut);
    }

    /// Manipulatorのターゲットが変わる直前に呼ばれる
    protected override void UnregisterCallbacksFromTarget()
    {
        target.UnregisterCallback<MouseDownEvent>(OnMouseDown);
        target.UnregisterCallback<MouseUpEvent>(OnMouseUp);
        target.UnregisterCallback<MouseMoveEvent>(OnMouseMove);
        target.UnregisterCallback<MouseCaptureOutEvent>(OnMouseCaptureOut);
    }

    protected void OnMouseDown(MouseDownEvent evt)
    {
        // 設定した有効化条件をみたすか (= 左クリックか)
        if (CanStartManipulation(evt))
        {
            m_Focus = true;
            target.BringToFront();
            target.CaptureMouse();
        }
    }

    protected void OnMouseUp(MouseUpEvent evt)
    {
        // CanStartManipulation()で条件を満たしたActivationのボタン条件と、
        // このイベントを発火させているボタンが同じか
        // (= 左クリックを離したときか)
        if (CanStopManipulation(evt))
        {
            target.ReleaseMouse();
            m_Focus = false;
        }
    }

    protected void OnMouseCaptureOut(MouseCaptureOutEvent evt)
    {
        m_Focus = false;
    }

    protected void OnMouseMove(MouseMoveEvent evt)
    {
        if (m_Focus)
        {
            target.transform.position += (Vector3)evt.mouseDelta;
        }
    }
}

RegisterCallBacksOnTarget()UnregisterCallbacksFromTarget()Manipulatorクラスの関数で、イベントのコールバックの登録・解除を担っています。
activatorsCanStartManipulation()CanStopManipulation()Manipulatorクラスを継承するMouseManipulatorクラスの関数で、マウスのボタンの管理がしやすくなっています。
細かいことはコード中のコメントに記載しました。

このManipulatorを使用するには、対象のVisualElementを設定しなければいけません。

// NodeElementクラス
    public NodeElement (string name, Color color, Vector2 pos)
    {
        style.backgroundColor = new StyleColor(color);
        style.position = Position.Absolute;
        style.height = 50;
        style.width = 100;

        transform.position = pos;

        Add(new Label(name));

        AddManipulator(new NodeDragger());  // 操作の追加が一行で済む
    }

AddManipulatorという関数によって対象のVisualElementを設定しています。
実はこのコードは以下のようにもかけます。

        new NodeDragger(){target = container};

内部の実装を見ると、AddManipulatorではIManipulator.targetプロパティに自身をセットしているだけでした。
そしてsetter内で、セットする前に既存のtargetがあればUnregisterCallbacksFromTarget()を呼び、そのあと新規のターゲットをセットしてからRegisterCallbacksOnTarget()を呼びます。

[参照:https://github.com/Unity-Technologies/UnityCsReference/blob/2019.1/Modules/UIElements/Manipulators.cs]
[参照:https://github.com/Unity-Technologies/UnityCsReference/blob/2019.1/Modules/UIElements/MouseManipulator.cs]

3. ノードを追加する

これまではテストのためにノードの数は2つで固定されていましたが、自在に追加できなければグラフエディタとはとても呼べません。
想定している挙動は、

  1. 右クリックでメニューが出てくる
  2. 「Add Node」を選択する
  3. 右クリックした場所にノードが生成される

です。

......実は前章の最後あたりで、このままのペースで書いていると時間がいくらあっても足りないと思い、先に実装してから記事を書くことにしました。
ですので、これからの説明は少しスムーズに(悪く言えば飛躍気味に)なるかもしれません。ご了承ください。

3.1 メニューを表示する

2.4節で見たようなManipulatorと同じように、この挙動も操作としてまとめることができそうです。
というか、こんなみんなが欲しそうな機能が公式に用意されていないはずがありません。
案の定、存在しました。例によって公式ドキュメントです。

コードを載せます。

// GraphEditorクラス
    public void OnEnable()
    {
        VisualElement root = this.rootVisualElement;

        root.Add(new NodeElement("One", Color.red, new Vector2(100, 50)));
        root.Add(new NodeElement("Two", Color.yellow, new Vector2(200, 50)));

        root.AddManipulator(new ContextualMenuManipulator(OnContextMenuPopulate));
    }

    void OnContextMenuPopulate(ContextualMenuPopulateEvent evt)
    {
        // 項目を追加
        evt.menu.AppendAction(
            "Add Node",  // 項目名
            AddEdgeMenuAction,  // 選択時の挙動
            DropdownMenuAction.AlwaysEnabled  // 選択可能かどうか
            );
    }

    void AddEdgeMenuAction(DropdownMenuAction menuAction)
    {
        Debug.Log("Add Node");
    }
}

さあ、どうでしょうか。
NodeEditor-10.gif
上手く動いていません。

期待していた挙動は、「背景を左クリックしたときはメニューが開いて、ノードを左クリックしたときは何も起こらない」です。でも、これでは逆ですね。
イベント発行についてのドキュメントを見てみます。
NodeEditor-11.PNG
[図は引用:https://docs.unity3d.com/2019.1/Documentation/Manual/UIE-Events-Dispatching.html]

イベントは root -> target -> root と呼ばれるみたいですね。イベント受け取りについてのドキュメントには、デフォルトではTargetフェイズとBubbleUpフェイズにイベントが登録されるともあります。

とにかく、思い当たるのは、ルートに登録したコールバックがノード経由で伝わっているということです。
いろいろ試してみてわかったのは、ルートではデフォルトでpickingModePickingMode.Ignoreに設定されているということでした。

リファレンスによると、マウスのクリック時にターゲットを決める際、その位置にある一番上のVisualElementを取ってきているらしいのですが、このpickingModePickingMode.Ignoreに設定されていた場合は候補から外す、という挙動になるようです。

実際、このようにすると動きます。

// GraphEditorクラス

    public void OnEnable()
    {
        VisualElement root = this.rootVisualElement;

        root.Add(new NodeElement("One", Color.red, new Vector2(100, 50)));
        root.Add(new NodeElement("Two", Color.yellow, new Vector2(200, 50)));

        root.pickingMode = PickingMode.Position;  // ピッキングモード変更

        root.AddManipulator(new ContextualMenuManipulator(OnContextMenuPopulate));
    }

NodeEditor-12.gif
でも、ルートをむやみに変更するのはよくないですね。
そこで、ルートの一つ下に一枚挟むことにします。今の作りでは、EditorWindowとVisualElementが不可分になってしまっていましたが、それを分離可能にするという意味合いもあります。

さあ、いよいよもってGraphViewに近づいてきました。
分離自体はすぐにできます。

// GraphEditor.cs
using UnityEngine;
using UnityEditor;
using UnityEngine.UIElements;

public class GraphEditor : EditorWindow
{
    [MenuItem("Window/GraphEditor")]
    public static void ShowWindow()
    {
        GraphEditor graphEditor = CreateInstance<GraphEditor>();
        graphEditor.Show();
        graphEditor.titleContent = new GUIContent("Graph Editor");
    }

    GraphEditorElement m_GraphEditorElement;

    public void OnEnable()
    {
        VisualElement root = this.rootVisualElement;

        m_GraphEditorElement = new GraphEditorElement();
        root.Add(m_GraphEditorElement);
    }
}

// GraphEditorElement.cs
using UnityEngine;
using UnityEngine.UIElements;
using System.Collections.Generic;

public class GraphEditorElement: VisualElement
{
    public GraphEditorElement()
    {
        style.flexGrow = 1;  // サイズを画面いっぱいに広げる
        style.overflow = Overflow.Hidden;  // ウィンドウの枠からはみ出ないようにする

        Add(new NodeElement("One", Color.red, new Vector2(100, 50)));
        Add(new NodeElement("Two", Color.yellow, new Vector2(200, 50)));

        this.AddManipulator(new ContextualMenuManipulator(OnContextMenuPopulate));
    }

    void OnContextMenuPopulate(ContextualMenuPopulateEvent evt)
    {
        evt.menu.AppendAction(
            "Add Node",
            AddNodeMenuAction,
            DropdownMenuAction.AlwaysEnabled
            );
    }

    void AddNodeMenuAction(DropdownMenuAction menuAction)
    {
        Debug.Log("Add Node");
    }
}

二つのスタイルを適用しました。
style.flexGrow = 1; によってGraphEditorElementのサイズが画面いっぱいに広がり、クリックイベントを拾う背景の役割を果たしてくれます。
style.overflow = Overflow.Hidden; は親の領域からはみ出た部分を表示しないようにします。ノードを動かすとウィンドウの枠からはみ出したりしていましたが、これでもう心配はいりません。

挙動はこのようになります。
NodeEditor-13.gif
まだノードの上で右クリックしたときもAdd Nodeメニューが出てしまいます。
これはノードに対しても何かを設定する必要がありそうですね。

後でノードを左クリックしたときにエッジを追加する挙動を実装します。そのとき考えましょう。

とにかくメニューは出たということで、次へ進んでいきます。

3.2 ノードを生成する

Add Nodeというログを出していた部分を少し変更すると、新しいノードが生成できます。

// GraphEditorElementクラス
    void AddNodeMenuAction(DropdownMenuAction menuAction)
    {
        Vector2 mousePosition = menuAction.eventInfo.localMousePosition;  // マウス位置はeventInfoの中にあります

        Add(new NodeElement("add", Color.green, mousePosition));
    }

挙動です。
NodeEditor-14.gif
これで、表示の上では新しいノードを生成できました。

4. ノードを永続化する

3章で生成したノードはGraphEditorウィンドウを開きなおしたりすると消えてしまいます。
Unityで何らかのデータを保存しておくには、どこかのファイルにシリアライズしておく必要があります。

「シリアライズとはXXXである」と一言で言えたらいいのですが、短く上手く説明できる気がしません。
脱線になってしまってもよくないので、気になる方は「Unity シリアライズ」などで検索してみてください。

4.1 グラフ構造とは

方針としては、グラフを再現するのに最低限必要なものを用意します。
冒頭でも少し触れましたが、ここでグラフの定義を明確にしておきます。

グラフには大きく分けて二種類あります。
無向グラフと有向グラフです。
graph_sample.jpg
[図は引用:https://qiita.com/drken/items/4a7869c5e304883f539b]

エッジ、つまり辺に向きがあるかないかの差があります。
ゲームで使う場合、ロジックを表現するためのグラフはほとんどが有向グラフなのではと思います。

ビヘイビアツリー: ノードはアクション、エッジは遷移
有限オートマトン: ノードは状態、エッジは遷移

ということで、作成中のグラフエディタも、有向グラフを表せるものを作りたいと思います。
余談ですが、無向グラフは有効グラフの矢印と逆向きに同じ矢印を付けると実現することができます。

4.2 シリアライズ用のクラスを作る

構造としては、
グラフアセット:ノードリストを持つ
ノード:エッジリストを持つ
エッジ:つながるノードを持つ

として、グラフアセットをアセットとして新規作成できるようにしようと思います。
実装は以下のようにします。

// GraphAsset.cs
using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(fileName ="graph.asset", menuName ="Graph Asset")]
public class GraphAsset : ScriptableObject
{
    public List<SerializableNode> nodes = new List<SerializableNode>();
}

[System.Serializable]
public class SerializableNode
{
    public Vector2 position;
    public List<SerializableEdge> edges = new List<SerializableEdge>();
}

[System.Serializable]
public class SerializableEdge
{
    public SerializableNode toNode;
}

ScriptableObject[CreateAssetMenu]を付けることで、Unityのプロジェクトなどで右クリックをしたときにメニューから生成できるようになります。
また、[System.Serializable]アトリビュートによって、指定したクラスをシリアライズ可能にしています。

早速、グラフアセットを作ってみました。
NodeEditor-15.gif
すると、このようなエラーが出ます。

Serialization depth limit 7 exceeded at 'SerializableEdge.toNode'. There may be an object composition cycle in one or more of your serialized classes.

Serialization hierarchy:
8: SerializableEdge.toNode
7: SerializableNode.edges
6: SerializableEdge.toNode
5: SerializableNode.edges
4: SerializableEdge.toNode
3: SerializableNode.edges
2: SerializableEdge.toNode
1: SerializableNode.edges
0: GraphAsset.nodes

UnityEditor.InspectorWindow:RedrawFromNative()

これはつまり、こういうことです。
NodeEditor-16.gif

そう、ノードがシリアライズしようとするエッジが、さらにノードをシリアライズしようとして、循環が発生しているのです。
シリアライズの仕組みとして、クラスの参照をそのまま保存することはできません。

では、どうするかというと、ノードのIDを保存しておくことにします。
UnityのGUIDみたいに大きなIDを振ってもいいのですが、振るのとか対応付けとかが面倒そうです。
そこで、ここではGraphAssetが持っているノードリストの何番目にあるか、というのをIDとしようと思います。

SerializableEdgeだけ以下のように直します。

// GraphAsset.cs
[System.Serializable]
public class SerializableEdge
{
    public int toId;
}

これでワーニングは出なくなります。

4.3 アセットとエディタを対応付ける

どのアセットを表示・編集するかを決めるために、エディタにアセットの情報を持たせなければいけません。
実際にエディタを使うときのことを考えると、アセットからエディタが開けて、その際にそのアセットについて編集するようにできたらいいですね。

というわけで要件としては、
1. GraphAssetをダブルクリックするとエディタが開く
2. どこかのGraphEditorElementクラスにGraphAssetクラスを渡す
です。

// GraphAsset.cs
using UnityEngine;
using UnityEditor;
using UnityEditor.Callbacks;  // OnOpenAssetアトリビュートのために追加
using UnityEngine.UIElements;
using System.Collections.Generic;

public class GraphEditor : EditorWindow
{
    [MenuItem("Window/GraphEditor")]
    public static void ShowWindow()
    {
        GraphEditor graphEditor = CreateInstance<GraphEditor>();
        graphEditor.Show();
        graphEditor.titleContent = new GUIContent("Graph Editor");

        if(Selection.activeObject is GraphAsset graphAsset)
        {
            graphEditor.Initialize(graphAsset);
        }
    }

    [OnOpenAsset()]  // Unityで何らかのアセットを開いたときに呼ばれるコールバック
    static bool OnOpenAsset(int instanceId, int line)
    {
        if(EditorUtility.InstanceIDToObject(instanceId) is GraphAsset)  // 開いたアセットがGraphAssetかどうか
        {
            ShowWindow();
            return true;
        }

        return false;
    }

    GraphAsset m_GraphAsset;  // メンバ変数として持っておく
    GraphEditorElement m_GraphEditorElement;

    public void OnEnable()
    {
        // ShowWindow()を通らないような時(スクリプトのコンパイル後など)
        // のために初期化への導線を付ける
        if (m_GraphAsset != null)
        {
            // 初期化はInitializeに任せる
            Initialize(m_GraphAsset);
        }
    }

    // 初期化
    public void Initialize(GraphAsset graphAsset)
    {
        m_GraphAsset = graphAsset;

        // 以下はもともとOnEnable() で行っていた処理
        // OnEnable() はCreateInstance<GraphEditor>() の際に呼ばれるので、まだgraphAssetが渡されていない
        // 初期化でもgraphAssetを使うことになるのでここに移す
        VisualElement root = this.rootVisualElement;

        m_GraphEditorElement = new GraphEditorElement();
        root.Add(m_GraphEditorElement);
    }
}

これで、GraphAssetファイルをダブルクリックしたときにエディタが開くようになります。
NodeEditor-17.gif

4.4 アセットのデータからノードを表示するようにする

続いて、アセットにある情報からノードを構築、表示したいと思います。
まずはGraphAssetにダミーの情報を手打ちします。
NodeEditor-18.PNG
(100, 50)と(200, 50)の位置、つまり今まで表示してきた赤と黄色の位置、にノードが表示されればOKです。

まず、NodeElementを少し変えます。
色の情報はアセットにはないので省きますし、位置はシリアライズされますからね。

具体的には、生成をSerializableNodeから行うようにします。

// NodeElement.cs

// BackgroundColorがなくなると見えなくなるので、周囲を枠線で囲んだVisualElement、Boxを継承する
public class NodeElement : Box  
{
    public SerializableNode serializableNode;

    public NodeElement (SerializableNode node)  // 引数を変更
    {
        serializableNode = node;  // シリアライズ対象を保存しておく

        style.position = Position.Absolute;
        style.height = 50;
        style.width = 100;

        transform.position = node.position;  // シリアライズされている位置を取る

        this.AddManipulator(new NodeDragger());
    }
}

GraphEditorElementも伴って変更します。

// GraphEditorElement.cs
using UnityEngine;
using UnityEngine.UIElements;
using System.Collections.Generic;

public class GraphEditorElement: VisualElement
{
    GraphAsset m_GraphAsset;  // 渡されたアセットを保存
    List<NodeElement> m_Nodes;  // 作ったノードを入れておく。順序が重要

    public GraphEditorElement(GraphAsset graphAsset)
    {
        m_GraphAsset = graphAsset;

        style.flexGrow = 1;
        style.overflow = Overflow.Hidden;

        this.AddManipulator(new ContextualMenuManipulator(OnContextMenuPopulate));

        m_Nodes = new List<NodeElement>();

        // 順番にノードを生成。この作る際の順番がSerializableEdgeが持つNodeのIDとなる
        foreach(var node in graphAsset.nodes)
        {
            CreateNodeElement(node);
        }
    }

    void CreateNodeElement(SerializableNode node)
    {
        var nodeElement = new NodeElement(node);

        Add(nodeElement);  // GraphEditorElementの子として追加
        m_Nodes.Add(nodeElement);  // 順番を保持するためのリストに追加
    }

/* ... 省略 */

    void AddNodeMenuAction(DropdownMenuAction menuAction)
    {
        Vector2 mousePosition = menuAction.eventInfo.localMousePosition;

        CreateNodeElement(new SerializableNode() { position = mousePosition });  // 追加生成時には仮で新しく作る
    }
}

GraphEditorElementのコンストラクタにGraphAssetを渡すようにしたので、GraphEditorから生成するときに必要です

// GraphEditorクラス
    public void Initialize(GraphAsset graphAsset)
    {
        m_GraphAsset = graphAsset;

        VisualElement root = this.rootVisualElement;

        m_GraphEditorElement = new GraphEditorElement(graphAsset);  // アセットを渡す
        root.Add(m_GraphEditorElement);
    }

以上で、アセットに保持された情報を描画することができました。
NodeEditor-19.gif
書き込みはしていないので、当然開きなおすと追加したノードは消えてしまいます。

4.5 追加作成したノードをアセットに書き込む

前節までできたら、あとはもう少し変えるだけです。

// GraphEditorElementクラス
    private void AddNodeMenuAction(DropdownMenuAction menuAction)
    {
        Vector2 mousePosition = menuAction.eventInfo.localMousePosition;
        var node = new SerializableNode() { position = mousePosition };

        m_GraphAsset.nodes.Add(node);  // アセットに追加する

        CreateNodeElement(node);
    }

これでアセットに書き込まれます。
NodeEditor-20.gif
おっと、動かしたことを記録するのを忘れていました。

// NodeDraggerクラス
    protected void OnMouseUp(MouseUpEvent evt)
    {
        if (CanStopManipulation(evt))
        {
            target.ReleaseMouse();

            if(target is NodeElement node)
            {
                //NodeElementに保存しておいたシリアライズ対象のポジションをいじる
                node.serializableNode.position = target.transform.position;
            }

            m_Focus = false;
        }
    }

動かしてドラッグをやめた瞬間に記録するとよいと思います。
これで動かしたことも保存されるようになりました。
NodeEditor-21.gif

5. エッジを追加する

頂点の表示ができたので、次は辺です。辺は頂点同士を線で結ぶことで表します。
コンテナ的な仕組みでは直線や曲線は引けないように思うので、ここは既存の仕組みで線を引きます。
Handles.Draw系の関数が一番楽かなと思います。
DrawLineDrawBezierなどです。

ちなみにGraphViewでは、エッジ用のメッシュを作って、Graphics.DrawMeshNow()で描画をしていました。

5.1 エッジを表示する

とりあえずダミーでデータを作ってみます。
NodeEditor-22.PNG
Element0のEgesに要素を追加しました。
このまま表示するとこうなります。
NodeEditor-23.PNG
イメージとしては、左上のノードから右下のノードへ繋がっている矢印があればいいなと思います。

VisualElementは初期化字に一度呼べば後は自動で描画してくれていましたが、Handlesで描画をするならウィンドウ更新のたびに呼ぶ必要があります。
EditorWindowの更新といえば、OnGUIです。
ウィンドウの更新のたびにOnGUIが呼ばれますので、そこからGraphEditorElementの描画関数を呼ぶことにします。

ひとまずこのように実装してみます。

// GraphEditorクラス
    private void OnGUI()
    {
        if(m_GraphEditorElement == null)
        {
            return;
        }

        m_GraphEditorElement.DrawEdge();
    }
// GraphEditorElementクラス
    public void DrawEdge()
    {
        for(var i = 0; i < m_GraphAsset.nodes.Count; i++)
        {
            var node = m_GraphAsset.nodes[i];
            foreach(var edge in node.edges)
            {
                DrawEdge(
                    startPos: m_Nodes[i].transform.position,
                    startNorm: new Vector2(0f, 1f),
                    endPos: m_Nodes[edge.toId].transform.position,
                    endNorm: new Vector2(0f, -1f));
            }
        }
    }

    private void DrawEdge(Vector2 startPos, Vector2 startNorm, Vector2 endPos, Vector2 endNorm)
    {
        Handles.color = Color.blue;  // 色指定

        // エッジをベジェ曲線で描画
        Handles.DrawBezier(
            startPos,
            endPos,
            startPos + 50f * startNorm,
            endPos + 50f * endNorm,
            color: Color.blue,
            texture: null,
            width: 2f);

        // 矢印の三角形の描画
        Vector2 arrowAxis = 10f * endNorm;
        Vector2 arrowNorm = 5f * Vector3.Cross(endNorm, Vector3.forward);

        Handles.DrawAAConvexPolygon(endPos,
            endPos + arrowAxis + arrowNorm,
            endPos + arrowAxis - arrowNorm);

        Handles.color = Color.white;  // 色指定をデフォルトに戻す
    }

このようになりました。
NodeEditor-24.gif

ポジションとして単にVisualElement.transform.positionを利用しているので左上隅に始点・終点が来ています。
元ノードは下辺中央から、先ノードの上辺中央につながってほしい気がします。
とはいえ、GraphEditorElementでNodeの形に関する部分を決め打ちで呼んでしまうのはちょっと気持ち悪いので、NodeElementに始点や終点の位置・方向の情報を返す関数を作ろうと思います。

// GraphEditorElementクラス
    public void DrawEdge()
    {
        for(var i = 0; i < m_GraphAsset.nodes.Count; i++)
        {
            var node = m_GraphAsset.nodes[i];
            foreach(var edge in node.edges)
            {
                // ノードに情報を問い合わせる
                DrawEdge(
                    startPos: m_Nodes[i].GetStartPosition(),
                    startNorm: m_Nodes[i].GetStartNorm(),
                    endPos: m_Nodes[edge.toId].GetEndPosition(),
                    endNorm: m_Nodes[edge.toId].GetEndNorm());
            }
        }
    }
// NodeElementクラス

    public Vector2 GetStartPosition()
    {
        return (Vector2)transform.position + new Vector2(style.width.value.value / 2f, style.height.value.value);
    }
    public Vector2 GetEndPosition()
    {
        return (Vector2)transform.position + new Vector2(style.width.value.value / 2f, 0f);
    }
    public Vector2 GetStartNorm()
    {
        return new Vector2(0f, 1f);
    }
    public Vector2 GetEndNorm()
    {
        return new Vector2(0f, -1f);
    }

ちゃんとそれらしい位置から生えました。
NodeEditor-25.gif

また、エッジを描画するだけなら、エッジのVisualElementを作らずにGraphAssetに保存されているSerializableEdgeの値を見ていればよいのですが、エッジの追加・削除・付け替えなど、いずれ必要になるであろう操作がやりにくくなります。

そこで、エッジにもEdgeElementクラスを作ります。

// EdgeElement.cs

using UnityEngine;
using UnityEngine.UIElements;
using UnityEditor;

public class EdgeElement : VisualElement
{
    public SerializableEdge serializableEdge;  // データを持っておく

    public NodeElement From { get; private set; }  // 元ノード
    public NodeElement To { get; private set; }  // 先ノード

    public EdgeElement(SerializableEdge edge, NodeElement from, NodeElement to )
    {
        serializableEdge = edge;
        From = from;
        To = to;
    }

    public void DrawEdge()
    {
        if(From != null && To != null)
        {
            DrawEdge(
                startPos: From.GetStartPosition(),
                startNorm: From.GetStartNorm(),
                endPos: To.GetEndPosition(),
                endNorm: To.GetEndNorm());
        }
    }

    // GraphEditorElementからそのまま移した
    private void DrawEdge(Vector2 startPos, Vector2 startNorm, Vector2 endPos, Vector2 endNorm)
    {
        Handles.color = Color.blue;
        Handles.DrawBezier(
            startPos,
            endPos,
            startPos + 50f * startNorm,
            endPos + 50f * endNorm,
            color: Color.blue,
            texture: null,
            width: 2f);

        Vector2 arrowAxis = 10f * endNorm;
        Vector2 arrowNorm = 5f * Vector3.Cross(endNorm, Vector3.forward);

        Handles.DrawAAConvexPolygon(endPos,
            endPos + arrowAxis + arrowNorm,
            endPos + arrowAxis - arrowNorm);
        Handles.color = Color.white;
    }
}

このクラスもノードと同様に、GraphEditorElementが生成し、GraphEditorElementの子として保持することにします。
ノードが持っていて、ノードの子として生成というのも考えましたが、GraphEditorで一元管理した方が構造が単純になりそうだと思ったのが理由です。

実装はこうです。

// GraphEditorElementクラス

    List<EdgeElement> m_Edges;  // エッジもノードと同じくまとめて保持しておく

    public GraphEditorElement(GraphAsset graphAsset)
    {
        m_GraphAsset = graphAsset;

        style.flexGrow = 1;
        style.overflow = Overflow.Hidden;

        this.AddManipulator(new ContextualMenuManipulator(OnContextMenuPopulate));

        m_Nodes = new List<NodeElement>();

        foreach(var node in graphAsset.nodes)
        {
            CreateNodeElement(node);
        }

        // すべてのノードの生成が終わってからエッジの生成を行う
        // エッジが持っているノードIDからノードを取得するため
        m_Edges = new List<EdgeElement>();

        foreach(var node in m_Nodes)
        {
            foreach(var edge in node.serializableNode.edges)
            {
                CreateEdgeElement(edge, node, m_Nodes);
            }
        }
    }

    // エッジの生成
    public EdgeElement CreateEdgeElement(SerializableEdge edge, NodeElement fromNode, List<NodeElement> nodeElements)
    {
        var edgeElement = new EdgeElement(edge, fromNode, nodeElements[edge.toId]);
        Add(edgeElement);
        m_Edges.Add(edgeElement);

        return edgeElement;
    }

    // GraphEditor.OnGUI() 内で呼ばれる。描画処理をエッジに移したので小さくなった
    public void DrawEdge()
    {
        foreach(var edge in m_Edges)
        {
            edge.DrawEdge();
        }
    }

見た目は先ほどと変わりません。

5.2 エッジを追加できるようにする

あるノードからあるノードにエッジをつけようと思う時、元ノードから先ノードへ線を伸ばしていくようなイメージになると思います。

UnityのGraphViewやUnrealEngineのBluePrintではノードに備わった接続用のポートをクリックしてそのままドラッグすると線が引かれていきます。
NodeEditor-26.gif

UnrealEngineのBehaviourTreeでは、ノードの上下にエッジ接続領域があります。
NodeEditor-27.gif

これらのようなポートや接続領域などはあると便利そうですが、いったんメニューにAdd Edgeを追加するので良いでしょう。
重要なのは、追加中に元ノードからエッジがマウスの位置を追従していることです。
このUIによって、現在エッジ追加操作中であることと、つなげるノードを指定する方法が直感的にわかります。
これは実装したいです。

挙動としては、
1. ノードを右クリックする
2. メニューから「Add Edge」を選択する
3. 元ノードからマウスの位置に向かうエッジ候補ができる
4. 他のノードを左クリックして、エッジの向かい先を確定する

を想定します。
ノードに対する操作なので、ノードにManipulatorを追加します。
エッジをつなぐ操作なので、EdgeConnectorクラスとします。

5.2.1 EdgeConnectorクラスを作る

EdgeConnectorの役割はメニューを出してエッジ追加モードに入ることと、そのあとに別のノードをクリックして実際にノードを接続することの二つあります。
その中でメニューを出す部分はContexturalMenuManipulatorの役割ですので、EdgeConnectorクラスの中でContexturalMenuManipulatorを作成し、それをEdgeConnectorのターゲットノードにAddManipulatorしようと思います。

こうすることで、NodeElementにEdgeConnectorを追加するだけで、エッジ追加の処理をすべてEdgeConnectorクラスに投げることができます。

// NodeElementクラス
    public NodeElement (SerializableNode node)
    {
        /* ... 省略 */

        this.AddManipulator(new NodeDragger());
        this.AddManipulator(new EdgeConnector());  // 追加
    }

そして、EdgeConnectorの内部はひとまずこのようにしておきます。

using UnityEngine;
using UnityEngine.UIElements;

public class EdgeConnector : MouseManipulator
{
    bool m_Active = false;

    ContextualMenuManipulator m_AddEdgeMenu;

    public EdgeConnector()
    {
        // ノードの接続は左クリックで行う
        activators.Add(new ManipulatorActivationFilter() { button = MouseButton.LeftMouse });

        m_Active = false;

        // メニュー選択マニピュレータは作っておくが、この時点ではターゲットが確定していないので、
        // RegisterCallbacksOnTarget()で追加する
        m_AddEdgeMenu = new ContextualMenuManipulator(OnContextualMenuPopulate);
    }

    private void OnContextualMenuPopulate(ContextualMenuPopulateEvent evt)
    {
        if (evt.target is NodeElement node)
        {
            // エッジ追加中に右クリックを押されたときのために、ノードの上かどうかを見る
            if (!node.ContainsPoint(node.WorldToLocal(evt.mousePosition)))
            {
                // イベントを即座に中断
                evt.StopImmediatePropagation();
                return;
            }

            evt.menu.AppendAction(
                "Add Edge",
                (DropdownMenuAction menuItem) =>
                {
                    m_Active = true;

                    Debug.Log("Add Edge");  // ここでエッジ追加モード開始処理を書く

                    target.CaptureMouse();
                },
                DropdownMenuAction.AlwaysEnabled);
        }
    }

    protected override void RegisterCallbacksOnTarget()
    {
        target.RegisterCallback<MouseDownEvent>(OnMouseDown);
        target.RegisterCallback<MouseUpEvent>(OnMouseUp);
        target.RegisterCallback<MouseMoveEvent>(OnMouseMove);
        target.RegisterCallback<MouseCaptureOutEvent>(OnCaptureOut);

        target.AddManipulator(m_AddEdgeMenu);
    }

    protected override void UnregisterCallbacksFromTarget()
    {
        target.RemoveManipulator(m_AddEdgeMenu);

        target.UnregisterCallback<MouseDownEvent>(OnMouseDown);
        target.UnregisterCallback<MouseUpEvent>(OnMouseUp);
        target.UnregisterCallback<MouseMoveEvent>(OnMouseMove);
        target.UnregisterCallback<MouseCaptureOutEvent>(OnCaptureOut);
    }

    protected void OnMouseDown(MouseDownEvent evt)
    {
        if (!CanStartManipulation(evt))
            return;

        // マウス押下では他のイベントが起きてほしくないのでPropagationを中断する
        if (m_Active)
            evt.StopImmediatePropagation();
    }

    protected void OnMouseUp(MouseUpEvent evt)
    {
        if (!CanStopManipulation(evt))
            return;

        if (!m_Active)
            return;

        Debug.Log("Try Connect");  // ここでマウスの下にあるノードにエッジを接続しようとする

        m_Active = false;
        target.ReleaseMouse();
    }

    protected void OnMouseMove(MouseMoveEvent evt)
    {
        if (!m_Active)
            return;

        Debug.Log("move");  // ここで、追加中のエッジの再描画を行う
    }

    private void OnCaptureOut(MouseCaptureOutEvent evt)
    {
        if (!m_Active)
            return;

        m_Active = false;
        target.ReleaseMouse();
    }
}

この時点では、以下のような挙動になります。
NodeEditor-28.gif

5.2.2 エッジ追加のためにエッジ・グラフクラスを整備

次に、EdgeElementクラスに追加中のEdgeを作成するための準備をします。
これまではEdgeには元ノードと先ノードを渡して作成していましたが、追加中には先ノード確定していないので、元ノードと矢印の位置からエッジを描画できるようにします。

// EdgeElementクラス

    Vector2 m_ToPosition;
    public Vector2 ToPosition
    {
        get { return m_ToPosition; }
        set
        {
            m_ToPosition = this.WorldToLocal(value);  // ワールド座標で渡されることを想定
            MarkDirtyRepaint();  // 再描画をリクエスト
        }  
    }

    // 新しいコンストラクタ
    public EdgeElement(NodeElement fromNode, Vector2 toPosition)
    {
        From = fromNode;
        ToPosition = toPosition;
    }

    // つなげるときに呼ぶ
    public void ConnectTo(NodeElement node)
    {
        To = node;
        MarkDirtyRepaint();  // 再描画をリクエスト
    }

    public void DrawEdge()
    {
        if (From != null && To != null)
        {
            DrawEdge(
                startPos: From.GetStartPosition(),
                startNorm: From.GetStartNorm(),
                endPos: To.GetEndPosition(),
                endNorm: To.GetEndNorm());
        }
        else {
            // 追加中の描画用
            if (From != null)
            {
                DrawEdge(
                    startPos: From.GetStartPosition(),
                    startNorm: From.GetStartNorm(),
                    endPos: ToPosition,
                    endNorm: Vector2.zero);
            }
        }
    }

これにより、追加中のEdgeElementをGraphEditorElementのEdgesに追加すれば自動的に描画されるようになったはずです。
ということで、GraphEditorElementにエッジ追加リクエストを投げられるようにします。
ついでに、ノード追加を中断したときのためにエッジ削除関数も作っておきます。

// GraphEditorElementクラス
    public EdgeElement CreateEdgeElement(NodeElement fromNode, Vector2 toPosition)
    {
        var edgeElement = new EdgeElement(fromNode, toPosition);
        Add(edgeElement);
        m_Edges.Add(edgeElement);

        return edgeElement;
    }

    public void RemoveEdgeElement(EdgeElement edge)
    {
        Remove(edge);
        m_Edges.Remove(edge);
    }

5.2.3 エッジ追加の挙動を実装

上で作った関数をEdgeConnectorクラスから呼びます。

// EdgeConnectorクラス

    GraphEditorElement m_Graph;
    EdgeElement m_ConnectingEdge;

    private void OnContextualMenuPopulate(ContextualMenuPopulateEvent evt)
    {
        if (evt.target is NodeElement node)
        {
            evt.menu.AppendAction(
                "Add Edge",
                (DropdownMenuAction menuItem) =>
                {
                    m_Active = true;

                    // 親をたどってGraphEditorElementを取得する
                    m_Graph = target.GetFirstAncestorOfType<GraphEditorElement>();
                    m_ConnectingEdge = m_Graph.CreateEdgeElement(node, menuItem.eventInfo.mousePosition);

                    target.CaptureMouse();
                },
                DropdownMenuAction.AlwaysEnabled);
        }
    }

    /* ... 省略 */

    protected void OnMouseUp(MouseUpEvent evt)
    {
        if (!CanStopManipulation(evt))
            return;

        if (!m_Active)
            return;

        var node = m_Graph.GetDesignatedNode(evt.originalMousePosition);

        if (node == null  // 背景をクリックしたとき
            || node == target  // 自分自身をクリックしたとき
            || m_Graph.ContainsEdge(m_ConnectingEdge.From, node))  // すでにつながっているノード同士をつなげようとしたとき
        {
            m_Graph.RemoveEdgeElement(m_ConnectingEdge);
        }
        else
        {
            m_ConnectingEdge.ConnectTo(node);
        }
        m_Active = false;
        m_ConnectingEdge = null;  // 接続終了
        target.ReleaseMouse();
    }

    protected void OnMouseMove(MouseMoveEvent evt)
    {
        if (!m_Active)
        {
            return;
        }

        m_ConnectingEdge.ToPosition = evt.originalMousePosition;  // 位置更新
    }

    private void OnCaptureOut(MouseCaptureOutEvent evt)
    {
        if (!m_Active)
            return;

        // 中断時の処理
        m_Graph.RemoveEdgeElement(m_ConnectingEdge);
        m_ConnectingEdge = null;

        m_Active = false;
        target.ReleaseMouse();
    }
// GraphEditorElementクラス

    // マウスの位置にあるノードを返す
    public NodeElement GetDesignatedNode(Vector2 position)
    {
        foreach(NodeElement node in m_Nodes)
        {
            if (node.ContainsPoint(node.WorldToLocal(position)))
                return node;
        }

        return null;
    }

    // すでに同じエッジがあるかどうか
    public bool ContainsEdge(NodeElement from, NodeElement to)
    {
        return m_Edges.Exists(edge =>
        {
            return edge.From == from && edge.To == to;
        });
    }

ここまでで、このような挙動になります。
NodeEditor-29.gif

5.2.4 追加したエッジをシリアライズする

今のままではEdgeElementを追加しただけなので、つないだエッジはデータとして残っていません。
ノードのときと同じようにシリアライズする必要があります。

// EdgeConnectorクラス

    protected void OnMouseUp(MouseUpEvent evt)
    {
        /* ... 省略 */
        var node = m_Graph.GetDesignatedNode(evt.originalMousePosition);

        if (node == null
            || node == target
            || m_Graph.ContainsEdge(m_ConnectingEdge.From, node))
        {
            m_Graph.RemoveEdgeElement(m_ConnectingEdge);
        }
        else
        {
            m_ConnectingEdge.ConnectTo(node);
            m_Graph.SerializeEdge(m_ConnectingEdge);  // つないだ時にシリアライズする
        }

        /* ... 省略 */
    }
// GraphEditorElementクラス

    public void SerializeEdge(EdgeElement edge)
    {
        var serializableEdge = new SerializableEdge()
        {
            toId = m_Nodes.IndexOf(edge.To)  // ここで先ノードのIDを数える
        };

        edge.From.serializableNode.edges.Add(serializableEdge);  // 実際に追加
        edge.serializableEdge = serializableEdge;  // EdgeElementに登録しておく
    }

そして、実際に開きなおしてみると、
NodeEditor-30.gif

保存されています。

5.3 エッジを削除できるようにする

エッジの追加ができるようになったので、やはり削除もできなければいけません。

ノードを削除するときと同様に、エッジの削除もコンテキストメニューから行いたいと思います。
しかし、このとき問題があります。
ノードは大きさのあるVisualElementだったため、ContextualManipulatorを付けるとそのままクリックで選択ができました。
しかし、エッジのVisualElementは大きさがありません。

5.3.1 エッジを選択できるようにする

VisualElementをクリックして選択するときの挙動について、ドキュメントに記載がありました。
Event targetのPicking mode and custom shapesの項です。

You can override the VisualElement.ContainsPoint() method to perform custom intersection logic.

このVisualElement.ContainsPoint()は、マウス座標を与えると、その座標と自分が衝突しているかを判定する関数です。
それをオーバーライドして、独自の衝突判定を埋め込むことで、VisualElementRect以外の形に対応させることができます。

実際にベジェ曲線と点との距離を計算するのは面倒なので、近似した線分との距離を計算して、指定距離以内だったら選択したことにしようと思います。

さて、衝突を判定の実装に当たって、ログを出すものが必要です
というわけで最初に、エッジに削除用のコンテキストメニューを作ります。

// EdgeElementクラス

    // 削除用マニピュレータの追加
    public EdgeElement()
    {
        this.AddManipulator(new ContextualMenuManipulator(evt =>
        {
            if (evt.target is EdgeElement)
            {
                evt.menu.AppendAction(
                "Remove Edge",
                (DropdownMenuAction menuItem) =>
                {
                    Debug.Log("Remove Edge");
                },
                DropdownMenuAction.AlwaysEnabled);
            }
        }));
    }

    public EdgeElement(NodeElement fromNode, Vector2 toPosition):this()  // 上のコンストラクタを呼ぶ
    {
        From = fromNode;
        ToPosition = toPosition;
    }

    public EdgeElement(SerializableEdge edge, NodeElement fromNode, NodeElement toNode):this()  // 上のコンストラクタを呼ぶ
    {
        serializableEdge = edge;
        From = fromNode;
        To = toNode;
    }

まず、接続元と接続先が収まるバウンディングボックスと衝突しているかどうかを判定してみます。

// EdgeElementクラス
    public override bool ContainsPoint(Vector2 localPoint)
    {
        if (From == null || To == null)
            return false;

        Vector2 start = From.GetStartPosition();
        Vector2 end = To.GetEndPosition();

        // ノードを覆うRectを作成
        Vector2 rectPos = new Vector2(Mathf.Min(start.x, end.x), Mathf.Min(start.y, end.y));
        Vector2 rectSize = new Vector2(Mathf.Abs(start.x - end.x), Mathf.Abs(start.y - end.y));
        Rect bound = new Rect(rectPos, rectSize);

        if (!bound.Contains(localPoint))
        {
            return false;
        }

        return true;
    }

結果はこうなりました。
NodeEditor-31.gif
確かに、エッジのバウンディングボックスとの当たりを判定できていそうです。

次に、近似線分との距離を計算してみます。
先にバウンディングボックスに入っていないものを弾いているので、端点が一番近い場合などを考えなくて済みます。
つまり、線分ではなく直線と点の距離を考えればよいということです。

// EdgeElementクラス
    readonly float INTERCEPT_WIDHT = 15f;  // エッジと当たる距離

    public override bool ContainsPoint(Vector2 localPoint)
    {
        /* ... 省略 */

        if (!bound.Contains(localPoint))
        {
            return false;
        }

        // 近似線分ab
        Vector2 a = From.GetStartPosition() + 12f * From.GetStartNorm();
        Vector2 b = To.GetEndPosition() + 12f * To.GetEndNorm();

        // 一致した場合はaからの距離
        if (a == b)
        {
            return Vector2.Distance(localPoint, a) < INTERCEPT_WIDHT;
        }

        // 直線abとlocalPointの距離
        float distance = Mathf.Abs(
            (b.y - a.y) * localPoint.x
            - (b.x - a.x) * localPoint.y
            + b.x * a.y - b.y * a.x
            ) / Vector2.Distance(a, b);

        return distance < INTERCEPT_WIDHT;
    }

結果はこうなりました。
NodeEditor-32.gif
...ちょっとずれている気もしますが、まあ、許容範囲でしょう。

5.3.2 エッジデータを削除する

GraphAssetからエッジのデータを消します。
EdgeElementには元ノードの情報が既にありますので、そこから自分のデータが入っているSerializableNodeを取得することができます。
これを消せばよいですね。

// EdgeElementクラス

    public EdgeElement()
    {
        this.AddManipulator(new ContextualMenuManipulator(evt =>
        {
            if (evt.target is EdgeElement)
            {
                evt.menu.AppendAction(
                "Remove Edge",
                (DropdownMenuAction menuItem) =>
                {
                    // 親をたどってGraphEditorElementに削除リクエストを送る
                    var graph = GetFirstAncestorOfType<GraphEditorElement>();
                    graph.RemoveEdgeElement(this);
                },
                DropdownMenuAction.AlwaysEnabled);
            }
        }));
    }
// GraphEditorElementクラス

    public void RemoveEdgeElement(EdgeElement edge)
    {
        // 消すエッジにSerializableEdgeがあれば、それを消す
        if(edge.serializableEdge != null)
        {
            edge.From.serializableNode.edges.Remove(edge.serializableEdge);
        }

        Remove(edge);
        m_Edges.Remove(edge);
    }

NodeEditor-33.gif

無事、削除できています。

6. ノードを削除する

最後に、ノードを削除できるようにしたいと思います。
ノードを削除したときには、
- NodeElementを削除する
- 対応するSerializableNodeを削除する
- そのノードとつながるEdgeElementを削除する
- 対応するSerializableEdgeを削除する
- 他ノードのIDが変わるので、それに応じてSerializableEdgeのIDを振りなおす

のすべてを行う必要があります。

// NodeElementクラス

    public NodeElement (SerializableNode node)
    {
        /* ... 省略 */

        this.AddManipulator(new NodeDragger());
        this.AddManipulator(new EdgeConnector());
        this.AddManipulator(new ContextualMenuManipulator(OnContextualMenuPopulate));  // 削除用マニピュレータ
    }

    private void OnContextualMenuPopulate(ContextualMenuPopulateEvent evt)
    {
        if (evt.target is NodeElement)
        {
            evt.menu.AppendAction(
                "Remove Node",
                RemoveNodeMenuAction,
                DropdownMenuAction.AlwaysEnabled);
        }
    }

    private void RemoveNodeMenuAction(DropdownMenuAction menuAction)
    {
        // 親をたどって削除をリクエスト
        var graph = GetFirstAncestorOfType<GraphEditorElement>();
        graph.RemoveNodeElement(this);
    }
// GraphEditorElementクラス

    public void RemoveNodeElement(NodeElement node)
    {
        m_GraphAsset.nodes.Remove(node.serializableNode);  // アセットから削除

        int id = m_Nodes.IndexOf(node);

        // エッジの削除とID変更
        // m_Edgesに変更が伴うため、降順で行う
        for (int i = m_Edges.Count - 1; i >= 0; i--)
        {
            var edgeElement = m_Edges[i];
            var edge = edgeElement.serializableEdge;

            // 削除されるノードにつながるエッジを削除
            if (edgeElement.To == node || edgeElement.From == node)
            {
                RemoveEdgeElement(edgeElement);
                continue;
            }

            // 変更が生じるIDを持つエッジに対して、IDに修正を加える
            if (edge.toId > id)
                edge.toId--;
        }

        Remove(node);  // VisualElementの子としてのノードを削除
        m_Nodes.Remove(node);  // 順序を保持するためのリストから削除
    }

これでノードを削除できるようになりました。
NodeEditor-35.gif

ウィンドウを開きなおしてもちゃんと構造が保存されています。

結果

NodeEditor-36.gif
ゼロからノードベースエディタを作りました。
現状ではグラフ構造を保存するアセットを作れるだけですが、このノード部分に何か情報を載せると立派なヴィジュアルツールが出来上がります。

おわりに

UIElementの使い方を勉強したいと思ったので、ノードベースエディタを作ってみました。
ドキュメントとリファレンスを読み込むことになり、GraphViewの実装もかなり追ったので勉強になってよかったです。
実をいうと、このGraphEditorを使ってBehaviorTreeを作るところまでやりたかったのですが、エディタを作るだけで相当の時間がかかってしまったので、この記事はここまでにしておきます。

また、ゼロから作るを銘打って、実装する手順通りに事細かく書いてしまったので、やたら長くなってしまいました。
とはいえ、エディタを作るにあたって得た知見をふんだんに盛り込めたのではないかと思います。

ここはもっとこうした方がよい、のような意見があればコメントで教えていただけるとありがたいです。
ご拝読ありがとうございました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Npgsqlを使用したBulk Copyのサンプル

Import

元データはタブ区切り。

NpgsqlConnection conn = new NpgsqlConnection(conStr);
conn.Open();
using (var writer = conn.BeginTextImport("COPY Accounts (account_id, record_date, prc_amount) FROM STDIN"))
{
    writer.Write(File.ReadAllText(@"table_import.txt", Encoding.GetEncoding(932)));
}
conn.Close();

Export

NULLは空文字に変更。行末はLFとなる。

NpgsqlConnection conn = new NpgsqlConnection(conStr);
conn.Open();
using (var reader = conn.BeginTextExport("COPY (select account_id, record_date, prc_amount from Accounts order by account_id) TO STDOUT NULL ''"))
{
    File.WriteAllText(@"table_export.txt", reader.ReadToEnd(), Encoding.GetEncoding(932));
}
conn.Close();

参考情報

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

C#での条件分岐の書き方、省略した書き方、nullチェックの書き方のまとめ

C#初心者の方の参考になれば幸いです。

if文

if文(基本形)

基本形。
条件文の結果により処理を分岐させます。

if (this.Age <= 6)
{
    message = "6歳以下です。";
}
else if (this.Age <= 12)
{
    message = "12歳以下です。";
}
else
{
    message = "12歳を超えています。";
}

if文(「if else」「else」の省略)

「if else」「else」は不要なら省略が可能です。

if (isRunning)
{
    // 処理
}

if文(「{ }」を省略)

処理が1行だけの場合「{}」を省略できます。

if (Age <= 6)
    message = "6歳以下です。";
else if (Age <= 12)
    message = "12歳以下です。";
else
    message = "12歳を超えています。";

if文(「改行」を省略)

処理が極端に短い場合は、さらに改行も省略して1行で書いても大丈夫です。

if (Age <= 6) message = "6歳以下です。";
else if (Age <= 12) message = "12歳以下です。";
else message = "12歳を超えています。";

三項演算子(?:)

三項演算子(または条件演算子とも呼ばれます)も短い分岐処理に便利な構文です。

構文

条件文 ? trueの場合の処理 : falseの場合の処理

記述内容が短い例

短くて単純な条件判定に便利。
if文で書くと複数行かかるものが1行にできたりして、コードの見通しが良くなる場合に使えます。

以下はif文で書いた場合です。

var message = "";
if (isRunning) 
{
    message = "実行中です";
} 
else 
{
    message = "停止中です";
}

上記を三項演算子で書くとスッキリ書けます。

var message = isRunning ? "実行中です" : "停止中です";

記述内容が長い例

あまりに長いと見にくくなってしまいますので三項演算子で記述するのは避けたほうがいいですが、改行することである程度見やすく整えることもできます。
長い場合は以下のように改行しインデントを揃えると読みやすくなると思います。

1行で書いた場合、長くなるほどだんだん見づらくなってきますが、

var message = articles.Any() ? articles.Count + "件見つかりました。" : "記事がありません。";

区切りのいいところで改行を入れると見やすく書けます。
C#は文末に必ず「;」が必要なため、改行を途中で入れても大丈夫です。

var message = articles.Any() 
              ? articles.Count + "件見つかりました。" 
              : "記事がありません。";

null合体演算子(??)

nullチェックでnullの場合の初期値を代入するようなときにスッキリ書ける構文です。三項演算子と同様に判定後の処理がシンプルな場合に便利です。

構文

nullチェックする値がnullでなければその値を返し、nullの場合は??の右側に指定した値を返します。

nullチェックする値 ?? nullの場合の値

if文で書いた例

if (TelTextField.Text == null) 
{
    User.Tel = "未登録";
} 
else 
{
    User.Tel = TelTextField.Text;
}

上記をnull合体演算子(??)で書いた例

User.Tel = TelTextField.Text ?? "未登録";

null合体割り当て演算子(??=)

C# 8.0 以降
nullの場合の初期値を代入するようなときに便利です。

構文

nullチェックする値がnullでなければその値を返し、nullの場合は??=の左側(nullチェックする値)に右側に指定した値を代入します。

nullチェックする値 ??= nullの場合に代入する値

if文で書いた例

if (list == null)
{
    list = new List<int>();
}

null合体割り当て演算子(??=)で書いた例(C# 8.0以降)

list ??= new List<int>();

null条件演算子(?.)

C# 6 以降
こちらもnullチェックで使える書き方です。nullだった場合はnullを返すだけでいい場合に使えます。

構文

値がnullでない場合にのみ、メンバー(プロパティやメソッドなど)にアクセスできます。nullだった場合はnullを返します。

nullチェックする値?.メンバー
nullチェックする値?[インデックス番号]

if文で書いた例

if (User == null) 
{
    NameTextField.Text = null;
} 
else 
{
    NameTextField.Text = User.Name;
}

null条件演算子(?.)で書いた例(C# 6.0以降)

NameTextField.Text = User?.Name;
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ScriptableObjectをそのまま別フォルダに上書き移動するAssetPostprocessor

テラシュールさんのExcelImporter
【Unity】Excel Importer Maker、xlsxに対応
で出力したデータを、独自のフォルダにコピーしてゲーム中にロードしているのですが、
独自フォルダに自動でコピーするAssetPostprocessorを書いたのでメモしておきます。

using UnityEngine;
using UnityEditor;

namespace Twilight
{

    public class MasterDataOverrider : AssetPostprocessor
    {
        private const string kSrcDirectoryPath = "Assets/App/Data";
        private const string kDestDirectoryPath = "Assets/App/Resources/Data";

        /// <summary>
        /// OnPostprocessAllAssetsには効かないらしいけど一応
        /// </summary>
        /// <returns></returns>
        public override int GetPostprocessOrder()
        {
            return 100;
        }

        static void OnPostprocessAllAssets(
            string[] importedAssets,
            string[] deletedAssets,
            string[] movedAssets,
            string[] movedFromPath)
        {
            foreach (var asset in importedAssets)
            {
                if (!asset.Contains(kSrcDirectoryPath))
                {
                    continue;
                }

                var loadAsset = AssetDatabase.LoadAssetAtPath<ScriptableObject>(asset);
                if (loadAsset == null)
                {
                    continue;
                }

                var destFilePath = asset.Replace(kSrcDirectoryPath, kDestDirectoryPath);

                AssetDatabase.CopyAsset(asset, destFilePath);
                AssetDatabase.DeleteAsset(asset);
                AssetDatabase.Refresh();
            }
        }
    }
}

使う時は kSrcDirectoryPath、kDestDirectoryPath を、書き換えると良いかと。

※最初にAssetDatabase.CopyAsset()ではなくFile.Copy()でやろうとして時間がかかってしまった;
アセットのコピー(AssetDatabase.CopyAsset VS File.Copy)【Unity】【エディタ拡張】

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

C# ジェネリック(ジェネリクス)の実践使用例

前置き

当記事は、前回の「C# ジェネリック(ジェネリクス)」 の続編です。
ジェネリックを活用できそうな関数を紹介します。

概要

使用する関数は、WinAPIのひとつである「ReadProcessMemory()」です。
これは、特定のアドレスにあるメモリを読み取ります。
具体的には、以下の通りです。

[DllImport("kernel32.dll")]
public static extern int ReadProcessMemory
(IntPtr hProcess, IntPtr BaseAddress, byte[] Buffer, int size, int BytesRead);

読み取られた値は引数のbufferbyteとして格納されます。
ここで、読み取られた値はアンマネージド型のいずれかであることが保証されます。
例えば、Int32(int)を読み取りたい時、

BitConverter.ToInt32(buffer, 0);

といちいち型別に関数を定義するのは面倒ですし、プログラムの保守性やメンテナンス性も最悪です。
読み取り先の値はintfloatdouble等のアンマネージド型ですね。
そうです。ジェネリックの出番です。

ジェネリック関数としてWrap

早速、関数をWrapしてみます。
理想は以下の様に簡単にすることです。

IntPtr address = ...
int x = Read<int>(address);

ソースコード:

public static unsafe T Read<T>(IntPtr hProc, IntPtr address) where T : unmanaged
{
    var size = sizeof(T);
    byte[] buffer = new byte[size];
    ReadProcessMemory(hProc, address, buffer, buffer.Length, 0);
    Span<T> span = MemoryMarshal.Cast<byte, T>(buffer);
    return span[0];
}

解説:

制約はunmanagedです。
ここでは、読み取った値(byte[])をMemoryMarshal.Cast<TFrom, TTo>(byte[])を使用してキャストしています。
byte[]はマネージ型であるため、アンマネージド型へのキャストには少し工夫が必要です。
それを代行してくれるのが、MemoryMarshal.Castです。
そして、返り値はSpan<T>として帰ってきます。
Span<T> spanは配列であり、キャストした値を配列から取り出して返り値としています。

MSDN: 「Span 構造体」
MSDN: 「MemoryMarshal.Cast メソッド」

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む