Unity
Unity拡張
UnityEditor
ScriptableObject

Unityでエディタ拡張を始めよう

グレンジ Advent Calendar 2017 21日目の記事担当kenjiです
クライアントエンジニアやってます

Unity触ってるけどエディタ拡張はしたことがないという人が意外といると思います(たぶん)
というか個人的にエディタ拡張が好きなんです

今回はwindow作成~ScriptableObjectの読み書き
おまけにその工程での小ネタや無駄にこだわるレイアウトについて書きます

と言っても単純にScriptableObjectを作るだけでは簡単なので
今回はマスターデータ的な位置づけとして作成してみます

主に以下の点を考慮して作成してみます
・書き出し後にインスペクターで編集できないようにする!
 →エディタウィンドウ上でしか編集できないようにする
・アプリケーション実行中に値を変えられないようにする!
 →ScriptableObjectは一時データやデバッグ設定とかでも使うけど今回はそういう使い方をしない
・やるからにはレイアウトもいい感じにする!
 →エディタは分かりやすさ重要!

※実装内容はUnity「2017.2.x」時点の話です

1. EditorWindowスクリプトの作成

まずエディタ用のスクリプトを作ります
作成するクラスはEditorWindowを継承

EditorWindowSample.cs
using UnityEngine;
using UnityEditor;

public class EditorWindowSample : EditorWindow
{
    [MenuItem("Editor/Sample")]
    private static void Create()
    {
        // 生成
        GetWindow<EditorWindowSample>("サンプル");
    }
}

とりあえず最低限の実装はこれだけ
Unityの上部メニューに「Editor > Sample」が追加されるので、そこからウィンドウを開く

小ネタ:Editorスクリプトについて

using UnityEditor;

エディタ用の名前空間を記述するファイルは基本的に「Editor」フォルダの中に入れる
「Editor」フォルダは綴りさえ合えばどこにあってもいい
それ以外の場所でエディタ機能を使用したい場合はそのままだとビルド時にエラーになるので

#if UNITY_EDITOR
using UnityEditor;
#endif

エディタ実行時のみのディレクティブで囲んじゃいましょう

小ネタ:windowの設定

// 生成
EditorWindowSample window = GetWindow<EditorWindowSample>("サンプル");
// 最小サイズ設定
window.minSize = new Vector2(320, 320);

こんな感じでウィンドウの参照取得して色々設定できたりもします

2. ScriptableObjectスクリプトの作成

次にデータ用のスクリプトを作ります
作成するクラスはScriptableObjectを継承

ScriptableObjectSample.cs
using System;
using UnityEngine;

[Serializable]
public class ScriptableObjectSample : ScriptableObject
{
    [SerializeField]
    private int _sampleIntValue;

    public int SampleIntValue
    {
        get { return _sampleIntValue; }
#if UNITY_EDITOR
        set { _sampleIntValue = Mathf.Clamp(value, 0, int.MaxValue); }
#endif
    }
}

ScriptableObject化するクラスには[Serializable]定義が必要
ScriptableObject化して保存する値には[SerializeField]定義が必要
とりあえず今回はint型の変数を保存することにします

setterはエディタ中でしか呼べないようにしたいのでディレクティブで囲む
(エディタ再生中は呼べてしまうがビルド時にエラーになる)

ついでにClampで入力制限もかけてみたりもしてみる

小ネタ:保存しない値

[NonSerialized]
private float _sampleFloatValue;

この値は保存したくないという場合は[NonSerialized]定義

3. 編集

エディタスクリプトに戻って
データを編集できるようにします

EditorWindowSample.cs
/// <summary>
/// ScriptableObjectSampleの変数
/// </summary>
private ScriptableObjectSample _sample;

private void OnGUI()
{
    if (_sample == null)
    {
        _sample = ScriptableObject.CreateInstance<ScriptableObjectSample>();
    }

    using (new GUILayout.HorizontalScope())
    {
        _sample.SampleIntValue = EditorGUILayout.IntField("サンプル", _sample.SampleIntValue);
    }
}

作成したScriptableObjectSampleの変数定義
見た目の記述は「OnGUI」メソッド内に記述する
これで「サンプル」の項目が追加されて値を編集できるようになる

レイアウト系でよく見かける「Horizontal」は横並び「Vertical」は縦並び処理のメソッド

小ネタ:HorizontalとVertical

using (new GUILayout.HorizontalScope())
{
    ...
}

Unity4以前からEditor拡張していた人は↑この記述を初めて見たときに感動する(というか自分はすごく感動した)

GUILayout.BeginHorizontal();
{
    ...
}
GUILayout.EndHorizontal();

以前はBeginとEndのメソッドで挟む書き方しかなかった
一度は対になるBegin or Endを見失ったことがあるはず…(なので中括弧で見やすくしたりしてた)

4. 書き込み

書き込み用のボタンと書き込み処理を作成します

EditorWindowSample.cs
/// <summary>
/// アセットパス
/// </summary>
private const string ASSET_PATH = "Assets/Resources/ScriptableObjectSample.asset";

...

private void OnGUI()
{
    using (new GUILayout.HorizontalScope())
    {
        ...
    }
    using (new GUILayout.HorizontalScope())
    {
        // 書き込みボタン
        if (GUILayout.Button("書き込み"))
        {
            Export();
        }
    }
}

private void Export()
{
    // 新規の場合は作成
    if (!AssetDatabase.Contains(_sample as UnityEngine.Object))
    {
        string directory = Path.GetDirectoryName(ASSET_PATH);
        if (!Directory.Exists(directory))
        {
            Directory.CreateDirectory(directory);
        }
        // アセット作成
        AssetDatabase.CreateAsset(_sample, ASSET_PATH);
    }
    // インスペクターから設定できないようにする
    _sample.hideFlags = HideFlags.NotEditable;
    // 更新通知
    EditorUtility.SetDirty(_sample);
    // 保存
    AssetDatabase.SaveAssets();
    // エディタを最新の状態にする
    AssetDatabase.Refresh();
}

書き込みボタンを押すと「ASSET_PATH」で定義した場所にファイルが書き出される

そしてこれが重要

_sample.hideFlags = HideFlags.NotEditable;

この設定をすることでインスペクターから直接編集できなくなる

エディタウィンドウ内でのみ編集させたい場合にはこれを使用する
この設定が反映されるのはファイルを保存するときなので書き込み前に設定しておく

あと最後の3行はおまじない的なもの
ざっと説明すると…

EditorUtility.SetDirty(_sample);

ファイルを更新したことを通知する
Unityでファイルを更新して保存していないときに出る「*」←これの状態にする

AssetDatabase.SaveAssets();

更新状態を保存する
「*」←これが消える

AssetDatabase.Refresh();

アセットの状態を最新にする
呼ばないと新規作成したデータが瞬時に反映されない時があるので呼んでおく
インポート処理もこのタイミングで呼ばれる

5. 読み込み

読み込み用のボタンと読み込み処理を作成します

EditorWindowSample.cs
private void OnGUI()
{
    ...

    using (new GUILayout.HorizontalScope())
    {
        // 読み込みボタン
        if (GUILayout.Button("読み込み"))
        {
            Import();
        }
        // 書き込みボタン
        ...
    }
}

private void Import()
{
    ScriptableObjectSample sample = AssetDatabase.LoadAssetAtPath<ScriptableObjectSample>(ASSET_PATH);
    if (sample == null)
        return;

    _sample = sample;
}

上記はエディタウィンドウで読み込む場合

アプリケーション実行中に読み込む時は

ScriptableObjectSampleLoader.cs
using UnityEngine;

public class ScriptableObjectSampleLoader : MonoBehaviour
{
    private ScriptableObjectSample _sample;

    private void Start()
    {
        // 読み込み & 生成
        _sample = ScriptableObject.Instantiate(Resources.Load<ScriptableObjectSample>("ScriptableObjectSample"));
    }
}

これでいける
public変数やsetterを定義してるとScriptableObject.Instantiateで複製しないと本体の値をアプリケーション実行中に自由に変えられてしまうので理由がない限りは複製して使用する
そもそも今回の場合はアプリケーション実行中に値編集できないようにするけど

6. 完成?

読み書き対応したし…これで一通りの実装ができた…?

ように思わせて
実際にエディタを触ってみるといくつか問題がでてくる

1つ目
新規作成 or 読み込み以降にエディタ上の「サンプル」の値を変えると書き出したScriptableObjectファイルの値も一緒に切り替わってしまう
同じオブジェクトを参照してるのでそりゃそうなる
今回は書き込みボタンを押したときにファイルを更新させたい!

2つ目
「書き込み(新規作成)」→「ウィンドウ閉じて開きなおす」→「(読み込みをせず)書き込み」でファイル名が同じ別物のデータで上書きされてしまう
別ファイルに変わってしまうのでプレハブとかに参照つけてたものも外される
一度作成したファイルを更新するようにしたいんだけど…

3つ目
最低限のものしか配置していないとはいえ見た目が質素←若干強引
なのでいい感じに改良してみましょう!←個人的にやりたいだけ

7. 書き込みでファイル更新

1つ目と2つ目の問題はまとめて対応
同じオブジェクトを参照しているのが原因なので
編集用と保存用で参照するScriptableObjectは別にします

まず書き込み処理の修正

EditorWindowSample.cs
private void Export()
{
    // 読み込み
    ScriptableObjectSample sample = AssetDatabase.LoadAssetAtPath<ScriptableObjectSample>(ASSET_PATH);
    if (sample == null)
    {
        sample  = ScriptableObject.CreateInstance<ScriptableObjectSample>();
    }

    // 新規の場合は作成
    if (!AssetDatabase.Contains(sample as UnityEngine.Object))
    {
        string directory = Path.GetDirectoryName(ASSET_PATH);
        if (!Directory.Exists(directory))
        {
            Directory.CreateDirectory(directory);
        }
        // アセット作成
        AssetDatabase.CreateAsset(sample, ASSET_PATH);
    }

    // コピー
    sample.Copy(_sample);

    // 直接編集できないようにする
    sample.hideFlags = HideFlags.NotEditable;
    // 更新通知
    EditorUtility.SetDirty(sample);
    // 保存
    AssetDatabase.SaveAssets();
    // エディタを最新の状態にする
    AssetDatabase.Refresh();
}

保存用ScriptableObjectを取得して編集用ScriptableObjectの値をコピーする
書き込み時は編集用のScriptableObjectを更新するようにする

ScriptableObjectSample.cs
[Serializable]
public class ScriptableObjectSample : ScriptableObject
{
    ...

#if UNITY_EDITOR
    public void Copy(ScriptableObjectSample sobj)
    {
        _sampleIntValue = sobj.SampleIntValue;
    }
#endif
}

コピーはScriptableObjectがディープコピー系のメソッド(ICloneableとか)に対応していないので地道にコピーする

次に読み込み処理を対応する

EditorWindowSample.cs
private void OnGUI()
{
    if (_sample == null)
    {
        // 読み込み
        Import();
    }

    ...
}

private void Import()
{
    if (_sample == null)
    {
        _sample = ScriptableObject.CreateInstance<ScriptableObjectSample>();
    }

    ScriptableObjectSample sample = AssetDatabase.LoadAssetAtPath<ScriptableObjectSample>(ASSET_PATH);
    if (sample == null)
        return;

    // コピーする
    _sample.Copy(sample);
}

読み込み時にもコピー処理を適応する
あとデータが生成されていなければデフォルトでファイルを読み込むようにしてみる

これで「サンプル」の値を更新しても保存ファイルは更新されず
書き込みボタンを押したときに更新されるようになる

これで目標としてたエディタの機能は一通り対応できた

8. レイアウト更新

ここまでで単純に配置してると見た目はこんな感じになってるはず
とりあえずプログラマーが分かればそれでいいや的な臭いがするやつ
sample01.png

これをプランナーのやる気を出させるためにエディタっぽい見た目にしてみる
sample02.png

このくらいは案外簡単にできちゃいます

EditorWindowSample.cs
private void OnGUI()
{
    ...

    Color defaultColor = GUI.backgroundColor;
    using (new GUILayout.VerticalScope(EditorStyles.helpBox))
    {
        GUI.backgroundColor = Color.gray;
        using (new GUILayout.HorizontalScope(EditorStyles.toolbar))
        {
            GUILayout.Label("設定");
        }
        GUI.backgroundColor = defaultColor;

        _sample.SampleIntValue = EditorGUILayout.IntField("サンプル", _sample.SampleIntValue);
    }
    using (new GUILayout.VerticalScope(EditorStyles.helpBox))
    {
        GUI.backgroundColor = Color.gray;
        using (new GUILayout.HorizontalScope(EditorStyles.toolbar))
        {
            GUILayout.Label("ファイル操作");
        }
        GUI.backgroundColor = defaultColor;

        GUILayout.Label("パス:" + ASSET_PATH);

        using (new GUILayout.HorizontalScope(GUI.skin.box))
        {
            GUI.backgroundColor = Color.green;
            // 読み込みボタン
            if (GUILayout.Button("読み込み"))
            {
                Import();
            }
            GUI.backgroundColor = Color.magenta;
            // 書き込みボタン
            if (GUILayout.Button("書き込み"))
            {
                Export();
            }
            GUI.backgroundColor = defaultColor;
        }
    }
}

GUI, Layout, Editor ...って連呼してますが
レイアウト的に重要なのは主に下記の2つ

EditorStyles
GUI.skin

この2つにデフォルトのスタイルが色々あるので
それらを使うことでいい感じの見た目にすることができます

小ネタ:カラー設定

結構知らない人がいるカラー設定
タグで設定できる
ログに使用するとぱっと見でわかるので個人的によく使う

16進で書いたり

Debug.Log("<color=#00FF00>Import!</color>" );

定数で書いたり

Debug.Log("<color=magenta>Export!</color>" );

カラーじゃないけど文字サイズを指定できたり色々できる

Debug.Log("<size=32>Export!</size>" );

まとめ

今回紹介したものはエディタ拡張のほんの一部です
他にもインスペクターを拡張したりファイルの読み込み時に操作したりビューワーを作ったり…
やれることは山ほどあります

エディタ拡張についての基本的な機能は公式に書かれているので興味を持った方は是非読んでみてください
http://anchan828.github.io/editor-manual/web/

エディタ拡張して便利機能を作ったりして作業の効率化をしてみましょう!
(見た目の分かりやすさも忘れずに!)