sh1’s diary

プログラミング、読んだ本、資格試験、ゲームとか私を記録するところ

Unity ScriptableObject を利用してシーンの流れをよくする、シーン間のデータやりとり

ScriptableObject を利用することで、Unity のシーンの流れを改善するためのサンプルがあがっていたので、テストしてみました。

シーンの間を超えるデータ共有のやり方

シーンとシーンを繋ぐデータのやり方は、Singleton パターンを利用したものが一般的だと思います。

もちろん、アプリケーション全体で共有する必要がないものは SceneManager.sceneLoaded イベントを利用してシーン切替時のコード間で解決する場合もあると思います。(このあたりは、コード設計の話ですね)

今回の ScriptableObject を利用したデータのやり取りは、前者の Singleton パターンを利用するケースの改善だと思っています。

Singleton パターンは、非常に強力ですが「システム間が密接に繋がってしまい、厳密な意味でモジュール化されなくなってしまいます1」また、「乱立する神々のクラス Singleton」はもっとも見たくないコードのひとつですよ。

そんなわけで、Singleton をちょっと改善してみる回答のひとつが ScriptableObject を利用したシーンフロー設計です。


ScriptableObject を利用したシーン管理の設計例

まず、ScriptableObject の基底クラスを準備しています。

using UnityEngine;

public class GameSceneParameter : ScriptableObject
{
    [Header("Information")]
    public string SceneName;
    public string DescriptionText;
}

SceneName は、Scene ファイルの名前を指定します。この部分が一致しないとエラーになってしまうので注意が必要です。(命名規則がヒントとして言及されています)Description は管理用に好きなコメントを入れておくだけで、プログラム側からはあんまり関係ないです。

つぎに、作成した基底クラスを継承した実際のシーンと対応するクラスを作成します。このクラスは、固有のシーンが持つパラメーターなんかを変数を追加しておくと、シーンがパラメーターを読み取ろうとするときに有用です。

using UnityEngine;

public enum MenuType
{
    MainMenu,
    PauseMenu,
}

[CreateAssetMenu(fileName = "NewMenu", menuName = "SceneScript/Menu")]
public class MenuSceneParameter : GameSceneParameter
{
    [Header("Menu Parameters")]
    public MenuType Type = MenuType.MainMenu;
}

f:id:shikaku_sh:20200731170609p:plain:w500
ScriptableObject リソースを追加

追加した継承クラスは、見ての通り CreateAssetMenu の属性を持っているので、右クリックからリソースに ScriptableObject を追加することができます。

例では、MainMenu と PauseMenu の2つの ScriptableObject リソースを追加しました。

f:id:shikaku_sh:20200731170800p:plain:w500
こんな感じかと

f:id:shikaku_sh:20200731170825p:plain:w450
実際のシーンファイルの名称と一致してる

追加したリソースに、実際の Scene ファイルの名前を設定しておきます。基本的には、この ScriptableObject を介して、シーン移動をやりたいというのが趣旨になっています。

みてのとおり、ScriptableObject のインスペクターでは、シーンとシーンのパラメーターを表示できます。これがいいところですね。各シーンのパラメーターを一覧できます。

なお、システム間と密接に繋がる部分は、つぎの ScriptableObject を利用することになります。


シーン移動を管理する ScriptableObject

シーンのながれを管理する SceneWorkFlowManager クラスを作成して、同じように ScriptableObject リソースを追加しておきます。

using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;
using UnityEngine.SceneManagement;

[CreateAssetMenu(fileName = "NewSample", menuName = "SceneScript/Sample")]
public class SceneWorkFlowManager : ScriptableObject
{
    public List<MenuSceneParameter> Menus = new List<MenuSceneParameter>();

    public void LoadMainMenu()
    {
        var menuScene = Menus.SingleOrDefault(p => p.Type == MenuType.MainMenu);

        Debug.Log("load:" + menuScene.SceneName);

        if (menuScene != null)
        {
            SceneManager.LoadSceneAsync(menuScene.SceneName);
        }
    }

    public void PopPauseMenu()
    {
        var menuScene = Menus.SingleOrDefault(p => p.Type == MenuType.PauseMenu);

        Debug.Log("pop:" + menuScene.SceneName);

        if (menuScene != null)
        {
            SceneManager.LoadSceneAsync(menuScene.SceneName, LoadSceneMode.Additive);
        }
    }

    public void UnpopPauseMenu()
    {
        var menuScene = Menus.SingleOrDefault(p => p.Type == MenuType.PauseMenu);

        Debug.Log("unpop:" + menuScene.SceneName);

        if (menuScene != null)
        {
            SceneManager.UnloadSceneAsync(menuScene.SceneName, UnloadSceneOptions.None);
        }
    }
}

f:id:shikaku_sh:20200731170957p:plain:w500
Sample がマネージャーファイルで、(残念だけど)見た目は一緒になってしまう

サンプルだと、リソースファイルの名前は Sample にしています。

基本的な仕込みはこれで完成です。あとは、ゲーム内でボタンが押下されたタイミングなんかに、 SceneWorkFlowManager クラスから必要なメソッドを呼び出してあげます。


使ってみる例

シーンの中に設置したボタンのクリックイベントの呼び出し対象を SceneWorkFlowManager のリソースデータにして、次のシーンをロードをするメソッドを設定します。簡単なしくみですね。

f:id:shikaku_sh:20200731173458p:plain:w500
ボタンを押下したときのイベントでシーンを移動

ScriptableObject 自体で sceneLoaded みたいなイベントを追加してあげてもデータの受け渡しは制御しやすそう。Loading と2つセットにするとよさげかも。


ポイント

インスペクターに必要なパラメーターを表示することで、ゲームデザインの変更をやりやすくしたり、可読性をあげています。(ゲーム難度のような数値も各シーンに追加してみるとよい)

f:id:shikaku_sh:20200731171841p:plain:w500
マネージャーがどうシーンを管理しているのか見える

たしかに、Singleton パターンは、設計したプログラマーにとっては、はっきりした必要性や価値があるのかもしれないですが、インスペクターを利用してパラメーターを属性 Header なんかをつけてやれば、みんなもっとわかりやすい。


LoadSceneMode.Additive の注意点

ScriptableObject によるシーン管理に限った話ではなくて、シーンの LoadSceneMode.Additive の注意です。

LoadSceneMode.Additive でシーンをロードすると、現在のシーンに指定したシーンが上乗せした状態になります。

SceneManager.LoadSceneAsync("追加するシーンの名前", LoadSceneMode.Additive);

いくつかのシーンで共用するパーツをモジュール化するには、プレハブ化という選択が用意されていますが、モジュールをシーンで分けて設計することもあります。

複数のコーダーが編集する場合は、シーンで分けて作業をすると、ソースコードの衝突が減るというメリットなんかがあります。

問題になるのは、それぞれのシーンにあるボタンが“重なってしまったとき”です。ボタンをクリックすると両方のボタンが反応してしまいます

この問題については、解決策……というよりは、シーンを共有している設計ミス自体を修正したほうがよいです。

一応下記 Unity フォーラムに対応策の回答が挙がっていますが、あまりポジティブな対策とはいえないのは明白だと思います。


シーン間でデータを安全に共有するプラクティス

それは危険! シーン間でデータを安全に共有する方法」のような記事もありますが、やや釣りタイトルでした。

記事中では、次のようになっています。危険だといったのに、使い分けの話になってる……。(意味は違いますが、「大きい主語」みたいな感じですね。解説記事でそれをやるのか)

グローバルなオブジェクトは決して悪ではありません。使いようによっては最高の武器になります。
ただプログラムが大きくなるにつれて、バグの原因になったり、テストがしにくくなったりと、問題も多くあります。
上手に使い分けて楽しいUnityライフを送りましょう。
それは危険! シーン間でデータを安全に共有する方法 より

たとえば「2つのシーン間に限ったデータやり取りなのか」、または、「たくさんのシーン間で共有するデータやり取りなのか」といった、設計の問題と、捉えてみます。

2つのシーン間だけでやり取りしたいデータをグローバルなリソースにしていたら、無駄に public なデータを増やすことになるし、システム間の結合も高まってしまう恐れがあるので、これはよくないかも。逆に、すべてのシーンに対して同じ変数(データ)を SceneManager.sceneLoaded を通してやりとりしていたなら、面倒な設計です。公開すべきデータは公開したらいいかも。

コード内で完結していると、インスペクターから見えないので、ゲームデザインしやすいのかは疑問です。スプレッドシートなんかを経由したデータならよいのかもしれませんが。

結局、設計によって、グローバルなオブジェクトのデータ管理方法は異なるでしょう、と。

その際は、ScriptableObject を利用したシーンのフロー設計は検討の余地がありそうです。データの受け渡しできるパラメーターを追加すると、(Rails っぽい)規則によって(インスペクターの補助もある)すこし具合よく設計できそうです。

安全設計をすべきなのは正しいと思うのですが、目的のプログラムにとって十分な品質で、効率よく仕上げるべきなんだと私は思っています。(Unity のプログラムの多くは10年程度さえ保守されないと思う)

話を進めればコードリファクタリングの問題として、すこし後に気づいてもいいし、気づくのが遅れても仕方ないことだってあると思う。その際は、シーン間のデータやり取りをリファクタリングしてあげよう。シーンのデータやりとりは、ひとつのベストプラクティスによって解決するものではなくて、パフォーマンスと生産性の両方の観点から、プロジェクトにあわせて上手につきあうものになっていると思います。

そもそも Unity はリソースやインスタンスに対して、かなり自由なアクセス権があるので、疎結合と言われても……という部分はあると思う。基本設計からして、品質設計よりもゲーム用の効率的なツールだと思っています。


サンプル

GitHub に「unity-scene-scriptable-practic」を公開しています。

f:id:shikaku_sh:20200731172920g:plain:w500
単純なシーン移動ですが、フロー制御が ScriptableObject です


参考