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

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

【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

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

完成!

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