[Unity] BuildSettingのScene定義をよしなにしたい

この記事は モバイルファクトリー Advent Calendar 2017 13日目の記事です。
前日は @yashims85 さんの[「小規模アプリで実装するシングルストアの強さを語る」](https://qiita.com/yashims85/items/20c0aaced60971a412d0)でした。
今日はゆるくBuildSettingsのScene定義をよしなにします。

## 実行環境
Unity 2017.2.0p1

## 背景
1つのプロダクトにおいて、ゲームジャムやカジュアルゲーム(not ステージ系)では機能自体が少ないため、UnityのSceneの数は片手で数えられる程度で済むことが多々あります。

一方、機能が徐々に増えて行くと、ページの出し分けの実装方針によっては、Sceneの数は平気で3,40まで達することもあるかと思います (特にマルチシーンでUIパーツを構成したりするケースでは顕著で、一方でPrefabを出し分けるような管理においては該当しません)。

### BuildSettings - Scene

<img width="664" alt="scene_build.png" src="https://qiita-image-store.s3.amazonaws.com/0/102664/6524c70c-a686-1338-9f27-12cb4f4bb577.png">

[BuildSettings](https://docs.unity3d.com/jp/current/Manual/BuildSettings.html) の `Scenes in Build` の項目に含まれるシーンがビルドの対象となります。逆に言えば、 `BuildSettings` に含まれていないシーンは読み込むことが出来ません。
Unity開発者は [SceneManager.LoadScene](https://docs.unity3d.com/ja/current/ScriptReference/SceneManagement.SceneManager.LoadScene.html) 実行時に必ず1度は以下の表示を見たことがあると信じています。

<img width="711" alt="スクリーンショット 2017-12-12 15.07.11.png" src="https://qiita-image-store.s3.amazonaws.com/0/102664/740e42d3-672c-f12e-8577-d0f540b5d086.png">

### BuildSettings - Scene が disable になるケース
UnityEditor上で `BuildSettings` に追加済みのSceneの名前を変更したり、Sceneを削除する場合、変更/削除がフックとなり `BuildSettings` は追従します。
一方でUnityEditor外 (Finderやコマンドライン) でSceneに変更した場合、`BuildSettings` は追従してくれません。

`BuildSettings` の中身を確認すると、ファイルとしては削除したSceneは残り続けているのが分かります。

![buildsettings.gif](https://qiita-image-store.s3.amazonaws.com/0/102664/73c0610a-2beb-dcab-0241-04ce859e1360.gif)

## BuildSettings - Scene を生成するスクリプト
Sceneの追加を忘れ都度追加しなくてはいけなくなるのは手間ですし、削除済みのSceneが残り続けるのは害は無いですが少し気持ち悪いです。コンフリクトしたときにも手作業で解消するのは面倒でしょう。

そこでEditor拡張を使い、既存のScene群から `BuildSettings` を追加するコマンドを作成します。
以下のScriptを `Editor` フォルダ以下に配置することで動作します。

https://gist.github.com/lycoris102/cf16ef82419e3184604f681a94090fd9

```csharp:BuildSettingScenesCreator.cs
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;

public class BuildSettingScenesCreator
{
    private const string sceneDir = "Scenes";
    private const string initialLoadScene = "Scenes/Title.unity";

    [MenuItem("Edit/BuildSettings/CreateScenes")]
    public static void CreateBuildSettingScenes()
    {
        if (!CheckExists())
            return;

        Create();
    }

    private static bool CheckExists()
    {
        string sceneDirFullPath = GetFullPath(sceneDir);
        string initialLoadSceneFullPath = GetFullPath(initialLoadScene);

        // 存在チェック
        if (!Directory.Exists(sceneDirFullPath))
        {
            Debug.LogError("Not Found Dir :" + sceneDirFullPath);
            return false;
        }

        if (!File.Exists(initialLoadSceneFullPath))
        {
            Debug.LogError("Not Found Inital Load Scene : " + initialLoadSceneFullPath);
            return false;
        }

        return true;
    }

    private static void Create()
    {
        string sceneDirAssetsPath = GetAssetsPath(sceneDir);
        string initialLoadSceneAssetsPath = GetAssetsPath(initialLoadScene);

        var scenes = AssetDatabase.FindAssets("t:Scene", new string[] {sceneDirAssetsPath})
            .Select(guid => AssetDatabase.GUIDToAssetPath(guid))
            .OrderBy(path => path)
            .Where(path => path != initialLoadSceneAssetsPath)
            .Select(path => new EditorBuildSettingsScene(path, true))
            .ToList();

        // 初回に呼び込まれて欲しいシーンを先頭に配置する
        scenes.Insert(0, new EditorBuildSettingsScene(initialLoadSceneAssetsPath, true));

        EditorBuildSettings.scenes = scenes.ToArray();
        AssetDatabase.SaveAssets();

        Debug.Log("Created BuildSettings.");
    }

    private static string GetFullPath(string path)
    {
        return Application.dataPath + "/" + path;
    }

    private static string GetAssetsPath(string path)
    {
        return "Assets/" + path;
    }
}
```

以下をScript上で指定して実行する必要があります。

- `sceneDir` : シーンを格納するフォルダ
- `initialLoadScene` : ゲーム起動時に開きたいシーン

![buildsettings2.gif](https://qiita-image-store.s3.amazonaws.com/0/102664/6db4727f-1a32-7d4b-24b7-ccdcd5ea117d.gif)

## Scene変更時に動的にBuildSettingsを更新する
さらに効率化するために、任意のSceneが更新(追加/削除)された時に、それが該当ディレクトリ以下であれば自動的に更新するように変更します。

https://gist.github.com/lycoris102/553a0e60cbaa6b462dc18cba01b127f7

### 📝 AssetPostprocessor
[AssetPostprocessor](https://docs.unity3d.com/ja/current/ScriptReference/AssetPostprocessor.html) を継承し [OnPostprocessAllAssets](https://docs.unity3d.com/ja/current/ScriptReference/AssetPostprocessor.OnPostprocessAllAssets.html) を定義することで、アセットの更新完了時に任意の処理を実行することが出来ます。
アセットインポート時の処理の呼び出し順序は以下のサイトにて詳しく紹介されており、参考になりました。

[Editor拡張入門 - 第28章　AssetPostprocessor]
(https://anchan828.github.io/editor-manual/web/assetpostprocessor.html) 

```csharp:BuildSettingScenesUpdater.cs
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;

public class BuildSettingScenesUpdater : AssetPostprocessor
{
    private const string sceneDir = "Scenes";
    private const string initialLoadScene = "Scenes/Title.unity";

    public static void OnPostprocessAllAssets(
        string[] importedAssets,
        string[] deletedAssets,
        string[] movedAssets,
        string[] movedFromAssetPaths)
    {
        if (!CheckExists())
            return;

        var assets = importedAssets
            .Union(deletedAssets)
            .Union(movedAssets)
            .Union(movedFromAssetPaths);
            
        if (CheckInSceneDir(assets))
        {
            Create();
        }
    }

    // Sceneディレクトリ以下のアセットが編集されたか
    private static bool CheckInSceneDir(IEnumerable<string> assets)
    {
        return assets.Any(asset => Path.GetDirectoryName(asset) == GetAssetsPath(sceneDir));
    }

    // 存在チェック
    private static bool CheckExists()
    {
        string sceneDirFullPath = GetFullPath(sceneDir);
        string initialLoadSceneFullPath = GetFullPath(initialLoadScene);

        if (!Directory.Exists(sceneDirFullPath))
        {
            Debug.LogError("Not Found Dir :" + sceneDirFullPath);
            return false;
        }

        if (!File.Exists(initialLoadSceneFullPath))
        {
            Debug.LogError("Not Found Inital Load Scene : " + initialLoadSceneFullPath);
            return false;
        }

        return true;
    }

    private static void Create()
    {
        string sceneDirAssetsPath = GetAssetsPath(sceneDir);
        string initialLoadSceneAssetsPath = GetAssetsPath(initialLoadScene);

        var scenes = AssetDatabase.FindAssets("t:Scene", new string[] {sceneDirAssetsPath})
            .Select(guid => AssetDatabase.GUIDToAssetPath(guid))
            .OrderBy(path => path)
            .Where(path => path != initialLoadSceneAssetsPath)
            .Select(path => new EditorBuildSettingsScene(path, true))
            .ToList();

        // 初回に呼び込まれて欲しいシーンを先頭に配置する
        scenes.Insert(0, new EditorBuildSettingsScene(initialLoadSceneAssetsPath, true));

        EditorBuildSettings.scenes = scenes.ToArray();
        AssetDatabase.SaveAssets();

        Debug.Log("Created BuildSettings.");
    }

    private static string GetFullPath(string path)
    {
        return Application.dataPath + "/" + path;
    }

    private static string GetAssetsPath(string path)
    {
        return "Assets/" + path;
    }
}
```

![buildsettings3.gif](https://qiita-image-store.s3.amazonaws.com/0/102664/b11ca64f-4cab-7a7f-6fff-3a71fdd08b8a.gif)

## 最後に
以上、BuildSettingsに関する小ネタでした。
明日は @shioiyan さんの「RubyでGoogleAPIを利用してみる」お話です。
楽しみですね！