学習記事一覧 · Unity

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

委譲パターンと UnityEvent

シリーズ目次: IkinokoBattle で学ぶ Unity OOP 設計パターン
前の記事: 第2回 テンプレートメソッドパターンと抽象クラス
次の記事: 第4回 単一責任の原則とコンポーネント設計


はじめに

「敵がプレイヤーを見つけたら追いかける」「攻撃範囲に入ったら攻撃する」——これらはどちらも「当たり判定(OnTriggerEnter / OnTriggerStay)」を起点とした処理です。

素朴に実装すると EnemyMoveMobAttack が直接 OnTriggerEnter / OnTriggerStay を持つことになります。しかし IkinokoBattle では、当たり判定そのものを専用の汎用コンポーネント CollisionDetector に切り出し、結果を UnityEvent<Collider> で通知する設計を採っています。

これが今回のテーマ、委譲(Delegation)パターンです。


今回の題材

クラス 役割
CollisionDetector OnTriggerEnter / OnTriggerStay を検知し、UnityEvent<Collider> として外部へ通知する汎用コンポーネント
EnemyMove CollisionDetector のイベントを受け取り、プレイヤーを追跡する
MobAttack CollisionDetector のイベントを受け取り、攻撃処理を行う

コード全文

CollisionDetector.cs(委譲先コンポーネント)

: プロジェクト内のファイル名は CollisionDetetor.cs("Detector" の c が抜けたタイポ)ですが、クラス名は CollisionDetector です。

using System;
using UnityEngine;
using UnityEngine.Events;
[RequireComponent(typeof(Collider))]
public class CollisionDetector : MonoBehaviour
{
    [SerializeField] private TriggerEvent onTriggerEnter = new TriggerEvent();
    [SerializeField] private TriggerEvent onTriggerStay  = new TriggerEvent();
    private void OnTriggerEnter(Collider other)
    {
        onTriggerEnter.Invoke(other);
    }
    // Is Trigger が ON の Collider と重なっているときに毎フレーム呼ばれる
    private void OnTriggerStay(Collider other)
    {
        onTriggerStay.Invoke(other);
    }
    // UnityEvent を継承したクラスに [Serializable] を付与すると
    // Inspector ウィンドウに表示できるようになる
    [Serializable]
    public class TriggerEvent : UnityEvent<Collider>
    {
    }
}

EnemyMove.cs(onTriggerStay のリスナー)

using UnityEngine;
using UnityEngine.AI;
[RequireComponent(typeof(NavMeshAgent))]
[RequireComponent(typeof(EnemyStatus))]
public class EnemyMove : MonoBehaviour
{
    [SerializeField] private LayerMask raycastLayerMask; // レイヤーマスク
    private NavMeshAgent _agent;
    private RaycastHit[] _raycastHits = new RaycastHit[10];
    private EnemyStatus _status;
    private void Start()
    {
        _agent  = GetComponent<NavMeshAgent>();
        _status = GetComponent<EnemyStatus>();
    }
    // CollisionDetector の onTriggerStay にセットし、衝突判定を受け取るメソッド
    public void OnDetectObject(Collider collider)
    {
        if (!_status.IsMovable)
        {
            _agent.isStopped = true;
            return;
        }
        // 検知したオブジェクトに "Player" タグがついていれば追いかける
        if (collider.CompareTag("Player"))
        {
            var positionDiff = collider.transform.position - transform.position;
            var distance     = positionDiff.magnitude;
            var direction    = positionDiff.normalized;
            // RaycastNonAlloc はヒット情報を配列に書き込む(GC 負荷が低い)
            var hitCount = Physics.RaycastNonAlloc(
                transform.position, direction, _raycastHits, distance, raycastLayerMask);
            if (hitCount == 0)
            {
                // ヒット数 0 = プレイヤーとの間に障害物なし → 追跡
                _agent.isStopped  = false;
                _agent.destination = collider.transform.position;
            }
            else
            {
                // 障害物あり → 停止
                _agent.isStopped = true;
            }
        }
    }
}

MobAttack.cs(onTriggerEnter のリスナー)

using System.Collections;
using UnityEngine;
[RequireComponent(typeof(MobStatus))]
public class MobAttack : MonoBehaviour
{
    [SerializeField] private float      attackCooldown = 0.5f; // 攻撃後のクールダウン(秒)
    [SerializeField] private Collider   attackCollider;
    [SerializeField] private AudioSource swingSound;           // 剣を振る音
    private MobStatus _status;
    private void Start()
    {
        _status = GetComponent<MobStatus>();
    }
    /// <summary>攻撃可能な状態であれば攻撃を行います。</summary>
    public void AttackIfPossible()
    {
        if (!_status.IsAttackable) return;
        _status.GoToAttackStateIfPossible();
    }
    /// <summary>攻撃対象が攻撃範囲に入ったときに呼ばれます。(CollisionDetector から委譲)</summary>
    public void OnAttackRangeEnter(Collider collider)
    {
        AttackIfPossible();
    }
    /// <summary>攻撃の開始時に呼ばれます。(アニメーションイベントから委譲)</summary>
    public void OnAttackStart()
    {
        attackCollider.enabled = true;
        if (swingSound != null)
        {
            swingSound.pitch = Random.Range(0.7f, 1.3f); // ランダムなピッチで単調にならないようにする
            swingSound.Play();
        }
    }
    /// <summary>attackCollider が攻撃対象に Hit したときに呼ばれます。(CollisionDetector から委譲)</summary>
    public void OnHitAttack(Collider collider)
    {
        var targetMob = collider.GetComponent<MobStatus>();
        if (null == targetMob) return;
        targetMob.Damage(1);
    }
    /// <summary>攻撃の終了時に呼ばれます。(アニメーションイベントから委譲)</summary>
    public void OnAttackFinished()
    {
        attackCollider.enabled = false;
        StartCoroutine(CooldownCoroutine());
    }
    private IEnumerator CooldownCoroutine()
    {
        yield return new WaitForSeconds(attackCooldown);
        _status.GoToNormalStateIfPossible();
    }
}

ポイント解説

① 委譲とは何か

委譲とは「ある仕事を、別のオブジェクトに任せること」です。継承が「is-a 関係(〜は〜の一種)」なのに対し、委譲は「has-a 関係(〜は〜を持っている)」で機能を組み合わせます。

IkinokoBattle では、OnTriggerEnter / OnTriggerStay という Unity のコールバック処理を CollisionDetector に任せ(委譲し)、EnemyMoveMobAttack は「通知を受けたら何をするか」だけを担当します。

【委譲なし:各クラスが OnTriggerEnter を持つ】
EnemyMove
  └─ OnTriggerStay(Collider other) { 追跡処理... }
MobAttack
  └─ OnTriggerEnter(Collider other) { 攻撃処理... }
↓ 当たり判定のロジックが各クラスに散らばる
【委譲あり:CollisionDetector に集約】
CollisionDetector ← OnTriggerEnter/Stay を持つ唯一のクラス

  ├─ onTriggerEnter.Invoke(other) → MobAttack.OnAttackRangeEnter()
  └─ onTriggerStay.Invoke(other)  → EnemyMove.OnDetectObject()
↓ EnemyMove も MobAttack も OnTriggerXxx を一切持たない

② UnityEvent<T> — 型安全なコールバック

UnityEvent<T> は Unity が提供するコールバック(イベント)の仕組みです。Invoke() で全リスナーを一括呼び出しでき、AddListener() でコードから、Inspector で直接、どちらでもリスナー登録が可能です。

CollisionDetector では UnityEvent<Collider> を継承した TriggerEvent クラスを定義しています。

[Serializable]
public class TriggerEvent : UnityEvent<Collider>
{
}

UnityEvent<Collider> を直接 [SerializeField] に使うのではなく、名前付きのサブクラスを作るのがポイントです。[Serializable] を付けることで Inspector に表示され、Unity Editor 上でリスナーを登録できます。

Inspector での見た目:
CollisionDetector
├─ On Trigger Enter (TriggerEvent)
│    └─ EnemyMove.OnAttackRangeEnter  ← ここに登録
└─ On Trigger Stay (TriggerEvent)
     └─ EnemyMove.OnDetectObject      ← ここに登録

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

CollisionDetector[RequireComponent(typeof(Collider))] が付いています。

[RequireComponent(typeof(Collider))]
public class CollisionDetector : MonoBehaviour

これにより、CollisionDetector を GameObject にアタッチすると Unity が自動的に Collider コンポーネントも追加します。「Collider なしでは動作しない」という依存関係をコードで明示し、設定ミスを防ぎます。


④ 1 つの GameObject に複数の CollisionDetector を使う

MobAttack では「攻撃範囲用の Collider」と「被弾範囲用の Collider」が別々に存在します。それぞれに CollisionDetector をアタッチし、用途ごとのイベントを分けて定義できます。

Enemy GameObject
├─ EnemyStatus(HP管理)
├─ EnemyMove(移動)
├─ MobAttack(攻撃)

├─ DetectionRange(子 GameObject:探知範囲用)
│    ├─ SphereCollider(Is Trigger = ON)
│    └─ CollisionDetector
│         └─ onTriggerStay → EnemyMove.OnDetectObject

└─ AttackRange(子 GameObject:攻撃範囲用)
     ├─ BoxCollider(Is Trigger = ON)
     └─ CollisionDetector
          ├─ onTriggerEnter → MobAttack.OnAttackRangeEnter
          └─ onTriggerEnter → MobAttack.OnHitAttack

同じ CollisionDetector コンポーネントを使い回しつつ、用途ごとに別の GameObject に配置するだけで振る舞いを変えられます。継承では実現できない柔軟な組み合わせです。


⑤ RaycastNonAlloc — GC 負荷を抑える視線判定

EnemyMove.OnDetectObject() では Physics.RaycastNonAlloc() を使っています。

private RaycastHit[] _raycastHits = new RaycastHit[10]; // フィールドに事前確保
var hitCount = Physics.RaycastNonAlloc(
    transform.position, direction, _raycastHits, distance, raycastLayerMask);

Physics.RaycastAll() との違いはメモリ確保の有無です。

メソッド 戻り値 メモリ
RaycastAll() RaycastHit[](毎回 new) 毎フレームGCゴミが発生
RaycastNonAlloc() ヒット数(int) 事前確保配列を使い回すためGCゼロ

hitCount == 0 であれば「プレイヤーとの間に障害物がない」=視線が通っているため追跡、という判定になります。本プロジェクトでは CharacterController を使ったプレイヤーに独立した Collider がないため、Raycast がプレイヤーにはヒットしないという前提で成立しています。


全体の処理の流れを整理する

[毎フレーム探知範囲内にプレイヤーがいるとき]
Physics エンジン

  └─ OnTriggerStay(other) が呼ばれる(探知範囲 CollisionDetector)

       └─ onTriggerStay.Invoke(other)

            └─ EnemyMove.OnDetectObject(other)
                 ├─ IsMovable でない → 停止
                 ├─ タグが "Player" でない → 無視
                 └─ RaycastNonAlloc で視線チェック
                      ├─ hitCount == 0 → _agent.destination = player位置(追跡)
                      └─ hitCount > 0  → 障害物あり → 停止
[攻撃範囲にプレイヤーが入ったとき]
Physics エンジン

  └─ OnTriggerEnter(other) が呼ばれる(攻撃範囲 CollisionDetector)

       └─ onTriggerEnter.Invoke(other)

            └─ MobAttack.OnAttackRangeEnter(other)
                 └─ AttackIfPossible()
                      └─ _status.GoToAttackStateIfPossible()
                           └─ Animator.SetTrigger("Attack")
                                └─ [アニメーションイベント] OnAttackStart()
                                └─ [アニメーションイベント] OnAttackFinished()

委譲 vs 継承:使い分けの基準

観点 継承 委譲
関係 is-a(〜は〜の一種) has-a(〜は〜を持つ)
柔軟性 1 つの親クラスしか持てない 複数のコンポーネントを自由に組み合わせ可能
再利用 派生クラス単位 コンポーネント単位
Unity での典型 MobStatusPlayerStatusEnemyStatus CollisionDetector を複数の GameObject で使い回す

Unity はもともと「コンポーネント指向(Component-Based Architecture)」の設計を採っており、委譲との相性が非常によいです。継承ツリーが深くなるほど「どのクラスがどこで何をしているか」が追いにくくなるため、機能は小さなコンポーネントに切り出し、それを組み合わせるスタイルが推奨されます。


まとめ

キーワード 意味と役割
委譲 仕事を別のオブジェクトに任せる設計。has-a 関係で機能を組み合わせる
UnityEvent<T> 型安全なコールバック。Invoke() で全リスナーを呼び出せる
TriggerEvent UnityEvent<Collider> を継承した内部クラス。[Serializable] で Inspector に表示
[RequireComponent] 依存するコンポーネントをコードで宣言し、設定ミスを防ぐ
RaycastNonAlloc 事前確保配列を使いまわし、毎フレームのGC負荷をゼロにする

IkinokoBattle の CollisionDetector は「当たり判定を検知するだけ」という一つの責任に徹し、その結果を UnityEvent で外に渡します。EnemyMoveMobAttackOnTriggerXxx を一切持たず、「通知を受けて何をするか」だけを書けばよい設計です。コンポーネントを小さく保ち、組み合わせで機能を拡張する——これが委譲パターンと Unity コンポーネント設計の核心です。


シリーズ目次: IkinokoBattle で学ぶ Unity OOP 設計パターン
次の記事: 第4回 単一責任の原則とコンポーネント設計


最終更新:2026年4月