第24章 シーンアセットにスクリプトをアタッチ - エディター拡張入門

第24章 シーンアセットにスクリプトをアタッチ

事の発端は、「空のゲームオブジェクトにスクリプトをアタッチするくらいなら、シーンファイルにスクリプトをアタッチしたい」というツイートを見かけたからです。面白そうだったので作ってみました。


24.1 GlobalManager のようなゲームオブジェクト

全体を管理するための管理クラスなどを空のオブジェクトにアタッチしていませんか?

私もよくやる Manager クラス

図24.1: 私もよくやる Manager クラス

このような管理クラスは、時には必要不可欠なものです。

これがもしシーンにスクリプトをアタッチできたら?

もしシーンファイルにスクリプトをアタッチできるのであれば、管理クラスのためにゲームオブジェクトを作成する必要がなくなります。それ以外は特に大きな変化はありませんが「もしかすると」こちらのほうが管理のイメージが楽になり、ミスも減るかもしれません。

空のゲームオブジェクトを生成せずにスクリプトをアタッチできる!

図24.2: 空のゲームオブジェクトを生成せずにスクリプトをアタッチできる!

本章では、シーンアセットにスクリプトをアタッチするまでに使った技術を解説していきます。

24.2 シーンアセットのカスタムエディター

まずはシーンファイルのインスペクターをカスタマイズできるようにしなければいけません。ですが、Scene クラスというものは存在せず、少し工夫が必要になります。

SceneAsset のカスタムエディター

まずは、シーンアセットである SceneAsset クラスのカスタムエディターを作成します。

シーンアセットを選択するとインスペクターにラベルが表示される

図24.3: シーンアセットを選択するとインスペクターにラベルが表示される

1点だけ気をつけて欲しいのは、シーンアセットのインスペクターは GUI.enabled = false となっており、GUI を操作することが不可能になっています。強制的に true を設定してあげなければいけません。

[CustomEditor (typeof(SceneAsset))]
public class SceneInspector : Editor
{
    public override void OnInspectorGUI ()
    {
        GUI.enabled = true;

        EditorGUILayout.LabelField ("シーンアセットのインスペクター!");
    }
}

24.3 シーンアセットにアタッチしたコンポーネントをどこに保持するか

通常はシーンアセットにコンポーネントをアタッチはできません。また、エディター拡張によってシーンアセットにコンポーネント情報を追加することもできません。

なので今回は見た目だけシーンアセットにコンポーネントがアタッチできる仕組みを実装します。

シーンアセットに対応したプレハブを生成する

シーンにアタッチするコンポーネントは自動生成されたプレハブに保持します。

特定のフォルダー(他のユーザーが触らない Editor フォルダー以下が好ましい)配下にプレハブを作成します。この時、プレハブ名をシーンアセットと関連するものにしますが、シーン名(パスも同様)は同名のものが存在する可能性があるので guid で管理します。

まずは、そのためのユーティリティクラスを作成します。

using UnityEngine;
using UnityEditor;
using System.IO;

public class ScenePrefabUtility
{
    const string PREFAB_FOLDER_PATH = "Assets/Editor/ScenePrefabs";

    [InitializeOnLoadMethod]
    static void CreatePrefabFolder ()
    {
        Directory.CreateDirectory (PREFAB_FOLDER_PATH);
    }

    public static GameObject CreateScenePrefab (string scenePath,
                                        params System.Type[] components)
    {
        var guid = ScenePathToGUID (scenePath);

        //HideFlags はコンパイルエラーなどの予想外のエラーによって中断された時の対策として
        //非表示 & 保存禁止
        var go = EditorUtility.CreateGameObjectWithHideFlags (guid,
                                     HideFlags.HideAndDontSave, components);

        var prefabPath = string.Format ("{0}/{1}.prefab", PREFAB_FOLDER_PATH, guid);

        var prefab = PrefabUtility.CreatePrefab (prefabPath, go);

        //プレハブ生成のために作成したゲームオブジェクトは破棄
        Object.DestroyImmediate (go);

        return prefab;
    }

    //プレハブ名をシーンアセットの guid にする
    public static GameObject GetScenePrefab (string scenePath)
    {
        //シーン名だと同名が存在する可能性があるので guid にする
        var guid = ScenePathToGUID (scenePath);
        var prefabPath = string.Format ("{0}/{1}.prefab", PREFAB_FOLDER_PATH, guid);
        return AssetDatabase.LoadAssetAtPath<GameObject> (prefabPath);
    }

    private static string ScenePathToGUID (string scenePath)
    {
        return AssetDatabase.AssetPathToGUID (scenePath);
    }
}

上記コードを使用してインスペクターを表示するときにプレハブを取得、または生成します。プレハブにアクセスするタイミングはインスペクターが表示される時、つまり SceneInspector のオブジェクトが生成されるときの OnEnable で行います。

using UnityEngine;
using UnityEditor;
using System.IO;

[CustomEditor (typeof(SceneAsset))]
public class SceneInspector : Editor
{
    GameObject scenePrefab;

    void OnEnable ()
    {
        var assetPath = AssetDatabase.GetAssetPath (target);

        //プレハブ取得
        scenePrefab = ScenePrefabUtility.GetScenePrefab (assetPath);

        //なければ生成
        if (scenePrefab == null)
            scenePrefab = ScenePrefabUtility.CreateScenePrefab (assetPath);

    }
}

これで、シーンアセットを選択したと同時にプレハブが生成されるようになりました。

プレハブ名はシーンアセットの guid

図24.4: プレハブ名はシーンアセットの guid

24.4 プレハブのコンポーネントをシーンアセットのインスペクターに表示

プレハブのコンポーネントを取得後 コンポーネントに対応した Editor オブジェクトを生成します。そして Editor.OnInspectorGUI を呼び出すことによって 図24.5 のようなコンポーネントの表示を行うことができます。

各コンポーネントのプロパティーが表示されている。いつも見る光景

図24.5: 各コンポーネントのプロパティーが表示されている。いつも見る光景

Editor オブジェクトの生成

Editor オブジェクトを生成します。

まずは、コンポーネントの取得です。これは「プレハブから GetComponents でコンポーネントを取得」するだけです。その後 Editor.CreateEditor でエディターオブジェクトを生成します。

問題は エディターオブジェクトの保持の仕方です。まず Dictionary<Editor, bool> で保持しているのは EditorGUI.InspectorTitlebar を使用するために bool 値と Editor オブジェクトを紐付けるために使用しています。

foldout の役割を持つため ▼ をクリックすることで開閉できる

図24.6: foldout の役割を持つため ▼ をクリックすることで開閉できる

次に、Editor オブジェクトの破棄です。普段カスタムエディターの実装のために Editor オブジェクトは使用します。その時は自動的に Editor オブジェクトの生成/破棄が行なわれているため、意識していないかもしれませんが、通常なんらかのオブジェクトを生成した場合は適切に破棄をする処理を加えなければいけません。

次のコードでは OnDisable メソッドや Editor オブジェクトの(再)取得の時に、明示的に Object.DestroyImmediate を呼び出して破棄しています。

破棄せずに放置しておくと、GenericInspector オブジェクトが破棄されずにメモリが圧迫されていきます。

メモリプロファイラーで見ると GenericInspector が大量にあることが分かる

図24.7: メモリプロファイラーで見ると GenericInspector が大量にあることが分かる

GameObject scenePrefab;

Dictionary<Editor,bool> activeEditors = new Dictionary<Editor, bool> ();

void OnEnable ()
{
    var assetPath = AssetDatabase.GetAssetPath (target);

    scenePrefab = ScenePrefabUtility.GetScenePrefab (assetPath);

    if (scenePrefab == null)
        scenePrefab = ScenePrefabUtility.CreateScenePrefab (assetPath);

    InitActiveEditors ();

}

void OnDisable ()
{
    ClearActiveEditors ();
}

//生成した Editor オブジェクトの破棄
void ClearActiveEditors ()
{
    foreach (var activeEditor in activeEditors) {
        Object.DestroyImmediate (activeEditor.Key);
    }
    activeEditors.Clear ();
}

void InitActiveEditors ()
{
    ClearActiveEditors ();

    //コンポーネントから Editor オブジェクトを生成
    foreach (var component in scenePrefab.GetComponents<Component> ()) {

        //Transform と RectTransform は省く
        //本章の目的では必要ないと判断したため
        if (component is Transform || component is RectTransform)
            continue;

        activeEditors.Add (Editor.CreateEditor (component), true);
    }
}

そして、実際にインスペクターに描画する GUI の部分です。

public override void OnInspectorGUI ()
{
    GUI.enabled = true;

    var editors = new List<Editor> (activeEditors.Keys);

    foreach (var editor in editors) {

        DrawInspectorTitlebar (editor);

        GUILayout.Space (-5f);

        if (activeEditors [editor] && editor.target != null)
            editor.OnInspectorGUI ();

        DrawLine ();
    }

    //コンテキストの Remove Component によって削除された場合、Editor.target は null になる
    //その時は初期化する
    if (editors.All (e => e.target != null) == false) {
        InitActiveEditors ();
        Repaint ();
    }
}

void DrawInspectorTitlebar (Editor editor)
{
    var rect = GUILayoutUtility.GetRect (GUIContent.none,
                                         GUIStyle.none,
                                         GUILayout.Height (20));
    rect.x = 0;
    rect.y -= 5;
    rect.width += 20;
    activeEditors [editor] = EditorGUI.InspectorTitlebar (rect,
                                                          activeEditors [editor],
                                                          editor.target,
                                                          true);
}

void DrawLine ()
{
    EditorGUILayout.Space ();
    var lineRect = GUILayoutUtility.GetRect (GUIContent.none,
                                             GUIStyle.none,
                                             GUILayout.Height (2));
    lineRect.y -= 3;
    lineRect.width += 20;
    Handles.color = Color.black;

    var start = new Vector2 (0, lineRect.y);
    var end = new Vector2 (lineRect.width, lineRect.y);
    Handles.DrawLine (start, end);
}

これで 図24.8 のような表示ができるようになりました。ですがまだシーンアセットにコンポーネントをアタッチできません。

ゲームオブジェクトにアタッチされたようなコンポーネントと同じ表示

図24.8: ゲームオブジェクトにアタッチされたようなコンポーネントと同じ表示

24.5 コンポーネントをインスペクターにドラッグ & ドロップ

次に 図24.9 のようにコンポーネントをドラッグ & ドロップによってアタッチします。

インスペクターの何もない空間にコンポーネントをドラッグ&ドロップできる

図24.9: インスペクターの何もない空間にコンポーネントをドラッグ&ドロップできる

void OnEnable ()
{
    //Undo によって変更された状態を初期化
    Undo.undoRedoPerformed += InitActiveEditors;
}

void OnDisable ()
{
    Undo.undoRedoPerformed -= InitActiveEditors;
}

public override void OnInspectorGUI ()
{

    //略

    //OnInspectorGUI の最後に実装

    //残りの余った領域を取得
    var dragAndDropRect = GUILayoutUtility.GetRect (GUIContent.none,
                                                     GUIStyle.none,
                                                     GUILayout.ExpandHeight (true),
                                                     GUILayout.MinHeight (200));

    switch (Event.current.type) {
        //ドラッグ中 or ドロップ実行
        case EventType.DragUpdated:
        case EventType.DragPerform:

            //マウス位置が指定の範囲外であれば無視
            if (dragAndDropRect.Contains (Event.current.mousePosition) == false)
                break;

            //カーソルをコピー表示にする
            DragAndDrop.visualMode = DragAndDropVisualMode.Copy;

            //ドロップ実行
            if (Event.current.type == EventType.DragPerform) {
                DragAndDrop.AcceptDrag ();

                //ドロップしたオブジェクトがスクリプトアセットかどうか
                var components = DragAndDrop.objectReferences
                    .Where (x => x.GetType () == typeof(MonoScript))
                    .OfType<MonoScript> ()
                    .Select (m => m.GetClass ());

                //コンポーネントをプレハブにアタッチ
                foreach (var component in components) {
                    Undo.AddComponent (scenePrefab, component);
                }

                InitActiveEditors ();
            }
            break;
    }

    //ドロップできる領域を確保
    GUI.Label (dragAndDropRect, "");
}

これで基本的な機能ができ上がりました。

最後に、Unity エディターがクラッシュしてしまうとプレハブの仕様に則って未保存のコンポーネント情報が消えてしまうのでコンポーネントを追加した直後や OnDisable 内など適切なタイミングで AssetDatabase.SaveAssets を呼び出すといいかもしれません。

void OnDisable ()
{
    AssetDatabase.SaveAssets ();
}

24.6 シーン再生時&ビルド時に自動的にプレハブをインスタンス化

シーンアセットにアタッチしたコンポーネントはプレハブで管理されています。なので「どこかのタイミングで」シーン内にプレハブをインスタンス化しなければいけません。

シーン再生時やビルド時にプレハブのインスタンス化したゲームオブジェクトを含めるには、UnityEditor.CallbacksPostProcessSceneAttribute を使用します。

using UnityEngine;
using UnityEditor;
using System.IO;
using UnityEditor.Callbacks;

public class ScenePrefabUtility
{
    [PostProcessScene]
    static void OnPostProcessScene ()
    {
        //現在開いているシーンからシーンパスを取得
        var scenePath = EditorBuildSettings.scenes [Application.loadedLevel].path;

        if (string.IsNullOrEmpty (scenePath))
            return;

        //自動で生成しているプレハブを取得
        var prefab = GetScenePrefab (scenePath);

        //インスタンス化
        if (prefab)
            GameObject.Instantiate (prefab).name = "ScenePrefab";
    }
}

これで「シーンアセットにスクリプトをアタッチ」から「実際に使用する」ことが可能になりました。

第23章 SpriteAnimationPreview(スプライトアニメーション) 第25章 時間を制御する TimeControl