妹(白髪ロリ)が『SOLID原則に最大限配慮した究極のRoll a Ballを作って!』と言ってきたから、お兄ちゃんは頑張ることにした。
目次
対象とする読者
これは、オブジェクト指向の考え方を用いてRoll a Ballをリファクタリングする記事です。インタフェースを使いますし、クラス図で説明を行います。一部UniRxを利用しています。←の文章の単語の意味が分かる人。
Roll a ballだけ分からなかった人はこの動画の11:00くらいからを見て、大体のゲーム性を把握しとくといいと思います。
はじめに
RiG++シナリオ/3D/コーディング課所属のBluckです。僕に妹はいません。
この記事では、SOLID原則に従った形でRoll a Ballを作り直します。作成したものはGitで公開中です。
分量の関係上、使用する技術( デザインパターン・Zenject )の使用方法の説明は外部リンクに頼り、使用するメリットや理由などのみ記述します。
SOLID原則とは?
オブジェクト指向で考えるとよい、たった五つのルールのことです。分かりやすく教えてくれるサイトがあるので、ぜひこちらを見てください。
イラストで理解するSOLID原則:https://qiita.com/baby-degu/items/d058a62f145235a0f007
決して説明がめんどくさくなったわけじゃなくて……。そ、そう! この記事はRoll a Ballの説明の責任だけを負う――つまりこれも単一責任の原則ってワケ!
SOLID原則に従うメリットって何?
拡張や保守に対してかなり強くなります。……とはいえこれじゃ抽象的すぎてよくわからないですね。
ここからはSOLID原則がなんであるかを理解している前提で、そのメリットについて述べます。
例えばPlayerクラスがあったとして、IDamageableを実現しているEnemyクラスがDamage(float DamageValue)を呼ぶ場合のことを考えてください。
普通ならば、Playerは
Enemy.Damage(_power);
と呼ぶところですが、
IDamageable.Damage(_power);
を呼びます。以下の図のようになりますね。ちなみにこれがSOLID原則でいうところの、依存性逆転の原則です。
こうすると、Playerは直接Enemyを知る必要が無くなります。もっと言うと、IDamageableさえ実現していれば相手が何になったっていいわけですね。つまり、Enemyを別のクラス(もちろんIDamageableを実現している)に置き換えたくなっても、Playerクラスの書き換えは最小限になります!
……まだメリットが分からない? なるほど。じゃあ、あなたは、さっきのスクリプトで
Enemy.Damage(10f);
と書くタイプでしょうか?
プログラミング初心者じゃないですし(……ないよね?)こんなコトしませんね。_powerという変数で保持していれば、値を買えたくなった時に_power一つを書き換えれば済みますが、マジックナンバーなんて使った暁には何十行にも渡るコードを修正するハメになったりします。最悪の場合、書き換えるべき行のどれかを見逃して攻撃力が高いまま残ってしまうせいでゲームバランスが崩壊してしまうことだって……。
Playerの移動編:クラス図
初級編ですね。
(理解に必要ない文言やらフィールド変数は省略しています。)
- PlayerMover:Rigidbodyを用いて移動を行う。Monobehaviourを継承しています。
- UnityInput:Unityの標準入力を用いて入力を受け付ける。
という責任をもって作られています。ちゃんと単一責任ですね。また、インタフェースは
- IInputReceivable:入力受付クラスが受け付けなければいけない操作を定義する
という定義があります。つまり今後スペースキーにより攻撃をしたくなったら、PlayerAttackerクラスなどを作成してから、それもIInputReceivableについかの処理を記入すればよいですね。
「お兄ちゃんちょっと待って! インタフェース分離の原則は?」
妹よ、良い質問ですね。確かにインタフェースがたくさん機能を持ってしまい、望ましくないような形に見えます。
インタフェース分離の原則は、使わないメソッドをインタフェースで実装するなよって話なので、インタフェースのうちから利用したいものだけ利用することは全く問題のないことです! むしろ変にIAttackReceivableのようにインタフェースを大量に定義するほうが複雑になって分かりにくいです。『どのインタフェース呼べばいいのか分かんないよ~~』ってやつになります。
これも別にダメではないですが、機能を追加するごとにインタフェースが増えていくのはさすがに綺麗な実装とは言えません。インタフェース分離の原則はそもそも、使わないメソッドの実装を強制してはならないという原則です。それに、このクラスは全ての入力を受け付けるクラスでなければならない(Xboxでプレイした時だけ攻撃できないとかあったら困ります)ので、分離させてしまうと余計わけわからなくなってしまいますね。
さて、では次はこのインタフェースが何のために作られたかについて考えてみましょう。
SOLID原則を使う理由は先にも述べた通り
拡張や保守に対してかなり強くなります。
chocoG(名言はchocoGに言わせると良いってじっちゃんが言ってた)
というメリットを持っていました。
PlayerMoverがインタフェースを通して移動の向きを知っているということは、移動の向きを計算する方法……つまり、実装が何であってもPlayerMoverにとっては別に問題ないってことです。
これによりPlayerMoverは移動入力の実装方法に依らず、移動することだけに専念することが出来ます! もしあなたがマウスでキャラクターを動かしたくなっても、VRに移植したくなっても、脳波で操作したくなっても、常に敵の方へ勝手に移動させたくなっても、InputReceivableを実現しているクラスをたった一つ置き換えるだけでOKになりますね!!
このことを考えると、さっきのインタフェースを分離しない話がよく理解できます。脳波で動かしている時でさえ、移動も攻撃も行えないと困ります。
これでクソディレクターがどんな仕様変更をぶつけてきても耐えられます。ヤッターッ!
(ちょっと懺悔:IInputReceivableですが、移動ベクトルを返すんじゃなくて入力の生データを0~1に正規化だけして渡すとかでも良かったですね。再利用性はこっちのが高くなります。敵の方に移動させるみたいな自由度を犠牲にすることになりますが)
Player移動編:DIについて
今回、IInputReceivableをPlayerMoverで参照する必要性があります。けど、このIInputreceivableを実現したインスタンスはどうやって取得すべきでしょうか? ……少し抽象的だったかもしれません。要は、 IInputReceivable.GetMoveVector()ってしたいけど、IInputReceivableってどうやって持ってくるの? って話です。
「 IInputReceivable = new IInputReceivable()で良くない?」って思った方、それは出来ません。インタフェースはインスタンスを持ちませんし、仮に持てたとしてそこに実装は書かれていません。つまり無意味です。
「 IInputReceivable = new UnityInput()だろ」って思った方、あなたはプログラムをよく勉強してきました。確かにそれで動きます。
が、もしIInputReceivableが複数個必要な状況……つまり、前章でいうところの PlayerAttacker クラスがスペースキー入力のために IInputReceivable を欲しがったらどうすればよいでしょうか?
愚かなあなたはPlayerAttackerクラスの中でも IInputReceivable = new UnityInput()するかもしれません。このクラスたくさん作ってもメモリの無駄なだけですね。
じゃあ、と馬鹿なあなたは PlayerAttacker クラスの中で PlayerMover クラスへの参照を持つことで解決するかもしれません。もちろんダメです。無意味な依存関係が生まれるからです。PlayerMoverの責任はPlayerの移動であって、IInputReceivableの提供ではないですし……ましてやコードを複雑にする馬鹿の世話などでもありません。
じゃあどうすればいいでしょうか。
一つの正解として、IInputReceivableを保持して返してくれるようなシングルトンを作成することが挙げられます。シングルトンならば、IInputReceivableは一つしか作成されず、また参照も比較的容易にできます。
まあ、この記事でもシングルトンは悪として扱わせてもらうんですけど!
そもそもPlayerMoverやらなんやらのオブジェクトが軒並みシングルトンに依存してしまうことが悪です。インタフェースを使って抽象に依存するようにしていたのに、いきなり具象(それも肥大化した)に依存しなきゃいけなくなるわけですし。これは依存性逆転の原則に思いっきし反してます。
他にも色々問題があったり(テストしにくくなるとか)するので、気になったら各自で調べてみてください。(記事の単一責任の原則定期)
「ねぇねぇお兄ちゃん。これって……UnityのSerializeFieldでどうにか出来たりしないの?」
確かにMonobehaviourを継承したクラスなら、hierarchy上にemptyを作ってそれにアタッチすればインスタンスが作られるし、それをSerializeFieldでPlayerMoverから取得することだってそんなに難しくないですね。
……そう、Monobehaviourを継承したクラス『だけ』なんですけどね!
不必要なクラスにMonobehaviourを継承させるのは避けたいですし(理由)、エディタ拡張を行わなければインタフェースは取得できないです。悲しい。
じゃあどうすればいいのかというと、DI……つまり、依存性の注入の出番です。Zenjectを使います。
DIとZenjectについてとても参考になるリンク『Zenject入門その1 疎結合とDI Container:https://qiita.com/toRisouP/items/b3d3c43db40857ca4ad4』
妹にも分かりやすく言うと、『自動で かつ なんでも入れてくれるSerializeFieldみたいなもの』です。何かのオブジェクトが要求されたときに、対応するオブジェクトを勝手に注入します(参照させます)。具体的に今回の例では『PlayerMover が IInputReceivable を要求した時に、UnityInput を作って(既に作られていればキャッシュしていたそれを)注入してくれる』ような存在です。
もちろん対応するオブジェクトは我々の方で決めます。その書き方としては
[Inject] IInputReceivable _a;
の様な具合で、SerializeFieldを使ってる感覚で使えます。イイネ! (というかSerializeField自体がDIの一種とも言えるので当然っちゃ当然ですが)
DIを利用することにより、インタフェースを注入することができるようになりました!
アイテムの生成編:クラス図
基本はManagerが処理の中枢になっていて、そこからFactoryを使ってIColloctableを実現したCollictableItemを生成しています。
クラスの責任は以下の通り。
- ItemFacade:外からアイテム関連のモジュールを簡単に利用するための窓口。要はモジュール外とのやりとり。
- ItemManager:ゲームロジックに関連する分野のアイテムの管理。Factoryにアイテム生成依頼を送ったりする。
- ItemFactoty:アイテムのインスタンスの生成。作っているアイテムはIColloctable。
- CollictableItem:IColloctableを実現
- IColloctable:集めることと消去することが可能なオブジェクトが実現すべきインタフェース
FactoryやらFacadeやら色々新しい単語が出てきました。これはプログラムのデザインパターンの一つです。デザインパターンは、良く起こる問題についてのパターン的な解決方法を示しています。参考リンクを下に示しておくので、詳しく知りたかったらどうぞ。C#でのサンプルコードとかもあってとてもステキです。
有用なサイト『Refactor GURU:https://refactoring.guru/ja/design-patterns/what-is-pattern』
リンク踏むのがめんどくさい人に最低限の説明をすると、
- Facade:モジュール(クラスを集めた一塊の機能、今回はアイテム関連のクラスの集合)を、外から利用しやすくするためのクラス。利用することにより、モジュール利用者が内部の設計を意識せず、必要な処理を簡易に呼ぶことができる。
- Factory:オブジェクトを作るため”だけ”のクラス。利用することにより、生成側が、生成しているもののことを詳しく知らなくて良くなる。
さて、では以上のことを踏まえて設計意図の説明を行います。
- ItemFacadeの責任は、モジュール外とのやり取りでした。Monobehaviourを継承しています。
- 『Itemの生成数を決めること、アイテムを生成・削除する判断、現在残っているアイテムの数の取得』は全て、モジュール外が行いたいことですね。なので、その要素がまとめられています。
- CreateItem()、RefreshAll()はItemAmoutの値に応じた数の物体を生成、および再生成するものになっています。
- CurrentGotItemAmountはUniRxのReactivepropertyを利用していて、アイテムが取得されるごとにイベントを発行します。
- 今回は小規模なシステムだったため、あまり恩恵は強くないです。しかし、例えばItemがエフェクトを放ったりアニメーションを行うようになった場合、我々はそれに応じた新しくクラスを作ります。それを外から一括で停止したくなったらどうでしょうか。いちいちItemAnimationとか参照せずに、Facadeにまとめられていた方が分かりやすくないですか? クライアントが利用したいものって、アイテムの集まりそのものであって、実体がどうとかエフェクトがどうとかは望みどおりに動かせたら別になんでも良いですよね。
- ItemManagerの責任はゲームロジックに関するアイテムの管理でした。アイテムのインスタンス作成依頼をFactoryに、削除依頼をIColloctableに行っています。また、作成したインスタンスをゲームシーンに綺麗に配置するメソッドもここが担当しています。
- 今後、規模が大きくなるならインスタンスの配置を切り分けて、Strategyパターンで作成してもいいですね。(Strategyパターン説明)
- また、取得済みアイテムの個数を調べてFacadeに流すこともしています。
- ItemFactoryの責任はインスタンスを生成することです。Createメソッドを実行されたらインスタンスを作って返す。たったそれだけです。
- そしてこれは、Zenject が作ってくれています!
- すごくわかりやすく書いてくれてるリンクです。Zenject Factoryの話
- 要約:ZenjectのFactoryで生成するインスタンスは特定のインタフェースを実現したもので、Createメソッドの戻り値はこのインタフェースになる。つまり、Managerはおろか、Factoryすら生成しているものの実体を知らずに済みます。(もちろんインタフェースは知る必要があるけど)
- Factoryによって生成されるCollectableItemの責任は、アイテムの実体の操作です。Monobehaviourを継承しています。hierarchy上のアイテム一つにつきこのクラスが一つついています。
- このクラスはUniRxを利用しています。CollectedSubjectというプロパティは、このオブジェクトが取得されたときにイベントを発行してManagerに通知を送ります。
- Collectedは取得されたときに呼ばれるメソッドで、CollectedSubjectを発火させて、Vanishメソッドでこのオブジェクトを消します。
ゲームデータ/UI:クラス図
まず、さっきと同様にクラスの責任を見て行きましょう。
- ScoreData:ゲームに関する数値的なデータを保持。
- なんとかText:現在のゲームの状態に応じてUIを変更する。
- ScorePresenter:データとTextの仲立ちを行う。
もちろん、さっき作ったFacadeもバチバチに使っていきます。
下はクラス図です。
このクラス図を見てまず目につくことは、ScorePresenterがそれ以外のすべてノクラスに依存していることではないでしょうか。
これは、MVP(model view presenter)パターンと言って、UIによく利用されるクラスの構造になっています。
要約すると、UIの表示方法を変えたくなってもデータのクラスを弄らなくて済むってことです。もちろんその逆も然りです。
そう。これによってデータやUIなどのクラス自体を変更したくなっても、問題が起こりにくくなりました!
ところで、仲介役として作ったPresenterですが、modelのデータをviewに持っていくに当たっての適切なデータへの加工も担当します。このことから、ModelやViewより、PresenterがItemモジュールとの繋がりを持つのが良いですね。なので、今回はPrensenterがItemFacadeに対する参照を持っています。
もちろんFacadeとPrensenterへの依存を持つような上位クラスをさらに作っても良かった(GameControllerみたい)のですが、さすがに過剰ですね。スコアデータとアイテムが離れすぎてしまうのも考え物ですし。
ともあれ、Presenterがゲームの核となってこのゲームの設計が組み終わりました。以上です!
まとめ/おわりに
この記事では、デザインパターン・Zenject・UniRxを使いました。また、SOLID原則――特にSODの部分に焦点を当てて、究極のRoll a Ballを作成しました。
ここまで読んでくれてありがとうございました。