hildsoftのコード置き場

プログラム関連で調べたことやコードの保管場所です

Unityでエディタ拡張を作るときのUndo、Redoの落とし穴

f:id:hildsoft:20180818151746j:plain

検証環境

Unity 2018.1.6f1

現象

ScriptabelObjectを継承しない自作クラスの参照を持っている状態でRedoを行うと、正しく参照が保持されない。

こちらの記事で指摘されている現象です。
http://sassembla.github.io/Public/2015:09:17%203-14-23/2015:09:17%203-14-23.html

原因

データの持ち方に問題がある。

実験

  • ScriptableObjectを継承したTestDocumentクラスを作成。
  • EditorWindow内にフィールド(doc)として保持。
  • TestDocument内に何種類かデータを保持。
  • Undo.RecordObject(this.doc, "Add Node")にてUndo、Redoを行う。
  • Undo、Redoでデータがどうなるか簡単に調査。

プリミティブ

public class TestDocument : ScriptableObject

    public int PublicCounter;

    private int privateCounter;
    public int PrivateCounter { get { return this.privateCounter; } set { this.privateCounter = value; } }

    [SerializeField]
    private int serializeCounter;
    public int SerializeCounter { get { return this.serializeCounter; } set { this.serializeCounter = value; } }

こちらはprivate以外はUndo、Redo共に問題ありませんでした。
privateについてはUndo、Redoで値が変更されません。

クラス

public class TestDocument : ScriptableObject

    // クラス
    [SerializeField]
    private List<Node> nodeList;
    public List<Node> NodeList { get { return this.nodeList; } }

    public List<Connection> ConnectionList;

クラスについてはprivateは実験しませんでした。
SerializeFieldもpublicもどちらもUndo、Redoで更新されます。

参照の問題点

Connectionクラスに

    [SerializeField]
    private Node inNode;
    public Node InNode { get { return this.inNode; } set { this.inNode = value; } }

    [SerializeField]
    private Node outNode;
    public Node OutNode { get { return this.outNode; } set { this.outNode = value; } }

と、Nodeクラスへの参照を持っているわけですが、

f:id:hildsoft:20180818154446j:plain

Nodeクラスが[System.Serializable]によってシリアライズ対象になっているときは、ConnectionをUndoで削除した後のRedo時にNodeがnewで生成されてしまいます。

NodeクラスがScriptableObjectを継承することによりシリアライズされている場合はコネクションの再生成時に、docで管理されているnodeListの中にあるNodeへの参照を設定してくれます。
その際、コネクションがScriptableObjectでなく[System.Serializable]でも問題はありませんが、コネクションが参照される可能性も含めてScriptableObjectにしておいた方が良いでしょう。

実験はしていないのですが、おそらくdoc単位でUndoしているので正しく参照関係を保持して戻してくれているのでしょう。

確かにぱっと見では正しく動いているように見えるので、浮いたノードを参照しているコネクション経由でノードへ変更をかけるアクセスをしない限り発覚しないので怖いですね。

結論

  • Undo、Redoを行う変数は、publicで宣言するか[SerializeField]を設定する
  • Undo、Redoの対象となる自作クラスはScriptableObjectを継承しておく
  • Undoで記録する際は参照範囲にあるオブジェクトを纏めて放り込む

を心がけておくと良いかと思います。

サンプルコード

WindowTest.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;

public class WindowTest : EditorWindow
{
    static WindowTest window;

    // データ管理インスタンス
    private TestDocument doc;

    private Node selectNode;

    [MenuItem("Window/UndoRedo Test")]
    private static void OpenWindow()
    {
        if (window == null)
        {
            window = EditorWindow.CreateInstance<WindowTest>();
            window.titleContent = new GUIContent("UndoRedo Test");
        }
        window.doc = CreateInstance<TestDocument>(); // ScriptableObjectはnewで作成しない
        window.doc.init();
        window.Show();

        // Undo、Redo実行時に再描画
        Undo.undoRedoPerformed += () =>
        {
            window.Repaint();
        };
    }

    private void OnGUI()
    {
        CheckEvent(Event.current);

        foreach (var item in this.doc.ConnectionList)
        {
            item.Draw();
        }
        foreach (var item in this.doc.NodeList)
        {
            item.Draw();
        }

        GUI.Label(new Rect(10, 10, 200, 30), new GUIContent("Public:" + this.doc.PublicCounter));
        GUI.Label(new Rect(10, 30, 200, 30), new GUIContent("Private:" + this.doc.PrivateCounter));
        GUI.Label(new Rect(10, 50, 200, 30), new GUIContent("Serialize:" + this.doc.SerializeCounter));

        if (GUI.changed) Repaint();
    }

    private void CheckEvent(Event e)
    {
        switch(e.type)
        {
            case EventType.MouseDown:
                Node clickNode = null;
                foreach (var item in this.doc.NodeList)
                {
                    if (item.Contains(e.mousePosition)) {
                        clickNode = item;
                        break;
                    }
                }

                if (clickNode == null)
                {
                    // 何もないところをクリックしたらノード作成
                    Node n = new Node();
                    //Node n = CreateInstance<Node>();
                    Undo.RecordObject(this.doc, "Add Node");
                    this.doc.NodeList.Add(n);
                    n.Pos = e.mousePosition;

                    e.Use();
                    GUI.changed = true;
                }
                else { 
                    if(this.selectNode == null)
                    {
                        this.selectNode = clickNode;
                    }
                    else
                    {
                        Connection c = new Connection();
                        //Connection c = CreateInstance<Connection>();
                        Undo.RecordObject(this.doc, "Add Connection");
                        this.doc.ConnectionList.Add(c);
                        c.InNode = clickNode;
                        c.OutNode = this.selectNode;

                        this.selectNode = null;

                        e.Use();
                        GUI.changed = true;
                    }
                }
                break;

            case EventType.KeyDown:
                if(e.keyCode==KeyCode.A)
                {
                    Undo.RecordObject(this.doc, "CountUp");

                    int addCount = Random.Range(1, 100);

                    this.doc.PublicCounter += addCount;
                    this.doc.PrivateCounter += addCount;
                    this.doc.SerializeCounter += addCount;

                    GUI.changed = true;
                }
                if (e.keyCode == KeyCode.S)
                {
                    // 参照が一致するか確認
                    this.doc.Log();
                }
                break;
        }
    }
}


TestDocument.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;

public class TestDocument : ScriptableObject
{
    // クラス
    [SerializeField]
    private List<Node> nodeList;
    public List<Node> NodeList { get { return this.nodeList; } }

    public List<Connection> ConnectionList;

    // プリミティブ

    public int PublicCounter;

    private int privateCounter;
    public int PrivateCounter { get { return this.privateCounter; } set { this.privateCounter = value; } }

    [SerializeField]
    private int serializeCounter;
    public int SerializeCounter { get { return this.serializeCounter; } set { this.serializeCounter = value; } }
    public void init()
    {
        this.nodeList = new List<Node>();
        this.ConnectionList = new List<Connection>();
        this.PublicCounter = 0;
        this.privateCounter = 0;
    }

    public void Log()
    {
        foreach (var conn in this.ConnectionList)
        {
            bool inNodeChek = false;
            bool outNodeChek = false;

            foreach (var node in this.nodeList)
            {
                if(ReferenceEquals(conn.InNode, node))
                {
                    inNodeChek = true;
                }
                if (ReferenceEquals(conn.OutNode, node))
                {
                    outNodeChek = true;
                }
            }
            if (inNodeChek && outNodeChek) { Debug.Log("OK"); }
            else { Debug.Log("NG"); }
        }
    }
}

[System.Serializable]
public class Node
//public class Node : ScriptableObject
{
    public Vector2 Pos { get { return this.rect.position; } set { this.rect.position = value; } }

    [SerializeField]
    private Rect rect = new Rect(0f, 0f, 150f, 40f);

    public bool Contains(Vector2 point)
    {
        return this.rect.Contains(point);
    }

    public void Draw()
    {
        GUI.Box(this.rect, new GUIContent("Rect:" + this.rect.position.ToString()));
    }
}

[System.Serializable]
public class Connection
//public class Connection : ScriptableObject
{
    [SerializeField]
    private Node inNode;
    public Node InNode { get { return this.inNode; } set { this.inNode = value; } }

    [SerializeField]
    private Node outNode;
    public Node OutNode { get { return this.outNode; } set { this.outNode = value; } }

    public void Draw()
    {
        if (this.inNode == null || this.outNode == null) return;

        float lineWidth = 3f;

        Vector3[] points = new Vector3[4];

        Vector3 inNodePos = this.inNode.Pos;
        Vector3 outNodePos = this.outNode.Pos;

        points[0] = new Vector3(inNodePos.x, inNodePos.y + 20f, 0);
        points[1] = new Vector3(inNodePos.x - 10f, inNodePos.y + 20f, 0);
        points[2] = new Vector3(outNodePos.x + 160f, outNodePos.y + 20f, 0);
        points[3] = new Vector3(outNodePos.x + 150f, outNodePos.y + 20f, 0);

        Handles.DrawAAPolyLine(lineWidth, points);
    }
}

Kotlinメモ

Kotlin独自の文法やイディオムを忘れやすいので自分用のメモです

f:id:hildsoft:20180731034211j:plain


公式サイト Kotlin Programming Language

nullかどうか判断して実行

// Java: extrasがnullの場合、intValueに初期値0を設定する
int intValue = 0;
if(intent.extras != null) {
  intValue = intent.extras?.getInt("key_intAAA");
}
// Kotlin: extrasがnullの場合、intValueに初期値0を設定する
val intValue = intent.extras?.getInt("key_intAAA") ?: 0

extrasがnullの場合だとgetIntでNullPointerExceptionになるので、安全呼び出し演算子(?.)でnullかどうかチェックしつつ実行し、 nullの場合はエルビス演算子(?:)でチェックして初期値を使用します。

三項演算子

// Java: 小さい方の値を取得
int a = 5;
int b = 10;
int minValue = a < b ? a : b;
// Kotlin: 小さい方の値を取得
val a = 5
val b = 10
val minValue = if (a < b) a else b

Kotlinには三項演算子が無いので、素直にif文を書いた方が良さそうです。

if文で値を返すことができるので、代入程度なら1行に書いても大差ないですし、 代入以外で三項演算子を使っても可読性が落ちる場合が多いので、特に問題は無いでしょう。

Kotlinでリスナーを書く方法とSAM変換

Kotlinでのリスナーの書き方

Kotlinは少し触っている程度で未だにSAM変換の書き方を覚えられていないため、必要なところだけ自分なりにまとめてみます。

何が何でもSAM変換する必要は無いと思っています。あくまでソースの述量減少に伴う可読性を重視することが大事なので、 コーディング規約に従ってある程度の裁量を持たせても良いかと思います。


SAM変換とSAM変換が使える条件

SAM変換はSAM(Single Abstract Method)を定義したinterfaceを、ラムダ式に置き換えることです。

名前から分かる通り、メソッドを1個だけ定義したインターフェースをクラス化するときに記述量を減らせるだけなので、 複数のメソッドを定義しているインターフェースは従来通りの書き方になります。

あと注意点として、Javaで定義されているinterface限定になります。Kotlinでinterfaceを定義した場合は使えません。

何故そのような仕様になっているのかは分からないですが、今後Kotlinに開発の主軸が移ってきたら対応されるのでしょうか?


冗長な書き方

Kotlinを使っている人はJavaの方が慣れているという人も多いと思うので、Javaのコードから始めていきます。

AndroidでボタンをクリックするとLogを出力する例を書いてみます。

Java

AAAButton.setOnClickListener(new View.OnClickListener() {
  @Override
  public void onClick(View view) {
    Log.v("ログ出力");
  }
});

Kotlin

AAAButton.setOnClickListener( object: View.OnClickListener{
  override fun onClick(view: View?): Unit {
    Log.v("ログ出力")
  }
})

この書き方ではKotlinの省略化のメリットは無いですね。
しかしほぼ1:1で対応しているのでJavaとKotlinを対比する上では見やすいと思います。

SAM変換による省略化

AAAButton.setOnClickListener( object: View.OnClickListener{
  override fun onClick(view: View?): Unit {
    Log.v("ログ出力")
  }
})

このKotlinのコードをベースに進めていきます。

まずsetOnClickListenerに渡すパラメータは、View.OnClickListenerインターフェースを実装したクラスインスタンス ということは文法上自明ですね。

またKotlinでのラムダ式の記述は

{ パラメータ -> メソッドの処理 }

で関数名を省略して書けます。

View.OnClickListenerインターフェースにはonClickメソッドにしかないので、
View.OnClickListenerで実装されているメソッドは一つで、onClickのことだとメソッド名を書かなくても特定出来るわけです。
メソッドの特定ができれば、もちろんパラメータの型も戻り値の型も特定できます。

となると、

  • View.OnClickListener
  • onClick
  • onClickのパラメータの型(View)、戻り値の型(Unit)

は特になくても良い情報となります。削ってしまいましょう。

削る前

AAAButton.setOnClickListener( object: View.OnClickListener{
  override fun onClick(view: View?): Unit {
    Log.v("ログ出力")
  }
})

削った後

AAAButton.setOnClickListener( { view ->
  Log.v("ログ出力")
})

ここからはラムダ式の文法で、

  • 引数が一つであれば省略できて、処理部分で必要ならitとして使用できる
  • メソッド(ここではsetOnClickListener)の最後の引数がラムダ式なら、{}部分を後ろに書ける
  • その状態で()内に引数が無ければ、つまりメソッドの引数がラムダ式だけなら()を省略できる

を適用して省略していくと

AAAButton.setOnClickListener({ Log.v("ログ出力") })
AAAButton.setOnClickListener(){ Log.v("ログ出力") }
AAAButton.setOnClickListener{ Log.v("ログ出力") }

と書くことができます。


中身の少ない同じような記述を並列して定義する場合は、わざわざ指定のinterfaceを実装した内部クラスを作ったりする必要なくシンプルに書くことができます。

個人的には内部の処理が長かったり複雑だったりするとクラス分けたくなりますが、この辺はコーディング規約などでケースバイケースの対応になるかと思います。


サンプル

SAM変換を理解したところで本題。いくつかのパターンを書いておきます。

インターフェース内のメソッド一つ

リスナークラスだけをパラメータに取るメソッド

AAA.setOnXXXListener{ Log.v("ログ出力") }

リスナークラスだけをパラメータに取るメソッドでメソッドの引数を使用したい場合(itを使用)

AAA.setOnXXXListener{ it.BBB() }

リスナー側で実装を要求されるメソッドにパラメータが複数ある場合

AAAButton.setOnClickListener{ p1, p2 ->
  Log.v("ログ出力")
}

インターフェース内のメソッドが複数

  AAA.setOnXXXListener(object : BBBinterface {
    override fun onCCCMethod(p: DDDParameterClass?) {
      Log.v("ログ出力")
    }
    override fun onEEEMethod(p: FFFParameterClass?) {
    }
})

処理が長い場合

  num = 10
  AAA.setOnXXXListener(BBBClass(num))

・・・中略・・・

// 要求されるインターフェースを実装する内部クラス
inner class BBBClass(num: Int) : OnXXXListener {
    override fun onCCCMethod(p: DDDParameterClass?) {
      ・・・中略・・・
    }
}

You need to add a reference to Mono.Android.Export.dll when you use ExportAttribute or ExportFieldAttribute.の対処法

Xamarin.AndroidでMono.Android.Export.dll参照エラーが出た時の対処法

エラー表示

You need to add a reference to Mono.Android.Export.dll when you use ExportAttribute or ExportFieldAttribute.

(ExportAttributeかExportFieldAttributeの属性を使う場合はMono.Android.Export.dllの参照を追加する必要があります。)

対応方法

参照が足りていないので追加するだけです。


f:id:hildsoft:20180527212709j:plain

ソリューションエクスプローラーのAndroidプロジェクトの参照に「Mono.Android.Export」がありません。


f:id:hildsoft:20180527212718j:plain

参照を右クリックしてメニューを表示し、「参照の追加」を選択します。


f:id:hildsoft:20180527211952j:plain

左側から「アセンブリ」を選択し、「Mono.Android.Export」にチェックを入れてOKボタンでダイアログを閉じます。


f:id:hildsoft:20180527212725j:plain

参照に「Mono.Android.Export」が追加されていればビルドが通ると思います。

UnityでuGUIを使ったUIに3Dオブジェクトを表示させたい

UnityでuGUIを使ったUIに3Dオブジェクトを表示させたい

f:id:hildsoft:20180110045224p:plain

検証環境

Unity:2017.2.0f3

Canvasの設定

CanvasにはRender Modeというプロパティがあります。

f:id:hildsoft:20180110031805p:plain

  • Screen Space - Overlay
  • Screen Space - Camera
  • World Space

Screen Space - Overlay

f:id:hildsoft:20180110033127p:plain

Overlayは被せるとか覆うという意味があります。

文字通り、カメラで映し出した映像の上にCanvasの情報を上書きします。

UIは通常画面の一番手前に表示され、他のもので隠されることは無いためこの使い方が一般的です。


Screen Space - Camera

f:id:hildsoft:20180110033356p:plain

この設定は、カメラから一定の位置にCanvasを配置するものです。

OverlayではUIが最前面に表示されるので3Dオブジェクトを配置することができません。

そこで、UI上でも3Dオブジェクトを使う場合は、UIをScene内に入れてしまいます。

今回はこの設定でUI上に3Dオブジェクトを表示させます。


World Space

f:id:hildsoft:20180110034327p:plain

せっかくなので、ついでにもう一つの設定も軽く見ておきましょう。

この設定は文字通り、ワールドスペース内にCanvasをオブジェクトとして配置します。

実際に空間内に存在するので、カメラを移動してもCanvasは動かないため、空間に固定されたものになります。

使ったことは無いですがVRなどで使用されるのかな?

ゲーム内でキャラクターが操作するパネルとして使用することも可能だと思います。


UI用のカメラを作成する

f:id:hildsoft:20180110033356p:plain

この例にもあるように、同じカメラを使用してしまうと位置関係がややこしくなったり、ボタンが隠れてしまったりするのでUI専用のカメラを用意します。

f:id:hildsoft:20180110035341p:plain

Hierarchyで右クリックして、Cameraを追加します。

ここでは「UI Camera」としておきます。

f:id:hildsoft:20180110035637p:plain

Cameraを追加するとAudio Listenerコンポーネントも付いてきますが、Main Cameraにもあるため、2個になってしまいます。

UIでは必要がないのと警告が出るので、これは削除します。

Canvasにカメラを設定

f:id:hildsoft:20180110040012p:plain

これでUI Cameraを移動してもCanvasは自動でUI Cameraに付いてきて、UI Cameraの視界内に入る3Dオブジェクトを描画することができます。


映したいものと映したくないものを分ける

UI用のカメラが3Dオブジェクトを映せるようになると一つ問題が出てきます。

UIだけに映したくてもMainカメラの視界内にUIのオブジェクトが存在すると表示されてしまいます。逆もしかりです。

そこで使用するのがレイヤー機能です。

f:id:hildsoft:20180110041700p:plain

レイヤーはオブジェクトを選択して、Inspectprから変更可能です。

f:id:hildsoft:20180110041854p:plain

0~7までの8個はシステムに予約されていますが、8~31までの24個はユーザーが自由に使用できます。

UI用のカメラに表示したい物を扱うレイヤーを追加します。ここでは「UI 3D Object」とします。


レイヤーを追加しただけでは何も変化はありません。

カメラ側の設定で、何を映すかを指定する必要があります。

f:id:hildsoft:20180110042456p:plain

メインカメラの設定ではUIに関する物を映さない様にCulling Maskから「UI」と「UI 3D Object」を外します。

f:id:hildsoft:20180110042625p:plain

UIカメラの設定ではUIに関する物を映す様にCulling Maskに「UI」と「UI 3D Object」をチェックします。

必要なら、Lightの設定も同様に変更してください。


カメラの描画順

このままではMain Camera描画後に、UI Cameraの映したものを上書きしてしまいます。

なので、あとから描画するUI Cameraの方は必要なところだけを上書きするようにします。

f:id:hildsoft:20180110043215p:plain

Clear FlagsをDepth onlyにすると、必要な個所だけが上書きで更新されます。

また、Depthの値がMain Cameraより大きな値になっていることを確認してください。(初期状態では問題無いと思います)


完成するとこんな感じに

f:id:hildsoft:20180110045224p:plain

作成した3Dオブジェクトは、紐づくUIの子要素にしておくことでカメラの回転やUI要素の移動時にも対応可能になります。

ただし、移動の際はパースの問題が出るため、カメラのProjectionを「Orthographic」にしておいた方がいいかもしれません

Arbor2を使ってみよう その6 ParameterContainer

前回の記事はこちら

code.hildsoft.com

検証バージョン

Unity 2017.1.1p1
Arbor 2.1.7

ParameterContainer

ParameterContainerの役割

ParameterContainerは複数のArborFSMから参照、更新するための変数を保持するためのコンポーネントです。

複数作成することもできますが、グローバル変数のようなものなので、無計画に増やしすぎると管理しづらくなるので注意してください。


f:id:hildsoft:20170929154910j:plain

GameObjectに追加して作成します。


f:id:hildsoft:20170929154919j:plain

空のGameObjectと一緒に作成することもできます。


パラメータの追加方法

f:id:hildsoft:20170929170140j:plain

右上にある+をクリックしてメニューを出し、追加したいクラスを選択するだけです。


パラメータの種類

f:id:hildsoft:20170929170319j:plain

よく使うクラスは一通りそろっているので、自分で拡張する必要は恐らく無いと思います。

Quaternionは内部ではw,x,y,zの4変数で持っている物を変換してx,y,zのオイラー回転で表示しているので直接入力すると若干ズレることがあります。

基本的にQuaternionは手作業で修正するものではないので、Arborやスクリプトから直接Quaternionとして操作した方が良いでしょう。


GlobalParameterContainerの役割

GlobalParameterContainerは、ParameterContainerをシーンを跨いで使用したいときに使います。

ParameterContainerと同じように変数を持つのではなく、ParameterContainerを管理する感じです。


f:id:hildsoft:20170929161747j:plain

まず、ParameterContainerをPrefab化する必要があります。


f:id:hildsoft:20170929161804j:plain

そして、Prefab化したものを登録します。


f:id:hildsoft:20170929162635j:plain

シーン起動時に存在しなければ自動で作成され、存在していれば何も起こりません。

複数のシーンで使用するものは初期化のタイミングや重複などに気を付けないといけないのですが、Prefab化してGlobalParameterContainerを使用するとその煩わしさを回避できます。


ParameterContainerの実際の使い方などは、また別途記事を作成したいと思います。

Arbor2を使ってみよう その5 Calculator

前回の記事はこちら

code.hildsoft.com

検証バージョン

Unity 2017.1.1p1
Arbor 2.1.7

Calculator

Calculatorの役割

f:id:hildsoft:20170925033442j:plain

Calculatorは変数を計算したり変換したりする時に使用します。


Behaviourの設定と接続

f:id:hildsoft:20170925034002j:plain

BehaviourでCalculatorの値を使う場合は、▼をクリックしてメニューを出してから「Calculator」を選択します。


f:id:hildsoft:20170925034321j:plain

変数のタイプをCalculatorにすると、Calculatorの出力からドラッグ&ドロップで紐づけすることができます。


f:id:hildsoft:20170925041250j:plain

接続することのできる入出力は、同じ型(クラス)でなければいけません。

変数のタイプは、Calculatorの欄にカーソルを合わせると、型(クラス)情報が表示されます。


Calculatorの出力値

f:id:hildsoft:20170925040410j:plain

一部の型のCalculatorは実行時にどのような値が出力されているのかを目視確認できます。


Calculatorの実行タイミング

CalculatorはBehaviourが必要としたタイミングで初めて計算されます。

また、キャッシュを持っているため、Calculatorに渡される入力値に変更がない場合は再計算されずキャッシュされた同じ値を返します。


Calculatorの用途

Calculatorは基本的な物しか用意されていません。

RPGでのダメージ計算や自キャラの一番近くの敵を探すなど、作るゲームで必要なものを作成していくことになります。


無いものは作る

Behaviourもそうですが、Arborは基本的な機能を提供し、必要な物を自前で作るという設計思想のようです。

新しくBehaviourやCalculatorを作成するにはこちらを参照してください。

http://arbor.caitsithware.com/manual/customize/

この辺を上手く作るにはプログラマが必要になると思います。


code.hildsoft.com