きゅぶろぐ

きゅぶんずの ぶろぐができて べんりだな

Unityで開発用に「どこでもセーブ」を実現してみる

まえがき

ゲーム開発中、何かちょっと変えるたびに、何度も何度も同じ場面まで行って確認するのは面倒ですよね。
現在のシーンの状態を丸っと保存して、そこから再開できる「どこでもセーブ」機能を実現してみました。

ツイートに書いてある通り、全てのComponentのSerializeFieldを保存/復元してるだけです。

準備

以下の2つのパッケージをインポートします。

Unity Runtime Scene SerializationはExperimental Packageなこともあり、
使い方に癖があるので順番に解説していきます。

シーンについて

シーンの扱いがややこしいので先に紹介します。
Boot Scene、Game Scene、Empty Scene、の3つのシーンを使います。
ゲームはBoot Sceneから起動し、Game SceneをLoadSceneMode.Additiveでロードします。
Boot Sceneに「どこでもセーブ」の仕組みを置いておき、セーブする時はGame Sceneの状態をセーブします。
ロードするときは、Game Sceneを破棄しEmpty Scene(名前の通り何もないシーン)をロード、そこに保存した状態を復元します。

Boot Scene

Boot Sceneには、以下のBoot Classを貼り付けたGameObjectが置いてあるだけです。
「シーンについて」の通りにGame Sceneをロードしています。

public class Boot : MonoBehaviour
{
    private Scene _bootScene;
    private Scene _gameScene;

    public void Start()
    {
        _bootScene = SceneManager.GetActiveScene();
        _gameScene = SceneManager.LoadScene("Game", new LoadSceneParameters(LoadSceneMode.Additive));
    }
}

セーブ

セーブは簡単で、Boot内で以下のように _gameScene をシリアライズして保存します。

private async UniTaskVoid Save()
{
    // SerializedRenderSettingsは渡さなくてもよさそうに見えるのですが、
    // nullにするとロード時にエラー吐きます
    var serializedString = SceneSerialization.SerializeScene(_gameScene, new SerializedRenderSettings());

    // 中身は素直なjsonなので、パースして好きなようにいじっても良いでしょう。
    await File.WriteAllTextAsync("test.json", serializedString, Encoding.UTF8);
}

ロード

ロードはちょっと複雑です。

private async UniTaskVoid Load()
{
    // まずは今あるGameSceneを破棄して、
    await SceneManager.UnloadSceneAsync(_gameScene);

    // 何もないシーンをロードして、
    await SceneManager.LoadSceneAsync("Empty", new LoadSceneParameters(LoadSceneMode.Additive));
    _gameScene = SceneManager.GetSceneAt(1);

    // SceneSerialization.ImportSceneはアクティブなシーンに展開してくるので
    // 一時的にActiveSceneをEmptySceneに切り替えて、
    SceneManager.SetActiveScene(_gameScene);

    // 何もないシーンにjsonから読み込んだシーンを復元する
    var serializedString = await File.ReadAllTextAsync("test.json", Encoding.UTF8);
    SceneSerialization.ImportScene(serializedString);

    SceneManager.SetActiveScene(_bootScene);
}

AssetPack

これで終わりなら簡単だったのですが、まだ続きがあります。
これまでのコードでGameObjectとComponentの復元はできるのですが、Assetへの参照が消えてしまいます。
シリアライズしたjson内のGUIDと、実際のAssetを紐づけるためにAssetPackという仕組みがあります。

AssetPackを作る

今回は一番お手軽なSceneからAssetPackを作る方法を使います。
以下のMenuItemを実行すると Assets/Scenes/Game.asset というファイルができます。

public static class SandboxEditor
{
    [MenuItem("Sandbox/CreateAssetPack")]
    public static void CreateAssetPack()
    {
        if (Application.isPlaying)
        {
            // Play中に実行するとAssetが保存されない
            Debug.LogError($"Application.isPlaying");
            return;
        }

        var activeScene = EditorSceneManager.OpenScene("Assets/Scenes/Game.unity");
        if (!activeScene.IsValid()) return;

        var assetPackPath = Path.ChangeExtension(activeScene.path, "asset");
        var assetPack = AssetDatabase.LoadAssetAtPath<AssetPack>(assetPackPath);
        var created = false;
        if (assetPack == null)
        {
            created = true;
            assetPack = ScriptableObject.CreateInstance<AssetPack>();
        }
        else
        {
            assetPack.Clear();
        }

        var renderSettings = SerializedRenderSettings.CreateFromActiveScene();
        var sceneAsset = AssetDatabase.LoadAssetAtPath<SceneAsset>(activeScene.path);
        if (sceneAsset != null) assetPack.SceneAsset = sceneAsset;

        // SerializeSceneを実行することで、AssetPackの内容が生成されます
        SceneSerialization.SerializeScene(activeScene, renderSettings, assetPack);

        if (created)
        {
            AssetDatabase.CreateAsset(assetPack, assetPackPath);
        }
        else
        {
            EditorUtility.SetDirty(assetPack);
        }

        AssetDatabase.SaveAssets();
        AssetDatabase.Refresh();
    }
}

使う

AssetDatabase.LoadAssetAtPath<AssetPack>("Assets/Scenes/Game.asset");

こんな感じでAssetPackが読めるので、
SceneSerialization.SerializeScene の第3引数と、
SceneSerialization.ImportScene の第2引数に渡してやればAssetPackを使ってくれます。

完成!

完成形はgistに置いておきます。
Unity Runtime Scene Serializationの使い方はだいぶ癖があるので、今後の改良に期待しましょう。
使われてる気配が全然無いので、そのうちしれっと消えてる気もしますが・・・