【Unity】テスト用のスポーン位置を簡単に切り替えるエディタ拡張

投稿者: | 2023-06-06

マップ上の目的の位置へ移動するのが面倒なので、ゲームの進行とは別にプレイヤーのテスト用のスポーン位置を設定できるようにしてみました。

スポーン位置を複数保存して、簡単に切り替えられるようにします。位置はシーンビューでハンドルで変更できるようにし、切り替えはハンドルの近くに表示したボタンかインスペクタで行います。ラベルも表示します。

スクリプト

スクリプトは、ChatGPTが出力したコードを修正して作りました。まず、スポーン位置を表すクラスと、それの配列とインデックスを持つスクリプタブルオブジェクトを作ります。

using UnityEngine;

[CreateAssetMenu(fileName = "PlayerSpawnManager", menuName = "Test/PlayerSpawnManager")]
public class PlayerSpawnManager : ScriptableObject
{
    public SpawnPositionData[] spawnPositions = new SpawnPositionData[0];
    public int currentSpawnIndex = -1;
    public float playerHeight = 2f;

    public Vector3 CurrentPosition
    {
        get
        {
            var pos = spawnPositions[currentSpawnIndex].position;
            pos.y += playerHeight / 2f;
            return pos;
        }
    }
}

[System.Serializable]
public class SpawnPositionData
{
    public Vector3 position;
    public string name = "Label";
}

スクリプタブルオブジェクトの派生クラスにはCreateAssetMenu属性を付けて、メインメニューからインスタンス化できるようにしています。

using UnityEngine;

[CreateAssetMenu(fileName = "PlayerSpawnManager", menuName = "Test/PlayerSpawnManager")]
public class PlayerSpawnManager : ScriptableObject
{

スポーン位置のデータの配列や使用する要素のインデックス、プレイヤーの高さのpublicフィールドがあります。実際に使う位置は、ハンドルの位置のY座標にプレイヤーのピボットまでの高さを足した位置です。

    public SpawnPositionData[] spawnPositions = new SpawnPositionData[0];
    public int currentSpawnIndex = -1;
    public float playerHeight = 2f;

スポーン位置データのクラスには、Vector3とラベル用のstringが定義されています。

[System.Serializable]
public class SpawnPositionData
{
    public Vector3 position;
    public string name = "Label";
}

位置をのゲッタープロパティでは、配列の中の現在のインデックスが示すデータのスポーン位置にプレイヤーの高さの半分を足した値を返します。

    public Vector3 CurrentPosition
    {
        get
        {
            var pos = spawnPositions[currentSpawnIndex].position;
            pos.y += playerHeight / 2f;
            return pos;
        }
    }
}

このスクリプタブルオブジェクト用のカスタムエディタを作ります。

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

[CustomEditor(typeof(PlayerSpawnManager))]
public class PlayerSpawnManagerEditor : Editor
{
    void OnEnable()
    {
        SceneView.duringSceneGui += OnSceneGUI;
    }

    void OnDisable()
    {
        SceneView.duringSceneGui -= OnSceneGUI;
    }
    public override void OnInspectorGUI()
    {
        PlayerSpawnManager settings = (PlayerSpawnManager)target;

        // Add a button for adding a new SpawnPositionData element
        if (GUILayout.Button("Add New Spawn Position"))
        {

            System.Array.Resize(ref settings.spawnPositions, settings.spawnPositions.Length + 1);
            settings.spawnPositions[settings.spawnPositions.Length - 1] = new SpawnPositionData();

            if (settings.currentSpawnIndex == -1)
            {
                settings.currentSpawnIndex = 0;
            }
        }

        for (int i = 0; i < settings.spawnPositions.Length; i++)
        {
            using (new EditorGUILayout.HorizontalScope())
            {
                settings.spawnPositions[i].name = EditorGUILayout.TextField(settings.spawnPositions[i].name);
                settings.spawnPositions[i].position = EditorGUILayout.Vector3Field("", settings.spawnPositions[i].position, GUILayout.MaxWidth(200f));

                // Add a button for removing this SpawnPositionData element
                if (GUILayout.Button("Remove", GUILayout.MaxWidth(55f)))
                {
                    var list = new List<SpawnPositionData>(settings.spawnPositions);
                    list.RemoveAt(i);
                    settings.spawnPositions = list.ToArray();

                    if (settings.currentSpawnIndex > settings.spawnPositions.Length - 1)
                    {
                        settings.currentSpawnIndex = settings.spawnPositions.Length - 1;
                    }

                }

                if (GUILayout.Button("Set as Current", GUILayout.MaxWidth(100f)))
                {
                    settings.currentSpawnIndex = i;
                }
            }
        }


        settings.currentSpawnIndex = EditorGUILayout.IntField("Current Spawn Index", settings.currentSpawnIndex);

        settings.playerHeight = EditorGUILayout.FloatField("Player Height", settings.playerHeight);


        if (GUI.changed)
        {
            EditorUtility.SetDirty(settings);
        }
    }

    void OnSceneGUI(SceneView sv)
    {
        PlayerSpawnManager settings = (PlayerSpawnManager)target;

        if (settings.spawnPositions == null) return;

        for (int i = 0; i < settings.spawnPositions.Length; i++)
        {
            EditorGUI.BeginChangeCheck();
            Color originalColor = Handles.color;
            if (i == settings.currentSpawnIndex)
            {
                Handles.color = Color.red;
            }

            Vector3 newPos = Handles.PositionHandle(settings.spawnPositions[i].position, Quaternion.identity);

            if (EditorGUI.EndChangeCheck())
            {
                Undo.RecordObject(settings, "Change Spawn Position");
                settings.spawnPositions[i].position = newPos;
            }

            Handles.Label(settings.spawnPositions[i].position, settings.spawnPositions[i].name);

            var buttonPos = settings.spawnPositions[i].position;
            buttonPos.y += 0.5f;

            if (Handles.Button(buttonPos, Quaternion.identity, 0.3f, 0.3f, Handles.SphereHandleCap))
            {
                settings.currentSpawnIndex = i;
            }
            Handles.color = originalColor;
        }
    }
}

Editorクラスを継承して、CustomEditor属性で対象のクラスをしています。シーンビューをカスタマイズするために、オブジェクトの有効/無効化時にSceneView.duringSceneGuiを購読開始/解除します。

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

[CustomEditor(typeof(PlayerSpawnManager))]
public class PlayerSpawnManagerEditor : Editor
{
    void OnEnable()
    {
        SceneView.duringSceneGui += OnSceneGUI;
    }

    void OnDisable()
    {
        SceneView.duringSceneGui -= OnSceneGUI;
    }

OnInspectorGUIメソッドをオーバーライドして、インスペクタ内の表示を実装します。まずターゲットを対象のクラスでキャストしています。

    public override void OnInspectorGUI()
    {
        PlayerSpawnManager settings = (PlayerSpawnManager)target;

インスペクタには「Add New Spawn Position」ボタンを表示します。ボタンを押すと、配列に要素を一つ増やしてスポーン位置のデータをインスタンス化します。

        if (GUILayout.Button("Add New Spawn Position"))
        {

            System.Array.Resize(ref settings.spawnPositions, settings.spawnPositions.Length + 1);
            settings.spawnPositions[settings.spawnPositions.Length - 1] = new SpawnPositionData();

            if (settings.currentSpawnIndex == -1)
            {
                settings.currentSpawnIndex = 0;
            }
        }

配列が空のときは現在のインデックスが -1 なので 0 に設定しています。

配列の要素の数だけ行を追加します。EditorGUILayout.HorizontalScopeメソッドでGUI要素を横に並べます。まず左からラベルと位置ベクトルを表示します。

        for (int i = 0; i < settings.spawnPositions.Length; i++)
        {
            using (new EditorGUILayout.HorizontalScope())
            {
                settings.spawnPositions[i].name = EditorGUILayout.TextField(settings.spawnPositions[i].name);
                settings.spawnPositions[i].position = EditorGUILayout.Vector3Field("", settings.spawnPositions[i].position, GUILayout.MaxWidth(200f));

GUILayout.MaxWidthメソッドで最大幅を調節できます。

各行のさらに右に「Remove」ボタンを表示します。クリックすると、配列からその行の要素を削除します。現在のインデックスが配列の末尾の要素を示す場合、インデックスを一つ下げます。

                if (GUILayout.Button("Remove", GUILayout.MaxWidth(55f)))
                {
                    var list = new List<SpawnPositionData>(settings.spawnPositions);
                    list.RemoveAt(i);
                    settings.spawnPositions = list.ToArray();

                    if (settings.currentSpawnIndex > settings.spawnPositions.Length - 1)
                    {
                        settings.currentSpawnIndex = settings.spawnPositions.Length - 1;
                    }

                }

その右にもう一つ「Set as Current」ボタンを置き、クリックすると、現在のインデックスがその行の要素を示すようにします。

                if (GUILayout.Button("Set as Current", GUILayout.MaxWidth(100f)))
                {
                    settings.currentSpawnIndex = i;
                }
            }
        }

その後、インデックスとプレイヤーの高さのフィールドを表示します。何か変更があれば保存できるようにします。

        settings.currentSpawnIndex = EditorGUILayout.IntField("Current Spawn Index", settings.currentSpawnIndex);

        settings.playerHeight = EditorGUILayout.FloatField("Player Height", settings.playerHeight);


        if (GUI.changed)
        {
            EditorUtility.SetDirty(settings);
        }
    }

次にシーンビューのカスタマイズをOnSceneGUIメソッドに実装します。まずターゲットをキャストして、配列がなければ終了します。

    void OnSceneGUI(SceneView sv)
    {
        PlayerSpawnManager settings = (PlayerSpawnManager)target;

        if (settings.spawnPositions == null) return;

配列の数だけハンドルを表示します。元のハンドルの色を保存し、現在のインデックスのハンドルの場合は色を赤に変更します。Handles.PositionHandleメソッドで位置を変更できるハンドルを表示します。

        for (int i = 0; i < settings.spawnPositions.Length; i++)
        {
            EditorGUI.BeginChangeCheck();
            Color originalColor = Handles.color;
            if (i == settings.currentSpawnIndex)
            {
                Handles.color = Color.red;
            }

            Vector3 newPos = Handles.PositionHandle(settings.spawnPositions[i].position, Quaternion.identity);

ハンドルに変更があれば、Undoできるように記録して、配列のデータに値をセットします。

            if (EditorGUI.EndChangeCheck())
            {
                Undo.RecordObject(settings, "Change Spawn Position");
                settings.spawnPositions[i].position = newPos;
            }

Handles.Labelメソッドを使って、ハンドルの位置にラベルを表示します。

            Handles.Label(settings.spawnPositions[i].position, settings.spawnPositions[i].name);

そして、ハンドルの近くにボタンを表示します。ボタンの位置は、ハンドルの少し上にしました。ハンドルをクリックするとそのハンドルのインデックスを現在のインデックスに入れます。

            var buttonPos = settings.spawnPositions[i].position;
            buttonPos.y += 0.5f;

            if (Handles.Button(buttonPos, Quaternion.identity, 0.3f, 0.3f, Handles.SphereHandleCap))
            {
                settings.currentSpawnIndex = i;
            }

最後に、以降のハンドルを保存した色に戻します。

            Handles.color = originalColor;
        }
    }
}

動作確認

メインメニューからスクリプタブルオブジェクトをインスタンス化します。

Projectウィンドウでインスタンスを選択すると、カスタマイズされたインスペクタが表示されます。

ボタンを押すと項目が追加されます。

さらに項目を追加して、ラベルを変更してみます。

位置はシーンビューに表示されるハンドルで変更できます。ハンドルの近くの球は片方だけ赤くなっています。ラベルから、1つ目の要素であることがわかります。

2つ目の「Set as Current」ボタンを押すと、Current Spawn Indexが 1 に変わり、2つ目のハンドルの球が赤くなります。

この行はRemoveボタンを押すと削除されて、現在のインデックスの値が変わります。

対応するハンドルは消えて、はじめのハンドルの球が赤くなっています。

現在のインデックスの変更はシーンビューの球をクリックすることでも行えます。

実際に使ってみる

このスクリプトをSteamでリリースしたホラーゲームの学校シーンで使ってみます。

このシーンには、セーブ設定のスクリプタブルオブジェクトがあり、テスト用のスポーン位置と、これを使用するかどうかのチェックボックが用意されています。

これを今回のスクリプタブルオブジェクトで指定する位置に置き換えてみます。このスクリプトにスポーン位置のスクリプタブルオブジェクトをアタッチできるようにします。

    [SerializeField] PlayerSpawnManager playerSpawnManager;

アタッチしたら、テスト用のスポーン位置を使うときにこのスクリプタブルオブジェクトから値を取得します。プレイヤーのスクリプトのSetStartPositionメソッドでスポーン位置を設定しています。

            if (useTestStartPosition)
            {
                // Player.SetStartPosition(testStartPosition, data.playerRotation);

                Player.SetStartPosition(playerSpawnManager.CurrentPosition, Quaternion.identity);
            }
            else
            {
                // ...

スポーン位置を設定

学校シーンのテストによく使うスポーン位置にハンドルを配置して、ラベルを付けました。プレイヤーの高さも設定しました。

ラベルはChatGPTに考えてもらいました。一番上はA棟3階の段ボール箱がたくさん置いてある教室です。

2つ目は中央2階の美術室です。

C_3_Barrier_1 は、C棟3階のバリケードの前に置きました。

最後は校舎裏の道路です。

現在のインデックスを0にしてプレイボタンを押すと、大量の段ボール箱がある教室からゲームが開始されました。

M_2_Art_1 の「Set as Current」ボタンを押してプレイモードに入ると、開始位置が美術室に変わりました。

他の位置からも正しくスポーンできました。

これで、テスト用のスポーン位置を簡単に切り替えられるようになりました。

コメントを残す

メールアドレスが公開されることはありません。