IkinokoBattle で学ぶ Unity OOP 設計パターン:第3回
委譲パターンと UnityEvent
シリーズ目次: IkinokoBattle で学ぶ Unity OOP 設計パターン
前の記事: 第2回 テンプレートメソッドパターンと抽象クラス
次の記事: 第4回 単一責任の原則とコンポーネント設計
はじめに
「敵がプレイヤーを見つけたら追いかける」「攻撃範囲に入ったら攻撃する」——これらはどちらも「当たり判定(OnTriggerEnter / OnTriggerStay)」を起点とした処理です。
素朴に実装すると EnemyMove や MobAttack が直接 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 に任せ(委譲し)、EnemyMove と MobAttack は「通知を受けたら何をするか」だけを担当します。
【委譲なし:各クラスが 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 での典型 | MobStatus → PlayerStatus・EnemyStatus |
CollisionDetector を複数の GameObject で使い回す |
Unity はもともと「コンポーネント指向(Component-Based Architecture)」の設計を採っており、委譲との相性が非常によいです。継承ツリーが深くなるほど「どのクラスがどこで何をしているか」が追いにくくなるため、機能は小さなコンポーネントに切り出し、それを組み合わせるスタイルが推奨されます。
まとめ
| キーワード | 意味と役割 |
|---|---|
| 委譲 | 仕事を別のオブジェクトに任せる設計。has-a 関係で機能を組み合わせる |
UnityEvent<T> |
型安全なコールバック。Invoke() で全リスナーを呼び出せる |
TriggerEvent |
UnityEvent<Collider> を継承した内部クラス。[Serializable] で Inspector に表示 |
[RequireComponent] |
依存するコンポーネントをコードで宣言し、設定ミスを防ぐ |
RaycastNonAlloc |
事前確保配列を使いまわし、毎フレームのGC負荷をゼロにする |
IkinokoBattle の CollisionDetector は「当たり判定を検知するだけ」という一つの責任に徹し、その結果を UnityEvent で外に渡します。EnemyMove も MobAttack も OnTriggerXxx を一切持たず、「通知を受けて何をするか」だけを書けばよい設計です。コンポーネントを小さく保ち、組み合わせで機能を拡張する——これが委譲パターンと Unity コンポーネント設計の核心です。
シリーズ目次: IkinokoBattle で学ぶ Unity OOP 設計パターン
次の記事: 第4回 単一責任の原則とコンポーネント設計
最終更新:2026年4月