自己紹介
サイバーエージェント26卒ゲームクライアントエンジニアの海道です!
Follow Me!
この記事はCyberAgent 26th Fresh Engineer's Advent Calendar 2024 1日目です!
本アドベントカレンダーについて
本アドベントカレンダーは、サイバーエージェント26卒の有志がお送りするものです。
あくまで有志での開催となります。楽しんでいってください!
本題
MVPパターンを使用して、コマンドバトルを制作していました。
その後、アウトゲーム(ゲームのホーム画面など)とインゲーム(ここではコマンドバトル)を接続するときに試行錯誤した記録を残します。
環境
- Unity 6(6000.0.23f1)
- UniTask
- (UniTaskを使用していますが、Unity6でのAwaitableでも同様に可能なはずです)
課題
アウトゲームとインゲーム、それぞれ別のシーンに独立したMVPとして実装しました。
もともとは、シーンの再生時にMonoBehaviourを継承したPresenterのAwakeからMVP全体を初期化させていました。
しかし、繋ぎこむにあたってアウトゲームからインゲームに情報を送らなければいけません。
その情報が行き届いたあとにMVPを初期化したいという状況になったのです。
本題に入る前に:MVPパターンとしてそもそも正しいのか
そもそもインゲームとアウトゲームを独立した実装は正しいのでしょうか。
これに関しては答えが出ていませんが、シーンを分ける以上モデル部分のインスタンスの引継ぎをPresenterに任せるという状態となり、冗長になっていくと考えました。
また、それぞれが独立していた方が、保守性などを考慮しても利点があると思います。
シーンを分ける必要性はあるのか
そもそもシーンを分けなければもっとシームレスな連携ができるのでは?という疑問が浮かんできます。
確かに、Prefabだけで管理をしたほうがスクリプト的には圧倒的に楽な気がします。
一方で、エディタの操作としてこのシーンを編集したいなどのワークフローを考えると、複雑になってくると感じました。
このあたりに関しては、議論の余地しかないので、どんどん力をつけて議論していきたいですね。
Unityにおけるシーン間でのデータの受け渡し
そもそも、MVPパターンの以前にシーン間でのデータの受け渡しについて、記載したいと思います。
Unityにおいて、シーン間でデータの受け渡しはよく躓くポイントのひとつです。
そして、その解決方法を多岐にわたり、ベストプラクティスというものも存在しません。
ここでは、一番大きな括りを紹介したいと思います。
1. staticクラス
staticクラスはシーンが変更されても破棄されません。
そのため、staticクラスに残しておけばほかのシーンから参照できます。
2. DontDestroyOnLoadを使う
Unityではシーンを跨いでも、破棄されないオブジェクトを作成することができます。
MonoBehaviour
で、DontDestroyOnLoad()
メソッドを呼び出すだけです。
これによって、シーン間の情報の共有をすることができます。
比較
これらを比較すると、UnityフレンドリーかC#フレンドリーかという違いしかありません。
しかし、これを利用した実装は多岐にわたります。
Unityを始めたころはstaticなクラスを作りその中のpublicな変数に全部詰め込んでいた記憶があります。
今回紹介するシーンマネージャーは使い勝手や保守性を向上させつつstaticクラスを利用した実装です。
シーンマネージャー
実際の実装は非常に簡単です。UniTaskに依存している設計ですが、ほかのやり方もあると思います。
実装の参考になれば幸いです。
シーンマネージャーの特徴
いくつかシーン間のデータ受け渡し方法がありますが、シーンマネージャーには以下の特徴があります。
- シーン切り替えと値を渡すときが同時
- 値を渡し忘れることがない
- Unityのデフォルトの
SceneManager
と使い勝手が大きく変わらない
これらによって、認知負荷を軽減しつつミスの少ない汎用的なシーンマネージャーになると考えています。
全体から呼び出す本体
public static class OriginalSceneManager
{
private static ISceneLifetimeManager _currentSceneLifetimeManager;
public static async UniTaskVoid LoadScene<T>(ISceneData data) where T : ISceneLifetimeManager, new()
{
cachedCurrentISceneLifetimeManager?.OnUnLoaded();
cachedCurrentISceneLifetimeManager = new T();
await SceneManager.LoadSceneAsync(cachedCurrentISceneLifetimeManager.SceneName).ToUniTask();
cachedCurrentISceneLifetimeManager.OnLoaded(data);
}
}
シーンごとに制作するクラスのインターフェース
public interface ISceneLifetimeManager
{
public string SceneName { get; }
public void OnLoaded(ISceneData data);
public void OnUnLoaded();
}
シーンごとに作るクラスのサンプル
public sealed class BattleSceneLifetimeManager : ISceneLifetimeManager
{
public string SceneName => "Battle";
public void OnLoaded(ISceneData data)
{
if (data is null || data is not BattleSceneData battleData)
{
Debug.LogError("data is null");
return;
}
// presenterを取得して、Presenter側の初期化メソッドを実行して、シーン全体を動かす
var presenter = Object.FindAnyObjectByType<CommandBattlePresenter>();
presenter.Initialize(battleData);
}
public void OnUnLoaded()
{
}
}
Object.FindAnyObjectByType<T>();
は最近追加されたAPIであり、FindObjectOfTypeより"比較的"早く動作するメソッドです。
負荷的にできれば使いたくないメソッドですが、使わざる負えないということで、、、使用しています。
この程度の頻度であれば大きな処理負荷ではありません。
詳しくはこちらをご覧ください。
Scene間で移動するstructにマーキング(空のインターフェースを実装する)するためのインターフェース
public interface ISceneData
{
}
Scene間で実際に移動されるclassのサンプル
public sealed class BattleSceneData : ISceneData
{
public BattleSceneData(string partyName)
{
PartyName = partyName;
}
public string PartyName { get; }
}
呼び出し側
RPGSceneManager.LoadScene<BattleSceneLifetimeManager>(new BattleSceneData( /* something */ )).Forget();
使うのは簡単ですね!
まとめ
個人的には結構ビタっと決まっている実装になっています。
ロジックの起動としては非常に正解に近い実装かなと思います。
MVPパターンはPresenterを初期化すれば全て起動するため、このように値を渡してあげるのが一番適切かなと思いました。
シーン切り替え時の演出などもほぼ同様な処理を走らせて実装できるため、汎用的なシーンマネージャーとして利用できるではないでしょうか。
そもそもMVPパターンの実装としてどうなのか、という議論も楽しいものだとは思います。
しかし、自分の中でこの実装に大きな問題点を感じなかったため、この方針で作り続けてみようと思います。
また、問題が発覚すれば記事に起こそうと思います。
さいごに
シーンマネージャーの存在はサイバーエージェントの社員さんから知ったもので、その後色々調べながら自分なりの形にしたものです。ただ、もしかしたら一般に使われていそうだなと思っているぐらいの完成度です。
内定者バイトなどでもっと色々な知見を得たいですね!
それでは、この後のCyberAgent 26th Fresh Engineer's Advent Calendar 2024もお楽しみください!
Special Thanks
この記事のコードを同期にレビューしていただきました!
ありがとう!!凄く勉強になりましたっ!