うら干物書き

ゲームを作っています。

【Unity】僕もPhotonを使いたい #18 シーン管理委員会

 SceneManagerも研究しましたし、今回のプロジェクトのシーンを管理するシステムを作っていきましょう。
 急いで作りましょう。
 Photonから離れて半年近く経っていますから、そろそろPhotonでゲームを作る自信がなくなってきましたよ。


この記事にはUnity5.6.0f3を使用しています。

こんな感じにシーンを管理したいな

 今回のプロジェクトで使うシーン構成はこんな感じなものを考えています。

f:id:urahimono:20170415204121p:plain

 SceneManagerAdditiveを全力で使った形を目指します。
 実際使いやすいかはわかりませんが、とりあえず作っていきます。

シーン管理委員会のメンバー紹介

会計:シーン情報クラスSceneData

 各シーン情報を管理するクラス、それがSceneDataだ。
 エディター上でパラメータを記述したりしちゃうぞ。
 とりあえず最初はID(シーン名)だけのパラメータから始めます。

[System.Serializable]
public class SceneData
{
    [SerializeField]
    private string  m_id   = null;
    public  string  ID  { get { return m_id; } }
}

書記:シーン情報テーブルSceneTable

 噂のScriptableObjectを使って、SceneDataをリストで持っちゃうのがSceneTableだ。
 Assetとして存在しちゃうぞ。
 SceneDataのリストと一緒に、指定したシーンの名前からSceneDataを取得する関数も追加しよう。

SceneTable.cs

using UnityEngine;
using System.Linq;

[CreateAssetMenu( menuName = "Game/Create SceneTable", fileName = "SceneTable" )]
public class SceneTable : ScriptableObject
{
    [SerializeField]
    private SceneData[] m_sceneList = null;
    
    /// <summary>
    /// シーン情報を取得するよ!
    /// </summary>
    /// <param name="i_id">取得したいシーンのID(名前)</param>
    /// <returns>シーン情報</returns>
    public SceneData GetSceneData( string i_id )
    {
        if( string.IsNullOrEmpty( i_id ) )
        {
            // 引数に変な文字列渡してんじゃないのさ!
            throw new System.ArgumentNullException( "i_id" );
        }

        if( m_sceneList == null || m_sceneList.Length == 0 )
        {
            // シーン情報が一つも登録されておらんのだが……。
            Debug.AssertFormat( false, "m_sceneList is empty!" );
            return null;
        }

        return m_sceneList.FirstOrDefault( value => value.ID == i_id );
    }

} // class SceneTable

会長:シーン管理クラスSceneController

 そして、シーンの読み込みなどありとあらゆるシーンの管理を司るのがSceneControllerだ。
 SceneManagerという名前にすると、UnityのSceneManagerと被るので絶対に止めようぜ!
 シーンを管理するクラスを作成。

SceneController.cs

using UnityEngine;
using UnityEngine.SceneManagement;
using System.Collections;

public class SceneController : MonoBehaviour
{
    /// <summary>
    /// シーンを読み込むぜ!
    /// </summary>
    /// <param name="i_sceneId">シーンのID(名前)</param>
    public void LoadScene( string i_sceneId )
    {
        StartCoroutine( LoadSceneProcess( i_sceneId ) );
    }
    private IEnumerator LoadSceneProcess( string i_sceneId )
    {
        // シーンを読み込む処理を書くよ。
        yield return null;
    }

} // class SceneController

 とりあえず、何もしないSceneControllerが出来ました。
 SceneControllerは仕事をするにはシーンの情報が必要なので、SceneTableにアクセスする必要がありますね。
 
 以前はScriptableObjectをどのクラスからでも取得できるようにしましたが、シーン情報はSceneControllerクラスが主にアクセスするためSceneTableSceneControllerが所持する形にしたほうがいいでしょう。

www.urablog.xyz

 アクセスできる人間(クラス)は限られていたほうが、管理しやすいです。
 という訳で、SceneTableを読み込む処理を追加しましょう。

SceneTable.cs

private static readonly string RESOURCE_PATH    = "SceneTable";

/// <summary>
/// シーンテーブルのアセットを読み込むよ!
/// </summary>
/// <returns>SceneTableのアセット</returns>
public static SceneTable LoadAsset()
{
    var asset   = Resources.Load( RESOURCE_PATH ) as SceneTable;
    if( asset == null )
    {
        // どうやらシーンテーブルのアセットが用意されていないようだ。
        // 早急に用意してくれたまえ。
        throw new System.IO.FileNotFoundException( RESOURCE_PATH );
    }
    return asset;
}

SceneController.cs

private SceneTable  m_sceneTableAsset   = null;

private void Awake()
{
    m_sceneTableAsset   = SceneTable.LoadAsset();
}

/// <summary>
/// シーンを読み込むぜ!
/// </summary>
/// <param name="i_sceneId">シーンのID(名前)</param>
public void LoadScene( string i_sceneId )
{
    StartCoroutine( LoadSceneProcess( i_sceneId ) );
}
private IEnumerator LoadSceneProcess( string i_sceneId )
{
    var sceneData   = m_sceneTableAsset.GetSceneData( i_sceneId );
    if( sceneData == null )
    {
        // そんなシーン、俺は知らん!
        Debug.AssertFormat( false, "Scene is missing! id={0}", i_sceneId, gameObject );
        yield break;
    }

    yield return SceneManager.LoadSceneAsync( sceneData.ID, LoadSceneMode.Additive );
}

シーン達の用意

 今回のプロジェクトで使用するシーンを空の状態で用意しましょう。

f:id:urahimono:20170415204341p:plain

 そして、先ほど作ったSceneTableにガンガン登録していきます。

f:id:urahimono:20170415204351p:plain

 文字列コピー面倒くさい……。
 エディタ拡張も必要かもしれない。

神:ゲーム制御者GameController

 今回のゲームを制御する絶対者、GameControllerを作っていきましょう。
 とは名乗ってますが、神クラスみたいな作りは僕は嫌いです。

 では早速、GameController様にSceneControllerを使ってシーンを読みこんでもらいましょう。

GameController.cs

using UnityEngine;
using System.Collections;

public class GameController : MonoBehaviour
{
    private SceneController m_sceneController   = null;

    private void Awake()
    {
        m_sceneController   = gameObject.AddComponent<SceneController>();
    }

    private IEnumerator Start()
    {
        // 仮機能でバンバンシーンを読み込む。
        string[] sceneList   = new string[]
        {
            "DebugMenu",
            "Title",
            "MainMenu",
            "Status",
            "Explain",
            "Option",
            "Gameplay",
            "Stage00",
            "Stage01",
        };

        foreach( var sceneID in sceneList )
        {
            m_sceneController.LoadScene( sceneID );
            yield return new WaitForSeconds( 1.0f );
        }

    }

} // class GameController

f:id:urahimono:20170415204417g:plain

 シーンが読み込めました。よかったです。
 ただこのままでは、理想には程遠い。
 改良していきますよ。
 

同じシーンがHerarchy上に存在することを阻止する

 先ほどのコード。少し問題があることに気づきました。
 GameControllerにて読み込むシーンを以下のようにして試してみます。

GameController.cs

string[] sceneList   = new string[]
{
    "DebugMenu",
    "DebugMenu",
    "DebugMenu",
    "DebugMenu",
    "Gameplay",
    "Stage00",
    "Stage01",
};

f:id:urahimono:20170415204509g:plain

 同じシーンが読み込まれてますね。
 まあこれはSceneManagerを調べた際にわかっていたことではあるんですけどね。

www.urablog.xyz

 ただ、同じシーンがあるのは管理上厄介そうなので、コード上でこんなことにはならないようにしましょう。

SceneController.cs

private IEnumerator LoadSceneProcess( string i_sceneId )
{
    var sceneData   = m_sceneTableAsset.GetSceneData( i_sceneId );
    if( sceneData == null )
    {
        // そんなシーン、俺は知らん!
        Debug.AssertFormat( false, "Scene is missing! id={0}", i_sceneId, gameObject );
        yield break;
    }

    Scene currentScene  = SceneManager.GetSceneByName( sceneData.ID );

    if( currentScene.IsValid() )
    {
        // そのシーンは既に読み込まれているようだ。
        // かぶるから読み込みは差し控えさせていただきます。
        Debug.LogWarningFormat( "The scene has already been loaded.! id={0}", i_sceneId, gameObject );
        yield break;
    }

    yield return SceneManager.LoadSceneAsync( sceneData.ID, LoadSceneMode.Additive );
}

f:id:urahimono:20170415204607g:plain

 同じシーンを読み込もうとすると、警告文が出て読み込めなくなりましたね。
 この状態になった場合は、ゲームのロジック部分で何かおかしなことが起きている証拠なのです。

シーンを型(カテゴリ)にはめよう

 さて、僕の目指すシーン管理の理想図をもう一度みてみましょう。
f:id:urahimono:20170415204121p:plain

 TitleGameplayなど共存できないシーンがあります。
 これを表現する機能が必要です。
 カテゴリという情報を追加してみましょう。

[System.Serializable]
public class SceneData
{
    [SerializeField]
    private string  m_id   = null;
    public  string  ID  { get { return m_id; } }

    [SerializeField]
    private string  m_category  = null;
    public  string  Category    { get { return m_category; } }  
}

f:id:urahimono:20170415204705p:plain

 そしてSceneControllerに同じカテゴリのシーンを取得する関数を追加しますね。

SceneController.cs

/// <summary>
/// 指定したシーンと同じカテゴリのHerarchy上のシーンを取得するぞ。
/// </summary>
private Scene GetSceneOfSameCategoryInHerarchy( SceneData i_sceneData )
{
    if( i_sceneData == null )
    {
        throw new System.ArgumentNullException( "i_sceneData" );
    }

    // カテゴリが無いシーンはルートシーン扱い。
    if( string.IsNullOrEmpty( i_sceneData.Category ) )
    {
        return new Scene();
    }

    for( int i = 0; i < SceneManager.sceneCount; ++i )
    {
        Scene scene = SceneManager.GetSceneAt( i );

        if( scene.name == i_sceneData.ID )
        {
            continue;
        }

        SceneData sceneData = m_sceneTableAsset.GetSceneData( scene.name );
        if( sceneData == null )
        {
            continue;
        }

        if( string.IsNullOrEmpty( sceneData.Category ) )
        {
            continue;
        }

        if( sceneData.Category == i_sceneData.Category )
        {
            return scene;
        }
    }

    return new Scene();
}

 この関数を利用して、シーンを読み込む際に同じカテゴリのシーンがHerarchy上にある場合は、読み込まれているシーンを破棄する機能を追加してみます。

SceneController.cs

private IEnumerator LoadSceneProcess( string i_sceneId )
{
    var sceneData   = m_sceneTableAsset.GetSceneData( i_sceneId );
    if( sceneData == null )
    {
        // そんなシーン、俺は知らん!
        Debug.AssertFormat( false, "The scene is missing! id={0}", i_sceneId, gameObject );
        yield break;
    }


    {
        Scene currentScene = SceneManager.GetSceneByName( sceneData.ID );
        if( currentScene.IsValid() )
        {
            // そのシーンは既に読み込まれているようだ。
            // かぶるから読み込みは差し控えさせていただきます。
            Debug.LogWarningFormat( "The scene has already been loaded.! id={0}", i_sceneId, gameObject );
            yield break;
        }
    }

    // 同一のカテゴリのシーンを複数存在させない。
    Scene sceneOfSameCategory   = GetSceneOfSameCategoryInHerarchy( sceneData );
    if( sceneOfSameCategory.IsValid() )
    {
        yield return SceneManager.UnloadSceneAsync( sceneOfSameCategory );
    }

    yield return SceneManager.LoadSceneAsync( sceneData.ID, LoadSceneMode.Additive );
}

 さて、うまくいっているか試してみましょう。

GameController.cs

private IEnumerator Start()
{
    // 仮機能でバンバンシーンを読み込む。
    string[] sceneList   = new string[]
    {
        "DebugMenu",
        "Title",
        "MainMenu",
        "Status",
        "Explain",
        "Option",
        "Gameplay",
        "Stage00",
        "Stage01",
    };

    foreach( var sceneID in sceneList )
    {
        m_sceneController.LoadScene( sceneID );
        yield return new WaitForSeconds( 1.0f );
    }
}

f:id:urahimono:20170415204755g:plain

 同じカテゴリのシーンを読む場合は、シーンを破棄してから読み込むようになっています。
 成功です。
 徐々に理想に近づいてきました。
 

親子の絆

f:id:urahimono:20170415204121p:plain  僕の理想を実現するためには、シーンに親子関係を持たせる必要がありそうです。
 親シーンという情報を追加しましょう。

[System.Serializable]
public class SceneData
{
    [SerializeField]
    private string  m_id   = null;
    public  string  ID  { get { return m_id; } }

    [SerializeField]
    private string  m_category  = null;
    public  string  Category    { get { return m_category; } }

    [SerializeField]
    private string  m_parentId  = null;
    public  string  ParentId    { get { return m_parentId; } }  
}

f:id:urahimono:20170415204851p:plain

親シーンが破棄された場合は、子シーンも破棄する

 
 親シーンが破棄された場合は、子シーンも破棄される仕組みを作りましょう。
 まずは、子シーンデータを取得する関数を追加します。

SceneTable.cs

/// <summary>
/// 指定したシーンを親に持つシーンを取得するよ。
/// </summary>
public SceneData[] GetChildScenes( string i_sceneId )
{
    if( string.IsNullOrEmpty( i_sceneId ) )
    {
        // 引数に変な文字列渡してんじゃないのさ!
        throw new System.ArgumentNullException( "i_id" );
    }

    if( m_sceneList == null || m_sceneList.Length == 0 )
    {
        // シーン情報が一つも登録されておらんのだが……。
        Debug.AssertFormat( false, "m_sceneList is empty!" );
        return null;
    }

    return m_sceneList.Where( value => value.ParentId == i_sceneId ).ToArray();
}

 親子シーン破棄用の関数を作り、SceneManager.UnloadSceneAsync()を呼んでいた箇所と差し替えます。

SceneController.cs

/// <summary>
/// シーンを破棄するよ。
/// 子シーンも破棄するよ。
/// </summary>
private IEnumerator UnloadScene( Scene i_scene )
{
    if( !i_scene.IsValid() )
    {
        throw new System.ArgumentException( "i_scene" );
    }

    SceneData[] childList   = m_sceneTableAsset.GetChildScenes( i_scene.name );
    if( childList != null )
    {
        foreach( var child in childList )
        {
            Scene childScene    = SceneManager.GetSceneByName( child.ID );
            if( childScene.IsValid() )
            {
                // 子シーンの子シーンがいる可能性があるため、再帰する。
                yield return UnloadScene( childScene );
            }
        }
    }

    yield return SceneManager.UnloadSceneAsync( i_scene );
}

 これでシーンを破棄する際に、子シーンがある場合は一緒に破棄してくれるようになります。

f:id:urahimono:20170415204931g:plain

親がいない状態で子シーンを読む場合は、親シーンも読む

 先ほどとは逆に子シーンのみ作成された場合には、親シーンを読んでから子シーンを読み込むようにしましょう。
 実際にこのような状況があるかはわかりませんけど……。

SceneController.cs

/// <summary>
/// シーンを読み込むよ。
/// 親シーンがいるけど、まだ読み込まれていない場合は親シーンも読み込むよ。
/// </summary>
private IEnumerator LoadScene( SceneData i_sceneData )
{
    if( i_sceneData == null )
    {
        throw new System.ArgumentNullException( "i_sceneData" );
    }

    string parentId = i_sceneData.ParentId;

    if( !string.IsNullOrEmpty( parentId ) )
    {
        Scene parentScene   = SceneManager.GetSceneByName( parentId );
        if( !parentScene.IsValid() )
        {
            SceneData parentSceneData   = m_sceneTableAsset.GetSceneData( parentId );
            if( parentSceneData == null )
            {
                Debug.AssertFormat( false, "Parent scene is missing! id={0} parentId={1}", i_sceneData.ID, parentId, gameObject );
            }

            // 親シーンの親シーンがいる可能性があるため、再帰する。
            yield return LoadScene( parentSceneData );
        }
    }

    yield return SceneManager.LoadSceneAsync( i_sceneData.ID, LoadSceneMode.Additive );
}

 この新しく作ったLoadScene()SceneManager.LoadSceneAsync()と差し替えます。
 動作を確認してみましょう。

GameController.cs

private IEnumerator Start()
{
    // 仮機能でバンバンシーンを読み込む。
    string[] sceneList   = new string[]
    {
        "DebugMenu",
        "Title",
        "MainMenu",
        "Status",
        "Explain",
        "MainMenu",
        "Option",
        "Stage00",
        "Stage01",
    };

    foreach( var sceneID in sceneList )
    {
        m_sceneController.LoadScene( sceneID );
        yield return new WaitForSeconds( 1.0f );
    }

    yield return null;
}

f:id:urahimono:20170415205045g:plain

 いい感じに動いています。
 僕の思い描いていたシーン管理を実現できたような気がしますよ!

完成!

 これで僕が望んでいたシーン管理システムが出来ました。
 実際使っていくと気になる点が出てくるとは思いますので、それは都度修正していきましょう。