1️⃣

『SOLID原則 in Unity』 1: Single Responsibility Principle編

2023/12/08に公開

はじめに

ゲーム開発において、設計のことを調べるとまず最初に出てくるSOLID原則。DRY(Don't Repeat Yourself)原則やKISS(Keep It Simple, Stupid)原則に並んで非常に大事な原則です。

SOLID原則を整理しながら、オブジェクト指向やUnityの勘所について見ていきましょう。

今回はSingle Responsibility Principle(SRP)について。

Single Responsibility Principle : 単一責任の原則

最も大事だと思っている、単一責任の原則について。

色々な解釈があります。

  • 1つのクラスは1つの責任のみを担う必要がある
  • 1つの責任は1つのクラスだけが担うべきである
  • クラスを変更する理由はただ1つである必要がある
  • クラスはたったひとつのアクター(ユーザー等)に対して責務を負うべきである

出来るだけクラスを細かく分割し、クラスごとの役割や責任がクラス名によってはっきりわかるようにすることがポイントです。したがって、クラス名の付け方が非常に重要になってきます

考え方としては、役割毎にクラスを分割する、「役割駆動設計」が非常に参考になります。

https://qiita.com/MinoDriven/items/2a378a09638e234d8614

SRP違反例(コードは流し見でいいです)

以下のコードは、1つのクラスに対する責任が多すぎます。
クラス名を見ただけでは、何をするクラスなのかが分かりづらく、コードを追うのが非常に大変になる他、神クラス(何でも屋さんクラス。GameManagerとか)が爆誕してしまう可能性が高くなります。

改善前
using TMPro;
using UnityEngine;
using UnityEngine.UI;

/// <summary>
/// Playerの全てを担当する
/// </summary>
public class Player : MonoBehaviour
{
    [SerializeField] private int _maxHp = 15;
    [SerializeField] private float _moveSpeed;

    private int _hp;

    private Rigidbody2D _rigidbody2D;

    [SerializeField] private AudioClip _damageClip;
    [SerializeField] private AudioClip _healClip;
    [SerializeField] private AudioClip _deadClip;
    [SerializeField] private TextMeshProUGUI _hpText;
    [SerializeField] private Image _hpGauge;


    private void Start()
    {
        _rigidbody2D = GetComponent<Rigidbody2D>();
        _hp = _maxHp;
        _hpText.text = $"{_hp} / {_maxHp}";
        _hpGauge.fillAmount = (float)_hp / _maxHp;
    }

    private void Update()
    {
        Move();
    }

    private void Move()
    {
        var x = Input.GetAxisRaw("Horizontal");
        var y = Input.GetAxisRaw("Vertical");
        var direction = new Vector2(x, y).normalized;
        _rigidbody2D.velocity = direction * _moveSpeed;

        UpdateRotation();
    }

    private void UpdateRotation()
    {
        if (Input.GetKey(KeyCode.UpArrow))
        {
            transform.rotation = Quaternion.Euler(0, 0, 0);
        }
        else if (Input.GetKey(KeyCode.DownArrow))
        {
            transform.rotation = Quaternion.Euler(0, 0, 180);
        }
        else if (Input.GetKey(KeyCode.LeftArrow))
        {
            transform.rotation = Quaternion.Euler(0, 0, 90);
        }
        else if (Input.GetKey(KeyCode.RightArrow))
        {
            transform.rotation = Quaternion.Euler(0, 0, -90);
        }
    }

    public void AddHp(int value)
    {
        _hp -= value;
        if (_hp <= 0)
        {
            _hp = 0;
            OnDie();
        }

        // ↓この辺もメソッドに聞け案件だったりする(参考:https://zenn.dev/qemel/articles/f8166ccb2957b7#%E3%83%95%E3%82%A3%E3%83%BC%E3%83%AB%E3%83%89%E3%81%AB%E8%81%9E%E3%81%8F%E3%81%AA%E3%80%81%E3%83%A1%E3%82%BD%E3%83%83%E3%83%89%E3%81%AB%E8%81%9E%E3%81%91)
        _hpText.text = $"{_hp} / {_maxHp}";
        _hpGauge.fillAmount = (float)_hp / _maxHp;
    }

    public void Heal(int heal)
    {
        _hp += heal;
        if (_hp > _maxHp)
        {
            _hp = _maxHp;
        }

        _hpText.text = $"{_hp} / {_maxHp}";
        _hpGauge.fillAmount = (float)_hp / _maxHp;
    }

    private void OnDie()
    {
        AudioSource.PlayClipAtPoint(_deadClip, transform.position);
        Destroy(gameObject);
    }
}

SRPを意識した例(コードは流し見でいいです)

以下のように、クラスを役割・責務毎に分割してみましょう。

改善後
using TMPro;
using UnityEngine;
using UnityEngine.UI;

/// <summary>
/// PlayerのStatusの制御を担当する
/// </summary>
public class PlayerModel : MonoBehaviour
{
    [SerializeField] private PlayerStatusUI _ui;
    [SerializeField] private PlayerAudio _audio;
    
    [SerializeField] private int _maxHp;

    private int _hp;

    public void Start()
    {
        _hp = _maxHp;
	_ui.Init(_maxHp);
    }

    public void AddHp(int value)
    {
        _hp -= value;
        if (_hp <= 0)
        {
            _hp = 0;
            OnDie();
        }
        
        _ui.SetCurrent(_hp);
    }

    public void Heal(int heal)
    {
        _hp += heal;
        if (_hp > _maxHp)
        {
            _hp = _maxHp;
        }

        _ui.SetCurrent(_hp);
    }

    private void OnDie()
    {
        Destroy(gameObject);
        _audio.PlayDeadClip();
    }
}

/// <summary>
/// Playerの移動を制御を担当する
/// </summary>
public class PlayerMovement : MonoBehaviour
{
    private Rigidbody2D _rigidbody2D;
    [SerializeField] private float _moveSpeed;
    

    private void Start()
    {
        _rigidbody2D = GetComponent<Rigidbody2D>();
    }

    private void Update()
    {
        Move();
    }

    private void Move()
    {
        var x = Input.GetAxisRaw("Horizontal");
        var y = Input.GetAxisRaw("Vertical");
        var direction = new Vector2(x, y).normalized;
        _rigidbody2D.velocity = direction * _moveSpeed;

        UpdateRotation();
    }

    private void UpdateRotation()
    {
        if (Input.GetKey(KeyCode.UpArrow))
        {
            transform.rotation = Quaternion.Euler(0, 0, 0);
        }
        else if (Input.GetKey(KeyCode.DownArrow))
        {
            transform.rotation = Quaternion.Euler(0, 0, 180);
        }
        else if (Input.GetKey(KeyCode.LeftArrow))
        {
            transform.rotation = Quaternion.Euler(0, 0, 90);
        }
        else if (Input.GetKey(KeyCode.RightArrow))
        {
            transform.rotation = Quaternion.Euler(0, 0, -90);
        }
    }
}

/// <summary>
/// Playerの音を制御を担当する
/// </summary>
public class PlayerAudio : MonoBehaviour
{
    [SerializeField] private AudioClip _damageClip;
    [SerializeField] private AudioClip _healClip;
    [SerializeField] private AudioClip _deadClip;

    public void PlayDamageClip()
    {
        AudioSource.PlayClipAtPoint(_damageClip, transform.position);
    }
    
    public void PlayHealClip()
    {
        AudioSource.PlayClipAtPoint(_healClip, transform.position);
    }
    
    public void PlayDeadClip()
    {
        AudioSource.PlayClipAtPoint(_deadClip, transform.position);
    }
}

/// <summary>
/// PlayerのステータスUIの表示を担当する
/// </summary>
public class PlayerStatusUI : MonoBehaviour
{
    [SerializeField] private TextMeshProUGUI _hpText;
    [SerializeField] private Image _hpGauge;

    private int _maxHp;

    public void Init(int maxHp)
    {
        _maxHp = maxHp;
    }


    /// <summary>
    /// 現在のHPを設定する
    /// </summary>
    /// <param name="value"></param>
    public void SetCurrent(int value)
    {
        _hpText.text = value.ToString();
        _hpGauge.fillAmount = (float)value / _maxHp;
    }
}

後者の状態の方が、どこにどんな仕様があるのかが分かりやすいので、今後の機能追加や修正に強くなります。

コツ

クラスを、モノで区別するのではなく、「役割」や「責務」で区別することです

オブジェクト指向は現実をそのまま反映するとか言いますが、正直そんなことは無いと思っています。

役割や責務をしっかり分割することで、尋ねる対象を絞ったり、再利用性を高めることが出来ます。

コンポーネント指向とSRP

Unityは、オブジェクト指向かつコンポーネント指向という考え方を取り入れたゲームエンジンです。

コンポーネント指向とは、簡単には、多数のコンポーネントを組み合わせることで、多様なGameObjectを表現しようぜ!という考え方です。

  • Transform
  • BoxCollider
  • Rigidbody2D
  • MeshRenderer
  • Camera
  • Light

色々ありますが、Unity側も多数のコンポーネントを用意しています。

これで足りない分は自分でMonoBehaviour継承して書いてね、ということで、C#, MonoBehaviourが用意されているわけです。

Unityでは、コンポーネント指向を使って簡単にクラスを分割できます。なんせ、分割しようが全部同じGameObjectにペタペタするだけで同じことが実現できるので

そういった性質上、できるだけ小さくクラスを分割して作る、と言うSRPの手法自体が、コンポーネント指向と非常に相性がいい考え方だと思います。

ありがちなアンチパターン(違反例)

  • なんちゃらManagerという名前のクラス
  • 結構いろんなロジックを含むクラスの名前が名詞1単語(Player, Enemy, Stageなど)になってる
  • クラスを代表する具体的な動詞・名詞を1つに絞れない
    • 動詞・名刺が2つ以上あるということは、基本的には役割が2つ以上存在する
    • 「管理する」「整理する」等は抽象的なので基本あまりよくない

Discussion