Skip to content

第07回 アイテムとオブジェクトプール

前回: 第06回 Enemyの状態制御とゲームフィール / 次回: 第08回 GameManagerとゲーム状態 / 発展: AnimatorとPlayerState

今回の授業内容

  • Triggerで回復アイテムの取得を検出する
  • DamageReceiver.ReceiveHeal()を使ってPlayerのHPを回復する
  • オブジェクトの生成と破棄の負荷を理解する
  • Enemyが倒れた位置へオブジェクトプールからアイテムを貸し出す
  • 取得後のアイテムをオブジェクトプールに返却する

今回の配布コード

第06回基本版に、回復アイテム、固定容量プール、敵ドロップ処理を追加した第07回版です。全23ファイルを第07回配布コードとしてまとめています。主な変更ファイルは次の通りです。

配布ファイル一覧
ファイル今回の変更
ItemCollector.csPlayer側でTriggerに入ったアイテムを見つける
HealItem.cs取得条件、回復処理、プールへの返却を扱う
HealItemPool.cs回復アイテムの事前生成、貸し出し、返却を管理する
ItemDropSystem.cs敵からのアイテムドロップ依頼を受け付ける
EnemyItemDropper.csEnemyの死亡時に回復アイテムのドロップを依頼する
DamageReceiver.csReceiveHeal()を回復アイテムから利用する

ヒエラルキーに新規オブジェクトを登録

Heal Item PoolとItem Drop SystemをSystems配下へ登録した例

回復アイテムをプールするHealItemPoolと、Enemyの死亡時にアイテムを出すItemDropSystemが必要です。ヒエラルキー上にHeal Item PoolItem Drop Systemを作り、各コンポーネントを追加してください。

INFO

システムやマネージャーのような重要なオブジェクトは、Hierarchy上で見つけやすいようにまとめておくことをおすすめします。例えばルートにSystemsやManagersなどの空オブジェクトを作り、その子階層にまとめましょう。

回復アイテムの実装

第04回では、HPの計算をHealth、外部からダメージや回復を受け取る処理をDamageReceiverに実装しました。DamageReceiverには、今後の拡張に備えてReceiveHeal()も実装済みです。

今回は回復アイテムを追加し、既存の回復処理へ接続します。アイテム自身がHPの値を直接変更するのは避け、アイテムオブジェクトとしての責務を果たすことだけに注力します。

部品担当すること
ItemCollectorTriggerでHealItemを検出し、取得を試みる
HealItem取得時の回復処理とプールへの返却を行う
DamageReceiverHealth.Heal()へ回復計算を任せ、HPが変化したときだけHealedイベントを発行する
PlayerHealedイベントを受け取り、HealthGaugeを更新する

アイテムドロップと生成破棄のコスト

今回は、Enemyが倒れた位置に回復アイテムを出す処理を実装します。例えば以下のような実装が考えられます。

cs
// アイテムの生成
var item = Instantiate(itemPrefab, dropPosition, Quaternion.identity);

// 取得時
Destroy(item.gameObject);

InstantiateGameObjectComponentを新しく作り、Destroyは不要になったオブジェクトを破棄します。少数をときどき生成する程度であれば、この方法でもなんの問題もありません。

ですが弾、敵、エフェクト、アイテムのように同じPrefabを何度も生成・破棄する場合は、次の処理が繰り返されます。

  • GameObjectComponentの生成
  • Awake()OnEnable()などの初期化
  • メモリの確保
  • 破棄されたオブジェクトとメモリの回収

短時間に大量の生成と破棄が集中すると処理負荷が高まり、フレーム時間が不安定になる原因になります。こうした場合、あらかじめ作成したオブジェクトを使い回すオブジェクトプールを使うことで、生成と破棄の負荷を回避できます。

オブジェクトプール

オブジェクトプールは、あらかじめ準備された利用可能なオブジェクトを保管し、必要なときに貸し出す仕組みです。使用済みのオブジェクトは破棄せず、無効化してプールに戻します。戻されたオブジェクトは再び貸し出されるまで待機します。

今回作るオブジェクトプールは、ゲーム開始時に必要数をまとめて作成しておく固定容量のプールです。ゲーム中に数が足りなくなっても追加生成せず、利用可能なアイテムがない場合は出現を見送ります。

ゲームとオブジェクト数の管理

ゲームを動作させるコンピューターは有限の機械です。無制限にオブジェクトを出現させていては処理速度が落ち、フレーム時間が不安定になり、最悪の場合はメモリ不足でクラッシュします。オブジェクトの最大出現数を固定することで、フレーム時間の安定化とメモリ使用量の上限を確保するのは定番の手法です。

今回はオブジェクトの最大数管理まで含めてオブジェクトプールが責任を担います。

INFO

オブジェクトプールが同時生成数の管理まで行うかはゲームの仕様次第です。ここでは固定容量のプールを作ることで同時に出現する回復アイテムの数を制御していますが、プールは単に処理負荷の削減に注力した方がよい場合もあります。

オブジェクトプールの実装

今回のオブジェクトプールでは、次の2つのコレクションを使います。

コレクション管理する内容使う理由
Queue<HealItem>利用可能なアイテム先に戻したアイテムから順番に再利用する
HashSet<HealItem>現在Scene上へ貸し出しているアイテム貸し出し中のアイテムを記録し、同じアイテムの二重返却を防ぐ

Queueは、先に追加した要素から順番に取り出すコレクションです。利用可能なアイテムを順番に再利用する用途に適しています。

この回で扱うプール

Unityにはより汎用的なプールを作るためのAPIもあります。実務では、プールを固定容量にするか、足りないときに追加生成するか、返却時にどこまで状態を戻すかなどを、ゲームの仕様に合わせて判断実装しなければなりません。

今回のプールは参考のため、あくまで簡素で直接的に、固定容量で実装しています。より汎用的なプールの自作にも挑戦してみてください。

敵のドロップアイテムの実装

今回は、Enemyが倒れたときに回復アイテムを落とす処理を実装します。ドロップされたHealItemのライフサイクルは以下の通りです。

タイミングHealItemの状態
ゲーム開始時Prefabから必要数を事前生成し、無効化してプールへ保管する
Enemyが倒れたときプールから1つ取り出し、Enemyの位置へ移動して有効化する
取得されたとき無効化してプールへ返し、次の利用を待つ

アイテムドロップ処理の担当

Enemyが倒れたときにアイテムを落とすとしても、EnemySpawnerにアイテム管理まで管理させる必要はありません。EnemySpawnerはEnemyを生成する役割に留め、アイテムドロップは別のコンポーネントで扱います。

部品担当すること
EnemyHPと死亡処理を扱う
EnemyItemDropperEnemyの死亡を検出し、アイテムドロップを依頼する
ItemDropSystemScene上のアイテムドロップ処理を受け付ける
HealItemPoolHealItemの事前生成、貸し出し、返却を管理する

今後アイテム出現の仕様が増えても、EnemyItemDropper相当の処理を増やすことで対応可能となります。

ItemCollectorでアイテムを回収する

回復アイテムがPlayerクラスを直接操作する実装では、例によってPlayerの責務が増えてしまいます。今回は、Player側に「アイテムを取得する役割」を持つItemCollectorを追加します。ItemCollectorがTriggerでアイテムを見つけ、取得を試みます。

cs
using UnityEngine;

// アイテムを取得する役割を持つ
[RequireComponent(typeof(DamageReceiver))]
public class ItemCollector : MonoBehaviour
{
    public DamageReceiver DamageReceiver { get; private set; }

    private void Awake()
    {
        DamageReceiver = GetComponent<DamageReceiver>();
    }

    private void OnTriggerEnter(Collider other)
    {
        // Triggerに入った相手が回復アイテムかどうかを確認する
        var item = other.GetComponentInParent<HealItem>();
        if (item == null)
            return;

        // 取得できるかどうかの判定は、アイテム側に任せる
        item.TryCollect(this);
    }
}

ItemCollectorは、同じGameObjectにあるDamageReceiverを取得し、アイテム取得時に使えるようにします。RequireComponentによって、ItemCollectorを追加したGameObjectにはDamageReceiverも必要であることを明示しています。回復アイテムはItemCollectorから渡された情報を使えば、取得した相手と回復先の両方を判断できます。

回復アイテムの取得

HealItemにはTrigger Colliderを設定します。Playerが範囲内に入ると、Player側のItemCollectorHealItemを見つけ、TryCollect()を呼びます。

cs
using UnityEngine;

// 取得した相手のHPを回復し、使用後はプールへ戻る
public class HealItem : MonoBehaviour
{
    [Tooltip("取得した相手を回復する量")]
    [SerializeField] private int healAmount = 3;

    private HealItemPool _pool;
    private bool _isCollected;

    // このアイテムを管理するプールを記録する
    public void Initialize(HealItemPool pool)
    {
        _pool = pool;
    }

    // プールから貸し出される前に取得可能な状態へ戻す
    public void PrepareForSpawn()
    {
        _isCollected = false;
    }

    public bool TryCollect(ItemCollector collector)
    {
        if (_isCollected)
            return false;

        // 回復先は、取得した相手のItemCollectorから受け取る
        var receiver = collector.DamageReceiver;

        // 死亡中やHPが満タンの場合は取得しない
        if (receiver.IsDead ||
            receiver.CurrentHealth >= receiver.MaxHealth)
            return false;

        _isCollected = true;
        receiver.ReceiveHeal(healAmount);

        // Destroyせず、管理元のプールへ返す
        _pool.Release(this);
        return true;
    }
}

HPが満タンなら取得しない

HPが満タンの相手へReceiveHeal()を呼んでも、Health.Heal()によって最大HPを超えないように制限されます。今回は回復が必要な場合だけ取得できるようにします。

cs
if (receiver.IsDead ||
    receiver.CurrentHealth >= receiver.MaxHealth)
    return false;

ここでは回復量を計算しているわけではなく「アイテムを取得できるかどうか」を判定しています。今回はHPが最大の時はアイテムを回収しないようにしていますが、取得条件は好みで変えてみてください。

消滅時にDestroyしない

取得後はDestroy(gameObject)を呼ばず、管理元のHealItemPoolへ自分自身を返します。アイテムは無効化されてプールへ戻り、次に必要なときに再利用されます。これで破棄と再生成の負荷を回避できます。

cs
receiver.ReceiveHeal(healAmount);
_pool.Release(this);

HealItemPoolを実装する

cs
using System.Collections.Generic;
using UnityEngine;

// HealItemの事前生成、貸し出し、返却を管理する
public class HealItemPool : MonoBehaviour
{
    [Tooltip("事前生成する回復アイテムのPrefab")]
    [SerializeField] private HealItem itemPrefab;

    [Tooltip("ゲーム開始時に用意する個数")]
    [SerializeField] private int poolSize = 5;

    private readonly Queue<HealItem> _availableItems = new();
    private readonly HashSet<HealItem> _activeItems = new();

    // ゲーム開始時に必要な個数をまとめて生成する
    private void Awake()
    {
        for (var i = 0; i < poolSize; i++)
            CreateItem();
    }

    // Prefabから生成し、利用可能なアイテムとして保管する
    private void CreateItem()
    {
        var item = Instantiate(itemPrefab, transform);
        item.Initialize(this);
        item.gameObject.SetActive(false);
        _availableItems.Enqueue(item);
    }

    // 利用可能なアイテムを指定位置へ配置する
    public bool TrySpawn(Vector3 position, Quaternion rotation)
    {
        // 固定容量のプールなので、足りない場合は追加生成しない
        if (_availableItems.Count == 0)
            return false;

        var item = _availableItems.Dequeue();

        // 貸し出し中のアイテムはScene直下へ出し、指定位置に置く
        item.transform.SetParent(null);
        item.transform.SetPositionAndRotation(
            position,
            rotation);

        item.PrepareForSpawn();
        _activeItems.Add(item);
        item.gameObject.SetActive(true);
        return true;
    }

    // 使用済みのアイテムを無効化してプールへ戻す
    public void Release(HealItem item)
    {
        // 貸し出し中ではないアイテムの二重返却を防ぐ
        if (!_activeItems.Remove(item))
            return;

        item.gameObject.SetActive(false);
        item.transform.SetParent(transform);
        _availableItems.Enqueue(item);
    }
}

ゲーム開始時に事前生成する

Awake()で、poolSizeの数だけHealItemを作成します。

INFO

こうした事前準備の処理をPrewarmと呼ぶことがあります。

cs
private void Awake()
{
    for (var i = 0; i < poolSize; i++)
        CreateItem();
}

生成直後のアイテムはSetActive(false)で無効化し、Queueへ追加します。非アクティブなGameObjectは表示や物理判定の対象から外れ、Update()なども呼び出されません。Triggerイベントも発生しません。

Enemyの位置へアイテムを貸し出す

TrySpawn()は、指定された位置を受け取り、利用可能なアイテムを1つ貸し出します。固定容量のプールなので、利用可能なアイテムがない場合は何も生成せず、falseを返します。

cs
var item = _availableItems.Dequeue();

取り出したアイテムはプールの子階層から外し、Enemyが倒れた位置へ移動します。その後、PrepareForSpawn()で再利用に必要な状態を初期化し、_activeItemsへ追加してから有効化します。

取得されたアイテムを返却する

Release()は、取得されたアイテムを_activeItemsから外します。その後、GameObjectを無効化してプールの子階層へ戻し、利用可能なQueueの末尾へ追加します。

cs
item.gameObject.SetActive(false);
item.transform.SetParent(transform);
_availableItems.Enqueue(item);

同じアイテムを誤って2回返却するとQueueへ重複登録されるため、HashSet.Remove()に成功した場合だけ返却処理を続けます。

ItemDropSystemで敵ドロップを実装する

回復アイテムは一定時間ごとに出すのではなく、Enemyが倒れたときに落とします。Enemy本体にはHPと死亡処理の責務を残し、ドロップ処理はEnemyItemDropperへ分けます。

ItemDropSystemでドロップ依頼を受け取る

Scene上にItemDropSystemを1つ置き、アイテムドロップの窓口にします。今回は、ItemDropSystemHealItemPoolを持ち、指定位置へ回復アイテムを出します。

cs
using UnityEngine;

// Scene上のアイテムドロップ処理を受け付ける
public class ItemDropSystem : MonoBehaviour
{
    [Tooltip("回復アイテムを貸し出すプール")]
    [SerializeField] private HealItemPool healItemPool;

    public void DropHealItem(Vector3 position)
    {
        // プールが空の場合、TrySpawnはfalseを返して何も出さない
        healItemPool.TrySpawn(position, Quaternion.identity);
    }
}

ItemDropSystemは、どのプールを使ってアイテムを出すかを管理します。EnemyやEnemySpawnerは、プールの中身や固定容量の管理を知る必要がありません。

EnemyItemDropperで死亡時に依頼する

EnemyItemDropperDamageReceiverのダメージイベントを受け取り、Enemyが死亡したときだけItemDropSystemへドロップを依頼します。Enemy Prefabへ追加しておけば、EnemySpawner以外の方法で生成されたEnemyでも同じように動作します。

cs
using UnityEngine;

// Enemyが倒れたときに回復アイテムのドロップを依頼する
[RequireComponent(typeof(DamageReceiver))]
public class EnemyItemDropper : MonoBehaviour
{
    [Tooltip("回復アイテムを落とす位置の補正")]
    [SerializeField] private Vector3 itemDropOffset = Vector3.up * 0.5f;

    private DamageReceiver _damageReceiver;
    private ItemDropSystem _itemDropSystem;
    private bool _dropped;

    // 死亡判定に使うDamageReceiverと、Scene上のドロップ管理を取得する
    private void Awake()
    {
        _damageReceiver =
            GetComponent<DamageReceiver>();
        _itemDropSystem =
            FindAnyObjectByType<ItemDropSystem>();

        if (_itemDropSystem == null)
            Debug.LogWarning("SceneにItemDropSystemがありません");
    }

    // 有効になったときにダメージイベントの購読を開始する
    private void OnEnable()
    {
        _dropped = false;
        _damageReceiver.Damaged += OnDamaged;
    }

    // 無効になったときにダメージイベントの購読を解除する
    private void OnDisable()
    {
        _damageReceiver.Damaged -= OnDamaged;
    }

    // 死亡した瞬間に1回だけ回復アイテムのドロップを依頼する
    private void OnDamaged(int damage, int maxHealth)
    {
        // 死亡していない場合と、すでにドロップ済みの場合は何もしない
        if (!_damageReceiver.IsDead || _dropped)
            return;

        _dropped = true;
        _itemDropSystem?.DropHealItem(
            transform.position + itemDropOffset);
    }
}

FindAnyObjectByType<ItemDropSystem>()は、Scene上にあるItemDropSystemを探します。この回では、Sceneに1つだけItemDropSystemを置く前提にします。見つからない場合は、Consoleへ警告を表示します。

次回のGameManagerとの関係

第08回でGameManagerを追加した後は、GameManagerからItemDropSystemを参照し、ゲーム中だけアイテムを落とす、ゲームオーバー中は落とさない、といった制御へ拡張できます。GameManagerにプール処理を直接書くのではなく、ItemDropSystemを管理対象として扱うと責務を分けたまま拡張できます。

プール実装を汎用化する場合

今回のHealItemPoolは、回復アイテム専用の固定容量プールです。授業では、プールの中身と動作を追いやすくするために、Queue<HealItem>HashSet<HealItem>を直接扱っています。

より多くのPrefabやエフェクトへ同じ仕組みを適用する場合は、より高度な実装が必要になるかもしれません。

要求
容量固定数にするか、足りないときに追加生成するか
再利用時の初期化Coroutine、Rigidbody、Particle System、Materialの停止とリセットの方法
所有関係プールが生成したオブジェクトだけ返却を受け付けるための機構
失敗時の扱いプールを消費しきっている場合の対応

Unityには専用のオブジェクトプールコレクションUnityEngine.Pool.ObjectPool<T>も用意されています。ただし、それを使っても「取得時に何を初期化するか」「返却時に何を戻すか」「容量をどのように扱うか」という設計判断は必要です。

今回の実装はあくまで最小構成です。自身のゲーム制作で何が必要かを考えてみてください。

再利用時に状態をリセットする

プールされたオブジェクトは、新しく作り直されるわけではありません。前回利用したときのフィールド、Transform、Coroutine、Materialなどの状態が残ります。

今回のHealItemでは、取得済みであることを表す_isCollectedが残っていると、再び取得できません。そこで、プールから貸し出す直前にPrepareForSpawn()を呼び、初期状態へ戻します。

cs
public void PrepareForSpawn()
{
    _isCollected = false;
}

初期化を貸し出し処理の一部として明示することで、どのタイミングで状態を戻すのかが分かりやすくなります。再利用するオブジェクトでは、どの状態を返却時または再貸し出し時に戻す必要があるかを必ず確認します。

状態リセット例
取得済みフラグfalseへ戻す
Rigidbodyの速度Vector3.zeroへ戻す
Particle System停止して先頭へ戻す
Coroutine停止する
HP最大値へ戻す
Material初期値へ戻す

HealItem Prefabを設定する

  1. Sphereや任意のModelを使ってHeal Itemを作ります
  2. Colliderを追加し、Is Triggerを有効にします
  3. HealItemを追加し、healAmountを設定します
  4. Heal ItemをPrefab化します
  5. Scene上の元オブジェクトは削除します

PlayerにはItemCollectorを追加します。Playerには第03回からRigidbodyDamageReceiverがあるため、Triggerイベントが発生する条件も満たしています。

HealItemPoolとItemDropSystemを設定する

  1. 空のGameObjectを作り、Heal Item Poolと名付けます
  2. HealItemPoolを追加します
  3. itemPrefabへHeal Item Prefabを割り当てます
  4. poolSizeを設定します
  5. 空のGameObjectを作り、Item Drop Systemと名付けます
  6. ItemDropSystemを追加します
  7. healItemPoolHeal Item Poolを割り当てます
  8. Enemy PrefabへEnemyItemDropperを追加します

poolSizeより多くのアイテムを同時にScene上へ出すことはできません。今回は、ゲーム中に追加生成を行わない固定容量のプールとして実装しています。

Play Modeで貸し出しと返却を確認する

HierarchyでHeal Item PoolとScene直下を確認し、アイテムの有効状態と親子関係を観察します。

操作・時間経過確認する内容
Play開始直後poolSize個の非アクティブなHeal Itemがプールの子にある
Enemyを倒す1個がプールの子階層から外れ、Enemyが倒れた位置で有効になる
HP満タンで触れるアイテムは取得されず、その場に残る
ダメージ後に触れるHPとHPゲージが回復し、アイテムが無効化されてプールの子階層へ戻る
別のEnemyを倒す同じInstanceが別のEnemyの位置で再利用される
アイテムを取らずにEnemyを倒し続けるpoolSizeを超える数のHeal Itemは表示されない

Play Mode中にInstance IDを確認すると、新しいオブジェクトを増やさず、同じInstanceを再利用していることを確認できます。

実習:回復アイテムをプールから再利用する

  1. ItemCollectorを作り、Playerへ追加します
  2. HealItemを作り、Trigger Colliderを設定します
  3. HealItemPoolを作り、ゲーム開始時にアイテムを事前生成します
  4. ItemDropSystemを作り、HealItemPoolを割り当てます
  5. EnemyItemDropperをEnemy Prefabへ追加します
  6. PlayerのHPが満タンの場合は取得できないことを確認します
  7. ダメージを受けた後に取得し、HPとHPゲージが回復することを確認します
  8. 取得したアイテムが破棄されず、プールから再利用されることを確認します

動作しない場合のチェックリスト

アイテムが出現しない

  • itemPrefabへHeal Item Prefabが割り当てられているか
  • Scene上にItemDropSystemがあるか
  • ItemDropSystemhealItemPoolHeal Item Poolが割り当てられているか
  • Enemy PrefabにEnemyItemDropperが追加されているか
  • Enemyが倒れているか
  • poolSizeが0になっていないか
  • すべてのHeal Itemが貸し出し中になっていないか

アイテムを取得できない

  • Heal ItemのColliderでIs Triggerが有効になっているか
  • PlayerにItemCollectorDamageReceiverがあるか
  • PlayerまたはHeal ItemにRigidbodyがあるか
  • PlayerのHPが最大値まで回復済みではないか
  • ItemCollector.OnTriggerEnter()からHealItem.TryCollect()が呼ばれているか
  • Heal ItemをSceneへ直接置かず、HealItemPoolから出現させているか

取得後に再出現しない

  • HealItem.Initialize()でプールが設定されているか
  • 取得時にHealItemPool.Release()が呼ばれているか
  • 貸し出し時にPrepareForSpawn()が呼ばれているか
  • ItemDropSystemhealItemPoolが外れていないか
  • poolSizeが同時に残るアイテム数に対して小さすぎないか

完成条件

  • 回復アイテムを取得するとPlayerのHPとHPゲージが回復する
  • HPが満タンのときは回復アイテムを取得しない
  • ゲーム開始時に指定数のHeal Itemを事前生成する
  • Enemyが倒れた位置へ利用可能なHeal Itemを配置する
  • 取得後のHeal Itemを破棄せずプールへ返す
  • 再利用時に取得済み状態をリセットする
  • InstantiateDestroyとオブジェクトプールの違いを説明できる

まとめ

  • ItemCollectorはTriggerでHealItemを見つけ、取得を試みる
  • 回復アイテムは取得時にDamageReceiver.ReceiveHeal()を呼ぶ
  • HPの回復計算は既存のHealthへ任せる
  • 同じPrefabを繰り返し使う場合は、事前生成したInstanceを再利用できる
  • Enemyが倒れたときに、プールから回復アイテムを貸し出せる
  • プールは利用可能なオブジェクトと貸し出し中のオブジェクトを分けて管理する
  • 使用済みのオブジェクトは無効化してプールへ返す
  • 再利用するオブジェクトは、前回の状態を明示的にリセットする

次回へ向けて

第08回ではGameManagerを追加し、ゲーム全体の状態を管理します。プレイ中、ゲームクリア、ゲームオーバーといったゲームの状態はゲームの細かな構成要素に任せず、GameManagerが一元的に管理するのが基本的な手法です。