はじめに
某社のエンジニア様に「大規模開発に参加するにあたって読むべき本はなにか?」という質問をしたところ「現場で役立つシステム設計の原則」と「Java言語で学ぶデザインパターン入門」をおススメされたので読んでみました。読んだだけだと全然実際の運用での肌感覚的なものが理解できなかったので、Unityと絡めてどんな時にどんなデザインパターンが有用なのか考えてみることにします。これを読んでるあなたも考えてね、間違ってるところがあったら教えてください。
デザインパターンとは
GoF(Gang of Four)(カッコいいね)と呼ばれる4人のエンジニアが「オブジェクト指向における再利用のためのデザインパターン」の中で言及した、再利用や修正が容易なコードの書き方(依存関係の持たせ方、抽象化の手法)などをまとめたもの。
全部で23パターンあるぞ。少しずつマスターしていこう…
まずは俯瞰してみよう
23パターンはその特性から
- 生成に関するパターン…インスタンスの生成に関与する
- Abstract Factory
- Builder
- Factory Method
- Prototype
- Singleton
- 構造に関するパターン…クラス同士を繋いだり、システムに扱いやすい窓口を作ったりする
- Adapter
- Bridge
- Composite
- Decorator
- Facade
- Flyweight
- Proxy
- 振る舞いに関するパターン…システムの流れを決定したり、システムを簡単に差し替えられるようにする
- Chain of Repsponsibility
- Command
- Interpreter
- Iterator
- Mediator
- Memento
- Observer
- State
- Strategy
- Template Method
- Visitor
に大別されます。
実装
今回は
- 各デザインパターンの紹介→実際にUnityで使ってみる
- 可能な限りMonoBehaviorを継承したクラスを作らない
を重視してやっていきたいと思います。
1. Iterator(イテレータ)
「集合から次々に要素を抽出する行為」を一般化したものです。
「for文を使って一つ一つの要素を触る」「for文を使って偶数番目の要素を触る」などといった実装をメインのコードや集合自身から分離した部分で定めることができます。
どんなことに使えるの? Ex)あらかじめ指定した敵のグループが出現するシステム
こんな風にインスペクタ上で設定した敵とステータスの書かれたUIが出現するシステムを作ってみます。
まずはMonoBehaviorを継承しない部分のクラスから
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;
public interface Iterator
{
bool HasNext();
System.Object Next();
}
public interface Aggregate
{
Iterator iterator();
}
public enum EnemyType
{
Sphere,Cube,Capsule
}
[System.Serializable]
public class Enemy {
[SerializeField]
private string name;
[SerializeField]
private int HP;
[SerializeField]
private int MP;
[SerializeField]
private EnemyType enemyType;
public Enemy(string name,int HP,int MP, EnemyType enemyType)
{
this.name = name;
this.HP = HP;
this.MP = MP;
this.enemyType = enemyType;
}
public string getName()
{
return name;
}
public int getHP()
{
return HP;
}
public int getMP()
{
return MP;
}
public EnemyType getEnemyType()
{
return enemyType;
}
}
public class EnemyGroup:Aggregate
{
private Enemy[] enemies;
private int last = 0;
public EnemyGroup(int maxSize)
{
this.enemies = new Enemy[maxSize];
}
public void AddEnemy(Enemy enemy)
{
this.enemies[last] = enemy;
last++;
}
public int GetLength()
{
return enemies.Length;
}
public Enemy GetEnemyAt(int index)
{
return enemies[index];
}
public Iterator iterator()
{
return new EnemyGroupIterator(this);
}
}
public class EnemyGroupIterator : Iterator
{
private EnemyGroup enemyGroup;
private int index;
public EnemyGroupIterator(EnemyGroup enemyGroup)
{
this.enemyGroup = enemyGroup;
this.index = 0;
}
public bool HasNext()
{
if (index < enemyGroup.GetLength()) return true;
return false;
}
public System.Object Next()
{
Enemy enemy = enemyGroup.GetEnemyAt(index);
index++;
return enemy;
}
}
まずはName、HP、MP、enemyType(形状)のフィールドを持ったEnemyクラスを作ります。次に集合用のインタフェース(Aggregate)とイテレータ用のインターフェース(Iterator)を作り(抽象クラス)、それを使ってEnemyGroupとEnemyGroupIteratorを生成します(具体クラス)。Enemyクラスにgetterがあるのは許して…
次にMonoBehaviorを継承した、Unityに直接かかわるクラスの実装をしていこうと思います。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class EnemyController : MonoBehaviour {
public Enemy[] enemies;
private EnemyGroup enemyGroup;
public GameObject enemyTag;//Text3Dのprefabを割り当て
// Use this for initialization
void Start () {
enemyGroup = new EnemyGroup(enemies.Length);
Iterator it = enemyGroup.iterator();
for(int i = 0; i < enemies.Length; i++)
{
enemyGroup.AddEnemy(enemies[i]);
}
while (it.HasNext())
{
var e = it.Next();
GenerateEnemy((Enemy)e);
}
}
// Update is called once per frame
void Update () {
}
public void GenerateEnemy(Enemy enemy)
{
var g = null;
switch (enemy.getEnemyType())
{
case EnemyType.Sphere:
g = GameObject.CreatePrimitive(PrimitiveType.Sphere);
break;
case EnemyType.Cube:
g = GameObject.CreatePrimitive(PrimitiveType.Cube);
break;
case EnemyType.Capsule:
g = GameObject.CreatePrimitive(PrimitiveType.Capsule);
break;
}
g.transform.position = new Vector3Int(Random.Range(-5,5), Random.Range(-5, 5),0);
Instantiate(enemyTag, g.transform);
enemyTag.GetComponent<TextMesh>().text = enemy.getName() + "\nHP:" + enemy.getHP() + "\nMP:" + enemy.getMP();
}
}
EnemyのListを生成、インスペクタに表示して、それをEnemyGroupに格納した上でwhile文でIteratorを回してEnemyを取得します。(今回は簡単のため格納して取り出すっていう無駄な処理をしています。許して)同じwhile文の中で取得したEnemyの情報からGameObjectを生成します。さらにenemyTagに格納してあったオブジェクトにあるTextMeshを取得して、textに情報を書き込みます。
コレでEnemy生成をすることができました。今回はInspectorから値を設定しているのでAggregateとIteratorの良さがいまいちわかりませんね。しかしここから簡単に次のようなことができます。
- ランダムな敵グループを生成(AddGroupに代入するEnemyをランダムに生成する)
- グループの奇数番目に格納された敵のみを生成(別のIteratorを生成すればよい)
- 任意の数列に従ってグループのn番目の敵を生成(別のIteratorを生成すればよい)
- EnemyGroupの中身をArrayからListに変更
こういった変更が簡単に出来るのがIteratorパターンのうま味です。
2. Adapter(アダプター)
既成のシステムと要求されるものに微妙なズレがあるとき、規制クラスの外部にそれを補うようなクラスを書いて要求される仕様に合わせるデザインパターンをAdapterと呼びます。既成システムに手を加えず外側で加工するコードを書くので、既成システムのテストなどをやり直さずに済むのでおいしいです。
具体的には
- あるクラスAが存在する
- 「これに合わせてほしい」という目的のインターフェースBが存在する
- しかしクラスAはインターフェースBの要求するメソッドを含んでいない
といった状況で、「クラスAを継承した仲介用のクラスA'を作り、クラスA'をインターフェースBに適合するように書こう」というのがAdapterの考え方です。
例えば、次のようなEnemy2クラスに加え、爆発物を取り扱うインターフェース、Bombが存在したとします。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Enemy2 : MonoBehaviour
{
[SerializeField]
public string name;
[SerializeField]
int HP;
[SerializeField]
int MP;
}
public interface Bomb {
void CountDown();
void ActivateBomb();
}
今までBombインターフェースは爆発物、例えば時限爆弾や毒ガスなどに用いられていたとします。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class BombEntity : MonoBehaviour, Bomb{
[SerializeField]
private int count;
[SerializeField]
private string name;
public void CountDown()
{
count--;
if(count == 0)
{
ActivateBomb();
}
else
{
Debug.Log("Object:" + this.name + " current count is " + count);
}
}
public void ActivateBomb()
{
Debug.Log("Object:" + name + " explode!");
Destroy(this.gameObject);
}
}
ここで、「ばくだんいわ」、みたいな一定時間で爆発する敵を作りたいと思ったときに、Bombインターフェースを付けられると良いのですが、残念ながらEnemy2クラスにはCoundDownやActivateBombといったメソッドは存在しません。ここでEnemy2クラスに直接これらのメソッドを付与してしまうと、爆発しない敵とする敵の両方がEnemy2クラスに集まってしまい、処理の分岐が面倒になってしまう可能性があります。
こういう時は、Enemy2クラスとBombインターフェースを継承したBombEnemyクラスを新しく作ると良さそうです。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class BombEnemy : Enemy2,Bomb {
[SerializeField]
int count;
public void CountDown()
{
count--;
if (count == 0) ActivateBomb();
else Debug.Log("Enemy:" + this.name + " current count is "+ count);
}
public void ActivateBomb()
{
Debug.Log("Enemy:"+ this.name +" explode!");
Destroy(this.gameObject);
}
}
こうすることで、Bombインターフェースを用いてオブジェクトとしての爆弾と爆発する敵を同じように処理することができます。
コレがAdapterの考え方です、多分。
では実際に爆破の処理を書いてみましょう。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class BombController : MonoBehaviour {
public float time;
// Use this for initialization
void Start () {
}
// Update is called once per frame
void FixedUpdate () {
time += Time.deltaTime;
if(time >= 1)
{
time = 0;
foreach (GameObject obj in UnityEngine.Object.FindObjectsOfType(typeof(GameObject)))
{
if(obj.GetComponent<Bomb>() != null)
{
obj.GetComponent<Bomb>().CountDown();
}
}
}
}
}
一秒ごとにすべてのオブジェクトを取得してBombインターフェースを持つコンポネントに対してCountDownメソッドを呼び出しています。
これらをエディタ上に配置してみましょう。
これのようにEnemyクラスとBombインターフェースから簡単に爆発する敵を生成することができます。
Adapterの利点として
- Enemyクラスを書き換えるとBombEnemyクラスにも反映される
- Bombの処理部分書き換えるとBombEntityとBombEnemy両方に反映される
一々書き直す手間を省いたり、新しくコードを書いてテストする手間を省いたり出来るので大変便利。
終わりに
本を読んだだけでは真の理解にはつながってないんだろうな、と思っていたところ案の定いい例を思いつくのに時間がかかりました。いいトレーニングになりそう。23パターンコンプリートしていきたいですね。
続きの投稿日時は未定ですが、長い目で見守っていてくれると幸いです。