Edited at

Unity シーン遷移の機構を作った話

More than 1 year has passed since last update.


今回の話

自分が過去に開発したゲーム、及び現在開発しているゲームで利用しているオレオレシーン遷移管理機構の紹介です。

何かの参考になれば。

1.gif

(こういう「蓋絵」を上に重ねて画面を隠している間にシーン遷移するアレです)


シーン遷移管理機構


必要なもの

必須



  • UniRx : イベント発行、コルーチンのタイミング制御に必要

別のもので代用可能

Easy Masking Transitionは有料アセットです。無料で実装したい場合はテラシュールブログさんなどを参考に自分で実装する必要があります。


備えている機能


  • シーン名を指定して遷移

  • マルチシーン読み込み

  • シーン間のデータ共有

  • 蓋絵を表示したまま一時停止&任意のタイミングで解除

  • シーンロード完了イベントの発行

  • トランジションアニメーション終了イベントの発行

  • シーン遷移管理GameObjectの自動生成(シーンに予めシングルトンを配置しなくて良い)


実装の紹介


スクリプト

パブリックドメインで公開するのでご自由に利用下さい。名前空間とかその辺はよしなに書き換えて下さい。


シーン一覧

//UnityのBuildSettingsに登録してあるシーン名と完全一致でここに記述してください

public enum GameScenes
{
Title,
Menu,
BattleManager,
Stage,
Result
}



シーンをまたいでやりとりするデータ

namespace BirdStrike.MIKOMA.Scripts.Utilities.SceneDataPacks

{
/// <summary>
/// シーンをまたいでデータを受け渡すときに利用する
/// </summary>
public abstract class SceneDataPack
{
/// <summary>
/// 前のシーン
/// </summary>
public abstract GameScenes PreviousGameScene { get; }

/// <summary>
/// 前シーンで追加ロードしていたシーン一覧
/// </summary>
public abstract GameScenes[] PreviousAdditiveScene { get; }
}

/// <summary>
/// デフォルト実装
/// </summary>
public class DefaultSceneDataPack : SceneDataPack
{
private readonly GameScenes _prevGameScenes;
private readonly GameScenes[] _additiveScenes;

public GameScenes[] AdditiveScenes
{
get { return _additiveScenes; }
}

public override GameScenes PreviousGameScene
{
get { return _prevGameScenes; }
}

public override GameScenes[] PreviousAdditiveScene
{
get { return null; }
}

public DefaultSceneDataPack(GameScenes prev, GameScenes[] additive)
{
_prevGameScenes = prev;
_additiveScenes = additive;
}
}
}



シーン遷移管理マネージャ本体

using System;

using System.Collections;
using System.Linq;
using BirdStrike.MIKOMA.Scripts.Utilities.SceneDataPacks;
using UniRx;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;

namespace BirdStrike.MIKOMA.Scripts.Utilities.Transition
{
/// <summary>
/// シーン遷移を管理する
/// </summary>
public class TransitionManager : SingletonMonoBehaviour<TransitionManager>
{
/// <summary>
/// 蓋絵(トランジションアニメーションの管理コンポーネント)
/// Easy Masking Transitionを利用しない場合は自作して下さい
/// </summary>
private EMTransition _transitionComponent;

/// <summary>
/// 蓋絵のImage
/// </summary>
private RawImage _image;

/// <summary>
/// シーン遷移処理を実行中であるか
/// </summary>
private bool _isRunning = false;

public bool IsRunning { get { return _isRunning; } }

/// <summary>
/// トランジションアニメーションを終了させてよいか
/// (蓋絵が開くアニメーションを再生してよいか)
/// </summary>
private ReactiveProperty<bool> CanEndTransition = new ReactiveProperty<bool>(false);

private GameScenes _currentGameScene;

/// <summary>
/// 現在のシーン情報
/// </summary>
public GameScenes CurrentGameScene
{
get { return _currentGameScene; }
}

/// <summary>
/// トランジションのアニメーションの終了通知
/// (蓋絵が開き切ったり、閉じきったことを通知する)
/// </summary>
private Subject<Unit> _onTransactionFinishedInternal = new Subject<Unit>();

/// <summary>
/// トランジションが終了しシーンが開始したことを通知する
/// </summary>
private Subject<Unit> _onTransitionAnimationFinishedSubject = new Subject<Unit>();

private Subject<Unit> onAllSceneLoaded = new Subject<Unit>();

/// <summary>
/// 全シーンのロードが完了したことを通知する
/// </summary>
public IObservable<Unit> OnScenesLoaded { get { return onAllSceneLoaded; } }

/// <summary>
/// トランジションが終了し、シーンが開始したことを通知する
/// OnCompletedもセットで発行する
/// </summary>
public IObservable<Unit> OnTransitionAnimationFinished
{
get
{
if (_isRunning)
{
return _onTransitionAnimationFinishedSubject.FirstOrDefault();
}
else
{
//シーン遷移を実行していないら即値を返却
return Observable.Return(Unit.Default);
}
}
}

/// <summary>
/// トランジションアニメーションを終了させる
/// (AutoMove=falseを指定した際に呼び出す必要がある)
/// </summary>
public void Open()
{
CanEndTransition.Value = true;
}

private void Awake()
{
//勝手に消さない
DontDestroyOnLoad(gameObject);

try
{
//現在のシーンを取得する
_currentGameScene =
(GameScenes)Enum.Parse(typeof(GameScenes), SceneManager.GetActiveScene().name, false);
}
catch
{
Debug.Log("現在のシーンの取得に失敗");
_currentGameScene = GameScenes.TitleScene; //Debugシーンとかの場合は適当なシーンで埋めておく
}
}

private void Start()
{
Initialize();

//トランジションの終了を待機してゲームを開始するような設定の場合を想定して
//初期化直後にシーン遷移完了通知を発行する(デバッグで任意のシーンからゲームを開始できるように)
onAllSceneLoaded.OnNext(Unit.Default);
}

/// <summary>
/// 初期化
/// </summary>
private void Initialize()
{
if (_transitionComponent == null)
{
_transitionComponent = GetComponent<EMTransition>();
_image = GetComponent<RawImage>();
_image.raycastTarget = false; //タッチイベントを蓋絵でブロクしない

//この辺はEMTの設定
//アニメーションが終わったら自動的に反転する
_transitionComponent.flipAfterAnimation = true;
//トランジションアニメーションが終了したイベントをObservableに変換する
_transitionComponent.onTransitionComplete.AddListener(
() => _onTransactionFinishedInternal.OnNext(Unit.Default));
}
}

/// <summary>
/// シーン遷移を実行する
/// </summary>
/// <param name="nextScene">次のシーン</param>
/// <param name="data">次のシーンへ引き継ぐデータ</param>
/// <param name="additiveLoadScenes">追加ロードするシーン</param>
/// <param name="autoMove">トランジションの自動遷移を行うか</param>
public void StartTransaction(
GameScenes nextScene,
SceneDataPack data,
GameScenes[] additiveLoadScenes,
bool autoMove
)
{
if (_isRunning) return;
StartCoroutine(TransitionCoroutine(nextScene, data, additiveLoadScenes, autoMove));
}

/// <summary>
/// シーン遷移処理の本体
/// </summary>
private IEnumerator TransitionCoroutine(
GameScenes nextScene,
SceneDataPack data,
GameScenes[] additiveLoadScenes,
bool autoMove
)
{
//処理開始フラグセット
_isRunning = true;

//トランジションの自動遷移設定
CanEndTransition.Value = autoMove;

if (_transitionComponent == null)
{
//初期化できてなかったらここで初期化する
Initialize();
yield return null;
}

//蓋絵でuGUIのタッチイベントをブロックする
_image.raycastTarget = true;

//トランジション開始(蓋絵で画面を隠す)
_transitionComponent.flip = false;
_transitionComponent.ignoreTimeScale = true;
_transitionComponent.Play();

//トランジションアニメーションが終了するのを待つ
yield return _onTransactionFinishedInternal.FirstOrDefault().ToYieldInstruction();

//前のシーンから受け取った情報を登録
SceneLoader.PreviousSceneData = data;

//メインとなるシーンをSingleで読み込む
yield return SceneManager.LoadSceneAsync(nextScene.ToString(), LoadSceneMode.Single);

//追加シーンがある場合は一緒に読み込む
if (additiveLoadScenes != null)
{
yield return additiveLoadScenes.Select(scene =>
SceneManager.LoadSceneAsync(scene.ToString(), LoadSceneMode.Additive)
.AsObservable()).WhenAll().ToYieldInstruction();
}
yield return null;

//使ってないリソースの解放とGCを実行
Resources.UnloadUnusedAssets();
GC.Collect();

yield return null;

//現在シーンを設定
_currentGameScene = nextScene;

//シーンロードの完了通知を発行
onAllSceneLoaded.OnNext(Unit.Default);

if (!autoMove)
{
//自動遷移しない設定の場合はフラグがtrueに変化するまで待機
yield return CanEndTransition.FirstOrDefault(x => x).ToYieldInstruction();
}
CanEndTransition.Value = false;

//蓋絵を開く方のアニメーション開始
_transitionComponent.Play();

//蓋絵が開ききるのを待つ
yield return _onTransactionFinishedInternal.FirstOrDefault().ToYieldInstruction();

//蓋絵のイベントブロックを解除
_image.raycastTarget = false;

//トランジションが全て完了したことを通知
_onTransitionAnimationFinishedSubject.OnNext(Unit.Default);

//終了
_isRunning = false;
}
}
}



ゲーム側から呼び出すためのstaticクラス

using BirdStrike.MIKOMA.Scripts.Utilities.SceneDataPacks;

using BirdStrike.MIKOMA.Scripts.Utilities.Transition;
using UniRx;
using UnityEngine;

namespace BirdStrike.MIKOMA.Scripts.Utilities
{
public static class SceneLoader
{
/// <summary>
/// 前のシーンから引き継いだデータ
/// </summary>
public static SceneDataPack PreviousSceneData;

/// <summary>
/// シーン遷移マネージャ
/// </summary>
private static TransitionManager _transitionManager;

/// <summary>
/// シーン遷移マネージャ
/// (存在しない場合は生成する)
/// </summary>
private static TransitionManager TransitionManager
{
get
{
if (_transitionManager != null) return _transitionManager;
Initialize();
return _transitionManager;
}
}

/// <summary>
/// トランジションマネージャが存在しない場合に初期化する
/// </summary>
public static void Initialize()
{
if (TransitionManager.Instance == null)
{
var resource = Resources.Load("Utilities/TransitionCanvas");
Object.Instantiate(resource);
}
_transitionManager = TransitionManager.Instance;
}

/// <summary>
/// シーン遷移のトランジションが完了したことを通知する
/// </summary>
public static IObservable<Unit> OnTransitionFinished
{
get { return TransitionManager.OnTransitionAnimationFinished; }
}

/// <summary>
/// シーンのロードが全て完了したことを通知する
/// </summary>
public static IObservable<Unit> OnScenesLoaded { get { return TransitionManager.OnScenesLoaded.FirstOrDefault(); } }

/// <summary>
/// トランジションアニメーションを終了させてゲームシーンを移す
/// (AutoMoveにfalseを指定した際に実行する必要がある)
/// </summary>
public static void Open()
{
TransitionManager.Open();
}

/// <summary>
/// シーン遷移処理中か
/// </summary>
public static bool IsTransitionRunning
{
get { return TransitionManager.IsRunning; }
}

/// <summary>
/// シーン遷移を行う
/// </summary>
/// <param name="scene">次のシーン</param>
/// <param name="data">次のシーンへ引き継ぐデータ</param>
/// <param name="additiveLoadScenes">追加でロードするシーン</param>
/// <param name="autoMove">トランジションアニメーションを自動的に完了させるか
/// falseの場合はOpen()を実行しないとトランジションが終了しない</param>
public static void LoadScene(GameScenes scene,
SceneDataPack data = null,
GameScenes[] additiveLoadScenes = null,
bool autoMove = true)
{
if (data == null)
{
//引き継ぐデータが未指定の場合はシーン情報のみを詰める
data = new DefaultSceneDataPack(TransitionManager.CurrentGameScene, additiveLoadScenes);
}
TransitionManager.StartTransaction(scene, data, additiveLoadScenes, autoMove);
}
}
}



Prefab構成

uGUIのCanvas(TranstionCanvas)の下に、TransitionManagerという名前でRawImageを配置し図のように設定。CanvasはLayerを調整して最前面に描画されるようにします。

image.png

そしてこのPrefabを「Resources/Utilities/TransitionCanvas」に配置。


使い方

Prefabをちゃんと指定のディレクトリにさえ配置しておけば、あとはSceneLoaderにアクセスした時に自動的に必要なコンポーネントを初期化してくれます。なのでシーンに最初から何かを配置しておくといった手間はありません。

本当に、ただ単純に、シーン遷移したくなったらSceneLoader.LoadSceneを呼ぶだけであとは勝手に全部やってくれます。


1. 1シーンだけ読み込んでシーン遷移

そのままSceneLoader.LoadSceneを実行すればOK


単純なシーン遷移

//メニューシーンへ遷移

SceneLoader.LoadScene(GameScenes.Menu);


2. 複数シーンを追加ロードしてシーン遷移

SceneLoader.LoadSceneのadditiveLoadScenesに配列で追加シーンを指定


複数シーン指定

// "Stage1"シーンを先にロードし、続けて"BattleManager"シーンをロード

SceneLoader.LoadScene(GameScenes.Stage1, additiveLoadScenes: new[] { GameScenes.BattleManager });


3. シーンをまたいでデータのやり取り

シーンをまたいで任意のデータをやり取りする場合は、SceneDataPackを継承した型をつくりそこに設定します。


受け渡すデータ型

//バトルシーンに遷移する際に利用するDataPack

public class ToBattleSceneDataPack : SceneDataPack
{
private readonly GameScenes _previousGameScene;
private readonly GameScenes[] _additiveGameSceneses;
public override GameScenes PreviousGameScene
{
get { return _previousGameScene; }
}

public override GameScenes[] PreviousAdditiveScene
{
get { return _additiveGameSceneses; }
}

/// <summary>
/// 参加人数
/// </summary>
public int PlayerCount { get; private set; }

public ToBattleSceneDataPack(int playerCount, GameScenes previousGameScene, GameScenes[] additiveSceneses)
{
PlayerCount = playerCount;
this._previousGameScene = previousGameScene;
_additiveGameSceneses = additiveSceneses;
}
}



遷移元シーン

//プレイヤ人数を3名に設定

var dataPack = new ToBattleSceneDataPack(3, GameScenes.Menu, new GameScenes[0]);
//シーン遷移
SceneLoader.LoadScene(GameScenes.Stage1, dataPack, new[] { GameScenes.BattleManager });


遷移先シーン

//遷移先のマネージャコンポーネントなどで値を読み取って初期化に利用できる

private void Start()
{
var data = SceneLoader.PreviousSceneData as ToBattleSceneDataPack;
if (data != null)
{
var playerCount = data.PlayerCount;
InitializeGame(playerCount);
}
else
{
Debug.LogError("初期化に必要な情報が足りません。デバッグデータを利用します。");
InitializeGame(1);
}
}


4. 初期化が終了するまで蓋絵で隠したままにする

SceneLoader.LoadSceneautoMoveにfalseを指定することでトランジションが自動終了しなくなります(蓋絵で閉じたままになります)

遷移先のシーンで初期化等が終わってからSceneLoader.Open()を呼び出すことでトランジションアニメーションが継続します。


シーン遷移元

//autoMoveをfalseにしてシーン遷移を実行

SceneLoader.LoadScene(GameScenes.Result, autoMove: false);


シーン遷移先

private void Start()

{
//初期化開始
StartCoroutine(InitializeCoroutine());
}

private IEnumerator InitializeCoroutine()
{
/**
* リソースを非同期でロードしたり、通信が発生するなど
* 1フレーム中に処理が終わらないことを想定してコルーチンで初期化する
* (このへんでyield returnが繰り返される想定)
*/

//トランジションアニメーション終了
SceneLoader.Open();

//トランジションのアニメーションが完全に終了するのを待機
yield return SceneLoader.OnTransitionFinished.ToYieldInstruction();

//蓋絵が完全に消えてからゲーム開始
GameStart();
}



まとめ

オレオレシーン遷移機構をメモがてら記述してみました。シーン遷移の実装で迷ってる方がいたら参考にしてもらえると良いかもしれません。


ただし自分が必要だと思った機能しか実装していないため、機能不十分な場合は自信で追加で実装する必要があります。

こういったシーン遷移のマネージャは一度作っておけば流用が効くため、1つしっかりとしたやつを作って資産としてとっておくと良いかもしれません。


おまけ:マルチシーンで初期化順をどうこうしたい時

マルチシーンでゲームの初期化をしようと、初期化順序の問題が出てきます。特に必要なデータがシーンをまたいでいた場合、Start()のタイミングでは情報が足りずに初期化ができないといったことが発生します。

こうった時、いくつか逃げ道があるので紹介したいと思います。


その1:シーンロード完了通知を持って初期化する

今回のシーン遷移管理機構の場合、SceneLoader.OnScenesLoadedで全てのシーンのロードが完了したことを通知してくれます。

そのため、初期化のタイミングをこのイベント通知を受けてから実行するように変更することで対応することができます。


その2: Zenject + SceneBinding + SceneDecorators + MethodInjection

Unity向けDIツールであるZenjectを利用することで、シーン構築に必要な情報を自動的に注入してくれるようにすることができます。

Zenject + Scene Bindingの設定については軽くですが過去の記事で触れているのでこちらを参考にして貰えれば大丈夫だと思います。

そしてマルチシーンでSceneBindigを利用する場合はDecoratoer Contextを利用する必要があります。ちょっとわかりにくいのでここはちゃんと解説します。


1.先にロードされるシーンにDecoratorContextを配置する

先にロードされるシーンにDecoratorContextを配置します。[GameObject]->[Zenject]->[Decorator Context]から配置できます

image.png


2.DecoratorContextのDecorated Contractを設定する

DecoratorContextのDecorated Contractに任意の文字列を設定します。SceneContext同士の関連付けを行うIDの様なものです。

今回は例としてMainという名前で設定します。

image.png


3.後からロードされるシーンにSceneContextを配置してContract Namesを設定知る

後にロードされるシーンにScene Contextを配置します。[GameObject]->[Zenject]->[Scene Context]から配置できます

配置できたら、Contract Namesに先程のMainを設定します。

image.png


4.スクリプトにInject設定を記述する

ここまで設定すると、最後のシーンのロードが終わってSceneContextが初期化されたタイミングでシーンをまたいでコンポーネントをInjectしてくれるようになります。

そのため、Decorator Contextしか配置してない側のシーンでField Injectionを利用していた場合、Start()の実行にInjectが間に合わずにnullになって初期化に失敗することがあります。そのため、マルチシーンでDIする場合はMethod Injectionを利用することをオススメします。


FiledInjectionの場合

public class Emeny : MonoBehaviour

{
[Inject]
private EnemyManager _manager;

void Start()
{
//マネージャに自身を登録したいが、DIが間に合わずにStart()が先に実行された場合はnullになって死ぬ
_manager.Register(this);
}
}



MethodInjectionの場合

public class Emeny : MonoBehaviour

{
[Inject]
private void Inject(EnemyManager manager)
{
//Start()が先に実行されるかここが先かはわからないがとりあえずなんとかなる
manager.Register(this);
}
}

正直なところ、もっといい方法がある気がしますけど…。

(ちなみにScene Bindingはシーンの初期化にしか使えないDIです。動的に生成されるPrefabにInjectする場合はInstallerを設定する必要があります)


おまけ2: プログレスバーを作る

今回紹介したシーン遷移機構にEnergy Bar Toolkitを組み合わせることで、簡単にロード中のプログレスバーを作ることができました。(詳しい実装は需要があったら書きます。とりあえずEnergy Bar Toolkitが便利だったよという紹介だけ)

2.gif

(マルチシーン読み込みでシーン遷移 → ZenjectでシーンをまたいでDI → 非同期で初期化 → 初期化完了したら蓋絵を解除 → トランジションが完全に終了したらタイマーのカウントダウン開始)