🌟

Unityにおけるコンポーネント指向(単一責任の法則)

2020/10/11に公開

この記事はUnityにおけるコンポーネント指向をいかに設計するかを検討する記事になります。以下に記すのは私の一つの結論になります。

サンプルケース

今回のサンプルケースとして2Dシューティングのチュートリアルを利用します。転載に問題があるようでしたら連絡して頂ければ直ちに記事を取り下げます。

では早速コードを見てみましょう。この記事で注目するのは以下のSpaceshipとそれを利用するPlayerEnemyのクラスになります。

using UnityEngine;

// Rigidbody2Dコンポーネントを必須にする
[RequireComponent(typeof(Rigidbody2D))]
public class Spaceship : MonoBehaviour
{
    // 移動スピード
    public float speed;
	
    // 弾を撃つ間隔
    public float shotDelay;
	
    // 弾のPrefab
    public GameObject bullet;
    
    // 弾の作成
    public void Shot (Transform origin)
    {
        Instantiate (bullet, origin.position, origin.rotation);
    }
    
    // 機体の移動
    public void Move (Vector2 direction)
    {
        GetComponent<Rigidbody2D>().velocity = direction * speed;
    }
}
using UnityEngine;
using System.Collections;

public class Player : MonoBehaviour
{
    // Spaceshipコンポーネント
    private Spaceship spaceship;

    IEnumerator Start ()
    {
        // Spaceshipコンポーネントを取得
        spaceship = GetComponent<Spaceship> ();

        while (true) {

            // 弾をプレイヤーと同じ位置/角度で作成
            spaceship.Shot (transform);

            // shotDelay秒待つ
            yield return new WaitForSeconds (spaceship.shotDelay);
        }
    }

    void Update ()
    {
        // 右・左
        float x = Input.GetAxisRaw ("Horizontal");

        // 上・下
        float y = Input.GetAxisRaw ("Vertical");

        // 移動する向きを求める
        Vector2 direction = new Vector2 (x, y).normalized;

        // 移動
        spaceship.Move (direction);
    }
}
using UnityEngine;
using System.Collections;

public class Enemy : MonoBehaviour
{
    // Spaceshipコンポーネント
    Spaceship spaceship;

    void Start ()
    {
        // Spaceshipコンポーネントを取得
        spaceship = GetComponent<Spaceship> ();

        // ローカル座標のY軸のマイナス方向に移動する
        spaceship.Move (transform.up * -1);
    }
}

コード依存からエディタ依存へ

まずはGetComponentは全部エディタ指定にします。これは私の一つの結論ではありますが、Unity を使う以上エディタを使わなければもったいないと感じます(というか、エディタが必要ないのであればUnityを利用する必要が無いでしょう)。また、感覚的にはDI(Dependency Injection)に似ています。これを前提にSpaceshipクラスをリファクタしてみます。なお、_rigidbodyにアンダースコアを入れているのはGameObjectrigidbodyと衝突しないためです。

using UnityEngine;

// Rigidbody2Dコンポーネントを必須にする
[RequireComponent(typeof(Rigidbody2D))]
public class Spaceship : MonoBehaviour
{
    // 移動スピード
    [SerializeField] private float speed;
    // 弾を撃つ間隔
    [SerializeField] private float shotDelay;
    // 弾のPrefab
    [SerializeField] private GameObject bullet;
    // SpaceshipのRigidbody
    [SerializeField] private Rigidbody2D _rigidbody;
    
    // 弾の作成
    public void Shot (Transform origin)
    {
        Instantiate (bullet, origin.position, origin.rotation);
    }
	
    // 機体の移動
    public void Move (Vector2 direction)
    {
        _rigidbody.velocity = direction * speed;
    }
}

PlayerEnemySpaceshipも同様の変更を行いました。

コンポーネント指向の適用

さて、次にクラスの分け方が妥当かを考えます。コンポーネント指向を適用する場合、現在の実装はふさわしくないでしょう。

例えば、敵のように移動する「隕石」を実装したいと考えます。その場合、EnemyコンポーネントをAsteroidゲームオブジェクトにつけることでそれを実現できますが、厳密には隕石は「敵」ではありません。どちらかといえば「もの」であり、Enemyというコンポーネントを使うのは名前からしておかしいでしょう。ですのでAsteroidというクラスを用意することになるのですが、移動するためにはやはりSpaceshipクラスを利用したくなります。結局、移動の関数をSpaceshipに用意したが故に、宇宙船以外にもSpaceshipクラスを利用してしまうことが考えられます。

ではクラスはどう分割するのが良いでしょう。私はこれを「作用ごとに分割」します。オブジェクト指向のように聞こえると思いますが、基本的には大差ないでしょう。ただし、機能を集約するのはエディタ上のGameObjectになります。

Spaceshipから独立できるのは以下二つの機能です。

  • Shot
  • Move

関数に分けられていることから、そのような機能を想定していることが考えられます。であれば、これらの機能は分離されるべきでしょう。以下のように変更します。

using UnityEngine;

public class Shooter : MonoBehaviour
{
    // 弾を撃つ間隔
    [SerializeField] private float shotDelay;
    // 弾のPrefab
    [SerializeField] private GameObject bullet;
    
    public float ShotDelay { get { return shotDelay; } }

    // 弾の作成
    public void Shot (Transform origin)
    {
        Instantiate (bullet, origin.position, origin.rotation);
    }

}
using UnityEngine;

public class Movable : MonoBehaviour
{
    // 移動スピード
    [SerializeField] private float speed;
    // SpaceshipのRigidbody
    [SerializeField] private Rigidbody2D _rigidbody;

    // 機体の移動
    public void Move (Vector2 direction)
    {
        _rigidbody.velocity = direction * speed;
    }
}

これで、SOLID原則における「単一責任の法則」を満たすことができたのではないでしょうか。この二つのクラスを利用したPlayerEnemyは以下のようになります。

using UnityEngine;
using System.Collections;

public class Player : MonoBehaviour
{
    // 移動用コンポーネント
    [SerializeField] private Movable movable;
    // 射撃用コンポーネント
    [SerializeField] private Shooter shooter;

    IEnumerator Start ()
    {
        while (true) {

            // 弾をプレイヤーと同じ位置/角度で作成
            shooter.Shot (transform);

            // shotDelay秒待つ
            yield return new WaitForSeconds (shooter.ShotDelay);
        }
    }

    void Update ()
    {
        // 右・左
        float x = Input.GetAxisRaw ("Horizontal");

        // 上・下
        float y = Input.GetAxisRaw ("Vertical");

        // 移動する向きを求める
        Vector2 direction = new Vector2 (x, y).normalized;

        // 移動
        movable.Move (direction);
    }
}
Enemy.cs
using UnityEngine;
using System.Collections;

public class Enemy : MonoBehaviour
{
    // 移動用コンポーネント
    [SerializeField] private Movable movable;

    void Start ()
    {
        // ローカル座標のY軸のマイナス方向に移動する
        movable.Move (transform.up * -1);
    }
}

こうすることにより、PlayerEnemyも、最悪「Spaceship」である必要がなくなります。
これを使ったAsteroidクラスは、以下のようになります。

using UnityEngine;
using System.Collections;

[RequireComponent(typeof(Movable))]
public class Asteroid : MonoBehaviour
{
    // 移動用コンポーネント
    [SerializeField] private Movable movable;

    void Start ()
    {
        // ローカル座標のY軸のマイナス方向に移動する
        movable.Move (transform.up * -1);
    }
}

Enemyクラスと全く同じではありますが、そこには「宇宙船」の概念は取り払われています。つまるところ、Player,Enemy,Asteroidクラスはすべて、「コンポーネントの利用方法を知るクラス」になり、集約の主役はエディタ上のゲームオブジェクトとなるのです。

以上が、Unityにおけるコンポーネント指向の考え方だと理解しています。いかがだっただろうか。私もまだまだ発展途上ではあるので、以上が正しいかの判断はこれからになりますが、今のところ私は上記のような運用方法でうまくいっていると考えています。結局のところ、Unityでは「エディタをちゃちゃっといじれば簡単にゲームを作れるよ!」という思想だと思っているので、なるべくコード依存からエディタ依存にしていくべきだと考えています。


お役に立てましたらライク、サポートしていただけますと嬉しいです!🙇🙇

Discussion