はじめに
Unity公式のデザインパターンデモについてまとめてみました。概要ではなく、具体的な詳細の理解を目的としているので、コードが多めです。
下は公式の資料です。
サンプルプロジェクト
公式資料
日本語公式記事
1~5 SOLID原則
1~5のサンプルはSOLID原則と呼ばれている、ソフトウェア開発においてその開発の改良や最適化をよりよく行うための心がけのようなものです。オブジェクト指向プログラミングを行っていく際に、非常にためになる内容だと思います。
すごくわかりやすい記事
この記事を見れば、概要は理解できると思いますが、これをUnityで利用している例をまとめていきます。
1. SingleResponsibility 単一責任の原則
内容
一つのクラスに持たせる機能は一つにしようという考え方。
サンプルの場合は、プレイヤーの機能をAudio
・Input
・Move
の3つに分割し、それをPlayer
クラスでまとめるというような実装をしています。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace DesignPatterns.SRP
{
[RequireComponent(typeof(PlayerAudio), typeof(PlayerInput), typeof(PlayerMovement))]
public class Player : MonoBehaviour
{
[SerializeField] private PlayerAudio playerAudio;
[SerializeField] private PlayerInput playerInput;
[SerializeField] private PlayerMovement playerMovement;
private void Start()
{
playerAudio = GetComponent<PlayerAudio>();
playerInput = GetComponent<PlayerInput>();
playerMovement = GetComponent<PlayerMovement>();
}
}
}
良くない例
単一責任の原則においてあまり良くない例として、UnrefactoredPlayer
というクラスも定義されています。Unrefactoredとは、ゲーム実際の挙動自体は変えずに、プログラムの整理を行う「リファクタリング」がされていないという意味です。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace DesignPatterns.SRP
{
public class UnrefactoredPlayer : MonoBehaviour
{
[SerializeField] private string _inputAxisName;
[SerializeField] private float _positionMultiplier;
private float _yPosition;
private AudioSource _bounceSfx;
private void Start()
{
_bounceSfx = GetComponent<AudioSource>();
}
private void Update()
{
float delta = Input.GetAxis(_inputAxisName) * Time.deltaTime;
_yPosition = Mathf.Clamp(_yPosition + delta, -1, 1);
transform.position = new Vector3(transform.position.x, _yPosition * _positionMultiplier, transform.position.z);
}
private void OnTriggerEnter(Collider other)
{
_bounceSfx.Play();
}
}
}
まとめ・感想
- メリット
- 問題が起きた際に、その場所が明確なので、より速い作業を行うことができる
- 機能ごとにクラスの分割を行うため、分業がしやすくなる
- デメリット
- どのくらい機能を分割したらいいか、基準が難しい
2. OpenClosed オープン・クローズドの原則
内容
作成したクラスは、拡張(追加)は可能にすべきだが、変更は不可能にするべきという考え方。
サンプルの場合は、Shape
という抽象クラスで CalculateArea()
メソッドを定義し、それを Rectangle
・Circle
クラスが継承して、CalculateArea()
メソッドをオーバーライドしています。
これによって、新しい図形のクラス作成を行う際に、今までのクラスを書き換えることなく機能の追加ができるようになっています。
namespace DesignPatterns.OCP
{
public abstract class Shape
{
public abstract float CalculateArea();
}
}
namespace DesignPatterns.OCP
{
public class Rectangle : Shape
{
public float Width { get; set; }
public float Height { get; set; }
public override float CalculateArea()
{
return Width * Height;
}
}
}
using UnityEngine;
namespace DesignPatterns.OCP
{
public class Circle : Shape
{
public float Radius { get; set; }
public override float CalculateArea()
{
return Radius * Radius * Mathf.PI;
}
}
}
良くない例
良くない例としてAreaCalculator
クラスが定義されていて、ここではRectangle
とCircle
の処理を一つのクラスで行っています。
using UnityEngine;
namespace DesignPatterns.OCP
{
public class AreaCalculator
{
//public float GetRectangleArea(Rectangle rectangle)
//{
// return rectangle.Width * rectangle.Height;
//}
//public float GetCircleArea(Circle circle)
//{
// return circle.Radius * circle.Radius * Mathf.PI;
//}
public float GetArea(Shape shape)
{
return shape.CalculateArea();
}
}
}
まとめ・感想
- メリット
- 後々機能が追加されていく想定のものを制作するうえで、必須と言ってもいいと思った
- 複数人で機能の作成をするときにも役立ちそう
- 一度作ってしまえば、クラスの使用先でのみエラーが発生するので、修正がしやすそう
- デメリット
- 逆に基底クラスに問題が起きた場合、全てに問題が起きてしまうかも?
3. LiskovSubstitution リスコフの置換原則
内容
派生クラスは、その基底クラスの仕様以外のことはできないようにすべきというものです。
サンプルでは、IMovable
を継承したRailVehicle
クラスと、IMovable
とITurnable
を継承したRoadVehicle
クラスを実装しています。
これによって、線路を走るTrain
クラスとCar
クラスを、それぞれに必要な機能だけ持たせて制作することができるようにしています。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace DesignPatterns.LSP
{
public class RailVehicle : IMovable
{
public string Name;
private float moveSpeed = 100f;
private float acceleration = 5f;
public float TurnSpeed = 5f;
public float MoveSpeed { get => moveSpeed; set => moveSpeed = value; }
public float Acceleration { get => acceleration; set => acceleration = value; }
public virtual void GoForward()
{
}
public virtual void Reverse()
{
}
}
}
using UnityEngine;
namespace DesignPatterns.LSP
{
public class RoadVehicle : IMovable, ITurnable
{
public string Name;
private float moveSpeed = 100f;
private float acceleration = 5f;
public float TurnSpeed = 5f;
public float MoveSpeed { get => moveSpeed; set => moveSpeed = value; }
public float Acceleration { get => acceleration; set => acceleration = value; }
public virtual void GoForward()
{
}
public virtual void Reverse()
{
}
public virtual void TurnLeft()
{
}
public virtual void TurnRight()
{
}
}
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace DesignPatterns.LSP
{
public class Car : RoadVehicle
{
}
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace DesignPatterns.LSP
{
public class Train : RailVehicle
{
}
}
良くない例
置換原則が守れていない例として、Vehicle
クラスが定義されています。このクラスでは
前に進む・反転する・曲がる という処理をするメソッドがまとめて実装されてしまっています。
これをTrain
とCar
クラス両方に継承する使い方をしてしまうと、Train.TurnRight()
のようなTrain
クラスに必要のない機能を実行できてしまいます。
using UnityEngine;
namespace DesignPatterns.LSP
{
public class Vehicle
{
public float speed = 100;
public string name;
public Vector3 direction;
//public void GoForward()
//{
//}
//public void Reverse()
//{
//}
//public void TurnRight()
//{
//}
//public void TurnLeft()
//{
//}
}
}
まとめ・感想
- メリット
- クラスごとに必要な機能しか持たせないので、見通しがよくなる
- 作成した基底クラスをほかの人に使ってもらう際に、問題が起きるのを防げる
- デメリット
- クラスの数が膨大になる
4. InterfaceSegregation インターフェイス分離の原則
内容
インターフェースは機能ごとに細かく分けて実装しようという考え方。
サンプルでは、IDamageable
IMovable
IUnitStats
を継承したEnemyUnit
クラスと、
IExplodable
IDamageable
を継承したExplodingBarrel
を定義しています。
これによってEnemyUnit
とExplodingBarrel
が持つ機能を明確にしています。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace DesignPatterns.ISP
{
public class EnemyUnit : MonoBehaviour, IDamageable, IMovable, IUnitStats
{
//長いので省略
}
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace DesignPatterns.ISP
{
public class ExplodingBarrel : MonoBehaviour, IExplodable, IDamageable
{
//長いので省略
}
}
良くない例
インターフェイス分離の原則に則っていない例として、IUnitStats
インターフェース内に記述があります。ここでは、現在は他のインターフェースに分割している機能をまとめて持つという実装をしてしまっています。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace DesignPatterns.ISP
{
// Interface Segregation tells us to use smaller interfaces. Rather than
// incorporate all of these Methods/Properties in one interface, use several.
public interface IUnitStats
{
//public float Health { get; set; }
//public int Defense { get; set; }
//public void Die();
//public void TakeDamage();
//public void RestoreHealth();
//public float MoveSpeed { get; set; }
//public float Acceleration { get; set; }
//public void MoveForward();
//public void Reverse();
//public void TurnLeft();
//public void TurnRight();
public int Strength { get; set; }
public int Dexterity { get; set; }
public int Endurance { get; set; }
//public float Mass { get; set; }
//public float ExplosiveForce { get; set; }
//public float FuseDelay { get; set; }
//public float Timeout { get; set; }
//public void Explode();
}
}
まとめ・感想
- インターフェースをクラスごとに使いまわすことができるので、効率がいい
- 機能の追加削除を行いやすい
- 不要な機能を持たせない程度に分割を行うのが難しそう
5. DependencyInversion 依存性逆転の原則
内容
動作を実行させるクラス(Switch
)は、動作を実行するクラス(今回の場合はDoor
)に対し、具体的な参照を持つべきではないという考え方。
サンプルの場合は、Door
クラスにISwitchable
を継承させ、Switch
クラスはISwitchable
に対して参照を持つことで、Door
クラスに対する具体的な参照を防いでいます。
また、これによってISwitchable
を継承したTrap
クラスに対しても、同じSwitch
クラスで処理を行うことができています。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace DesignPatterns.DIP
{
public class Door : MonoBehaviour, ISwitchable
{
private bool isActive;
public bool IsActive => isActive;
public void Activate()
{
isActive = true;
Debug.Log("The door is open.");
}
public void Deactivate()
{
isActive = false;
Debug.Log("The door is closed.");
}
}
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace DesignPatterns.DIP
{
public class Trap : MonoBehaviour, ISwitchable
{
//Doorと一緒なので省略
}
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace DesignPatterns.DIP
{
public class Switch : MonoBehaviour
{
public ISwitchable client;
public void Toggle()
{
if (client.IsActive)
{
client.Deactivate();
}
else
{
client.Activate();
}
}
}
}
良くない例
依存性逆転の原則に則っていない例として、Door
クラスと Switch
クラス内に記述があります。ここでは、Switch
がDoor
に対し具体的な参照を持ってしまっているので
-
Switch
が単独で動くことができない -
Door
のようなギミックを追加する際に、Switch
に追記しなければいけない
のような問題が発生してしまいます。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace DesignPatterns.DIP
{
public class Door : MonoBehaviour
{
public void Open()
{
Debug.Log("The door is open.");
}
public void Close()
{
Debug.Log("The door is closed.");
}
}
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace DesignPatterns.DIP
{
public class Switch : MonoBehaviour
{
public Door Door;
public bool IsActivated;
public void Activate()
{
if (IsActivated)
{
IsActivated = false;
Door.Close();
}
else
{
IsActivated = true;
Door.Open();
}
}
}
}
まとめ・感想
- それぞれの機能を独立させて制作を行える
- チーム内でインターフェースの共有さえしておけば、分業がしやすくなる
- 新しい機能の追加やデバッグがやり易くなる
6. Factory
内容
このデモでは、共通のインターフェースを用いて複数のオブジェクトの生成を1つのクラスで行い、そのオブジェクトにはそれぞれ異なった処理を行わせています。
このような実装にすることで、生成の指示を行うクラスは、生成を行うクラスと生成するものの処理や数に影響を受けることなく、実装ができるようになります。
コードの内容を4つのステップで説明します
-
IProduct
を継承したProductA
・ProductB
(生成する球の処理)を作成
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace DesignPatterns.Factory
{
public class ProductA : MonoBehaviour, IProduct
{
public void Initialize()
{
//ProductAで行う処理
}
}
}
2. Factory
を継承したConcreteFactoryA
・ConcreteFactoryB
を作成
3. 作成したConcreteFactory
クラスで、基底クラスのFactory
に定義されている、IProduct
型のProduct
を生成するメソッドをオーバーライドする
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace DesignPatterns.Factory
{
public class ConcreteFactoryA : Factory
{
//生成するプレハブ
[SerializeField] private ProductA productPrefab;
public override IProduct GetProduct(Vector3 position)
{
// プレハブを生成し、そのコンポーネントを取得
GameObject instance = Instantiate(productPrefab.gameObject, position, Quaternion.identity);
ProductA newProduct = instance.GetComponent<ProductA>();
// プレハブの処理を実行
newProduct.Initialize();
return newProduct;
}
}
}
4. ClickToCreate
クラスでFactory
型の変数を定義し、そのメソッドを実行
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace DesignPatterns.Factory
{
public class ClickToCreate : MonoBehaviour
{
[SerializeField] private LayerMask layerToClick;
[SerializeField] private Vector3 offset;
//ファクトリーを持っておく
[SerializeField] Factory[] factories;
private Factory factory;
private void Update()
{
GetProductAtClick();
}
private void GetProductAtClick()
{
if (Input.GetMouseButtonDown(0))
{
//ファクトリーをランダムに選択
factory = factories[Random.Range(0, factories.Length)];
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
RaycastHit hitInfo;
if (Physics.Raycast(ray, out hitInfo, Mathf.Infinity, layerToClick) && factory != null)
{
//ファクトリーを生成
factory.GetProduct(hitInfo.point + offset);
}
}
}
}
}
というような実装方法になっています。
まとめ・感想
・バリエーションの多い、RPGの敵などを制作するときに便利
・生成する側も生成される側も、互いの詳細を知らなくていいのがいい
・今後バリエーションの追加が予定されている場合にも役立つ
・生成処理の間に条件を挟みやすくなる
7. ObjectPool
内容
このデモでは、生成したオブジェクトを使用が終わった際に削除せず、オブジェクトのアクティブ・非アクティブと座標を変更することで、オブジェクトの再利用を行うということをしています。
オブジェクトプールは、オブジェクトの生成と破棄によってメモリに負荷がかかることを回避するという目的のデザインパターンです。
デモの使用例として、2021年にUnityからUnityEngine.Poolを使用する例と使用しない例があります。ここではUnityEngine.Poolを使用する例のほうを見ながら説明していきます。
1. まず、RevisedProjectile
(弾)を作成し、ObjectPoolへの参照を持たせる
2. 弾のクラスに、オブジェクトをプールに追加する処理を定義する
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Pool;
namespace DesignPatterns.ObjectPool
{
public class RevisedProjectile : MonoBehaviour
{
// 非アクティブになるまでの時間
[SerializeField] private float timeoutDelay = 3f;
private IObjectPool<RevisedProjectile> objectPool;
// 弾にObjectPoolへの参照を与えるパブリックプロパティ
public IObjectPool<RevisedProjectile> ObjectPool { set => objectPool = value; }
// プールに戻す際の処理
public void Deactivate()
{
StartCoroutine(DeactivateRoutine(timeoutDelay));
}
IEnumerator DeactivateRoutine(float delay)
{
yield return new WaitForSeconds(delay);
// 動きをリセット
Rigidbody rBody = GetComponent<Rigidbody>();
rBody.velocity = new Vector3(0f, 0f, 0f);
rBody.angularVelocity = new Vector3(0f, 0f, 0f);
// このゲームオブジェクトをプールに戻す
objectPool.Release(this);
}
}
}
3. 射撃を行う銃にアタッチするコンポーネントで、RevisedProjectile
(弾) のObjectPool
を作成する
ObjectPoolのコンストラクタの引数は以下のものです
コンストラクタ | 内容 |
---|---|
Func<T> createFunc | 生成の処理 |
Action<T> actionOnGet = null | プールから一時削除する処理 |
Action<T> actionOnRelease = null | プールから使用するときの処理 |
Action<T> actionOnDestroy = null | プールから破棄するときの処理 |
bool collectionCheck = true | すでにオブジェクトがプールにあるか |
int defaultCapacity = 10 | プールのデフォルトの容量 |
int maxSize = 10000 | プールの最大サイズ |
using UnityEngine;
using UnityEngine.Pool;
namespace DesignPatterns.ObjectPool
{
public class RevisedGun : MonoBehaviour
{
//変数いろいろ//
~~~~~~~~
private IObjectPool<RevisedProjectile> objectPool;
// すでにこのオブジェクトがプールに含ませているか示す
[SerializeField] private bool collectionCheck = true;
// プール容量と最大サイズを指定
[SerializeField] private int defaultCapacity = 20;
[SerializeField] private int maxSize = 100;
private void Awake()
{
//オブジェクトプールを作成
objectPool = new ObjectPool<RevisedProjectile>(
CreateProjectile,
OnGetFromPool,
OnReleaseToPool,
OnDestroyPooledObject,
collectionCheck,
defaultCapacity, maxSize);
}
// 生成の処理
private RevisedProjectile CreateProjectile()
{
RevisedProjectile projectileInstance = Instantiate(projectilePrefab);
projectileInstance.ObjectPool = objectPool;
return projectileInstance;
}
// プールから一時削除する処理
private void OnReleaseToPool(RevisedProjectile pooledObject)
{
pooledObject.gameObject.SetActive(false);
}
// プールから使用するときの処理
private void OnGetFromPool(RevisedProjectile pooledObject)
{
pooledObject.gameObject.SetActive(true);
}
// プールから破棄するときの処理
private void OnDestroyPooledObject(RevisedProjectile pooledObject)
{
Destroy(pooledObject.gameObject);
}
~~~~~~~~~~
//生成の処理//
}
処理の順序をよりまとめると、以下の通りになります
- オブジェクトを生成
- 上限までプールに追加
- オブジェクトを非アクティブに
- 再び生成する際にプールの中から生成
まとめ・感想
・大量のオブジェクトを扱うゲーム(シューティングとか、無双ゲームとか)を制作するうえで非常に役立つ
・Flyweightパターン・ScriptableObjectと併用すると効果的
8. Singleton
内容
シングルトンとは次のようなものです
- クラスが自身のインスタンスを1つだけ作成できることを保証する
- その1つのインスタンスに簡単にどこからでもアクセスできるようにする
デモの場合は、サウンドを再生するゲームオブジェクトをシングルトンで実装することで、そのゲームオブジェクトの重複を防ぐとともに、ほかのクラスからの参照を行いやすくしています。
using UnityEngine;
namespace DesignPatterns.Singleton
{
public class Singleton<T> : MonoBehaviour where T : Component
{
private static T _instance;
public static T Instance
{
get
{
if (_instance == null)
{
_instance = (T)FindObjectOfType(typeof(T));
if (_instance == null)
{
SetupInstance();
}
else
{
string typeName = typeof(T).Name;
Debug.Log("[Singleton] " + typeName + " instance already created: " +
_instance.gameObject.name);
}
}
return _instance;
}
}
public virtual void Awake()
{
RemoveDuplicates();
}
//このクラスのインスタンスの生成処理を行う
private static void SetupInstance()
{
_instance = (T)FindObjectOfType(typeof(T));
if (_instance == null)
{
GameObject gameObj = new GameObject();
gameObj.name = typeof(T).Name;
_instance = gameObj.AddComponent<T>();
DontDestroyOnLoad(gameObj);
}
}
//Awakeで処理を行う
private void RemoveDuplicates()
{
//インスタンスなければ、DontDestroyOnLoadに登録
if (_instance == null)
{
_instance = this as T;
DontDestroyOnLoad(gameObject);
}
//そうでなければ破棄する
else
{
Destroy(gameObject);
}
}
ここでやっている処理をまとめると、以下の通りになります
- シングルトンクラスのインスタンスが既にあるか判定
- インスタンスが無かった場合生成
- インスタンスがあった場合ゲームオブジェクトを削除
まとめ・感想
このパターンは、今回紹介しているデザインパターン12種の中でもっとも有名ではないでしょうか。多くの人がこれを利用したクラスの作成をしていると思います。
しかし上記のような実装のシングルトンには、明確なデメリットが存在しています
- クラスに対するアクセスが簡単になりすぎ、依存関係の整理が難しくなる
- クラスがそれぞれ独立したものでなくなるので、テストがしずらくなる
というようなものです。今まで、クラスの依存関係を切り離す方法を説明してきましたが、このパターンでは逆にクラスが密結合になってしまいます。
ですが、このようなデメリットだけでなく、強力なメリットもあるのが事実です
- ほかのクラスからの参照が行いやすくなる
- インスタンスをほかのシーンにすべて引き継ぐことができる
- 静的なインスタンスを1つだけ持ち続けられるので、パフォーマンスがいい
このように、非常に便利な機能として利用することもできるので、シングルトンを利用する際には、最大限注意を払う必要があります。
9. Command
内容
コマンドパターンとは、入力をコマンドオブジェクトとしてカプセル化することで行動を追跡し、入力の保存やシミュレーションを行えるようにするパターンです。
デモのように、ストラテジーゲームなどによく見られる、リプレイ機能などを作成するときに役立ちます。
デモでのUndo・Redoの実装方法は以下の通りです
-
ICommand
を継承したMoveCommand
を作成 -
_undoStack
と_redoStack
に、Commandをスタックしておく - 入力を受け取るごとに、
MoveCommand
を発行し保存する
このようにすることで、入力の保存を行っています。
using System.Collections.Generic;
using UnityEngine;
namespace DesignPatterns.Command
{
public interface ICommand
{
//実行処理
public void Execute();
//戻る処理
public void Undo();
}
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace DesignPatterns.Command
{
public class MoveCommand : ICommand
{
private PlayerMover _playerMover;
private Vector3 _movement;
public MoveCommand(PlayerMover player, Vector3 moveVector)
{
this._playerMover = player;
this._movement = moveVector;
}
// コマンド実行時の処理
public void Execute()
{
//パス(通った地点)のリストに追加
_playerMover?.PlayerPath.AddToPath(_playerMover.transform.position + _movement);
//プレイヤーを移動させる
_playerMover.Move(_movement);
}
// コマンドを戻すときの処理
public void Undo()
{
//プレイヤーを前の場所に移動
_playerMover.Move(-_movement);
//パスのリストから移動前の座標を削除
_playerMover?.PlayerPath.RemoveFromPath();
}
}
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
namespace DesignPatterns.Command
{
public class InputManager : MonoBehaviour
{
[Header("Button Controls")]
[SerializeField] Button forwardButton;
[SerializeField] Button backButton;
[SerializeField] Button leftButton;
[SerializeField] Button rightButton;
[SerializeField] Button undoButton;
[SerializeField] Button redoButton;
[SerializeField] private PlayerMover player;
private void Start()
{
//ボタンにイベントをセット
forwardButton.onClick.AddListener(OnForwardInput);
backButton.onClick.AddListener(OnBackInput);
rightButton.onClick.AddListener(OnRightInput);
leftButton.onClick.AddListener(OnLeftInput);
undoButton.onClick.AddListener(OnUndoInput);
redoButton.onClick.AddListener(OnRedoInput);
}
private void RunPlayerCommand(PlayerMover playerMover, Vector3 movement)
{
if (playerMover == null)
{
return;
}
// 先に進めるかどうか判定
if (playerMover.IsValidMove(movement))
{
// コマンドを発行し、undoに保存
ICommand command = new MoveCommand(playerMover, movement);
// 発行したコマンドのExecuteを実行
CommandInvoker.ExecuteCommand(command);
}
}
//========= ここから下は入力時の処理 =========
private void OnLeftInput()
{
RunPlayerCommand(player, Vector3.left);
}
private void OnRightInput()
{
RunPlayerCommand(player, Vector3.right);
}
private void OnForwardInput()
{
RunPlayerCommand(player, Vector3.forward);
}
private void OnBackInput()
{
RunPlayerCommand(player, Vector3.back);
}
private void OnUndoInput()
{
CommandInvoker.UndoCommand();
}
private void OnRedoInput()
{
CommandInvoker.RedoCommand();
}
}
}
まとめ・感想
- 入力処理とそれによって行う処理が分かれているので、機能の追加や変更によって既存のクラスが受ける影響を削減できる
- ストラテジーやシミュレーションゲームなどを作る際には必須級の実装方法かも
10. State
内容
ステートパターンは、オブジェクトの処理を状態ごとに分割することで、その時に必要な処理だけを行うようにするというパターンです。
デモでは、Idle
Walk
Jump
という3つのStateがあり、その状態に応じてジャンプや移動の処理を行っています。
このパターンの例の一つとして、UnrefactoredPlayerController
というクラスが作成されています。このクラスでのStateの判定は、以下のように行っています
private void Update()
{
switch (state)
{
case PlayerControllerState.Idle:
Idle();
break;
case PlayerControllerState.Walk:
Walk();
break;
case PlayerControllerState.Jump:
Jump();
break;
}
}
おそらく、最初にStateで動きを制御しようとしたら、みんなこの方法で実装を行うと思います。これでも実装は可能ですが、PlayerController
内の処理が複雑になるだけでなく、Stateを追加・修正するたびにコードを変更しなければいけなくなってしまいます。
この問題を解消するために、デモで使用されている例では別の方法をとっています。
実装方法としては、IState
を継承した、Stateごとの処理を定義したクラスを作成し、PlayerController
では、その時のStateの処理を実行するとだけ書くという方法です。
そして、そのStateの管理をStateMachine
というクラスで行うことで、Playerの動き・Stateの管理・Stateの実行する処理の3つを、すべて別のクラスで行っています。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace DesignPatterns.State
{
public class PlayerController : MonoBehaviour
{
//===============この上でいろいろな変数を定義===============
[SerializeField] private PlayerInput playerInput;
private StateMachine playerStateMachine;
public CharacterController CharController => charController;
public bool IsGrounded => isGrounded;
public StateMachine PlayerStateMachine => playerStateMachine;
private void Awake()
{
playerInput = GetComponent<PlayerInput>();
charController = GetComponent<CharacterController>();
//StateMachineをインスタンス化
playerStateMachine = new StateMachine(this);
}
private void Start()
{
//StateMachineの初期化処理
playerStateMachine.Initialize(playerStateMachine.idleState);
}
private void Update()
{
//StateMachineで現在のStateの処理を実行
playerStateMachine.Update();
}
//==========以下はプレイヤーの動きの処理===============
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace DesignPatterns.State
{
[Serializable]
public class StateMachine
{
public IState CurrentState { get; private set; }
// Stateのクラス
public WalkState walkState;
public JumpState jumpState;
public IdleState idleState;
// Stateが切り替わった際に行うAction
public event Action<IState> stateChanged;
//Stateクラスのコンストラクタに必要なパラメーターを渡す
public StateMachine(PlayerController player)
{
//各Stateクラスのインスタンスを生成し、PlayerControllerに渡す
this.walkState = new WalkState(player);
this.jumpState = new JumpState(player);
this.idleState = new IdleState(player);
}
public void Initialize(IState state)
{
CurrentState = state;
state.Enter();
//状態の変化を通知する
stateChanged?.Invoke(state);
}
//Stateを遷移させる
public void TransitionTo(IState nextState)
{
CurrentState.Exit();
CurrentState = nextState;
nextState.Enter();
//状態の変化を通知する
stateChanged?.Invoke(nextState);
}
// 現在のStateのUpdateを実行
public void Update()
{
if (CurrentState != null)
{
CurrentState.Update();
}
}
}
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace DesignPatterns.State
{
public interface IState : IColorable
{
public void Enter() {}
public void Update() {}
public void Exit() {}
}
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace DesignPatterns.State
{
public class IdleState : IState
{
private PlayerController player;
//プレイヤーの色
private Color meshColor = Color.gray;
public Color MeshColor { get => meshColor; set => meshColor = value; }
//コンストラクタでプレイヤーの参照を渡す
public IdleState(PlayerController player)
{
this.player = player;
}
public void Enter()
{
//IdleStateになった際の処理を書く
}
//このStateで行う処理と、別のStateに遷移する処理を書く
public void Update()
{
//JumpStateへの遷移
if (!player.IsGrounded)
{
player.PlayerStateMachine.TransitionTo(player.PlayerStateMachine.jumpState);
}
//WalkStateへの遷移
if (Mathf.Abs(player.CharController.velocity.x) > 0.1f || Mathf.Abs(player.CharController.velocity.z) > 0.1f)
{
player.PlayerStateMachine.TransitionTo(player.PlayerStateMachine.walkState);
}
}
public void Exit()
{
//このStateから抜けた際の処理を書く
}
}
}
StateMachineは、常に一つの状態を持つということが、大きなポイントになっています。UnityEditorでは、AnimatorがStateMachineをうまく組み合わせて作成されているので、このパターンを理解するのに役立つかもしれません。
また、Stateパターンは、状況に応じて行動を自動で切り替える、簡単な敵などのAIを作成する際にも利用できます。
まとめ・感想
- ゲームを作るうえで、必ずと言っていいほど利用する場面がある
- 一つのものにいろいろな行動をさせたいときに役立つ
- 状態ごとに処理を書けるので、SOLID原則に則ることができる
- 余計な判定を行うことが減るので、パフォーマンス的にもいい
11. Observer
内容
オブザーバーパターンとは、多数のクラスからある1つのクラスの値を監視し、それが変化した場合に処理を行うデザインパターンのことです。
デモでは、監視される側のSubject
クラス内で定義されているAction
に対し、監視する側であるObserver
クラスが登録(購読)を行い、ボタンが押されるたびにそれを呼び出すというような実装をしています。
これによって、Subject
はObserver
がいくつあろうが、気にすることなく実装することができ、「これをどう使うか知らないけど、とりあえず公開だけしておくね」というスタンスをとることができます。
using UnityEngine;
using System;
namespace DesignPatterns.Observer
{
[RequireComponent(typeof(Collider))]
public class ButtonSubject: MonoBehaviour
{
//登録する用のAction
public event Action Clicked;
private Collider collider;
void Start()
{
collider = GetComponent<Collider>();
}
//ボタンが押された際に呼び出すメソッド
public void ClickButton()
{
Clicked?.Invoke();
}
void Update()
{
CheckCollider();
}
//ボタンの接触判定
private void CheckCollider()
{
if (Input.GetMouseButtonDown(0))
{
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
RaycastHit hitInfo;
if (Physics.Raycast(ray, out hitInfo, 100f))
{
if (hitInfo.collider == this.collider)
{
//ボタンが押されたらイベントを実行
ClickButton();
}
}
}
}
}
}
using UnityEngine;
namespace DesignPatterns.Observer
{
public class AnimObserver : MonoBehaviour
{
[SerializeField] Animation animClip;
[SerializeField] ButtonSubject subjectToObserve;
void Start()
{
if (subjectToObserve != null)
{
//イベントに自身の処理を追加
subjectToObserve.Clicked += OnThingHappened;
}
}
private void OnThingHappened()
{
if (animClip != null)
{
animClip.Stop();
animClip.Play();
}
}
}
}
ここまでdelegateでの実装について説明しましたが、現在このパターンを使用するときはだいたいUniRxを使うと思います。ボタンくらいならいいかもしれませんが、そうでないならデモの方法だといろいろ使い勝手が悪いです。
理由としては
- このままでは、値の変化そのものを監視できない
- 変化した値に応じた処理を行いずらい
などがあります
まとめ・感想
- 機能ごとに追加を行うことができるので、SOLID原則に則ることができる
- 機能の追加削減が行われやすい箇所で使いやすい
- クラスが疎結合になり、他のクラスの使用方法を気にせず実装できる
12. MVP
内容
MVPとは、主にGUI周りの実装をする際に用いられます。実装の要素をModel・View・Presenterの3つに分割することで、ごちゃつきがちなGUIの実装をわかりやすくしようという目的のデザインパターンです。
要素の内容は以下の通りです
Presenterからのみ参照を行うことで、ModelとViewは他のクラスへの参照を持つことなく実装を行うことができるという利点があります。
デモでは、HPの管理をMVPで実装しています。Health
クラスに値が変化した際に呼ばれるAction
を定義し、それにHealthPresenter
クラスでView
(今回の場合だとSlider)の処理を追加しています。
using UnityEngine;
using System;
namespace DesignPatterns.MVP
{
public class Health : MonoBehaviour
{
//値が変わった際に呼び出すイベント
public event Action HealthChanged;
private const int minHealth = 0;
private const int maxHealth = 100;
private int currentHealth;
public int CurrentHealth { get => currentHealth; set => currentHealth = value; }
public int MinHealth => minHealth;
public int MaxHealth => maxHealth;
public void Increment(int amount)
{
currentHealth += amount;
currentHealth = Mathf.Clamp(currentHealth, minHealth, maxHealth);
UpdateHealth();
}
public void Decrement(int amount)
{
currentHealth -= amount;
currentHealth = Mathf.Clamp(currentHealth, minHealth, maxHealth);
UpdateHealth();
}
public void Restore()
{
currentHealth = maxHealth;
UpdateHealth();
}
//値の変更が行われた際に呼び出す
public void UpdateHealth()
{
HealthChanged.Invoke();
}
}
}
using UnityEngine;
using UnityEngine.UI;
namespace DesignPatterns.MVP
{
public class HealthPresenter : MonoBehaviour
{
[Header("Model")]
[SerializeField] Health health;
[Header("View")]
[SerializeField] Slider healthSlider;
[SerializeField] Text healthText;
private void Start()
{
if (health != null)
{
health.HealthChanged += OnHealthChanged;
}
Reset();
}
private void OnDestroy()
{
if (health != null)
{
health.HealthChanged -= OnHealthChanged;
}
}
//ここで値の変更を行う
public void Damage(int amount)
{
health?.Decrement(amount);
}
//値の変更を行う
public void Heal(int amount)
{
health?.Increment(amount);
}
public void Reset()
{
health?.Restore();
}
public void UpdateView()
{
if (health == null)
return;
if (healthSlider !=null && health.MaxHealth != 0)
{
healthSlider.value = (float) health.CurrentHealth / (float)health.MaxHealth;
}
if (healthText != null)
{
healthText.text = health.CurrentHealth.ToString();
}
}
public void OnHealthChanged()
{
UpdateView();
}
}
}
MVP
パターンは、Observer
パターンと密接な関係にあります。というか後者を理解するには、前者の理解が必要不可欠だと思います。
というのも、MVPはGUI実装をするうえでObserverパターンを用いた際に、機能をより細かく分割したデザインパターンだからです。なので、これもUniRxを用いたほうがよりよい実装ができます。
まとめ・感想
- Observerと同じような効果を得られる
- 値の変更と反映を分業して作業することができて便利
最後に
ここまで説明したデザインパターンのほとんどは、SOLID原則に則った設計を行えるようなものになっているので、ざっと概要を理解するだけでもクラスの参照関係やコードの見通しをかなり良くできるはずです。
ただSOLID原則は、拡張や修正を行いやすくすることを目的としたものであり、やたらめったら使っても逆にコードの見通しが悪くなったり、必要のない柔軟性を持たせすぎて無駄に時間がかかってしまったりするので、状況に応じて臨機応変に利用するべきだと思います。