今日から使えるデザインパターン
こんにちは! Coading課のguuです。
アドカレも中盤となりますが、皆さん楽しんでますか?
この記事では、先人が残した設計思想について紹介していきます。
目次
前提知識
デザインパターンとは
よく使う設計思想に名前を付けてまとめたものです。
クラス
データ(変数)やロジック(関数)が集まってできたグループ。
インスタンス
クラスは、設計図のようなものでnewで新しく生成しないと使うことができません。
この時、新しく生成したクラスをインスタンスと呼んでいます。
継承
クラスを継承することで、あたかも継承元のクラスが継承先のクラスに記述されます。
例えば、2D横スクロールアクションゲームで、Mというキャラクターを作成しました。
しかし、その後Pという滑空という新しい操作ができるキャラクターを追加しようという話になりました。
MとPの違いは、滑空できるかどうかだけであとは全く同じです。
この時、コピペでコードを作るのはめんどくさいですよね。
そんな時に、継承を使えば、Mの機能を引き継いだPを作成することができます。
オーバーライド
継承した時に、継承元の関数を上書きして新しく書き直せるのがオーバーライドです。
例えば、今度は新しくMを継承したLをしました。
このLは、Mよりも高くジャンプします。
この時、ジャンプの関数をオーバライドし、書き換えること高くジャンプを実装できます。
ジェネリック
ジェネリックは、リストや辞書型などでよく見る「<>」のあれです。
例えば、リストには、どんなクラスでも配列のように保存しておけます。
このどんなクラスかを引数みたいな形で指定できるのがジェネリックのいいところです。
インターフェース
インターフェースは、クラスの自己紹介カードです。
このインターフェースを継承したクラスに何ができるか書いています。
詳しくは、西ちゃんが書いてくれたアドカレを読んでみてください
モジュール
一つの機能群。クラスの集合体。
例えば、Playerに関するクラスは、すべてPlayerモジュールに属します。
名前空間で区切られるのが一般的です。
デザインパターンを学ぶ上での心構え
デザインパターンに縛られるな
これが、一番重要です。
これから、たくさんのデザインパターンを紹介しますが、「デザインパターンから設計を行う」のではなく、「設計した結果、デザインパターンが適用できるかも」となるようにしましょう。
よく使うデザインパターン一覧
たくさんの処理を一元管理したい
シングルトンパターン
シングルトンパターンで、シングルトンオブジェクトがあるシーン上の誰もがいつでも使えるただ1つのクラスを作成できます。
考え方
シングルトンパターンのオブジェクトを使用する時、static変数「Instance」を通じて使用します。
staticな変数はどこからでもいつでも使用することができます。
実装例
シングルトンベース
namespace Common
{
/// <summary>
/// シングルトンベース
/// </summary>
/// <typeparam name="T"> シングルトン化したいクラス名 </typeparam>
[DisallowMultipleComponent]
public class SingletonBase<T> : MonoBehaviour
where T : MonoBehaviour
{
private static T _instance;
/// <summary>
/// Instanceを通じてアクセス
/// </summary>
public static T Instance
{
get
{
if (_instance is null)
{
// シーン内からインスタンスを取得
_instance = FindObjectOfType<T>();
// シーン内に存在しない場合、エラーログを出力
if (_instance is null)
{
DebugUtility.LogError(typeof(T) + "is nothing");
}
}
return _instance;
}
}
protected virtual void Awake()
{
// すでにインスタンスが存在する場合、自身を破棄
if (this != Instance)
{
Destroy(this);
}
}
}
}
シングルトン
// シングルトンベースを継承
public class Manager: Common.SingletonBase<Manager>
{
public void ShowLog()
{
Debug.Log("log");
}
}
呼び出す
public class Call: MonoBehaviour
{
private void Awake()
{
// ManagerのShowLog()を実行
Manager.Instance.ShowLog();
}
}
良いところ
- 楽に、雑に実装・使用できる
- 多くのクラスで使用するMangerのようなクラスが作りやすい
- staticクラスと違い、生成・破棄ができる
- staticクラスと違い、Unityのインスペクターでプロパティの設定ができる
悪いところ
- 広すぎる影響範囲
- クラスが肥大化しやすい
- 「~Manager」のような、名前から何をするクラスか予測しずらいクラスが生成されがち
総評
雑に便利だが、影響範囲が広すぎるので、DIを理解できたら使用しないことをおすすめします。
ただ、簡単に誰でも使える点はとても魅力的なので、ゲームジャムなど短期間での開発で使うのをおすすめします。
また、そのときにも同じ名前空間内のシングルトンクラスにしかアクセスしてはいけないのように、コーディング規約で縛っておくのがおすすめです。
参考記事
ゲーム進行をやりやすく
Stateパターン
ゲームの流れは大体、タイトル→ゲーム→リザルトのように、それぞれ状態があります。
この「タイトル」のような1つの状態をStateと言います。
そして、このステートの流れを1つのクラスで決めようというのがStateパターンです。
考え方
まず、それぞれのStateを1つのクラスとして表現します。
このクラスには、Stateの初期化を行う処理、Stateに入った時に行う処理、Stateから出ていく時に行う処理を用意します。
次に、このステートを管理するクラスを作成します。
このクラスで、上記の画像のような矢印の流れを表現します。
つまり、Stateを次に進める処理が実行された時にどのStateに入るのか決めるということです。
実装例
State
public abstract StateBase
{
//初期化
public abstract void Initialize();
// Stateに入った時の処理
public abstract void Enter();
// Stateを出ていく時の処理
public abstract void Exit();
}
State例
public class TitleState: StateBase
{
// タイトルのUI
private GameObject _titlePanel;
// 最初はUIを非表示にする
public void Initialize()
{
_titlePanel.SetActive(false);
}
// タイトル状態になったら、UIを表示にする
public void Enter()
{
_titlePanel.SetActive(true);
}
// タイトル状態じゃなくなったら、UIを非表示にする
public void Exit()
{
_titlePanel.SetActive(false);
}
}
StateManager
public class StateManager: SingletonBase<StateManager>
{
[SerializeField]
private List<StatePair> _statePair;
// enumからStateBaseを特定できるようにする
private Dictionary<StateType, StateBase> _stateDic;
// 現在のステート
private StateBase _currentState;
//初期化
public void Initialize()
{
_stateDic = _statePair.ToDictionary(x => x.Type, x => x.State);
int count = _statePair.Length;
for(int i=0; i< count; i++)
{
_statePair.State.Initialize();
}
}
// ステートを変更
public void ChangeState(StateType state)
{
if(_currentState is not null)
{
_currentState.Exit();
}
_currentState = _stateDic[state];
_currrentState.Enter();
}
// StateのEnumとStateBaseのペア
[Serializable]
private class StatePair
{
[SerializeField]
private StateType _type;
public StateType Type => _type;
[SerializeField]
private StateBase _state;
public StateBase State => _state;
}
}
良いところ
- 状態ごとにまとめて処理を実行できるので、処理の順序関係によるエラーが起こりにくい
- 状態ごとにどのような処理があるか確認しやすい
- 状態の遷移が明確になる
悪いところ
- 人によって実装方法が異なることがあり、その対応が面倒
総評
ゲームの状態だけでなく、プレイヤーの動作状態などさまざまなところで適用できる便利な設計思想です。
しかし、便利だからといってやりすぎると逆にわかりにくくなるので注意が必要です。
参考記事
UIの保守性を高めたい
MVPパターン
UIのグループをModel、Presenter、Viewに分けて実装する設計思想です。
考え方
Model
UIのデータ部分
ex) タイマーUIの経過時間、音量設定スライダーの現在の音量
View
UIの見た目
ex) ボタンのアニメーション、 UIを表示する処理
Presenter
ModelとViewを繋げ、外部のクラスの窓口となる
実装例
Model
public class TimeManager: SingletonBase<TimeManager>
{
// 時間
private float _time = 0f;
public float Time => _time;
private void Update()
{
// 時間を増やす
_time += Time.deltaTime;
}
}
View
public class ClockView
{
[SerializeField]
private TMP_Text _timeText;
// 時間をテキストで表示
public void UpdateText(float time)
{
_timeText = time.ToString();
}
}
Presenter
public class ClockPresenter
{
private TimeManger _model => TimeManager.Instance;
[SerializeField]
private ClockView _view;
private void Upadate()
{
// modelの時間をviewに伝える
_view.SetText(_model.Time);
}
}
良いところ
- 拡張性が高い
- データの差し替えや見た目の差し替えが簡単
悪いところ
- スクリプトが大量にできる
- どこがViewでどこがModelなのか考えるのが面倒
総括
UIの拡張性、保守性を高める設計思想です。
とはいえ、実装には手間がかかるので、本当に短期間で実装しないといけない場合は、使用しないことをすすめます。
状態変化を検知したい
Observarパターン
常に監視ではなく、監視される側に動いて欲しい。
考え方
例えば、ゲームシーン上にある敵オブジェクトが破壊されたか確認する時、いちいちゲームシーンのEnemy全員に毎フレーム破壊されたか確認するのは、面倒ですよね。
Observarパターンでは、敵オブジェクトが破壊されたことをObserverに伝えてあげることで、敵の状態をずっと確認する必要がなくなります。
用語
イベント
変数の値の変更やボタンが押されたのような状態の変化のこと
Observer
Subjectを監視する者
Subject
イベントの発生を通知する者
購読
ObserverがSubjectを監視すること
Push型
SubjectがObserverにイベントを通知する
Pull型
イベント検知時に、ObserverがSubjectに問い合わせる
実装例
実装はR3(UniRx)を使用すると楽です。
public class EnemyManager
{
private Enemy _enemyBase;
private int _enemyCount = 0;
// 敵を生成
public void Spawn()
{
var enemy = (Enemy)Instantiate(_enemyBase);
BindDeath(enemy);
_enemyCount++;
}
// 敵が死んだかどうかを監視して、敵が死んだら敵の数を減らす
private void BindDeath(Enemy enemy)
{
enemy.IsDeathProp
.where(isDeath => isDeath)
.Take(1)
.Subscribe(_ =>
{
_enemyCount--;
}).RegisterTo(destroyCancellationToken);
}
}
良いところ
- Upateから解放される
- SubjectやObserverの差し替えが用意
- SubjectとObserverがお互いの内部仕様を知らなくていい
悪いところ
- 慣れるまで分かりにくい
総括
値が変わった時に実行したいときやMVPと合わせてUIで使用するのがおすすめです。
参考記事
オブジェクトを生成する
Factoryパターン
オブジェクトを生成するためのクラスを実装するパターンです。
考え方
オブジェクトの生成時には、生成位置やステータスの設定など必ず初めにやることがよくあります。
この初期設定をテキトウなところでやってしまうと後から変更しにくい。
そこで、オブジェクトの生成は、工場というスペシャリストに任せましょう。
実装例
public class Factory
{
private GameObject _enemy;
private Transform _enemyParent;
public Factory(GameObject enemyObj, Transform parent)
{
_enemy = enemyObj;
_enemyParent = parent;
}
// オブジェクトを生成
public Enemy Create()
{
var enemy = Instantiate(_enemy, _enemyParent);
}
}
良いところ
- 生成する処理の修正をしたい時には、Factoryを確認すればいい
- 生成の処理の使い回しが容易
- 実装が簡単
悪いところ
- 初期化が複雑な場合には向いていない(たくさんのクラスと関連する場合など)
総評
実装が簡単で、生成の処理の修正も楽になるので、おすすめのパターンです。
参考記事
ObjectPool
オブジェクトの繰り返しの生成処理を軽くする。
考え方
ゲーム中に何度も同じオブジェクトの生成、破壊を繰り返すのは無駄です‼︎
生成したオブジェクトを使い終わったら、使用済みリストに追加します。
そこから、取り出して生成、破壊の処理をしないようにしましょう。
実装例
// オブジェクトプール
public class ObjectPool<T>: IDisposable
where : IActiveObj
{
// オブジェクトを生成するクラス
private Factory _factory;
// 使用済みのオブジェクトを格納
private Stack<T> _stack;
// R3のイベント購読止める用
private CancellationTokenSource _cts;
// 初期化
public ObjectPool(Factory factory, int maxObjCount)
{
_factory = factory;
_stack = new();
_cts = new();
}
// オブジェクトを取得
public T Get()
{
// 使用済みオブジェクトが空でないとき
if(_stack.IsEmpty)
{
return Create();
}
var obj = _stack.Pop();
BindReturn(obj);
return obj;
}
// 新しくオブジェクト生成
private T Create()
{
var obj = _factory.Create();
BindReturn(obj);
return obj;
}
// オブジェクトが破壊されたら、オブジェクトをスタックに格納
private void BindReturn(T obj)
{
obj.IsActiveProp
.where(isActive => !isActive)
.Take(1)
.Subscribe(_ =>
{
_stack.Push(obj);
}).RegisterTo(_cts.Token);
}
// オブジェクトプール破壊時にイベントの購読も終了
public void Dispose()
{
_cts.Cancell();
}
}
// 動作終了したかどうかの変数を持つインタフェース
public interface IActiveObj
{
ReadOnlyReactiveProperty<bool> IsActiveProp { get; }
}
良いところ
- 繰り返しのオブジェクト生成処理を軽くできる。
- C#のライブラリで簡単に実装できる
- ゲーム上に存在できる同一オブジェクト数を設定できる
悪いところ
- 自分で実装する場合は、少し面倒
総評
弾の生成や敵の生成など大量に同じオブジェクトを生成する場合には、ぜひ適用したいパターンです。
参考記事
モジュールを使いやすく
Mediatorパターン
モジュール内のクラス同士を繋ぐためだけのクラスを実装するパターンです。
考え方
Playerの中にData1、Data2がありました。
ここで、Data1とData2は統合できると気づきました。
しかし、Data1とData2がPlayer内のどこで使用されるかわからないので、すべてのPlayerファイルを調べました。
こんな事態にならないために、Data1などのクラス同士がMediatorを介してやり取りを行うようにしようという考え方です。
実装例
// 動きの情報
private class Data
{
private float _speed;
public float Speed => _speed;
public Data(float speed)
{
_speed = speed;
}
}
// 動かす処理
private class Mover
{
private Transform _transform;
public Mover(Transform transform)
{
_transform = transform;
}
public void Move(float speed)
{
_transform.positon += _transform.forward * speed;
}
}
// 動きの情報と動かす処理を繋げる
public class Mediator
{
private Data _data;
private Mover _mover;
public void Move()
{
_mover.Move(_data.Speed);
}
}
良いところ
- モジュール内のクラス同士のつながりがわかりやすくなる
- クラスの追加、削除に強い
- 実装が簡単
悪いところ
- Mediatorが肥大化しがち
総評
実装が簡単でクラス同士の繋がりもわかりやすくなるので、かなりおすすめです。
Facadeパターン
モジュールの説明書を実装するパターンです。
考え方
PlayerのHPの情報が欲しいとします。
しかし、Playerモジュールでは、Data1、Data2のようなクラスがたくさんあり、どのクラスにHPの情報があるか分かりません。
しかし、全部調べるのは面倒くさい。
このような問題を解決するために、外部のモジュールが使用するためのクラスを作りましょう。
実装例
// Mediatorの使い方のクラス(Data, Mover, Mediator)を実装してるとする
// モジュールの説明書
public class Facade
{
private Mediator _meditor;
// 外部のモジュールもプレイヤーを動かせる
public void Move()
{
_mediator.Mover();
}
}
良いところ
- モジュールを使いたい時は、Facadeを確認するだけでいい
- 外部モジュールとのつながりでエラーが発生した時にも、Facadeを確認すればいい
- 何を公開しているのか、明確になる
- 実装が簡単
悪いところ
- Facadeが肥大化しがち
- 基本Mediatorのコードをコピペすることになる
総評
チーム開発においては、誰かが作ったクラスを使用する時に迷いがちです。
Facadeパターンを実装することで、この迷いを減らせるので、特にチーム開発ではおすすめのパターンです。
命令を一元化
Commandパターン
複数のパターンでアクセスされる関数があるときに、アクセスを統一させたい。
考え方
プレイヤーを動かすシステムがあります。
このプレイヤーの動かすシステムは、コントローラの場合はスティック入力、キーボードの場合はキーボード、スマホの場合はドラッグがにゅりょくとなります。
そして、これらの入力は型が違うという問題がありました。
そこで、それぞれの入力に対応した関数を作成するのは、やりたくないですよね。
そんな時に、Commandのインタフェースを作り、どの入力でも対応できる関数にします。
実装例
// 入力のインタフェース
public interface ICommand
{
// 動く方向
float Direction{ get; }
}
//プレイヤー
public class Player
{
public float Speed = 10f;
// Direction方向にSpeed分移動
public void Move(ICommand command)
{
transform.postion += new Vector3(commnad.Direction*Speed, 0f, 0f);
}
}
// コントローラー入力
public class Controller: ICommand
{
private float _direction;
public float Direction => _direction;
private void Update()
{
_direction = Input.GetAxis("Horizontal");
}
}
// キーボード入力
public class KeyBoard: ICommand
{
private float _direction;
public float Direction => _direction;
private void Update()
{
if(Input.GetKeyDown(KeyCode.A))
{
_direction = -1;
}
if(Input.GetKeyDown(KeyCode.D))
{
_direction = 1;
}
}
}
良い点
- メソッドの共通化ができる
- 実行側が入力を意識しなくて良くなる
悪い点
- インタフェースの理解が必要
総括
複数の入力方法がある関数を作成する場合にこのパターンの適用を考慮してみるのがおすすめです。
おわりに
以上、9パターンを紹介しました。
デザインパターンと似たようなことをこれまでやったことがある人もいるのではないでしょうか?
今回紹介したのは、数あるデザインパターンの一部なので、自身でも色々調べてみても面白いかもです。
今回のデザインパターンを参考にぜひ設計に挑戦してみてください‼︎