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

  • 22
    いいね
  • 3
    コメント

この記事はUnityにおけるコンポーネント指向をいかに設計するかを検討する記事になる。
中級者向けの記事なので、初心者の皆様には設計などに囚われずに自由にゲーム開発をしてほしい。

以下に記すのは私の一つの結論である。暇な人は是非、この実装方法の是非を考えてみてほしい。

サンプルケース

今回のサンプルケースとして2Dシューティングのチュートリアルを利用する。転載に問題があるようなら連絡してもらえると直ちに記事を取り下げる。
では早速コードを見てみよう。

この記事で注目するのは以下のSpaceshipとそれを利用するPlayer、Enemyのクラスになる。

Spaceship.cs
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;
    }
}
Player.cs
using UnityEngine;
using System.Collections;

public class Player : MonoBehaviour
{

    // Spaceshipコンポーネント
    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);
    }
}
Enemy.cs
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の設計思想に反すると考えている。
これの根拠は、GetComponentがイテレータを利用する点だ。もしGetComponentをメインとして利用することを前提としているなら、即座にGameObjectが取得できる機構が存在してよいと考える。
また、これのために[SerializeField]という属性が存在する。

これを持ってSpaceshipクラスをリファクタすると、以下のようになる。
アンダースコアを入れているのはrigidbodyがGameObjectのrigidbodyに衝突しないためである。

Spaceship.cs
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;
    }
}

PlayerやEnemyのSpaceshipも同様の変更を行う。

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

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

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

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

Spaceshipから独立できるのは二つの機能だ。

  • Shot
  • Move

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

Shooter.cs
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);
    }

}
Movable.cs
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原則における「単一責任の法則」を満たすことができたのではないだろうか。
この二つのクラスを利用したPlayerとEnemyは以下のようになる。

Player.cs
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);
    }
}

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

Asteroid.cs
using UnityEngine;
using System.Collections;

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

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

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

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