【Unity】Unityでシンプルなサンプルを通してSOLID原則を学びましょう

UnityでSOLID原則を適用した簡単なサンプルプロジェクトを紹介します。ここでは、プレイヤーキャラクターの移動と攻撃、敵の移動を含むプロジェクトを作成し、各原則に従って設計します。

作成するサンプル

上下矢印キーで移動、マウス左クリックで遠隔攻撃のアプリケーションになります

学習の狙い

上記アプリの作成から、コーディングノウハウ(格言・ことわざ)を学びます
C#,Unityの基礎を学習された方は、シンプルな構成で短期期間でプログラミングができるかもしれませんね

ここでは、大規模で機能追加・メンテナンス性の向上を狙って先人達の考えられたコーディング手法を学んでいきます
シンプルな構成から習得することが理解を深めると考えますので、そのような視点で取り組んでみましょう

アプリの規模によっては、このような手法が完璧とは限らないことにも注意してください

プロジェクトの準備

  1. Unity Hubから新しいプロジェクトを作成し、2Dテンプレートを選択します。
  2. 必要なスクリプトを作成します。

スクリプトの作成

Assetsフォルダー内に「Scripts」フォルダーを作成し、以下のスクリプトファイルを作成します。

単一責任の原則(SRP)

説明: クラスは単一の責任を持ち、その責任をすべて実行する。変更の理由が一つだけであるべきです。

説明

単一責任の原則(SRP)は、プログラムをシンプルにするためのルールです。1つのクラスやスクリプトが1つの仕事だけをするようにします。例えば、キャラクターの移動をするスクリプトと攻撃をするスクリプトを別々にします。こうすると、何かを直したり新しい機能を追加するのが簡単になります。それぞれのスクリプトが自分の仕事だけを知っているので、他の部分に影響を与えません。

キャラクターの移動と攻撃を別々のクラスに分けます。

PlayerMovement.cs

キャラクターの移動を担当するスクリプトです。

using UnityEngine;

public class PlayerMovement : MonoBehaviour
{
    public float speed = 5.0f;

    void Update()
    {
        Move();
    }

    void Move()
    {
        float moveHorizontal = Input.GetAxis("Horizontal");
        float moveVertical = Input.GetAxis("Vertical");

        Vector3 movement = new Vector3(moveHorizontal, moveVertical, 0.0f);
        
        // 移動ベクトルを正規化して、斜め移動の速度を一定にする
        if (movement.magnitude > 1)
        {
            movement.Normalize();
        }

        transform.Translate(movement * speed * Time.deltaTime);
    }
}

PlayerAttack.cs

キャラクターの攻撃を担当するスクリプトです。

using UnityEngine;

public class PlayerAttack : MonoBehaviour
{
    private IWeapon weapon;

    void Start()
    {
        // ここで具体的な武器を設定する(依存性逆転の原則を適用)
        weapon = GetComponentInChildren<IWeapon>();
    }

    void Update()
    {
        if (Input.GetButtonDown("Fire1"))
        {
            weapon?.Attack();
        }
    }

    public void SetWeapon(IWeapon newWeapon)
    {
        weapon = newWeapon;
    }
}

Projectile.cs

発射されるプロジェクト(弾丸)のスクリプトです。

using UnityEngine;

public class Projectile : MonoBehaviour
{
    public float speed = 10.0f;

    void Start()
    {
        // 3秒後に消えるように設定
        Destroy(gameObject, 3f);
    }

    void Update()
    {
        transform.Translate(Vector3.right * speed * Time.deltaTime);
    }
}

オープン・クローズドの原則(OCP)

説明: ソフトウェアのエンティティ(クラス、モジュール、関数など)は拡張に対して開かれているが、修正に対して閉じられているべき。

説明

オープン・クローズドの原則(OCP)とは、プログラムを新しく作るときには、新しい機能を追加することができるけど、すでに作った部分を変更しなくてもいいように設計することです。たとえば、ゲームの敵キャラクターの動きを追加したいときに、今のコードを変えずに新しい動きだけを追加することで、バグが起きにくくなります。

敵キャラクターの移動を拡張可能な形で設計します。

Enemy.cs

基底クラスとしての敵キャラクター。

using UnityEngine;

public abstract class Enemy : MonoBehaviour
{
    public float speed;

    public abstract void Move();
}

BasicEnemy.cs

直線的に移動する敵キャラクター。

using UnityEngine;

public class BasicEnemy : Enemy
{
    void Update()
    {
        Move();
    }

    public override void Move()
    {
        transform.Translate(Vector3.left * speed * Time.deltaTime);
    }
}

AdvancedEnemy.cs

ジグザグに移動する敵キャラクター。

using UnityEngine;

public class AdvancedEnemy : Enemy
{
    private float direction = 1.0f;

    void Update()
    {
        Move();
    }

    public override void Move()
    {
        direction = Mathf.PingPong(Time.time, 1) > 0.5f ? 1 : -1;
        Vector3 movement = new Vector3(-1, direction, 0).normalized;
        transform.Translate(movement * speed * Time.deltaTime);
    }
}

リスコフの置換原則(LSP)

説明:サブクラスはその基底クラスと置換可能でなければならないため、Enemyクラスの設計に注意します。

説明

リスコフの置換原則(LSP)は、プログラムで親クラス(基本のクラス)の代わりに子クラス(派生クラス)を使っても正しく動くべき、というルールです。たとえば、動物クラスを親として、犬や猫のクラスを子にすると、どの子クラスでも「鳴く」などの共通の動きが同じように使えます。これにより、プログラムが分かりやすく、修正しやすくなります。

既に上記で示したBasicEnemyAdvancedEnemyは、Enemy基底クラスのMoveメソッドをオーバーライドし、正しく機能します。

インターフェース分離の原則(ISP)

説明: クライアント(利用する側のクラス)が使用しない機能に依存してはならないという考え方です。つまり、インターフェースは、クライアントが必要とするメソッドだけを含む小さくて特化されたものにするべきだということです。

説明


インターフェース分離の原則(ISP)は、一つの大きなインターフェースをたくさんの小さなインターフェースに分けることです。例えば、動物が「歩く」と「泳ぐ」能力を持っている場合、「歩く」ためのインターフェースと「泳ぐ」ためのインターフェースを別々に作ります。これにより、歩く動物は「歩く」だけを使い、泳ぐ動物は「泳ぐ」だけを使うので、無駄な機能を持たずに済みます。

各インターフェースが特定の機能に焦点を当てるように設計します。

IHealable.cs

ヒール機能を持つインターフェース。

public interface IHealable
{
    void Heal(int amount);
}

IDamageable.cs

ダメージ機能を持つインターフェース。

public interface IDamageable
{
    void TakeDamage(int amount);
}

Player.cs

プレイヤーキャラクターがヒールとダメージの両方を実装。

using UnityEngine;

public class Player : MonoBehaviour, IHealable, IDamageable
{
    public int health;

    public void Heal(int amount)
    {
        health += amount;
        Debug.Log("Healed: " + amount);
    }

    public void TakeDamage(int amount)
    {
        health -= amount;
        Debug.Log("Took Damage: " + amount);
    }
}

依存関係逆転の原則(DIP)

説明: 高レベルモジュールは低レベルモジュールに依存してはならない。両方が抽象に依存すべきである。

説明

依存関係逆転の原則(DIP)は、プログラムの中で「大きな部品が小さな部品に頼るのではなく、両方がルールに従う」ことです。例えば、プレイヤーが「攻撃」をするとき、具体的な「剣」や「弓」に頼るのではなく、「攻撃する方法」というルールに従います。これにより、剣を弓に変えるのが簡単になります。ゲームの中で武器を簡単に追加・変更できるようになります。

メッセージ送信機能を抽象化し、具体的な送信方法を切り替えられるようにする。

IWeapon.cs

攻撃機能を持つインターフェース。

public interface IWeapon
{
    void Attack();
}

ProjectileWeapon.cs

遠隔攻撃を実装するクラス。

using UnityEngine;

public class ProjectileWeapon : MonoBehaviour, IWeapon
{
    public GameObject projectilePrefab;
    public Transform firePoint;

    public void Attack()
    {
        if (projectilePrefab != null && firePoint != null)
        {
            Instantiate(projectilePrefab, firePoint.position, firePoint.rotation);
        }
    }
}

MeleeWeapon.cs

近接攻撃を実装するクラス。

using UnityEngine;

public class MeleeWeapon : MonoBehaviour, IWeapon
{
    public void Attack()
    {
        Debug.Log("近接攻撃を行った!");
    }
}

Unityでの設定

  1. キャラクターオブジェクトを作成
    • Hierarchyビューで、新しい2Dスプライトを作成し、「Player」と名前を付けます。
    • PlayerMovementPlayerAttack スクリプトを追加します。
  2. ProjectileWeaponオブジェクトを作成
    • Playerオブジェクトの子オブジェクトとして新しい空のGameObjectを作成し、「ProjectileWeapon」と名前を付けます。
    • ProjectileWeapon スクリプトを追加し、projectilePrefabfirePoint を設定します。(設定は後でします)
  3. MeleeWeaponオブジェクトを作成
    • Playerオブジェクトの子オブジェクトとして新しい空のGameObjectを作成し、「MeleeWeapon」と名前を付けます。
    • MeleeWeapon スクリプトを追加します。
    • このオブジェクトは非アクティブにしておきます
  4. Projectileプレハブを作成
    • 新しい2Dスプライトを作成し、「Projectile」と名前を付けます。
    • Projectile スクリプトを追加します。
    • このオブジェクトをプレハブとして保存します。
  5. FirePointを設定
    • Playerオブジェクトの子オブジェクトとして新しい空のGameObjectを作成し、「FirePoint」と名前を付けます。
    • ProjectileWeapon スクリプトの firePoint にこのFirePointオブジェクトをドラッグして設定します。
  6. 敵オブジェクトを作成
    • Hierarchyビューで、空のGameObjectを作成し、「BasicEnemy」と「AdvancedEnemy」と名前を付けます。
    • それぞれに BasicEnemyAdvancedEnemy スクリプトを追加します。

攻撃パターンの選択

攻撃パターンとして、ProjectileWeapon(遠隔攻撃)かMeleeWeapon(近接攻撃)かを選択します

遠隔攻撃の場合

近接攻撃の場合

テスト

  • ゲームを実行して、プレイヤーキャラクターを移動させたり攻撃したり、敵キャラクターが正しく移動することを確認します。
  • PlayerオブジェクトのInspectorで、ProjectileWeaponMeleeWeapon のどちらかを設定することで、異なる攻撃方法を切り替えることができます。

これで、SOLID原則に従ったUnityのサンプルプロジェクトが完成です。各クラスとインターフェースは、それぞれの責任を持ち、変更や拡張が容易に行えるように設計されています。

参考(クラス図)

クラス図の説明

  • IWeaponインターフェースは、攻撃方法を抽象化しています。
  • ProjectileWeaponMeleeWeaponは、IWeaponインターフェースを実装しています。
  • Playerクラスは、ヒールとダメージのインターフェースを実装しており、PlayerMovementPlayerAttackクラスと関係しています。
  • PlayerAttackクラスは、IWeaponインターフェースを利用して攻撃を実行します。
  • Enemyは抽象クラスで、BasicEnemyAdvancedEnemyがそれを拡張しています。
  • 各クラスのメンバ変数とメソッドが図に含まれています。