学習記事一覧 · Unity本格入門

Unity本格入門:いきのこバトルで学ぶ敵スポーンと NavMesh

題材・出典: 技術評論社刊『作って学べる Unity本格入門[Unity 6対応版]』(賀好 昭仁 著)に基づく学習補助の解説です。書籍の代替提供を目的とせず、コード掲載は学習上必要な範囲(必要最小限)にとどめます。利用条件は書籍記載(P.4〜5)および出版社サポート情報に従ってください。本シリーズ目次(書籍・著作の注記)

対象読者:第3回まで読み、プレイヤー周りの流れを把握したあと、敵の生成と上限管理を追いたい方
Spawner.csSpawnerManager.cs を読み、コルーチン・InstantiateNavMesh・難易度との関係を整理します。

前提第2回:メインシーンの時間と昼夜PassedDay(経過日数)が、スポーン間隔や距離の計算に使われます。


記事の目次

ポイント一覧


この記事で扱う範囲

  • 対象ファイルSpawner.csSpawnerManager.cs
  • 前提知識:シーンに NavMesh がベイクされており、敵プレハブに NavMeshAgent がある想定(3D で経路探索させる一般的な構成です)

NavMesh 上への敵生成とスポーンループのイメージ

説明(学習のヒント):コルーチンで 一定間隔に Instantiate し、NavMesh 上の有効な位置に敵を置くイメージです。経過日数に応じた難易度や、距離による削除も記事で追います。


コードを全部見てみよう

SpawnerManager.cs

using System.Collections.Generic;
using System.Linq;
using UnityEngine;
public class SpawnerManager : MonoBehaviour
{
    private const float DistanceLimitFromPlayer = 20f;
    [SerializeField] private PlayerController player;
    [SerializeField] private int max;
    public bool IsSpawnable => _enemies.Count < max;
    private List<GameObject> _enemies = new List<GameObject>();
    public void RefreshEnemies()
    {
        var removeTargetEnemies = _enemies.Where(
            x => null != x
                 && (x.transform.localPosition - player.transform.localPosition).magnitude > DistanceLimitFromPlayer);
        foreach (var enemy in removeTargetEnemies)
        {
            Destroy(enemy.gameObject);
        }
        _enemies = _enemies.Where(x => x != null).ToList();
    }
    public void AddEnemy(GameObject enemy)
    {
        _enemies.Add(enemy);
    }
}

Spawner.cs

using System.Collections;
using UnityEngine;
using UnityEngine.AI;
[RequireComponent(typeof(SpawnerManager))]
public class Spawner : MonoBehaviour
{
    [SerializeField] private PlayerStatus playerStatus;
    [SerializeField] private GameObject enemyPrefab;
    private SpawnerManager _manager;
    private void Start()
    {
        _manager = GetComponent<SpawnerManager>();
        StartCoroutine(SpawnLoop());
    }
    private IEnumerator SpawnLoop()
    {
        while (true)
        {
            var passedDay = MainSceneController.Instance.PassedDay;
            _manager.RefreshEnemies();
            if (_manager.IsSpawnable)
            {
                var distanceVector = new Vector3(5, 0);
                var spawnPositionFromPlayer = Quaternion.Euler(0, Random.Range(0, 360f), 0) * distanceVector *
                                              Random.Range(2.5f - Mathf.Min(0.5f * passedDay, 1.5f), 4f);
                var spawnPosition = playerStatus.transform.position + spawnPositionFromPlayer;
                if (NavMesh.SamplePosition(spawnPosition, out var navMeshHit, 10, NavMesh.AllAreas))
                {
                    var enemy = Instantiate(enemyPrefab, navMeshHit.position, Quaternion.identity);
                    _manager.AddEnemy(enemy);
                }
            }
            yield return new WaitForSeconds(8f - Mathf.Min(2f * passedDay, 6f));
            if (playerStatus.Life <= 0)
            {
                break;
            }
        }
    }
}

クラス関係(この記事で登場する範囲)

classDiagram direction TB class MonoBehaviour class Spawner class SpawnerManager class MainSceneController MonoBehaviour <|-- Spawner MonoBehaviour <|-- SpawnerManager Spawner ..> SpawnerManager : "RequireComponent" Spawner ..> MainSceneController : "PassedDay参照"

説明(学習のヒント)SpawnerSpawnerManager は同じオブジェクトに必ず付く関係(点線の RequireComponent)。難易度は MainSceneController の経過日数を見に行きます。


UMLで整理する(シーケンス)

SpawnLoopMainSceneController の経過日数を参照し、SpawnerManager で敵を管理する関係を表します。

sequenceDiagram participant Sp as Spawner participant MSC as MainSceneController participant SM as SpawnerManager Sp->>MSC: "PassedDay" MSC-->>Sp: "経過日数" Sp->>SM: "AddEnemy"

説明(学習のヒント):スポナーがまず経過日数を聞き、敵を足す処理はマネージャーに任せる、という呼び出しの順番です。


ポイント①:SpawnerSpawnerManager の役割分担

  • Spawner:コルーチンで一定間隔に敵を出したい位置を計算し、Instantiate する
  • SpawnerManager:出現済み敵の List を持ち、遠くに行った敵を消す数の上限を見る

[RequireComponent(typeof(SpawnerManager))] で、同じ GameObject に両方が付くようになっています。


ポイント②:StartCoroutineSpawnLoop

SpawnLoop は無限ループ while (true) ですが、毎周 yield return new WaitForSeconds(...) で待つので、フレームを占有し続けません。プレイヤーが倒れたら break でコルーチンを終えます。


ポイント③:PassedDay と出現位置・間隔の難易度

var passedDay = MainSceneController.Instance.PassedDay;

経過日数が増えると、

  • プレイヤーからのオフセットのランダム範囲が変化(近づきやすくなる)
  • 待ち時間 8f - Mathf.Min(2f * passedDay, 6f) が短くなる(最短 2 秒付近まで)

といった形で、時間経過とともに敵が出やすい方向に振れています。


ポイント④:NavMesh.SamplePositionInstantiate

if (NavMesh.SamplePosition(spawnPosition, out var navMeshHit, 10, NavMesh.AllAreas))
{
    var enemy = Instantiate(enemyPrefab, navMeshHit.position, Quaternion.identity);

ランダムに決めた座標が NavMesh の外だと、エージェントが動けません。一番近い NavMesh 上の点SamplePosition で取得し、そこに敵を生成します。


ポイント⑤:RefreshEnemies:距離で敵を削除する

プレイヤーから 20 以上離れた敵は Destroy し、負荷と敵の滞留を抑えます。Destroy次のフレームで実際に消えるため、その直後に List を掃除する必要があります(次項)。


ポイント⑥:ListLINQ で null を除去する

_enemies = _enemies.Where(x => x != null).ToList();

Destroy された GameObject は参照が null になる性質を利用し、死んだ参照を List から除外しています。


ポイント⑦:IsSpawnable で同時存在数を制限する

public bool IsSpawnable => _enemies.Count < max;

Inspector で max を設定し、場にいる敵の数が上限未満のときだけ新規生成します。RefreshEnemies で数が減れば、また生成可能になります。


コードの流れを整理しよう

flowchart TD spLoop["SpawnLoop 各周期"] spLoop --> day["PassedDay 取得"] spLoop --> refresh["RefreshEnemies"] spLoop --> cap{"IsSpawnable?"} cap -->|"yes"| pos["出現オフセット"] pos --> nav["NavMesh.SamplePosition"] nav --> inst["Instantiate"] cap -->|"no"| waitOnly["待機のみ"] spLoop --> waitSec["WaitForSeconds"] spLoop --> alive{"Life 0?"} alive -->|"yes"| loopExit["ループ終了"]

説明(学習のヒント):ひと周の SpawnLoop で何をするかをまとめた図です。ひし形は「分岐」、上から下へ進むと1周期の流れが追いやすいです。


自分でカスタマイズしてみよう!

挑戦①:maxDistanceLimitFromPlayer

同時敵数と「どのくらい離れたら消すか」はゲームの手触りに直結します。数値を変えて挙動を比較してみましょう。

挑戦②:スポーン間隔の式

8f - Mathf.Min(2f * passedDay, 6f) のグラフを意識すると、何日目から頭打ちになるか説明しやすくなります。


まとめ

  • スポナーがコルーチンで周期処理し、マネージャがリストと上限・距離削除を担当する分業
  • MainSceneController.Instance.PassedDay で難易度パラメータを変える
  • NavMesh.SamplePosition で walkable な位置に Instantiate する
  • Destroy 後の null を List から除き、同時存在数 max で負荷と難易度を調整する

続きは 第5回:UI とメニュー です。


最終更新:2026年4月