徳島ゲーム開発ごっこ 技術ブログ

ゲームを作るために役に立ったり立たなかったりする技術を学んでいきます!

【Unity】SceneLauncherを作ってみよう

EditorWindow を使って SceneLauncher を作ってみたお話。
0から作るのは大変だけど、先人の知恵を借りつつ自分独自の調味料を加えれば、
あっというまに素敵な SceneLauncher が出来ました。
f:id:urahimono:20211015223008p:plain


この記事にはUnity2020.3.20f1を使用しています。
.Netのバージョン設定には.Net4.x を使用しています。

シーンが増えてきて管理が大変!

プロジェクトが進んでいくと、シーンが増えてきますよねー。
デバッグやテスト用のシーンや、複数の開発者で作業をしているとシーンが乱立してどこにどのシーンがあるかがわからない!
シーンがどこにあるかを管理して、各シーンを簡単に開けるメニューが欲しいものです。
無いのならば作ればいい!
というわけでシーンを立ち上げる専用のメニュー、SceneLauncher をエディタ拡張で作っていきましょう。

先人の知恵を拝借

僕のような凡人が考えるようなことは、大抵の場合既に誰かがやってくれているのです。
調べてみたら、やっぱり既に SceneLauncher を作っている方はいらっしゃるようですね。

tmls.hatenablog.com
qiita.com

では、これらの記事を参考に自分好みの SceneLaunch を作りましょう。

まずはウィンドウを作成しよう

ではチャチャッと自前のウィンドウを作っちゃいましょうか。
EditorWindow を継承して、 MenuItem に登録してっと。

// SceneLaunchWindow.cs
using UnityEngine;
using UnityEditor;
public class SceneLaunchWindow : EditorWindow
{
    [MenuItem("MyGame/Scene Launcher")]
    private static void ShowWindow()
    {
        // ウィンドウを表示!
        GetWindow<SceneLaunchWindow>("Scene Launcher");
    }
} // class SceneLaunchWindow
f:id:urahimono:20211012212727j:plainf:id:urahimono:20211012212730j:plain

はい、何もないウィンドウが出来ましたー!

プロジェクト内のシーンファイルを検索しよう

次はプロジェクト内のシーンを探し出す必要がありますね。
AssetDatabase.FindAssets() を使えばいけますね。
www.urablog.xyz

// SceneLaunchWindow.cs
using UnityEngine;
using UnityEditor;
using System.Linq;
public class SceneLaunchWindow : EditorWindow
{
    [MenuItem("MyGame/Scene Launcher")]
    private static void ShowWindow()
    {
        // ウィンドウを表示!
        GetWindow<SceneLaunchWindow>("Scene Launcher");
    }
    private void OnGUI()
    {
        // コードを分かりやすくするため、一度 ToArray() を使ってローカル変数化してるけど、
        // 別にまとめちゃってもいいよ(……というか処理的にはそっちのほうがいいはず),
        string[] guids = AssetDatabase.FindAssets("t:Scene", new string[] { "Assets" });
        string[] paths = guids.Select(guid => AssetDatabase.GUIDToAssetPath(guid)).ToArray();
    }
} // class SceneLaunchWindow

はい、出来ました。
これでプロジェクト上の全てのシーンが持ってこれるはず。

……ちょっと待てよ。
OnGUI()描画されるたびに呼ばれる関数です。
毎回毎回プロジェクト内のファイルを全検索していたら、負荷がヤバいことになるのでは。

OnFocus() に変えましょう。
これならフォーカスした瞬間だけ検索がかかるようになるはず。

// SceneLaunchWindow.cs
using UnityEngine;
using UnityEditor;
using System.Linq;
public class SceneLaunchWindow : EditorWindow
{
    private string[] m_scenePaths = null;

    [MenuItem("MyGame/Scene Launcher")]
    private static void ShowWindow()
    {
        // ウィンドウを表示!
        GetWindow<SceneLaunchWindow>("Scene Launcher");
    }

    private void OnFocus()
    {
        Reload();
    }

    private void OnGUI()
    {
        // OnFocus() より前に呼ばれる対策(あるのかな?)
        if (m_scenePaths == null)
            Reload();
    }

    private void Reload()
    {
        // コードを分かりやすくするため、一度 ToArray() を使ってローカル変数化してるけど、
        // 別にまとめちゃってもいいよ(……というか処理的にはそっちのほうがいいはず),
        string[] guids = AssetDatabase.FindAssets("t:Scene", new string[] { "Assets" });
        m_scenePaths = guids.Select(guid => AssetDatabase.GUIDToAssetPath(guid)).ToArray();
    }
} // class SceneLaunchWindow

ボタンを作ってシーンを開く処理の追加

見つかったシーンの数分のボタンを作って、シーンを開く処理を作れば完成ですね。
シーンを開く関数は EditorSceneManager.OpenScene(); です。
mode を指定すれば Additive として追加も可能です。
docs.unity3d.com

// SceneLaunchWindow.cs
using UnityEngine;
using UnityEditor;
using UnityEditor.SceneManagement;
using System.Linq;
public class SceneLaunchWindow : EditorWindow
{
    private string[] m_scenePaths = null;

    [MenuItem("MyGame/Scene Launcher")]
    private static void ShowWindow()
    {
        // ウィンドウを表示!
        GetWindow<SceneLaunchWindow>("Scene Launcher");
    }

    private void OnFocus()
    {
        Reload();
    }

    private void OnGUI()
    {
        // OnFocus() より前に呼ばれる対策(あるのかな?)
        if (m_scenePaths == null)
            Reload();

        foreach (var path in m_scenePaths)
        {
            if (GUILayout.Button(path))
            {
                EditorSceneManager.OpenScene(path);
            }
        }
    }

    private void Reload()
    {
        // コードを分かりやすくするため、一度 ToArray() を使ってローカル変数化してるけど、
        // 別にまとめちゃってもいいよ(……というか処理的にはそっちのほうがいいはず),
        string[] guids = AssetDatabase.FindAssets("t:Scene", new string[] { "Assets" });
        m_scenePaths = guids.Select(guid => AssetDatabase.GUIDToAssetPath(guid)).ToArray();
    }
} // class SceneLaunchWindow

f:id:urahimono:20211012212743j:plain
f:id:urahimono:20211012212750g:plain

ボタンを押したらシーンが開くように出来ましたね。OKです。
しかし……、ちょっと気になる点がいくつかあるなぁ。
改良してみましょう。

スクロール機能をつける

まずはウィンドウのサイズ以上のシーン数がある場合は、ボタンが途切れてしまっています。
スクロール機能をつけて、全部表示できるようにしてみましょう。
EditorGUILayout.BeginScrollView()EditorGUILayout.EndScrollView() 使いましょう。

// SceneLaunchWindow.cs
using UnityEngine;
using UnityEditor;
using UnityEditor.SceneManagement;
using System.Linq;
public class SceneLaunchWindow : EditorWindow
{
    private string[] m_scenePaths = null;
    private Vector2 m_scrollPosition = Vector2.zero;

    private void OnGUI()
    {
        // OnFocus() より前に呼ばれる対策(あるのかな?)
        if (m_scenePaths == null)
            Reload();

        // この波括弧は見やすくするためだけにあるよ.
        m_scrollPosition = EditorGUILayout.BeginScrollView(m_scrollPosition);
        {
            foreach (var path in m_scenePaths)
            {
                if (GUILayout.Button(path))
                {
                    EditorSceneManager.OpenScene(path);
                }
            }
        }
        EditorGUILayout.EndScrollView();
    }
    
    // ...
} // class SceneLaunchWindow

f:id:urahimono:20211012212806g:plain

スクロール出来ましたねー。

ボタンの名前が長い!

いまはボタンにはパスが書かれていますが……、長いよ!
パスである必要あるかな?
拡張子なんて表示する必要はないと思うし。

.net関数の System.IO.Path.GetFileNameWithoutExtension() を使ってファイル名だけを抜き出しましょう。

// SceneLaunchWindow.cs
using UnityEngine;
using UnityEditor;
using UnityEditor.SceneManagement;
using System.Linq;
public class SceneLaunchWindow : EditorWindow
{
    private string[] m_scenePaths = null;
    private Vector2 m_scrollPosition = Vector2.zero;

    private void OnGUI()
    {
        // OnFocus() より前に呼ばれる対策(あるのかな?)
        if (m_scenePaths == null)
            Reload();

        // この波括弧は見やすくするためだけにあるよ.
        m_scrollPosition = EditorGUILayout.BeginScrollView(m_scrollPosition);
        {
            foreach (var path in m_scenePaths)
            {
                string name = System.IO.Path.GetFileNameWithoutExtension(path);
                if (GUILayout.Button(name))
                {
                    EditorSceneManager.OpenScene(path);
                }
            }
        }
        EditorGUILayout.EndScrollView();
    }
} // class SceneLaunchWindow

f:id:urahimono:20211012212817j:plain

すっきりしましたね。
ただ画像を見てわかる通り、
同じ名前のシーン名がある場合はこのような悲劇が生まれるので、この処理の追加はお好みで。

作業中の場合の対応を追加

シーンを開くわけですから、ゲーム実行中にこのボタンが押されたら危険なわけですよ。
ゲームが実行していないとき意外はボタンが押せないようにしてしまいましょう。

EditorGUI.BeginDisabledGroup()EditorGUI.EndDisabledGroup() ですね。

// SceneLaunchWindow.cs
using UnityEngine;
using UnityEditor;
using UnityEditor.SceneManagement;
using System.Linq;
public class SceneLaunchWindow : EditorWindow
{
    private string[] m_scenePaths = null;
    private Vector2 m_scrollPosition = Vector2.zero;

    private void OnGUI()
    {
        // OnFocus() より前に呼ばれる対策(あるのかな?)
        if (m_scenePaths == null)
            Reload();

        // この波括弧は見やすくするためだけにあるよ.
        EditorGUI.BeginDisabledGroup(EditorApplication.isPlaying);
        {
            // この波括弧も見やすくするためだけにあるよ.
            m_scrollPosition = EditorGUILayout.BeginScrollView(m_scrollPosition);
            {
                foreach (var path in m_scenePaths)
                {
                    string name = System.IO.Path.GetFileNameWithoutExtension(path);
                    if (GUILayout.Button(name))
                    {
                        EditorSceneManager.OpenScene(path);
                    }
                }
            }
            EditorGUILayout.EndScrollView();
        }
        EditorGUI.EndDisabledGroup();
    }
} // class SceneLaunchWindow

f:id:urahimono:20211012212837g:plain

さらに、シーンで作業中の時のことを考える必要がありますね。
保存するかどうかの確認メッセージを出してあげましょう。
SaveModifiedScenesIfUserWantsTo() を使ってみましょう。

// SceneLaunchWindow.cs
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEditor;
using UnityEditor.SceneManagement;
using System.Linq;
using System.Collections.Generic;
public class SceneLaunchWindow : EditorWindow
{
    private string[] m_scenePaths = null;
    private Vector2 m_scrollPosition = Vector2.zero;

    private void OnGUI()
    {
        // OnFocus() より前に呼ばれる対策(あるのかな?)
        if (m_scenePaths == null)
            Reload();

        // この波括弧は見やすくするためだけにあるよ.
        EditorGUI.BeginDisabledGroup(EditorApplication.isPlaying);
        {
            // この波括弧も見やすくするためだけにあるよ.
            m_scrollPosition = EditorGUILayout.BeginScrollView(m_scrollPosition);
            {
                foreach (var path in m_scenePaths)
                {
                    string name = System.IO.Path.GetFileNameWithoutExtension(path);
                    if (GUILayout.Button(name))
                    {
                        Scene[] dirtyScenes = GetDirtyScenes();
                        if (EditorSceneManager.SaveModifiedScenesIfUserWantsTo(dirtyScenes))
                            EditorSceneManager.OpenScene(path);
                    }
                }
            }
            EditorGUILayout.EndScrollView();
        }
        EditorGUI.EndDisabledGroup();
    }

    private Scene[] GetDirtyScenes()
    {
        var scenes = new List<Scene>();
        for (int i = 0; i < SceneManager.sceneCount; ++i)
        {
            Scene scene = SceneManager.GetSceneAt(i);
            if (scene.isDirty)
                scenes.Add(scene);
        }
        return scenes.ToArray();
    }
} // class SceneLaunchWindow

f:id:urahimono:20211012212854j:plain

複数のシーンを触っている場合の対応もばっちりです。

Scenes in build と分けたい

今は全てのシーンをパス順に並べていますが、実際にゲームで使われる「Scenes in build」に登録されているシーンとその他のシーンは分けておきたいです。

登録されているシーンは EditorBuildSettings.scenes で取ってこれますよ。
この関数を使って、更にスペースなども調整しておきましょうか。

// SceneLaunchWindow.cs
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEditor;
using UnityEditor.SceneManagement;
using System.Linq;
using System.Collections.Generic;
public class SceneLaunchWindow : EditorWindow
{
    private string[] m_buildScenePaths = null;
    private string[] m_othersScenePath = null;
    private Vector2  m_scrollPosition  = Vector2.zero;

    [MenuItem("MyGame/Scene Launcher")]
    private static void ShowWindow()
    {
        // ウィンドウを表示!
        GetWindow<SceneLaunchWindow>("Scene Launcher");
    }

    private void OnFocus()
    {
        Reload();
    }

    private void Reload()
    {
        m_buildScenePaths = EditorBuildSettings.scenes.Select(scene => scene.path).ToArray();

        // コードを分かりやすくするため、一度 ToArray() を使ってローカル変数化してるけど、
        // 別にまとめちゃってもいいよ(……というか処理的にはそっちのほうがいいはず),
        string[] guids    = AssetDatabase.FindAssets("t:Scene", new string[] { "Assets" }); ;
        string[] paths    = guids.Select(guid => AssetDatabase.GUIDToAssetPath(guid)).ToArray();
        m_othersScenePath = paths.Where(path => !m_buildScenePaths.Any(buildPath => buildPath == path)).ToArray();
    }

    private void OnGUI()
    {
        // OnFocus() より前に呼ばれる対策(あるのかな?)
        if (m_buildScenePaths == null && m_othersScenePath == null)
            Reload();

        // この波括弧は見やすくするためだけにあるよ.
        EditorGUI.BeginDisabledGroup(EditorApplication.isPlaying);
        {
            // この波括弧も見やすくするためだけにあるよ.
            m_scrollPosition = EditorGUILayout.BeginScrollView(m_scrollPosition);
            {
                EditorGUILayout.LabelField("Scenes in build");
                GenerateButtons(m_buildScenePaths);

                GUILayout.Space(10.0f);

                EditorGUILayout.LabelField("Others");
                GenerateButtons(m_othersScenePath);
            }
            EditorGUILayout.EndScrollView();

        }
        EditorGUI.EndDisabledGroup();
    }

    private void GenerateButtons(string[] scenePaths)
    {
        if (scenePaths != null && scenePaths.Length > 0)
        {
            foreach (var path in scenePaths)
            {
                string name = System.IO.Path.GetFileNameWithoutExtension(path);
                if (GUILayout.Button(name))
                {
                    if (EditorSceneManager.SaveModifiedScenesIfUserWantsTo(GetDirtyScenes()))
                    {
                        EditorSceneManager.OpenScene(path);
                    }
                }
                GUILayout.Space(5.0f);
            }
        }
        else
            EditorGUILayout.LabelField("シーンがありません");
    }

    private Scene[] GetDirtyScenes()
    {
        var scenes = new List<Scene>();
        for (int i = 0; i < SceneManager.sceneCount; ++i)
        {
            Scene scene = SceneManager.GetSceneAt(i);
            if (scene.isDirty)
                scenes.Add(scene);
        }
        return scenes.ToArray();
    }
} // class SceneLaunchWindow

f:id:urahimono:20211012212907j:plain

これで完成形ですー!
たくさんのシーンがある場合はこういうちょっとしたメニューが役にたつと思いますよー!