Unityを触り始めた頃って、プレイヤーの移動も敵の出現もスコア管理も、全部ひとつの Update() に書いてしまいがちですよね。最初は動くので満足できますが、あとから「敵の出現パターンを変えたい」「ステージごとに出現場所を変えたい」となった瞬間、その巨大なスクリプトを触るのが怖くなります。

そこで今回は「敵を定期的に出現させる」という機能だけを切り出した、小さなコンポーネントを作ってみましょう。
プレイヤーのロジックとは完全に分離して、「召喚だけ」を担当する Summoner(召喚士)コンポーネント です。

【Unity】敵を定期湧きさせて戦場をデザイン!「Summoner」コンポーネント

このコンポーネントは、指定したプレハブ(雑魚敵など)を一定間隔でインスタンス化してシーンに送り出す「召喚士」です。
・どの敵を出すか
・どこに出すか
・どれくらいの間隔で出すか
をインスペクターから調整できるようにして、レベルデザインをしやすくします。

フルコード:Summoner.cs


using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// 一定間隔で敵プレハブをインスタンス化する「召喚士」コンポーネント。
/// - Summoner がアタッチされた GameObject の位置を基準にスポーン
/// - 複数種類の敵プレハブからランダムに選択可能
/// - 同時に存在できる最大数を制限可能
/// - ゲーム開始時に自動で召喚開始 or 手動で開始を選択可能
/// </summary>
public class Summoner : MonoBehaviour
{
    [Header("召喚対象のプレハブ設定")]
    [Tooltip("召喚する敵プレハブ(1つ以上)。複数登録するとランダムに選ばれます。")]
    [SerializeField] private List<GameObject> enemyPrefabs = new List<GameObject>();

    [Header("召喚位置設定")]
    [Tooltip("true の場合は Summoner 自身の位置を基準に召喚します。")]
    [SerializeField] private bool useOwnPosition = true;

    [Tooltip("false の場合、ここで指定した Transform の位置に召喚します。")]
    [SerializeField] private Transform customSpawnPoint;

    [Tooltip("召喚位置にランダムな揺らぎを加えます(X,Z平面)。")]
    [SerializeField] private Vector2 randomOffsetRange = Vector2.zero;

    [Header("召喚タイミング設定")]
    [Tooltip("ゲーム開始時に自動で召喚を開始するかどうか。")]
    [SerializeField] private bool autoStartOnAwake = true;

    [Tooltip("最初の召喚までの待ち時間(秒)。")]
    [SerializeField] private float firstSpawnDelay = 1.0f;

    [Tooltip("召喚間隔(秒)。一定間隔で召喚します。")]
    [SerializeField] private float spawnInterval = 3.0f;

    [Tooltip("召喚間隔にランダムな揺らぎを加えます。0 なら完全な固定間隔。")]
    [SerializeField] private float spawnIntervalRandomRange = 0.0f;

    [Header("召喚数制限")]
    [Tooltip("同時に存在できる敵の最大数。0 以下の場合は無制限。")]
    [SerializeField] private int maxAliveCount = 10;

    [Tooltip("総召喚数の上限。0 以下の場合は無制限。")]
    [SerializeField] private int maxTotalSpawnCount = 0;

    [Header("デバッグ / 可視化")]
    [Tooltip("Scene ビューに召喚範囲をギズモ表示します。")]
    [SerializeField] private bool drawGizmos = true;

    [Tooltip("ギズモの色。")]
    [SerializeField] private Color gizmoColor = new Color(1f, 0.3f, 0.3f, 0.3f);

    // 実行時の内部状態
    private readonly List<GameObject> _aliveEnemies = new List<GameObject>();
    private Coroutine _spawnRoutine;
    private int _totalSpawnedCount;
    private bool _isSpawning;

    // 召喚を外部から制御したいとき用のプロパティ
    public bool IsSpawning => _isSpawning;

    private void Awake()
    {
        // Awake のタイミングで自動開始するかどうか
        if (autoStartOnAwake)
        {
            StartSummon();
        }
    }

    /// <summary>
    /// 召喚処理を開始します。
    /// すでに開始済みの場合は何もしません。
    /// </summary>
    public void StartSummon()
    {
        if (_isSpawning) return;

        // 敵プレハブが1つも設定されていない場合は警告を出して終了
        if (enemyPrefabs == null || enemyPrefabs.Count == 0)
        {
            Debug.LogWarning($"[Summoner] enemyPrefabs が設定されていません: {name}", this);
            return;
        }

        _isSpawning = true;
        _spawnRoutine = StartCoroutine(SpawnLoop());
    }

    /// <summary>
    /// 召喚処理を停止します。
    /// すでに停止している場合は何もしません。
    /// </summary>
    public void StopSummon()
    {
        if (!_isSpawning) return;

        _isSpawning = false;
        if (_spawnRoutine != null)
        {
            StopCoroutine(_spawnRoutine);
            _spawnRoutine = null;
        }
    }

    /// <summary>
    /// 現在生きている敵の数を返します。
    /// </summary>
    public int GetAliveCount()
    {
        CleanupDeadReferences();
        return _aliveEnemies.Count;
    }

    /// <summary>
    /// 総召喚数を返します。
    /// </summary>
    public int GetTotalSpawnedCount()
    {
        return _totalSpawnedCount;
    }

    /// <summary>
    /// メインの召喚ループ。
    /// コルーチンで一定間隔ごとに敵を召喚します。
    /// </summary>
    private IEnumerator SpawnLoop()
    {
        // 最初の待ち時間
        if (firstSpawnDelay > 0f)
        {
            yield return new WaitForSeconds(firstSpawnDelay);
        }

        while (_isSpawning)
        {
            // 上限チェック(同時存在数)
            CleanupDeadReferences();
            if (maxAliveCount > 0 && _aliveEnemies.Count >= maxAliveCount)
            {
                // 上限に達している場合は、次のチェックまで待つだけ
                yield return new WaitForSeconds(GetNextInterval());
                continue;
            }

            // 上限チェック(総召喚数)
            if (maxTotalSpawnCount > 0 && _totalSpawnedCount >= maxTotalSpawnCount)
            {
                // これ以上召喚できないのでループ終了
                _isSpawning = false;
                yield break;
            }

            // 敵を召喚
            SpawnOneEnemy();

            // 次の召喚まで待機
            yield return new WaitForSeconds(GetNextInterval());
        }
    }

    /// <summary>
    /// 1体の敵を召喚する。
    /// </summary>
    private void SpawnOneEnemy()
    {
        // プレハブをランダム選択
        GameObject prefab = GetRandomEnemyPrefab();
        if (prefab == null)
        {
            Debug.LogWarning($"[Summoner] 有効な enemyPrefabs がありません: {name}", this);
            return;
        }

        // 召喚位置を計算
        Vector3 spawnPos = GetSpawnPosition();
        Quaternion spawnRot = Quaternion.identity;

        // 実際にインスタンス化
        GameObject enemy = Instantiate(prefab, spawnPos, spawnRot);

        // 管理リストに追加
        _aliveEnemies.Add(enemy);
        _totalSpawnedCount++;
    }

    /// <summary>
    /// 有効な敵プレハブをランダムに1つ取得する。
    /// </summary>
    private GameObject GetRandomEnemyPrefab()
    {
        // null が含まれている可能性もあるので一度フィルタリング
        var validList = enemyPrefabs.FindAll(p => p != null);
        if (validList.Count == 0) return null;

        int index = Random.Range(0, validList.Count);
        return validList[index];
    }

    /// <summary>
    /// 実際の召喚位置を計算する。
    /// useOwnPosition / customSpawnPoint / randomOffsetRange を考慮。
    /// </summary>
    private Vector3 GetSpawnPosition()
    {
        Vector3 basePos;

        if (useOwnPosition || customSpawnPoint == null)
        {
            basePos = transform.position;
        }
        else
        {
            basePos = customSpawnPoint.position;
        }

        // ランダムオフセット(XZ平面)
        if (randomOffsetRange != Vector2.zero)
        {
            float offsetX = Random.Range(-randomOffsetRange.x, randomOffsetRange.x);
            float offsetZ = Random.Range(-randomOffsetRange.y, randomOffsetRange.y);
            basePos += new Vector3(offsetX, 0f, offsetZ);
        }

        return basePos;
    }

    /// <summary>
    /// 次の召喚までのインターバルを計算する。
    /// spawnIntervalRandomRange を考慮。
    /// </summary>
    private float GetNextInterval()
    {
        if (spawnIntervalRandomRange <= 0f)
        {
            return Mathf.Max(0.01f, spawnInterval);
        }

        float half = spawnIntervalRandomRange * 0.5f;
        float randomOffset = Random.Range(-half, half);
        float interval = spawnInterval + randomOffset;

        // マイナスにならないように下限を設ける
        return Mathf.Max(0.01f, interval);
    }

    /// <summary>
    /// すでに Destroy 済みの敵をリストから取り除く。
    /// </summary>
    private void CleanupDeadReferences()
    {
        // null になっている要素を削除
        _aliveEnemies.RemoveAll(e => e == null);
    }

    /// <summary>
    /// Scene ビューで召喚位置・範囲を可視化する。
    /// </summary>
    private void OnDrawGizmosSelected()
    {
        if (!drawGizmos) return;

        // ギズモの色を設定
        Gizmos.color = gizmoColor;

        // 基準位置
        Vector3 basePos;
        if (useOwnPosition || customSpawnPoint == null)
        {
            basePos = transform.position;
        }
        else
        {
            basePos = customSpawnPoint.position;
        }

        // 中心を球で表示
        Gizmos.DrawSphere(basePos, 0.2f);

        // ランダムオフセット範囲を矩形で表示(XZ平面)
        if (randomOffsetRange != Vector2.zero)
        {
            Vector3 size = new Vector3(
                randomOffsetRange.x * 2f,
                0.01f,
                randomOffsetRange.y * 2f
            );
            Gizmos.DrawWireCube(basePos, size);
        }
    }

    /// <summary>
    /// Summoner が破棄されるときにコルーチンを止める。
    /// </summary>
    private void OnDestroy()
    {
        StopSummon();
        _aliveEnemies.Clear();
    }
}

使い方の手順

ここでは「雑魚敵を定期的に湧かせる敵スポナー」として使う具体例を見ていきましょう。

  1. 敵プレハブを用意する
    • シーン上に「Enemy」オブジェクトを作成し、見た目(Mesh / Sprite)や当たり判定(Collider)、移動用スクリプトなどを設定します。
    • 完成したら、そのオブジェクトを Project ウィンドウにドラッグ&ドロップしてプレハブ化します(例: EnemyWeak)。
    • 必要なら、別バリエーション(EnemyFastEnemyTank など)も同様にプレハブ化しておきましょう。
  2. Summoner 用の空オブジェクトを作る
    • シーン上で 右クリック > Create Empty を選び、名前を EnemySummoner などに変更します。
    • このオブジェクトの位置が「召喚の基準地点」になります。
      たとえばステージの左端に置けば「左から敵が湧いてくる」イメージですね。
    • EnemySummonerSummoner.cs をアタッチします。
  3. インスペクターでパラメータを設定する
    • Enemy Prefabs: 用意した敵プレハブを 1 個以上ドラッグ&ドロップします。
      複数入れた場合は、毎回ランダムでどれか1つが召喚されます。
    • Use Own Position: EnemySummoner の位置から湧かせたいなら ON のままでOK。
    • Custom Spawn Point: 特定の Empty をスポーン位置にしたい場合だけ指定します(Use Own Position を OFF にする)。
    • Random Offset Range: 例えば (5, 5) にすると、基準位置の周囲 XZ ±5 の範囲にランダムで出現します。
    • Auto Start On Awake: シーン開始と同時に召喚を始めたいなら ON。
    • First Spawn Delay: 最初の召喚までの待ち時間(例: 2秒)。
    • Spawn Interval: 召喚間隔(例: 3秒ごと)。
    • Spawn Interval Random Range: 例えば 2 にすると「3秒 ±1秒」の間隔で少しランダムに湧きます。
    • Max Alive Count: 同時に存在できる敵の上限(例: 10)。0 なら無制限。
    • Max Total Spawn Count: 合計で何体まで召喚するか(例: 50)。0 なら無制限。
  4. 実際のシーンで使う具体例
    • プレイヤー vs 雑魚ラッシュ
      ステージ両端に EnemySummoner_LeftEnemySummoner_Right を置いて、左右から雑魚が押し寄せるようにします。
      左右で Enemy Prefabs の種類や Spawn Interval を変えると、難易度調整がしやすいです。
    • ボス戦中に雑魚を呼ぶ召喚士
      ボスの子オブジェクトとして BossSummoner を配置し、Use Own Position を ON にしておけば、ボスの周囲から雑魚が湧き続けます。
      ボスがフェーズ2に移行したタイミングで Summoner.StartSummon() を呼ぶ、といった制御も簡単です。
    • 動く床の上から敵が落ちてくる
      動く床オブジェクトに Summoner をアタッチしておけば、その床の移動に合わせて召喚位置も動いてくれます。
      Random Offset Range を少し広めにしておくと、「床の上のどこかから敵が落ちてくる」演出が作れます。

メリットと応用

Summoner コンポーネントを使う最大のメリットは、「敵の出現ロジックをプレイヤーやゲーム進行のスクリプトから完全に切り離せる」ことです。

  • プレハブ管理がシンプルになる
    敵の種類を増やしたいときは、単に新しい敵プレハブを作って Enemy Prefabs に追加するだけ。
    既存のプレイヤーコードや GameManager を触らずに、敵のバリエーションを増やせます。
  • レベルデザインが楽になる
    シーン上に Summoner をぽんぽん置いて、位置とパラメータを変えるだけで「このエリアは雑魚が多い」「ここは間隔を短くして緊張感アップ」などを視覚的に調整できます。
    スクリプトをいじらなくても、レベルデザイナーがインスペクターだけで調整できるのも嬉しいポイントですね。
  • 責務が小さいのでテストしやすい
    Summoner は「召喚する」ことだけに責務を絞っているので、バグの切り分けがしやすくなります。
    「敵が動かない」のか「敵が出ていない」のか、どこに問題があるかが明確になります。

さらに、少しコードを足すだけで応用の幅が広がります。
例えば「プレイヤーが一定距離まで近づいたら召喚を開始する」トリガーを付けるのも簡単です。


    /// <summary>
    /// 指定したターゲットとの距離が threshold 以下になったら召喚を開始する。
    /// (例: プレイヤーが近づいたら敵が湧き始める)
    /// </summary>
    public void StartSummonWhenNear(Transform target, float threshold)
    {
        StartCoroutine(StartWhenNearRoutine(target, threshold));
    }

    private IEnumerator StartWhenNearRoutine(Transform target, float threshold)
    {
        // ターゲットが存在し、まだ召喚を開始していない間は距離を監視
        while (target != null && !IsSpawning)
        {
            float distance = Vector3.Distance(transform.position, target.position);
            if (distance <= threshold)
            {
                StartSummon();
                yield break;
            }
            yield return null;
        }
    }

このように、小さなコンポーネントとして Summoner を育てていくと、「敵の湧き方」を自由自在にデザインできるようになります。
巨大な God クラスにロジックを詰め込まず、「召喚は Summoner に任せる」という発想で、スッキリしたプロジェクト構成を目指していきましょう。