処理フローや依存関係を整えるために役立つデザインパターン(Unity)
はじめに
処理フロー/依存関係は設計における重要なテーマの1つです。ゲーム制作でもこれらを整えるべき場面は多くあります。
その際に使える選択肢についてまとめました。
DIP(Dependency Inversion Principle)
依存性逆転の原則は、上位モジュールは下位モジュールに依存してはならず、どちらも抽象に依存すべきであるという原則であり、処理フローを整える際に一役買ってくれる方法です。
レイヤー間での処理フロー/依存関係を整える際によく使われます。
たとえばView
-> Application
のようなレイヤー構造をルールとして持つ一方で、Application
からView
にアクセスしたい場合、以下の依存関係は違反になります。
かわりにインターフェースを使って、以下のようにします。
public interface ISomeView // このinterfaceはApplicationに置くことに注意
{
void SomeMethod();
}
public class SomeView : MonoBehaviour, ISomeView
{
public void SomeMethod()
{
// 処理
}
}
public class SomeApplication
{
private readonly ISomeView _view;
public SomeApplication(ISomeView view)
{
_view = view;
}
public void SomeMethod()
{
_view.SomeMethod();
}
}
こうすることでレイヤー間の全体の矢印の方向が整い、ルールを保持できます。
抽象に依存するやり方になるため、レイヤー間の依存であっても、より疎結合なやり取りができるようになります。
特にViewは変更が多いクラスになりがちなので、このような抽象的な定義をかませること自体にもある程度意味があると思います。
しかしながらUnityはViewだらけになるため、毎度この原則を使っているとインターフェースだらけになって億劫かもしれません。
Observerパターン
Observerパターンは、オブジェクトの状態変化を他のオブジェクトに通知するためのパターンです。
Unityでは主にR3
やUniRx
を使ってObserverパターンを実装することになります。
こちらも処理フロー/依存関係を整えるために使えるパターンです。特に循環参照を避けるための利用が最も分かりやすいです。
たとえば、親子構造の敵クラスがあるとして、子どもが死ぬと親も死ぬという処理を実装する場合、以下のようにします。
public class EnemyParent : MonoBehaviour
{
[SerilizeField] private EnemyChild _child;
void Start()
{
_child.OnDead.Subscribe(_ => Dead());
}
private void Dead()
{
// 親の死亡処理
}
}
public class EnemyChild : MonoBehaviour
{
public Observable<Unit> OnDead => _onDead;
private readonly Subject<Unit> _onDead = new Subject<Unit>();
public void Dead()
{
_onDead.OnNext(Unit.Default);
}
}
子にあたるEnemyChild
は親のことを一切知らず、ただ死ぬというイベントを発行するだけになっています。
このように愚直な実装だと循環参照になりそうなところを、Observerパターンを使って一方向にすることができます。
Event
Event(/Message/Commandなど)は特定のイベントが発生した際に、そのイベントを購読しているオブジェクトに通知するパターンです。
Eventでは、発行側も購読側もイベントに関するインターフェースにしか依存しないため、より疎結合なやり取りができます。
さらに、インターフェースから先はライブラリ上で管理されている(自作除く)ため、安定した依存関係を持つことができます。
UnityではUniRx.MessageBroker
, MessagePipe
, VitalRouter
, ZeroMessenger
などが該当するライブラリです。
プレイヤーにダメージを与える処理を例にしてみます(MessagePipeの例)。
/// <summary>
/// プレイヤーにダメージを与えるイベント
/// </summary>
public readonly record struct PlayerDamageEvent(int Damage);
public class PlayerDamageController : IStartable
{
private readonly PlayerStatus _status;
private readonly ISubscriber<PlayerDamageEvent> _subscriber;
public PlayerDamageController(PlayerStatus status, ISubscriber<PlayerDamageEvent> subscriber)
{
_status = status;
_subscriber = subscriber;
}
public void Start()
{
_subscriber.Subscribe(OnPlayerDamage);
}
void OnPlayerDamage(PlayerDamageEvent e)
{
_status.Damage(e.Damage);
}
}
public class PlayerStatus
{
public int HP { get; private set; }
public void Damage(int damage)
{
HP -= damage;
}
}
public class PlayerView : MonoBehaviour
{
[Inject] private IPublisher<PlayerDamageEvent> _publisher;
private void OnCollisionEnter(Collision collision)
{
if (collision.gameObject.TryGetComponent(out EnemyView enemy))
{
_publisher.Publish(new PlayerDamageEvent(10));
}
}
}
IPublisher
とISubscriber
に対する依存は、安定したライブラリへのものなので、そこから先を意識する必要はほとんどありません。
イベントの処理を、本質的なイベントのデータのやり取りのみで構築できるというのもわかりやすいです。
とは言えこれも濫用すると、変な抽象度のイベントが増えてしまい、逆に処理フローを追いにくくなることもあるので注意が必要です。
Discussion