「ホーム画面から装備画面へ遷移して、確認ダイアログを開いて、前の画面に戻る」
どんなゲームにも必ずある、ごくありふれた処理です。それなのに、シーン遷移とダイアログ管理の実装はプロジェクトごとに毎回ゼロから書かれ、毎回少しずつ壊れやすいものが出来上がっていきます。なぜでしょうか。
この記事では、そういった問題の解決を目指したシーン基盤と、その上に載せる「シーンを跨ぐ共通機能」(ダイアログ)を、VContainerのDIスコープを使って安定した拡張性のある形として設計する方法を紹介します。
実際に作ったものを例として、記事の最後でまとめて紹介します。
1. ゲーム基盤はこうして壊れていく
設計云々の前に、まずどういうシチュエーションを想定しているかだけ提示しておきます。
特定のプロジェクトの話ではなく、おそらく多くの現場で似たようなことが起きているはずです。
フェーズ1: 無邪気な始まり
プロジェクト初期、誰かがこう書きます。
public static class SceneLoader
{
public static void Load(string sceneName)
{
SceneManager.LoadScene(sceneName);
}
}
SceneManager.LoadScene の薄いラッパーです。この時点では誰も困っていません。
フェーズ2: 要求が足される
開発が進むと要求が増えます。フェードを入れたい。ローディング画面を挟みたい。遷移時にBGMを止めたい。前の画面に戻れるようにしたい。
そのたびに SceneLoader は成長します。
public static class SceneLoader
{
public static bool IsTransitioning; // 遷移中の多重実行を防ぎたい
public static string PreviousSceneName; // 「戻る」に使いたい
public static void Load(
string sceneName,
bool withFade = true,
bool showLoading = false,
bool stopBgm = false,
Action onComplete = null)
{
if (IsTransitioning)
{
return; // ここでreturnしていいのかは、誰も知らない
}
IsTransitioning = true;
PreviousSceneName = SceneManager.GetActiveScene().name;
FadeCanvas.Instance.FadeOut(() =>
{
if (stopBgm)
{
BgmManager.Instance.Stop();
}
SceneManager.LoadScene(sceneName);
// ロードの完了はどこで待つ? onCompleteはいつ呼ぶ?
// IsTransitioningは誰がfalseに戻す?
});
}
}
bool引数とフラグが増え、コールバックの中で次のコールバックが呼ばれ、「フラグを戻し忘れると二度と遷移できなくなる」バグがときどき起きるようになります。
フェーズ3: ダイアログが加わる
確認ダイアログ、報酬ダイアログ、通信エラーダイアログ。それぞれの画面が自前でPrefabを Instantiate し、自前で閉じる実装になっていきます。
すると、こういうことが起き始めます。
- ボタンを連打すると確認ダイアログが2枚開く
- 通信エラーダイアログと結果ダイアログが同時に出て、閉じる順番によってUIが操作不能になる
- 「今なにかダイアログが開いているか」を知る方法がなく、
GameObject.Find("ConfirmDialog(Clone)")のようなコードが生える
フェーズ4: 「戻る」で崩壊する
そして「戻る」です。
スマートフォンゲームではお馴染みの「Androidバックキー対応」というやつです。
- 装備画面から戻ったのに、ホーム画面の初期化が走らない。あるいは二重に走る
- 「どこから来たか」で挙動を変えるifが画面側に増殖する
-
if (SceneLoader.PreviousSceneName == "Shop")...
-
- 遷移中にもう一度遷移ボタンが押せてしまい、ロード完了前の参照で
NullReferenceException
この頃には、QAから上がるバグの再現条件が複雑になり、ひとつ直すと別の画面が壊れる、という状態になっています。
フェーズ5: チームが壊れる
そして問題はコードからチームに波及します。
- 新しい画面を1枚足すだけで、神クラス化した
SceneLoaderへの追記が必要になる。全員が基盤を触り、誰も全体を把握していない - 新しく参画したメンバーの1コミットが全画面の遷移を壊しうるので、ベテランのメンバーが全PRをレビューするしかなく、ボトルネックになる
- 新メンバーへのキャッチアップ資料が、実質「触ってはいけない場所リスト」になっている
- そもそもまともに資料を作れていないかも
根本原因はどこにあるのか
ここまでの症状は、個々のバグが問題なのではありません。
私は 「基盤」と「コンテンツ」の境界が、コードのどこにも存在しないことが問題だと考えました。
遷移フラグ、前画面の名前、ダイアログの開閉状態。こうした基盤の内部状態に、コンテンツ実装者の誰もが触れられる。つまり、壊し方が無限に存在する構造になっているのです。
そこで、こう考えました。
コンテンツ実装者が、基盤の中身に触れられない・触れる必要がない形にできないだろうか?
2. 目指す状態: コンテンツ実装者が中身を知らなくても使える
「利用側コード」の「理想」を考えてみます。
画面を実装する人が書く遷移・ダイアログ操作のほとんどのケースは、処理や振る舞いを細かくハンドリングをしたいわけではありません。
プロジェクトで予め定めておいたシーン遷移のルールやダイアログの振る舞いに沿った挙動をしてくれれば十分なはずです。
例えばごく一般的なシチュエーションを想定するのであれば、これだけで十分なはずです。
// 装備画面へ遷移する
await sceneManager.TransitionScene(new EquipScene.EquipTransitionData(itemId));
// 前の画面へ戻る
await sceneManager.BackScene();
// ダイアログを開く / 閉じる
await screenStackModule.Open(new ConfirmDialogData("この装備を売却しますか?"));
await screenStackModule.Close();
フェードの待ち合わせも、多重遷移の防止も、ダイアログの開閉順も、この呼び出しの向こう側(基盤側)に閉じ込めます。利用側は await で完了を待つだけです。
なぜこれを目指すのか: スキルギャップに設計で応える
この形を目指す理由は、きれいなコードのためではありません。
チーム開発の現実に応えるためです。
現実的にベテランのエンジニアだけのチームというのは中々ありません。開発途中で新しく若いエンジニアが入ってきたり、異動前の別プロジェクトではUnityを使っていませんでした、のような混成のチームがほとんどです。非同期処理の競合、キャンセル処理、オブジェクトの寿命管理を全員が正しく書けるわけではありませんし、それが「みんな出来る」という前提でチームマネジメントを行うと、見えない所で時間や品質を犠牲にしてしまっていたりします。
スキルギャップがあるのにそれぞれ1人月として換算し、同じタスクの切り方でスケジュールに乗せるのは非常に効率が悪いです。このスキルに応じた自然な棲み分けに最も適切な境界が、私は基盤とコンテンツの間にあると考えました。
つまり、
- シニアエンジニアは基盤側を設計・保守する。遷移中の競合、キャンセル、寿命管理といった難しい問題はすべて基盤側に集約されるので、難易度の高いレビューもここに集中する
- ジュニアエンジニアはコンテンツ側(画面・ダイアログの実装)を担当する。用意されたライフサイクルイベントを埋めるだけで画面が完成し、基盤を壊す方法が構造的に存在しなくなる
という形で、「コンテンツ実装者が知らなくてもいいこと」を最大化することで、柔軟な人材配置や開発効率の上昇、OJTのキャッチアップ項目の最小化に貢献します。
| 知らなくていいこと(基盤の中身) | 知るべきこと(利用側の契約) |
|---|---|
| 遷移パイプラインの構造 | 遷移データ(TransitionData)の作り方 |
| シーンのロード/アンロードとキャッシュ | シーンのライフサイクルイベント |
| ダイアログの開閉キューと競合制御 | ダイアログのデータクラスとライフサイクル |
| DIスコープの親子関係の構築方法 | 自分のシーンのLifetimeScopeに何を登録するか |
この記事で言う「安定」と「拡張性」も、ここで定義しておきます。
-
安定 = 遷移・開閉の複雑な状態管理が基盤側に閉じていて、利用側は
await一発。壊れる場所が構造的に少ない - 拡張性 = 基盤の機能がすべてインターフェース経由で提供され、差し替え・取捨選択ができる
以降の章では、この2つを満たす作り方を「土台(DIスコープ)→ シーン基盤 → シーンを跨ぐ共通機能」の順で見ていきます。
3. 土台: DIスコープ階層で境界を切る
「1. ゲーム基盤はこうして壊れていく」で提示した例の根本原因は「境界がコード上に存在しない」ことでした。
VContainerを使う最大の理由は、この境界をLifetimeScopeの階層という実体にできることです。
各階層の責務と寿命はこう分けます。
| スコープ | 寿命 | 置くもの |
|---|---|---|
| Root | アプリ全体 | 起動フローだけ。ほぼ空。エラーログの送信準備など |
| Product | アプリ全体 | シーン基盤・ダイアログ基盤などの基盤サービス |
| 各シーン | シーンがロードされている間 | その画面のPresenter・View |
ポイントは2つです。
1. 寿命管理をDIコンテナに委譲する。 シーンスコープに登録したPresenterは、シーンがアンロードされればスコープごと破棄されます。「画面を閉じたのにイベント購読が残っていた」類のバグは、寿命をコンテナに任せることで構造的に消えます。
2. 基盤はインターフェースしか見せない。 基盤サービスは次のように登録し、コンテンツ側には ISceneManager のようなインターフェースだけを注入します。
public class ProductLifetimeScope : LifetimeScope
{
protected override void Configure(IContainerBuilder builder)
{
// シーン基盤(後述) — 実装クラスはコンテンツ側から見えない
builder.Register<SceneManager>(Lifetime.Singleton).AsImplementedInterfaces();
builder.Register<SceneTransitionController>(Lifetime.Singleton).AsImplementedInterfaces();
builder.Register<MainSceneManager>(Lifetime.Singleton).AsImplementedInterfaces();
builder.Register<ModuleSceneManager>(Lifetime.Singleton).AsImplementedInterfaces();
builder.Register<SceneGroupProvider>(Lifetime.Singleton).AsImplementedInterfaces();
// ... ダイアログ基盤などもここに登録します(後述)
}
}
最初の例にあげた神クラスとの違いはここです。static クラスは「全員が触れる」構造でしたが、スコープ階層では基盤の実装は Product スコープの中に隠れ、コンテンツ実装者の手元にはインターフェースしか届きません。境界が、規約や口約束ではなくコードの実体になります。
VContainerを知らん!という方は、AIに聴くか、Googleに聴くか、
ずんだもんのVContainer解説を読んでください
4. シーン基盤の構造
土台ができたので、主役のひとつ、シーン基盤を設計します。
ここからは私が実際にこの思想で設計してみたコードや想定があり、新しい概念をポンポン出すので少しややこしいかもしれません。
4-1. メインシーン + モジュールシーンというモデル
最初に解決したいのは共通UIの問題です。ヘッダー・フッター・背景のような共通UIは画面をまたいで生き続けてほしい。しかしメインの画面と同じシーンに置けば遷移で消えてしまうし、かといって「共通UIを壊さないように画面を作ってね」とコンテンツ実装者に生存管理を委ねるのは、冒頭で見た崩壊の入り口です。
そこでこの設計では、共通UIを基盤側で管理するために、Unityの「シーン」というわかりやすい境界をそのまま利用します。共通UIを独立したシーン(モジュールシーン)に切り出すのです。
スマートフォンゲームなどのカジュアルゲームだと、「1つのシーンにPrefabを配置する」というアーキテクチャもあるようですが、シーン管理を前提としたUnityの機能がちょくちょくあるので、個人的にはオススメはしません。
(まぁゲームなんて完成するなら何でも良いんですが)
シーンはエディタ上で目に見え、additiveロードでき、ロード/アンロードの単位になります。誰にでも分かる既存の境界なので、新しい概念を発明せずに済みます。
┌─────────────────────────────┐
│ Header (モジュールシーン) │ ← 画面をまたいで表示され続ける
├─────────────────────────────┤
│ │
│ Home / Equip / Shop ... │ ← メインシーン。ここだけ入れ替わる
│ (メインシーン) │
│ │
├─────────────────────────────┤
│ Footer (モジュールシーン) │ ← 画面をまたいで表示され続ける
└─────────────────────────────┘
画面 = 1つのメインシーン + 複数のモジュールシーンの合成と定義します。モジュールシーンのロード/アンロードと表示切り替えは基盤が自動で行うため、コンテンツ実装者は自分のメインシーンだけを書けばよくなります。
さらに、どのシーンを同時にロード・キャッシュしておくかを SceneGroup として定義します。
// ISceneGroupProviderの実装としてプロダクト側で定義し、DIに登録する
new SceneGroup(new Dictionary<MainSceneId, ModuleSceneId[]>
{
{ SceneIds.Home, new[] { SceneIds.Header, SceneIds.Footer } },
{ SceneIds.Equip, new[] { SceneIds.Header, SceneIds.Footer } },
{ SceneIds.Detail, new[] { SceneIds.Header } },
});
同じグループに属するシーンはまとめてロードされキャッシュされるので、グループ内の遷移は高速です。グループの切り方でユーザー体験とメモリのバランスを製品側が調整できます。これも「基盤の仕組みは共通、方針は製品側が注入する」という拡張性の形です。
4-2. 利用側の契約: TransitionDataとシーン基底クラス
コンテンツ実装者と基盤の間の「契約」は2つだけにします。
契約1: 遷移は TransitionData で表現する。 遷移先・遷移に必要なデータ・遷移可否を1クラスに封じます。
契約2: 画面はシーン基底クラスを継承する。 ライフサイクルイベントを埋めるだけで画面が動きます。
実装者が書くのは次の3点セットです。
// ① シーンクラス + ② 遷移データ(ネストクラスとして定義)
public class EquipScene : CanvasMainSceneBase<EquipScene.EquipTransitionData>
{
IEquipPresenter equipPresenter;
public override MainSceneId MainSceneId => YourGameMainSceneId.Equip;
public class EquipTransitionData : TransitionDataBase
{
public override MainSceneId MainSceneId => YourGameMainSceneId.Equip;
public string ItemId { get; }
public EquipTransitionData(string itemId) => ItemId = itemId;
}
[Inject]
public void Construct(IEquipPresenter equipPresenter)
{
this.equipPresenter = equipPresenter;
}
protected override UniTask OnEnter(EquipTransitionData data,
ISceneTransitionContext context, CancellationToken ct)
{
// 遷移データは型付きで受け取れる。文字列のシーン名もobject引数も登場しない
equipPresenter.Setup(data.ItemId);
return UniTask.CompletedTask;
}
}
// ③ このシーン専用のLifetimeScope(シーン内に配置)
public class EquipLifetimeScope : LifetimeScope
{
[SerializeField] EquipScene equipScene;
protected override void Configure(IContainerBuilder builder)
{
builder.RegisterComponent(equipScene);
builder.Register<EquipPresenter>(Lifetime.Singleton).AsImplementedInterfaces();
}
}
呼び出す側は new EquipScene.EquipTransitionData(itemId) を渡すだけです。「遷移に何が必要か」がコンストラクタで強制されるので、口約束のパラメータ受け渡しが消えます。
シーン基底クラスが提供するライフサイクルイベントは次のとおりです。
| イベント | タイミング | 用途の例 |
|---|---|---|
OnSetup |
初回ロード時に一度だけ | 一度きりの初期化 |
OnLoad |
シーンのロード時 | 表示前の準備(この時点では非表示) |
OnEnter |
画面に入るたび | 遷移データを受け取って画面を構築 |
InAnimation / OutAnimation
|
入場/退場の演出時 | フェードやスライドの再生 |
OnLeave |
画面から出るたび | 後片付け |
OnUnload |
シーンのアンロード時 | リソース解放 |
OnSceneTransitionFinished |
遷移が完全に終わった後 | 遷移完了後にだけ始めたい処理 |
冒頭の例で出した「戻ったのに初期化が走らない/走りすぎる」は、この契約で消えます。戻る遷移でも OnEnter は必ず呼ばれると基盤が保証し、「初回だけの処理」は OnSetup に書く、と置き場所が決まっているからです。遷移元による分岐も、TransitionDataに必要な情報を積むことで画面側のifから消えます。
4-3. 基盤の内側: 遷移はフェーズ×ステップのパイプライン
ここからは基盤の内側、つまりシニアエンジニアが設計する側の話です。
冒頭の例 SceneLoader が壊れた直接の原因は、フェード・ロード・BGM・コールバックという「順序があるはずの処理」が、フラグとコールバックで場当たり的に繋がれていたことでした。そこで基盤側では、シーン遷移を順序付きのフェーズが並んだパイプラインとして定義します。
構造は2層です。
- フェーズ: 順序を保証する単位。前のフェーズが完了してから次へ進む
- ステップ: フェーズの中で並列実行される単位。たとえばOutAnimationフェーズでは、メインシーンと各モジュールシーンの退場演出が並列に再生される
遷移中かどうかは、パイプラインを実行している基盤自身が IsTransition として一元管理し、遷移リクエストの受付制御も基盤の入り口で行えます。コールバック地獄も、フェーズの直列とステップの並列という構造になります。
さらに、このフェーズ列自体を ISceneTransitionSequenceProvider というインターフェースで差し替え可能にしておきます。たとえばクロスフェード風の遷移も、フェーズの並び替えとして定義できます。「遷移の流れを変えたい」という将来の要求に、基盤の書き換えではなくシーケンスの差し替えで応えられるわけです。
このパイプラインの上に、実用的な機能が素直に載ります。2つ紹介します。
戻るナビゲーション。 遷移履歴は基盤内にスタックとして積まれ、BackScene() はそれを遡ります。2章のゴール像で見せた「戻る」の正体はこれです。ローディング画面のような「戻り先にしたくない画面」は、TransitionDataで CanBackTransition = false を宣言すれば履歴から除外されます。画面側は「どこから来たか」を一切知らなくてよくなります。
遷移のインターセプト。 「イベント画面に遷移しようとしたら開催期間が終わっていた」のようなケースは、TransitionDataの事前処理フェーズ(LoadNextSceneState)で例外を投げて別の遷移にリダイレクトします。
public class EventTransitionData : TransitionDataBase
{
public override MainSceneId MainSceneId => YourGameMainSceneId.Event;
public override async UniTask LoadSceneState(
TransitionDirectionType direction, CancellationToken ct)
{
if (await eventService.IsExpired(ct))
{
// 遷移パイプラインが例外を受け取り、ホームへの遷移としてやり直す
throw new SceneInterceptException(new HomeScene.HomeTransitionData());
}
}
}
呼び出し側は相変わらず TransitionScene(new EventTransitionData()) を呼ぶだけです。リダイレクトの分岐が呼び出し側に漏れません。
4-4. VContainer結合の山場: additiveロードされるシーンに親スコープを与える
さきほど「シーンのLifetimeScopeはProductLifetimeScopeの子になる」とさらっと書きましたが、実はここに罠があります。
additiveロードされたシーン内のLifetimeScopeは、何もしなければProductLifetimeScopeと親子関係を持ちません。 シーンに置いた EquipLifetimeScope は、単体では孤立したスコープとして構築され、ISceneManager も IEquipPresenter の依存先も解決できないのです。
VContainerはこのための仕組みを用意しています。LifetimeScope.EnqueueParent です。using ブロックの中でロードされたシーンのLifetimeScopeは、指定したスコープの子として構築されます。
using (LifetimeScope.EnqueueParent(productLifetimeScope))
{
// この間にロードされたシーン内のLifetimeScopeは
// productLifetimeScopeの子として構築される
await SceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Additive);
}
シーン基盤のロード処理をこれで包めば、「シーンのスコープは自動的にProductの子になる」が実現します。コンテンツ実装者はこの仕組みを知る必要すらありません。自分のLifetimeScopeに登録した型が、なぜか(実際には親スコープのおかげで)解決できる、という体験だけが残ります。
また、ついでにシーン基盤のコアにVContainerの型を直接持ち込むと、基盤がDIライブラリに結合してしまうので、コアは Func<IDisposable> を受け取るだけにして、起動時のエントリポイントから中身を注入します。
(これは基盤とプロダクトを出来る限り分離したいため)
// シーン基盤コア側: VContainerを知らない。IDisposableのファクトリだけ知っている
public interface IMainSceneManager
{
void SetEnqueueParentLifetimeScope(Func<IDisposable> enqueueParentLifetimeScope);
}
// ロード処理はそれをusingで包むだけ
using (enqueueParentLifetimeScope.Invoke())
{
await SceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Additive);
}
// 起動時のエントリポイント側: ここで初めてVContainerの語彙が登場する
mainSceneManager.SetEnqueueParentLifetimeScope(
() => LifetimeScope.EnqueueParent(productLifetimeScope));
「どのスコープを親にするか」という方針はアプリ起動側の知識、「ロードを何かで包む」という機構は基盤の知識。依存の方向を制御することで、基盤コアはDIライブラリからも独立します。
ロード進捗は0.9で止めて、表示前に初期化します
LoadSceneAsync は allowSceneActivation = false にすると進捗0.9でロードを完了し、アクティブ化を保留できます。この状態でシーン内のコンポーネントに OnLoad を呼ばせて表示物を初期化しておき、その後アクティブ化すると、「一瞬初期化前の画面が見える」チラつきを防げます。4-2のライフサイクルで OnLoad が「表示前の準備」とされているのはこのため。
これだからUnityは・・・。
EnqueueParent が効くのは using スコープの内側でロードが開始されたシーンだけです。ロード処理を using の外に書いてしまうと、シーンのLifetimeScopeは黙って孤立スコープになり、注入エラーとして表面化します。ロードと using は必ず基盤側で一体にして、コンテンツ実装者の手が触れない場所に置きましょう。
5. シーンを跨ぐ共通機能をつくる — 例: ダイアログ基盤
シーン基盤で「画面の入れ替え」は解決しました。しかし実際にゲームを作り始めると、どの画面にいても使いたい機能があります。ダイアログ、オーバーレイ通知、画面全体のインジケータといった機能は、当たり前ですがメインシーンに置くと遷移時に消えてしまいます。
これは、全画面に同伴するモジュールシーンとして持たせ、DIで全シーンから使えるようにすることで解決できます。ここでは例としてダイアログ基盤(ScreenStack)を設計します。ダイアログ固有の話に見える部分も、「シーンを跨ぐ共通機能の一般的な作り方」として読めるはずです。
なお、モジュールシーンもSceneGroupを跨ぐ遷移ではアンロードされ得ます。つまりダイアログ基盤は「消えない場所に置く」のではなく、自分の状態をシーンの外に退避・復元できるように作ることになります(5-3で触れます)。
5-1. 利用側の契約
まず利用側です。APIは実質これだけにします。
await screenStackModule.Open(new ConfirmDialogData("この装備を売却しますか?"));
await screenStackModule.Close();
await screenStackModule.ClearAll();
シーン遷移と同じ構造です。ダイアログもデータクラスで表現し、表示のポリシーはデータクラス自身が宣言します。
public class ConfirmDialogData : IScreenStackData
{
public bool IsSystem => false; // システムレイヤー(通常UIより上)に置くか
public bool IsOverlayOpen => false; // 直前のスクリーンを閉じずに重ねるか
public string Message { get; }
public ConfirmDialogData(string message) => Message = message;
}
「エラーダイアログは他の全部より上」「チュートリアルの吹き出しは下の画面を残したまま重ねる」といった振る舞いが、呼び出し側のbool引数ではなくデータの定義に固定されます。同じダイアログを開くコードは、どこから呼んでも同じ振る舞いになります。
5-2. 安定性の核: スタック + 操作キュー
内側の設計の話で、冒頭の「連打で2枚開く」「閉じる順で壊れる」といった問題はここで解決します。
- 開いているダイアログはスタックで管理する。最前面が常に1つに定まる
- Open / Close / ClearAll の操作はキューに積まれ、前の操作(アニメーション込み)が完了してから次が実行される
操作がキューで直列化されるので、利用者はいつ Open を呼んでも構いません。連打されても、通信エラーと結果ダイアログが同時に来ても、基盤の中では1つずつ順番に処理されます。新人エンジニアが競合を意識しなくていいのは、行儀よく呼んでもらう規約があるからではなく、「どう呼ばれても壊れない構造にあらかじめしておく」からです。
最前面のダイアログの背後に全画面のRaycastをBlockする処理を差し込み、背面UIへのTouchスルーを防ぐのも基盤側の責務にします。画面側に「ダイアログ表示中はボタンを無効化する」コードを書かせたら負け、くらいの気持ちで基盤に吸収します。
5-3. ダイアログのライフサイクル
ダイアログのPrefabに付けるビュー側も、シーンと同じ発想で基底クラス(ScreenStackBase)を用意し、イベントを埋めるだけにします。
| イベント | タイミング |
|---|---|
OnInitialize |
生成直後、表示前。データを受け取って構築する |
OnEnter(isResume) |
スタックの最前面になったとき |
PlayInAnimation / PlayOutAnimation
|
入場・退場アニメーション |
OnLeave |
最前面でなくなるとき |
Dispose |
クローズ後の破棄 |
シーンのライフサイクルと対応する形にしてあるのがポイントです。シーンを1枚書いた実装者は、ダイアログも同じメンタルモデルで書けます。
isResume は「上に重なっていたスクリーンが閉じて、再び最前面に戻ってきた」ことを表します。初回表示と復帰で処理を分けたいケース(BGMの再開、データの再取得など)のための引数です。
そして章の冒頭で予告した話です。モジュールシーンは遷移でアンロードされ得るので、ダイアログ基盤はスタックの状態をSuspend / Resumeできるように作ります。前進遷移で離脱するときスタックを退避し、戻る遷移で入場したとき復元する。これで「ダイアログを開いたまま別画面へ遷移し、戻ってきたらダイアログが復元されている」という体験を基盤の機能として提供できます。画面側の実装は何も変わりません。
5-4. VContainer結合の見どころ: スコープをまたぐProxy
最後にもうひとつ、DIならではの問題を解決します。
ダイアログ基盤の実体は、モジュールシーンのスコープに住んでいます。Canvasやルートオブジェクトといったシーン資産と寿命を揃えるためです。しかし利用者は、ホーム画面からでも装備画面のPresenterからでも、つまり任意のスコープから IScreenStackModule を注入して使いたい。
VContainerの解決はスコープ階層を親方向にしか辿れないので、兄弟スコープにある実体は直接解決できません。そこでProduct階層にProxyを置きます。
- Proxyは全シーンの共通祖先であるProduct階層に登録する。どのシーンスコープからでも解決できる
- ダイアログのモジュールシーンがロードされると、実体がProxyに自身を接続する
- 利用者はProxyを
IScreenStackModuleとして注入され、実体の居場所もライフサイクルも知らずに使う
このProxyパターンはダイアログ専用ではなく、「実体はシーン資産と寿命を揃えたい、でも利用はどこからでもしたい」という形の共通機能(オーバーレイ演出、チュートリアル制御、画面共通のHUDなど)すべてに同じ構造が使えます。オススメです。
6. この設計がもたらすもの
冒頭の例を回収しましょう。
| 症状 | どの設計で消えたか |
|---|---|
| フラグ・コールバック地獄 (フェーズ2) | 遷移のパイプライン化。遷移中状態は基盤が一元管理 (4-3) |
| ダイアログの連打・競合 (フェーズ3) | スタック+操作キューによる直列化 (5-2) |
| 「戻る」での状態不整合 (フェーズ4) | ライフサイクル契約と履歴スタック。OnEnterは必ず呼ばれる (4-2, 4-3) |
| 全員が基盤を触りチームが詰まる (フェーズ5) | DIスコープによる境界。コンテンツ側から基盤の中身に触れない |
そのうえで、最初に示した方針の2つの性質がこう実現されています。
-
安定: 競合・順序・寿命という壊れやすい問題がすべて基盤側に閉じ、利用側は
await一発。壊れる場所が構造的に減る - 拡張性: 遷移シーケンスの差し替え、サービスの差し替え、機能の取捨選択が、すべてDI登録の変更だけで済む
チーム開発の観点では、基盤チームとコンテンツチームの作業境界が、そのままスコープ境界に一致します。シニアは基盤のスコープに集中し、ジュニアは自分のシーンのスコープの中で完結する。レビューもOJTもこの境界に沿って設計できます。
シーン・TransitionData・LifetimeScopeの3点セットのような定型コードは、テンプレートからのコード生成で自動化できます(本記事では割愛します)。
7. この設計のサンプルは「Lighthouse」としてGitHubに公開しています
ここまで一般的な設計論として書きましたが(たぶん)、
同じ思想でUnity向けフレームワークとして実装してみたので、コード見たほうがわかりやすいって方は見てください。
| リポジトリ | 内容 |
|---|---|
| Lighthouse | フレームワーク本体 (Unity 6 / URP / VContainer / UniTask) |
| LighthouseSample | 使い方を示すサンプルプロジェクト |
| LighthouseQuickPackage | セットアップ済みの空プロジェクト |
WebGLデモがあるのでとりあえず触ってみたいという人はこちら
Unity Package Manager の Git URL で導入できます。手順とセットアップガイドはリポジトリの README と Docs にまとめています。
また、この記事で触れなかった拡張モジュール群(多言語切り替え / 言語別フォント / テキストテーブル / InputLayer / Addressablesラッパー / 遷移アニメーション / UIコンポーネント)も、同じ「使うものだけDI登録する」思想のもとで同リポジトリに含まれています。シーン・TransitionData・LifetimeScope 3点セットのコード生成ツールも同梱しています。
(v1.0.0のままなのでゲーム一本作ったら更新します・・・)
まとめ
- 基盤とコンテンツの境界を、DIスコープという実体で切る
- 基盤の公開はインターフェースだけにして、複雑さを基盤側に閉じ込め、かつプロダクト側で自由に機能を差し替え・拡張出来るようにする
- ライフサイクルは基盤が握り、コンテンツ実装者はイベントを埋めるだけにする
もし内容が気に入ったらブックマークやフォローしてくれると励みになります!