4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Unity-Technologies game-programming-patterns について勉強

Last updated at Posted at 2023-02-05

はじめに

Unity公式のデザインパターンデモについてまとめてみました。概要ではなく、具体的な詳細の理解を目的としているので、コードが多めです。

下は公式の資料です。
 

サンプルプロジェクト

公式資料

日本語公式記事

1~5 SOLID原則

 1~5のサンプルはSOLID原則と呼ばれている、ソフトウェア開発においてその開発の改良や最適化をよりよく行うための心がけのようなものです。オブジェクト指向プログラミングを行っていく際に、非常にためになる内容だと思います。

すごくわかりやすい記事

この記事を見れば、概要は理解できると思いますが、これをUnityで利用している例をまとめていきます。

1. SingleResponsibility 単一責任の原則

内容

 一つのクラスに持たせる機能は一つにしようという考え方。

 サンプルの場合は、プレイヤーの機能をAudioInputMoveの3つに分割し、それをPlayerクラスでまとめるというような実装をしています。

Player.cs
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とは、ゲーム実際の挙動自体は変えずに、プログラムの整理を行う「リファクタリング」がされていないという意味です。

UnrefactoredPlayer.cs
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() メソッドを定義し、それを RectangleCircle クラスが継承して、CalculateArea() メソッドをオーバーライドしています。

 これによって、新しい図形のクラス作成を行う際に、今までのクラスを書き換えることなく機能の追加ができるようになっています。

Shape
namespace DesignPatterns.OCP
{
    public abstract class Shape
    {
        public abstract float CalculateArea();
    }
}
Rectangle
namespace DesignPatterns.OCP
{
    public class Rectangle : Shape
    {
        public float Width { get; set; }
        public float Height { get; set; }

        public override float CalculateArea()
        {
            return Width * Height;
        }
    }
}
Circle
using UnityEngine;

namespace DesignPatterns.OCP
{
    public class Circle : Shape
    {
        public float Radius { get; set; }

        public override float CalculateArea()
        {
            return Radius * Radius * Mathf.PI;
        }
    }
}

良くない例

 良くない例としてAreaCalculatorクラスが定義されていて、ここではRectangleCircle処理を一つのクラスで行っています。

AreaCalculator
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クラスと、IMovableITurnableを継承したRoadVehicleクラスを実装しています。

 これによって、線路を走るTrainクラスとCarクラスを、それぞれに必要な機能だけ持たせて制作することができるようにしています。

RailVehicle
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()
        {
           
        }
    }
}
RoadVehicle
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()
        {
            
        }
    }
}
Car
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace DesignPatterns.LSP
{
    public class Car : RoadVehicle
    {

    }
}
Train
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace DesignPatterns.LSP
{
    public class Train : RailVehicle
    {

    }
}

良くない例

 置換原則が守れていない例として、Vehicleクラスが定義されています。このクラスでは
前に進む・反転する・曲がる という処理をするメソッドがまとめて実装されてしまっています。
 これをTrainCarクラス両方に継承する使い方をしてしまうと、Train.TurnRight()のようなTrainクラスに必要のない機能を実行できてしまいます。

Vehicle
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を定義しています。

これによってEnemyUnitExplodingBarrelが持つ機能を明確にしています。

EnemyUnit
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace DesignPatterns.ISP
{
    public class EnemyUnit : MonoBehaviour, IDamageable, IMovable, IUnitStats
    {
        //長いので省略
    }
}
ExplodingBarrel
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace DesignPatterns.ISP
{
    public class ExplodingBarrel : MonoBehaviour, IExplodable, IDamageable
    {
        //長いので省略
    }
}

良くない例

 インターフェイス分離の原則に則っていない例として、IUnitStatsインターフェース内に記述があります。ここでは、現在は他のインターフェースに分割している機能をまとめて持つという実装をしてしまっています。

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クラスで処理を行うことができています。

Door
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.");
        }
    }

}
Trap
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace DesignPatterns.DIP
{
    public class Trap : MonoBehaviour, ISwitchable
    {
        //Doorと一緒なので省略
    }
}
Switch
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クラス内に記述があります。ここでは、SwitchDoorに対し具体的な参照を持ってしまっているので

  • Switchが単独で動くことができない
  • Doorのようなギミックを追加する際に、Switchに追記しなければいけない

 のような問題が発生してしまいます。

Door
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.");
        }
    }
}
Switch
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

内容

Image from Gyazo
 このデモでは、共通のインターフェースを用いて複数のオブジェクトの生成を1つのクラスで行い、そのオブジェクトにはそれぞれ異なった処理を行わせています。

 このような実装にすることで、生成の指示を行うクラスは、生成を行うクラスと生成するものの処理や数に影響を受けることなく、実装ができるようになります。

コードの内容を4つのステップで説明します

  1. IProductを継承したProductAProductB(生成する球の処理)を作成
ProductA
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace DesignPatterns.Factory
{
    public class ProductA : MonoBehaviour, IProduct
    {
        public void Initialize()
        {
            //ProductAで行う処理
        }
    }
}

 
2. Factoryを継承したConcreteFactoryAConcreteFactoryBを作成
3. 作成したConcreteFactoryクラスで、基底クラスのFactoryに定義されている、IProduct型のProductを生成するメソッドをオーバーライドする

ConcreteFactoryA
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型の変数を定義し、そのメソッドを実行

ClickToCreate
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

内容

Image from Gyazo
 このデモでは、生成したオブジェクトを使用が終わった際に削除せず、オブジェクトのアクティブ・非アクティブと座標を変更することで、オブジェクトの再利用を行うということをしています。

 オブジェクトプールは、オブジェクトの生成と破棄によってメモリに負荷がかかることを回避するという目的のデザインパターンです。

 デモの使用例として、2021年にUnityからUnityEngine.Poolを使用する例と使用しない例があります。ここではUnityEngine.Poolを使用する例のほうを見ながら説明していきます。

 
 1. まず、RevisedProjectile (弾)を作成し、ObjectPoolへの参照を持たせる
 2. 弾のクラスに、オブジェクトをプールに追加する処理を定義する

RevisedProjectile
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 プールの最大サイズ

 

RevisedGun
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);
        }
        ~~~~~~~~~~
        //生成の処理//
    }

 
処理の順序をよりまとめると、以下の通りになります

  1. オブジェクトを生成
  2. 上限までプールに追加
  3. オブジェクトを非アクティブに
  4. 再び生成する際にプールの中から生成

まとめ・感想

・大量のオブジェクトを扱うゲーム(シューティングとか、無双ゲームとか)を制作するうえで非常に役立つ
・Flyweightパターン・ScriptableObjectと併用すると効果的

8. Singleton

内容

 Image from Gyazo

 シングルトンとは次のようなものです

  • クラスが自身のインスタンスを1つだけ作成できることを保証する
  • その1つのインスタンスに簡単にどこからでもアクセスできるようにする

 デモの場合は、サウンドを再生するゲームオブジェクトをシングルトンで実装することで、そのゲームオブジェクトの重複を防ぐとともに、ほかのクラスからの参照を行いやすくしています。

Singleton.cs
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();
        }
SetupInstance
        //このクラスのインスタンスの生成処理を行う
        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);
            }
        }
RemoveDuplicates
        //Awakeで処理を行う
        private void RemoveDuplicates()
        {
            //インスタンスなければ、DontDestroyOnLoadに登録
            if (_instance == null)
            {
                _instance = this as T;
                DontDestroyOnLoad(gameObject);
            }
            //そうでなければ破棄する
            else
            {
                Destroy(gameObject);
            }
        }

 
ここでやっている処理をまとめると、以下の通りになります

  1. シングルトンクラスのインスタンスが既にあるか判定
  2. インスタンスが無かった場合生成
  3. インスタンスがあった場合ゲームオブジェクトを削除

まとめ・感想

 このパターンは、今回紹介しているデザインパターン12種の中でもっとも有名ではないでしょうか。多くの人がこれを利用したクラスの作成をしていると思います。

 
しかし上記のような実装のシングルトンには、明確なデメリットが存在しています

  • クラスに対するアクセスが簡単になりすぎ、依存関係の整理が難しくなる
  • クラスがそれぞれ独立したものでなくなるので、テストがしずらくなる

 というようなものです。今まで、クラスの依存関係を切り離す方法を説明してきましたが、このパターンでは逆にクラスが密結合になってしまいます。
 
 
ですが、このようなデメリットだけでなく、強力なメリットもあるのが事実です

  • ほかのクラスからの参照が行いやすくなる
  • インスタンスをほかのシーンにすべて引き継ぐことができる
  • 静的なインスタンスを1つだけ持ち続けられるので、パフォーマンスがいい

 
 このように、非常に便利な機能として利用することもできるので、シングルトンを利用する際には、最大限注意を払う必要があります。

9. Command

内容

Image from Gyazo
 コマンドパターンとは、入力をコマンドオブジェクトとしてカプセル化することで行動を追跡し、入力の保存やシミュレーションを行えるようにするパターンです。

 デモのように、ストラテジーゲームなどによく見られる、リプレイ機能などを作成するときに役立ちます。

 
 デモでのUndo・Redoの実装方法は以下の通りです

  • ICommandを継承したMoveCommandを作成
  • _undoStack_redoStackに、Commandをスタックしておく
  • 入力を受け取るごとに、MoveCommandを発行し保存する

 このようにすることで、入力の保存を行っています。

ICommand
using System.Collections.Generic;
using UnityEngine;

namespace DesignPatterns.Command
{
    public interface ICommand
    {
        //実行処理
        public void Execute();
        //戻る処理
        public void Undo();
    }
}
MoveCommand
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(); 
        }
    }
}
InputManager
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

内容

Image from Gyazo
 ステートパターンは、オブジェクトの処理を状態ごとに分割することで、その時に必要な処理だけを行うようにするというパターンです。

 デモでは、Idle Walk Jumpという3つのStateがあり、その状態に応じてジャンプや移動の処理を行っています。

 
 このパターンの例の一つとして、UnrefactoredPlayerControllerというクラスが作成されています。このクラスでのStateの判定は、以下のように行っています

UnrefactoredPlayerController.Update()
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();
        }

        //==========以下はプレイヤーの動きの処理===============
StateMachine
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();
            }
        }
    }
}
IState
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() {}
    }
}
IdleState
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

内容

Image from Gyazo

オブザーバーパターンとは、多数のクラスからある1つのクラスの値を監視し、それが変化した場合に処理を行うデザインパターンのことです。

 デモでは、監視される側のSubjectクラス内で定義されているActionに対し、監視する側であるObserverクラスが登録(購読)を行い、ボタンが押されるたびにそれを呼び出すというような実装をしています。

 これによって、SubjectObserverがいくつあろうが、気にすることなく実装することができ、「これをどう使うか知らないけど、とりあえず公開だけしておくね」というスタンスをとることができます。

ButtonSubject
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();
                    }
                }
            }
        }
    }
}
AnimObserver
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

内容

Image from Gyazo
 MVPとは、主にGUI周りの実装をする際に用いられます。実装の要素をModel・View・Presenterの3つに分割することで、ごちゃつきがちなGUIの実装をわかりやすくしようという目的のデザインパターンです。

 要素の内容は以下の通りです

  • Model   :データの実体を持つ部分
  • View   :データの反映を行う、画面に表示を行う部分 
  • Presenter:ModelとViewを仲介する
    mvp.png

 Presenterからのみ参照を行うことで、ModelとViewは他のクラスへの参照を持つことなく実装を行うことができるという利点があります。

 
 デモでは、HPの管理をMVPで実装しています。Healthクラスに値が変化した際に呼ばれるActionを定義し、それにHealthPresenterクラスでView(今回の場合だとSlider)の処理を追加しています。

Health
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();
        }
    }
}
HealthPresenter
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原則は、拡張や修正を行いやすくすることを目的としたものであり、やたらめったら使っても逆にコードの見通しが悪くなったり必要のない柔軟性を持たせすぎて無駄に時間がかかってしまったりするので、状況に応じて臨機応変に利用するべきだと思います。

4
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?