RYO620
DESIGN & DEVELOPMENT
UniRxとZenjectを使ってMV(R)Pで南京錠を作る
Ryosuke

UniRxとZenjectを使ってMV(R)Pで南京錠を作る

先月リリースしたPIXBOXの開発時に多用した MV(R)P の実装方法を備忘録として残しておきます。 UniRxZenject 、どちらのライブラリも奥深く難易度の高いものなので、正直なところ使いこなしているとは言えないです。 ただ、MV(R)Pのデザインパターンは使い勝手がよく、PIXBOXのギミックや各種UIの実装で非常に重宝しました。

今回この2つのライブラリを使った簡単なデモゲームを作っていきます。

南京錠を作ろう

脱出ゲームあるあるの 「4桁を入力させて、正解であれば解錠する南京錠」 です。

南京錠DEMO (unityroomへ)

ダイヤルの下にあるグレーのボタンを押すと数字が1ずつ加算されていくので、ヒントとなる四隅の点の数をそれぞれの色に従って入力します。 答えは、左から 6 3 9 5 。 脱出ゲームのギミックとしては難易度★☆☆☆☆くらいですかね。

HowTo記事ではなく自身の脳内整理&&備忘録なのですが、誤りやもっと良いやり方などがあれば指摘もらえると嬉しいです!

せっかくなのでスクリプトと画像をGithubに公開しています。
ryo620org/Padlock


準備

unityは2018.4.2f1を使用、Asset Storeから UniRx と Zenject をインポートする。 今回使用したバージョンは、UniRx 6.2.2、Zenject 7.3.1。

数字とボタンを「Dial」として一つのPrefabにしてる数字とボタンを「Dial」として一つのPrefabにしてる

SceneにCanvasを生成して各種GameObjectを生成/配置しておく。 (本題ではないので詳細は割愛)


MV(R)Pのスクリプトを書く

Model View (Reactive) Presenterの略。 詳細は 「UniRxでMV(R)Pパターン をやってみた」(torisoupさん) のスライドが非常にわかりやすい。

Modelはビジネスロジックを書くところ、Viewは画面の表示について書くところ、PresenterはViewとModelの橋渡し、そしてその橋渡し方法が Reactive である、という感じかな。

  • Viewがユーザ入力を通知する
  • PresenterはViewの通知を監視している、検知するとユーザ入力内容に応じたModelのメソッドを呼ぶ
  • 呼ばれたModelは何らかの処理を行い ReactiveProperty の値を変更する
  • PresenterはModelのReactivePropertyを監視している、値の変更を検知するとその値に応じたViewのメソッドを呼ぶ
  • 呼ばれたViewはUIを変更する

南京錠ゲームは以下の6つのクラスで構成することに。

注意点は矢印の向き。 View から Presenter、 Model から Presenter の矢印があるけど、実際には View はPresenter を参照しておらず、 Model も Presenter を参照してない。 Presenter が View と Model を参照してるので、 View が通知したユーザ入力を Presenter が検知、 そして Model の値変更は ReactiveProperty を変数として使用しているので Presenter が変更を監視できる形。

UnityのGameObjectと関連するのは View のみ。 なので MonoBehaviour を継承するのも View のみ。

6つのスクリプトは以下の通り。

AddButtonView.cs
using System; using System.Collections; using UnityEngine; using UniRx; using UnityEngine.UI; using Zenject; public class AddButtonView : MonoBehaviour { private readonly Subject<Unit> _subject = new Subject<Unit>(); public IObservable<Unit> OnClick => _subject; [Inject] private RectTransform _bodyRectTransform; private void Start() { GetComponent<Button>() .OnClickAsObservable() // ボタンの押下を検知 .Subscribe(_ => { _subject.OnNext(Unit.Default); StartCoroutine(nameof(PushButton)); }) .AddTo(gameObject); } /// <summary> /// ボタンの見た目を押下状態にする /// </summary> private IEnumerator PushButton() { _bodyRectTransform.localPosition = new Vector3(0, -1, 0); yield return new WaitForSeconds(0.1f); _bodyRectTransform.localPosition = new Vector3(0, 0, 0); } }

ダイヤルのボタンUIに関するクラス。 ボタンが押されたら Subscribe の処理を実行する。 その処理内では SubjectOnNext で値を渡している。 この時、 ボタンが押された ということだけを伝えたいので Unit という値のないクラスを使用。 例えばボタン押された時に整数値も一緒に流したい場合は Subject<int> になる。

[Inject] private RectTransform _bodyRectTransform;フィールドインジェクション 。 注入したいコンポーネントを持つGameObject Zenject Bindingコンポーネント をアタッチして、 Components にセットする。

「AddButton GameObject」の子要素、「Body GameObject」「AddButton GameObject」の子要素、「Body GameObject」

上記の場合 Components には Rect Transform をセットしているので、 [Inject]アトリビュート のついた変数に、この Body の Rect Transform がインジェクトされる。 この使い方が Zenject による依存解決で一番お手軽な方法。

  • 注入したい(他から参照される)コンポーネントを持つ GameObject に対して、 ZenjectBinding をアタッチする
  • インスペクター上で、 ZenjectBinding の Components に 注入したいコンポーネントを設定する
  • 参照したいスクリプトで、 [Inject]アトリビュート を付ける

とはいえ、 フィールドインジェクションは using Zenject; が必要になってしまうので、 後述の コンストラクタインジェクション を積極的に使ったほうがよさそう。


DialPresenter.cs
using UniRx; public class DialPresenter { private int _dialIndex; private AddButtonView _addButtonView; private NumberView _numberView; private PadlockModel _padlockModel; // コンストラクタで各種参照を注入する public DialPresenter(AddButtonView addButtonView, NumberView numberView, PadlockModel padlockModel, int dialIndex) { _addButtonView = addButtonView; _numberView = numberView; _padlockModel = padlockModel; _dialIndex = dialIndex; InitView(); SetSubscribe(); } /// <summary> /// Viewの初期化 /// </summary> private void InitView() { _numberView.ChangeNumber((int) _padlockModel.DialNumberList[_dialIndex]); } private void SetSubscribe() { // ボタンの押下を監視 _addButtonView.OnClick .Subscribe(index => { _padlockModel.CountUpDial(_dialIndex); }); // DialNumberListの値変更を監視 _padlockModel.DialNumberList .ObserveReplace() // リスト要素の値が変更された場合のみメッセージを流す .Where(x => x.Index == _dialIndex) // 変更されたListのインデックスとDialインデックスが一致したらメッセージを通過させる .Subscribe(x => { _numberView.ChangeNumber((int) x.NewValue); }); } }

Dial内の AddButtonView と NumberView 、 PadlockModel の橋渡しをするクラス。 Presenter は View と Model の参照を持つが、このクラスは MonoBehaviour を継承していないので SerializeField 等でのインスペクターによる設定は行わない。 Zenject の コンストラクタインジェクション で依存性の注入を行う。 このクラス自体のインスタンス生成に関しては、 後ほど Installer に記述。

SetSubscribe() 内で AddButtonViewの監視と、 PadlockModel の DialNumberList の値の変更を監視している。

PadlockModel.cs
using UniRx; using Zenject; public class PadlockModel { public ReactiveCollection<DialNumber> DialNumberList { get; } = new ReactiveCollection<DialNumber>(); public ReactiveProperty<bool> IsCleared { get; } = new ReactiveProperty<bool>(false); private readonly PadlockNumber _padlockKeyNumber; private readonly PadlockNumber _padlockInitNumber; public PadlockModel( [Inject(Id = "KeyNumber")] PadlockNumber padlockKeyNumber, [Inject(Id = "InitNumber")] PadlockNumber padlockInitNumber) { _padlockKeyNumber = padlockKeyNumber; _padlockInitNumber = padlockInitNumber; InitDialNumberList(); } /// <summary> /// ダイヤルの初期値を設定する /// </summary> private void InitDialNumberList() { for (var i = 0; i < _padlockInitNumber.dialNumberList.Count; i++) { DialNumberList.Add(_padlockInitNumber.dialNumberList[i]); } } /// <summary> /// ダイヤルをカウントアップする /// </summary> public void CountUpDial(int dialIndex) { if (IsCleared.Value) return; if ((int) DialNumberList[dialIndex] < 9) DialNumberList[dialIndex] = DialNumberList[dialIndex] + 1; else DialNumberList[dialIndex] = 0; if (GetIsCleared()) IsCleared.Value = true; } /// <summary> /// クリア状態かどうかを返す /// </summary> private bool GetIsCleared() { for (var i = 0; i < _padlockKeyNumber.dialNumberList.Count; i++) { if (DialNumberList[i] != _padlockKeyNumber.dialNumberList[i]) return false; } return true; } }

南京錠のModelクラス。 ダイヤル4桁の数字を ReactiveCollectionクラス で、 クリアしたかどうか真偽値を ReactivePropertyクラス で持つ。 このクラスも コンストラクタインジェクションで依存性の注入を行う。

この時、 PadlockNumberクラス を複数バインディングする必要があるため、 [Inject(Id = "KeyNumber")] で特定のインスタンスを指定している。 ちなみに Id = "KeyNumber" は南京錠の鍵となるナンバー、 Id = "InitNumber" は南京錠の初期値となるナンバー。

コンストラクタインジェクションを使用すると、 zenjectを使用せずに依存性注入ができるけど Identifier を指定するために結局 using Zenject; している……。

[追記 7/22]
このように複数の同じタイプを渡したい時にidを指定する方法は管理が大変なので、読取専用データクラスを用意してまとめて渡すという方法を教えてもらいました! (id指定で渡す方法もあるよということで、スクリプトは以前のまま掲載しています)


NumberView.cs
using UnityEngine; using UnityEngine.UI; public class NumberView : MonoBehaviour { public void ChangeNumber(int newNumber) { GetComponent<Text>().text = newNumber.ToString(); } }

ダイヤルの数字部分に関するクラス。 DialPresenterクラス から ChangeNumber() が呼ばれて数字の表示を変更する。

LockPresenter.cs
using UniRx; public class LockPresenter { private PadlockModel _padlockModel; private LockView _lockView; private LockPresenter(PadlockModel padlockModel, LockView lockView) { _padlockModel = padlockModel; _lockView = lockView; SetSubscribe(); } private void SetSubscribe() { _padlockModel.IsCleared .DistinctUntilChanged() // 値が変わった場合のみメッセージを以降に流す .Where(x => x) // IsCleared が true の場合メッセージを以降に流す .Subscribe(x => _lockView.ChangeUnlock()); } }

錠の LockView 、 PadlockModel の橋渡しをするクラス。 PadlockModel の IsCleared を監視して、 クリア状態になったら View のメソッドを呼ぶ。

LockView.cs
using UnityEngine; using UnityEngine.UI; public class LockView : MonoBehaviour { [SerializeField] private Sprite unlockSprite; private Image Image { get { if (_image == null) _image = GetComponent<Image>(); return _image; } } private Image _image; public void ChangeUnlock() { Image.sprite = unlockSprite; } }

錠の表示に関するクラス。 LockPresenterクラス から ChangeUnlock() が呼ばれて錠のSpriteを解錠したものに差し替える。


Zenject の Context と Installer

Zenject の Context にはいくつかタイプがあり、 Project ContextScene ContextGameObject Context はそれぞれ プロジェクト全体の依存関係を定義した Context、 シーン内の依存関係を定義した Context、 アタッチされた GameObject とその子要素以下の GameObject の依存関係を定義した Context、 とスコープが異なる。 この Context の定義範囲でインスタンスが要求された際に依存性注入してくれるのがZenject……という解釈であってる?(このあたりから語句の正しい使い方がちょっと自信ない…)

この Context に対して DIコンテナに登録するオブジェクトを記述した Installer を設定する。 Installer は Mono Installer、 Scriptable Object Installer等があり、 名前の通り MonoBehaviour として振る舞う(コンポーネントとしてアタッチできる)Installer、 ScriptableObject として振る舞う(独自アセットを生成する)Installerである。

DialInstaller.cs
using UnityEngine; using Zenject; public class DialInstaller : MonoInstaller { [SerializeField] private int dialIndex; public override void InstallBindings() { // ここで DialPresenterクラスをnewする // 通常はインスタンスが参照されたタイミングで生成されるが、 // DialPresenterクラスは依存の頂点(他から参照されていない)なので、 // NonLazy を付けて、明示的に生成タイミングを指定する必要がある Container .Bind<DialPresenter>() .AsSingle() .NonLazy(); // int型もバインドできる // インスタンスはインスペクターで指定 Container .Bind<int>() .FromInstance(dialIndex); } }

影響範囲は Dial Prefab 内に限るので Game Object Context を用意する。 Dial Prefab のルートGameObjectに対して Game Object Context をアタッチ。 更に上記スクリプト DialInstaller をアタッチ。 Game Object Context の Mono Installers の List に DialInstaller を設定することで、このPrefab内での依存関係が解決できるようになる。


PadlockInstaller.cs
using Zenject; public class PadlockInstaller : MonoInstaller { public override void InstallBindings() { // PadlockModelを生成してバインドする Container.Bind<PadlockModel>().AsSingle(); // LockPresenterを生成してバインドする // DialPresenterと同様に、 NonLazyで明示的に生成タイミングを指定している Container.Bind<LockPresenter>().AsSingle().NonLazy(); } }

影響範囲は Scene内に限るので、 Hierarchyビューで Create -> Zenject -> Scene Context を生成する。 そのGameObjectに上記スクリプ PadlockInstaller をアタッチ。 Scene Context の MonoInstallers の List に PadlockInstaller を設定することで、このScene内での依存関係が解決できるようになる。


GameSettingsInstaller.cs
using System; using System.Collections.Generic; using UnityEngine; using Zenject; public enum DialNumber { Num0, Num1, Num2, Num3, Num4, Num5, Num6, Num7, Num8, Num9 } [Serializable] public class PadlockNumber { public List<DialNumber> dialNumberList; } [CreateAssetMenu(fileName = "GameSettingsInstaller", menuName = "Padlock/GameSettingsInstaller")] public class GameSettingsInstaller : ScriptableObjectInstaller<GameSettingsInstaller> { public PadlockNumber padLockKeyNumber; public PadlockNumber padLockInitNumber; public override void InstallBindings() { // PadlockNumberのインスタンス、 padLockKeyNumber を、Id = "KeyNumber" と指定された場合にバインドする Container.BindInstance(padLockKeyNumber).WithId("KeyNumber").IfNotBound(); // PadlockNumberのインスタンス、 padLockInitNumber を、Id = "InitNumber" と指定された場合にバインドする Container.BindInstance(padLockInitNumber).WithId("InitNumber").IfNotBound(); } }

ゲームセッティングを記述した ScriptableObject の依存関係を解決するために ScriptableObjectInstaller を使用。 今回は PadlockNumberクラス南京錠の鍵用南京錠の初期値用 としてIDを指定してバインドする。

通常の ScriptableObject と同じようにProjectビューで右クリック Create からアセットファイルを作成し、 SceneContext の Scriptable Object Installers の List に設定する。

アセットには鍵用数字リストと初期値用数字リストを記載する。


summary

一応これでゲームは動くようになります。

PIXBOXのスクリプトは7、8割くらいがこのMV(R)Pパターンです。 ギミックごとにPrefabを作って GameObjectContextを使ってます。 今回は触れてませんが、 マルチシーンで Sceneの親子関係とか設定することで、親SceneのContextの影響範囲に子Sceneを含めることができたりも(Scene Parenting)。 PIXBOXでは GameScene と MenuScene をマルチシーンで表示していますが、 MenuScene の親に GameScene を指定することで、 Menu で表示させるマップに必要な情報を GameScene から引っ張ってきています。

他にも動的生成時のFactoryパターンとかもあるようですが、ちょっとまだ理解に至らず使えてないです……。

全体的にちょっと説明が下手なので今後学習していく過程で知識がアップデートされたら記事も適宜直していけたらと思います。 文字で書き出そうとすると認識が怪しい箇所が多く、 だいぶあやふやな状態で使っていたんだなと痛感。