学習記事一覧 · Unity

IkinokoBattle で学ぶ Unity OOP 設計パターン:第4回

単一責任の原則とコンポーネント設計

シリーズ目次: IkinokoBattle で学ぶ Unity OOP 設計パターン
前の記事: 第3回 委譲パターンと UnityEvent
次の記事: 第5回 状態パターン


はじめに

「このクラスが大きすぎてどこを直せばいいかわからない」「1 か所修正したら別の部分が壊れた」——こうした問題の多くは、クラスが複数の責任を抱えすぎていることが原因です。

これを防ぐのが SOLID 原則の 1 つ目、**単一責任の原則(Single Responsibility Principle:SRP)**です。「クラスを変更する理由はひとつだけにすべき」という考え方で、IkinokoBattle では PlayerController とそれを取り巻くコンポーネント群がこの原則を体現しています。


今回の題材

クラス 担当する責任
PlayerController 入力を読み取り、各コンポーネントへ指示する調整役
CharacterController 物理移動・コライダー処理(Unity 組み込み)
PlayerInput New Input System との橋渡し(Unity 組み込み)
PlayerStatus HP 管理・ダメージ受付・状態管理
MobAttack 攻撃処理・攻撃コライダーの ON/OFF
LifeGauge HP バーの表示・位置更新(UI)
LifeGaugeContainer 全 Mob の HP バーを管理するコンテナ(UI)

コード全文

PlayerController.cs

using UnityEngine;
using UnityEngine.InputSystem;
[RequireComponent(typeof(CharacterController))]
[RequireComponent(typeof(PlayerInput))]
[RequireComponent(typeof(PlayerStatus))]
[RequireComponent(typeof(MobAttack))]
public class PlayerController : MonoBehaviour
{
    [SerializeField] private Animator animator;
    [SerializeField] private float moveSpeed = 3; // 移動速度
    [SerializeField] private float jumpPower = 3; // ジャンプ力
    private CharacterController _characterController;
    private Transform _transform;
    private InputAction _jump;
    private InputAction _move;
    private InputAction _attack;
    private Vector3 _moveVelocity;
    private PlayerStatus _status;
    private MobAttack _mobAttack;
    private void Start()
    {
        // 毎フレームアクセスするコンポーネントは Awake/Start でキャッシュする
        _characterController = GetComponent<CharacterController>();
        _transform = transform;
        _status    = GetComponent<PlayerStatus>();
        _mobAttack = GetComponent<MobAttack>();
        var input = GetComponent<PlayerInput>();
        input.currentActionMap.Enable(); // Default Map で指定されたアクションマップを有効化
        _move   = input.currentActionMap.FindAction("Move");
        _jump   = input.currentActionMap.FindAction("Jump");
        _attack = input.currentActionMap.FindAction("Attack");
    }
    private void Update()
    {
        // 攻撃入力
        if (_attack.WasPressedThisFrame())
        {
            _mobAttack.AttackIfPossible(); // 攻撃の判断は MobAttack に委譲
        }
        // 移動入力
        if (_status.IsMovable)
        {
            var moveValue = _move.ReadValue<Vector2>();
            _moveVelocity.x = moveValue.x * moveSpeed;
            _moveVelocity.z = moveValue.y * moveSpeed;
            // 移動方向に向く
            _transform.LookAt(_transform.position + new Vector3(_moveVelocity.x, 0, _moveVelocity.z));
        }
        else
        {
            _moveVelocity.x = 0;
            _moveVelocity.z = 0;
        }
        // ジャンプ・重力
        if (_characterController.isGrounded)
        {
            if (_jump.WasPressedThisFrame())
            {
                _moveVelocity.y = jumpPower;
            }
        }
        else
        {
            _moveVelocity.y += Physics.gravity.y * Time.deltaTime; // 重力加速
        }
        // 移動を実行(物理判定は CharacterController に委譲)
        _characterController.Move(_moveVelocity * Time.deltaTime);
        // アニメーション(Animator への反映)
        animator.SetFloat("MoveSpeed", new Vector3(_moveVelocity.x, 0, _moveVelocity.z).magnitude);
    }
}

LifeGauge.cs

using UnityEngine;
using UnityEngine.UI;
public class LifeGauge : MonoBehaviour
{
    [SerializeField] private Image fillImage;
    private RectTransform _parentRectTransform;
    private Camera _camera;
    private MobStatus _status;
    private void Update()
    {
        Refresh();
    }
    /// <summary>ゲージを初期化します。</summary>
    public void Initialize(RectTransform parentRectTransform, Camera camera, MobStatus status)
    {
        _parentRectTransform = parentRectTransform;
        _camera = camera;
        _status = status;
        Refresh();
    }
    /// <summary>ゲージを更新します。</summary>
    private void Refresh()
    {
        // 残りライフを表示
        fillImage.fillAmount = _status.Life / _status.LifeMax;
        // 対象 Mob の位置にゲージを移動(3D 座標 → UI 座標変換)
        var screenPoint = _camera.WorldToScreenPoint(_status.transform.position);
        Vector2 localPoint;
        // Canvas の Render Mode が Screen Space - Overlay なので第3引数は null
        RectTransformUtility.ScreenPointToLocalPointInRectangle(
            _parentRectTransform, screenPoint, null, out localPoint);
        transform.localPosition = localPoint + new Vector2(0, 80); // キャラの頭上に表示
    }
}

LifeGaugeContainer.cs

using System;
using System.Collections.Generic;
using UnityEngine;
[RequireComponent(typeof(RectTransform))]
public class LifeGaugeContainer : MonoBehaviour
{
    public static LifeGaugeContainer Instance => _instance;
    private static LifeGaugeContainer _instance;
    [SerializeField] private Camera mainCamera;
    [SerializeField] private LifeGauge lifeGaugePrefab;
    private RectTransform _rectTransform;
    private readonly Dictionary<MobStatus, LifeGauge> _statusLifeBarMap
        = new Dictionary<MobStatus, LifeGauge>();
    private void Awake()
    {
        if (null != _instance) throw new Exception("LifeBarContainer instance already exists.");
        _instance = this;
        _rectTransform = GetComponent<RectTransform>();
    }
    /// <summary>ライフゲージを追加します。</summary>
    public void Add(MobStatus status)
    {
        var lifeGauge = Instantiate(lifeGaugePrefab, transform);
        lifeGauge.Initialize(_rectTransform, mainCamera, status);
        _statusLifeBarMap.Add(status, lifeGauge);
    }
    /// <summary>ライフゲージを破棄します。</summary>
    public void Remove(MobStatus status)
    {
        Destroy(_statusLifeBarMap[status].gameObject);
        _statusLifeBarMap.Remove(status);
    }
}

ポイント解説

① 単一責任の原則(SRP)とは

Robert C. Martin("Uncle Bob")が SOLID 原則として定式化した「クラスを変更する理由はひとつだけにすべき」という原則です。

「変更する理由」とは、クラスが抱えている関心事(Concern)の数のことです。関心事が多いほど、一か所の修正が無関係な機能を壊すリスクが高まります。

【SRP 違反の例:何でも PlayerController に詰め込んだ場合】
PlayerController(1 クラス)
  ├─ 入力受付       ← 入力デバイスが変わると修正
  ├─ 移動・物理判定 ← 移動アルゴリズムが変わると修正
  ├─ HP 管理        ← バランス調整のたびに修正
  ├─ 攻撃処理       ← 攻撃システムが変わると修正
  └─ HP バー表示    ← UI デザインが変わると修正
↓ 「変更理由」が 5 種類もある = SRP 違反
【SRP 準拠:IkinokoBattle の実際の設計】
PlayerController   ← 変更理由:入力→各コンポーネント橋渡しの仕様変更のみ
CharacterController← 変更理由:移動・衝突仕様の変更のみ(Unity 組み込み)
PlayerInput        ← 変更理由:入力デバイス・マッピングの変更のみ(Unity 組み込み)
PlayerStatus       ← 変更理由:HP や状態管理仕様の変更のみ
MobAttack          ← 変更理由:攻撃システムの変更のみ
LifeGauge          ← 変更理由:HP バー UI の変更のみ
LifeGaugeContainer ← 変更理由:HP バー管理ロジックの変更のみ

② [RequireComponent] — 依存関係をコードで宣言する

PlayerController の先頭に 4 つの [RequireComponent] があります。

[RequireComponent(typeof(CharacterController))]
[RequireComponent(typeof(PlayerInput))]
[RequireComponent(typeof(PlayerStatus))]
[RequireComponent(typeof(MobAttack))]
public class PlayerController : MonoBehaviour

この属性の効果は 2 つです。まず PlayerController を GameObject にアタッチした瞬間、不足しているコンポーネントが自動追加されます。また依存コンポーネントを Inspector や コードから削除しようとすると Unity が警告を出します。これにより「PlayerController は必ずこれらのコンポーネントを必要とする」という設計意図をコードで表明でき、設定ミスを防げます。


③ GetComponent のキャッシュ — パフォーマンスの常識

PlayerController.Start() では GetComponent<T>() の結果を全てフィールドに保存しています。

private void Start()
{
    _characterController = GetComponent<CharacterController>();
    _transform = transform;
    _status    = GetComponent<PlayerStatus>();
    _mobAttack = GetComponent<MobAttack>();
    // ...
}

GetComponent<T>() は呼ばれるたびに GameObject のコンポーネントリストを線形探索します。Update() で毎フレーム呼ぶと処理コストが積み重なるため、Start()(または Awake())で一度だけ呼んでフィールドに保持するのが Unity の定石です。transform も同様で、アクセスのたびに内部でコストが発生するためキャッシュすると効果的です。


④ PlayerController は「調整役」に徹する

PlayerController.Update() を読むと、このクラスが何をしていないかが見えてきます。

private void Update()
{
    if (_attack.WasPressedThisFrame())
        _mobAttack.AttackIfPossible();   // ← 攻撃の実行は MobAttack に任せる
    if (_status.IsMovable)               // ← 状態判断は PlayerStatus に任せる
    {
        var moveValue = _move.ReadValue<Vector2>();
        _moveVelocity.x = moveValue.x * moveSpeed;
        _moveVelocity.z = moveValue.y * moveSpeed;
        _transform.LookAt(...);
    }
    _characterController.Move(...);      // ← 物理移動は CharacterController に任せる
    animator.SetFloat(...);              // ← アニメーションは Animator に任せる
}

PlayerController 自身は HP を持たず、攻撃判定も行わず、コライダーの制御もしません。「入力を読んで、担当コンポーネントのメソッドを呼ぶだけ」——このシンプルさが SRP の実践です。


⑤ CharacterController — Rigidbody を使わない移動

PlayerControllerRigidbody ではなく CharacterController を使っています。

_characterController.Move(_moveVelocity * Time.deltaTime);

CharacterController は Unity 組み込みのコンポーネントで、物理エンジン(Rigidbody)を使わずにキャラクター移動と衝突判定を処理します。Rigidbody と比べた特徴は以下のとおりです。

比較項目 CharacterController Rigidbody
移動制御 Move() で直接指定 力(AddForce)や速度で間接制御
重力 コードで手動計算が必要 物理エンジンが自動適用
慣性 慣性なし(キビキビ動く) 慣性あり(リアルな動き)
向いているキャラ プレイヤー操作キャラ 物理挙動が重要なオブジェクト

重力を手動計算しているのが PlayerController のコードに現れています。

if (_characterController.isGrounded)
{
    if (_jump.WasPressedThisFrame())
        _moveVelocity.y = jumpPower;
}
else
{
    _moveVelocity.y += Physics.gravity.y * Time.deltaTime; // 重力による加速
}

Physics.gravity.y は通常 -9.81 です。空中にいる間は毎フレーム Y 速度に重力加速度を加算し続けることで、自由落下を再現しています。


⑥ LifeGauge — 3D 座標を UI 座標へ変換する

LifeGauge.Refresh() には、3D ワールド座標を uGUI の Canvas 座標に変換する処理が含まれています。

// Step 1: 3D 座標 → スクリーン座標
var screenPoint = _camera.WorldToScreenPoint(_status.transform.position);
// Step 2: スクリーン座標 → Canvas のローカル座標
Vector2 localPoint;
RectTransformUtility.ScreenPointToLocalPointInRectangle(
    _parentRectTransform, screenPoint, null, out localPoint);
// Step 3: キャラ頭上 (+80) にオフセットして配置
transform.localPosition = localPoint + new Vector2(0, 80);

Canvas の Render Mode が Screen Space - Overlay の場合、第 3 引数(カメラ)は null で問題ありません。Screen Space - Camera モードであれば対象カメラの指定が必要です。このような座標変換を LifeGauge に閉じ込めることで、MobStatus は UI のことを一切知らずに済みます。


責任分担を図で整理する

Player GameObject
├─ PlayerController      ← 入力受付・各コンポーネント調整
│    │ 使う ↓
│    ├─ CharacterController  ← 移動・衝突判定
│    ├─ PlayerInput          ← Input System との橋渡し
│    ├─ PlayerStatus         ← HP 管理・状態管理
│    └─ MobAttack            ← 攻撃処理

└─ Animator              ← アニメーション
UI Canvas
└─ LifeGaugeContainer   ← 全 Mob の HP バーを管理(Singleton)
     └─ LifeGauge(Prefab × N) ← 個別の HP バー表示 + 座標変換
          └─ MobStatus を参照 ← HP 値を読み取るだけ(書き込まない)

LifeGaugeMobStatus を「読み取り専用」で参照しています。LifeLifeMaxpublic プロパティとして公開されており、ゲージ側からは決して HP を変更できません。データとビューの一方向の関係が明確に保たれています。


SRP 違反を見つける 3 つのチェックポイント

自分のクラスが SRP に違反していないかチェックする際は、次の 3 つを問いかけてみてください。

① 「このクラスを変更する理由」をいくつ思いつくか
2 つ以上思いつくなら、分割を検討するサインです。

② クラス名に "And"・"Manager"・"Helper" などが含まれるか
「PlayerMoveAndAttack」「GameManager」のように何でも入れがちな名前は、複数の責任を持っているサインです。

③ メソッドを追加するとき「このクラスに足すのは自然か」と感じるか
「なんとなくここに書いてしまう」が繰り返されると、クラスは肥大化します。


まとめ

キーワード 意味と役割
単一責任の原則(SRP) クラスを変更する理由はひとつだけにすべきという SOLID の原則
[RequireComponent] 依存コンポーネントをコードで宣言。自動追加・削除防止の効果あり
GetComponent のキャッシュ Start() で一度だけ取得してフィールドに保存し、毎フレームの探索コストを削減
調整役パターン PlayerController のように「呼ぶだけ」に徹し、ロジックは専門クラスに委ねる
CharacterController Rigidbody を使わない移動。重力は手動計算、慣性なしのキビキビした操作感
WorldToScreenPoint + ScreenPointToLocalPointInRectangle 3D ワールド座標を Canvas ローカル座標へ変換する 2 ステップ

IkinokoBattle の PlayerController はどのような仕様変更が来ても「入力と調整」以外の理由では修正が不要です。HP の仕様が変われば PlayerStatus、攻撃システムが変われば MobAttack、UI デザインが変われば LifeGauge——それぞれ独立して修正でき、他クラスへの影響を最小化できます。これが SRP の実践から得られる最大の恩恵です。


シリーズ目次: IkinokoBattle で学ぶ Unity OOP 設計パターン
次の記事: 第5回 状態パターン


最終更新:2026年4月