Unity初心者が無謀にも2Dタワーディフェンスゲームを制作しています。今回はウェーブ機能の実装です。
- Mac mini (M1, 2020)
- Unity 2022.3.36f1
ウェーブの設定を管理する
ウェーブの設定データをどのように管理しましょうか。ScriptableObject, JSON, CSVなどが思いつきますね。コードにベタ書きすることもできます。今回はScriptableObjectを使うことにしました。
WaveSettingスクリプトを作成します。
using System;
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(fileName = "WaveSetting", menuName = "ScriptableObject/Wave Setting")]
public class WaveSetting : ScriptableObject
{
// ウェーブのデータを格納するリスト
[SerializeField] private List<WaveData> waveDataList = new List<WaveData>();
public List<WaveData> WaveDataList => waveDataList;
// ウェーブのデータ構造
[Serializable]
public class WaveData
{
[SerializeField] private int stageId; // ステージID
public int StageId => stageId;
[SerializeField] private int waveId; // ウェーブID
public int WaveId => waveId;
[SerializeField] private string enemyId; // 敵の種類
public string EnemyId => enemyId;
[SerializeField] private int enemyCount; // 敵の数
public int EnemyCount => enemyCount;
[SerializeField] private float spawnInterval; // 出現間隔
public float SpawnInterval => spawnInterval;
[SerializeField] private PathData path; // 移動経路
public PathData Path => path;
}
}
ウェーブ設定のパラメータは以下のようにしました。
- ステージID
- ウェーブID
- 敵の種類
- 敵の数
- 出現間隔
- 移動経路
DBManagerスクリプトを修正して、ウェーブの設定データを他のスクリプトから扱えるようにします。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class DBManager : MonoBehaviour
{
// DBManagerのインスタンスを保持する静的変数
public static DBManager Instance { get; private set; }
// 敵の設定データを保持する変数
[SerializeField] private EnemySetting enemySetting;
public EnemySetting EnemySetting => enemySetting;
// 砲台の設定データを保持する変数
[SerializeField] private TurretSetting turretSetting;
public TurretSetting TurretSetting => turretSetting;
// ここから 追加
// ウェーブの設定データを保持する変数
[SerializeField] private WaveSetting waveSetting;
public WaveSetting WaveSetting => waveSetting;
// ここまで
void Awake()
{
// ...省略...
}
}
「Assets/Data」フォルダで右クリック「Create > ScriptabelObject > Wave Setting」を選択して、WaveSettingアセットを作成します。

WaveSettingのインスペクターでウェーブデータを登録していきます。

ウェーブを実装する
ウェーブを発生させる
GameManagerスクリプトを修正します。
using UnityEngine;
using UnityEngine.EventSystems;
public class GameManager : MonoBehaviour
{
[SerializeField] private SideBarManager sideBarManager;
[SerializeField] private GameObject tileHighlighter; // ハイライト用オブジェクト
public GameObject TileHighlighter => tileHighlighter; // tileHighlighterにアクセスするためのプロパティ
private Vector2 tileSize = new Vector2(1, 1); // マス目のサイズ(必要に応じて変更)
// ここから 追加
private int currentStageId = 1; // 現在のステージID
public int CurrentStageId => currentStageId;
// ここまで
// ...省略...
}
現在のステージIDを入れておく変数を追加しました。
今のところ1で固定ですが、あとでプレイするステージに応じて値が変わるようにします。
EnemySpawnerスクリプトを修正します。
using System.Collections;
// ここから 追加
using System.Collections.Generic; // Listを使うために必要
// ここまで
using UnityEngine;
public class EnemySpawner : MonoBehaviour
{
[SerializeField] private EnemyController enemyPrefab; // 敵のプレハブ
// ここから 削除
//[SerializeField] private PathData[] pathDataArray; // 移動経路情報の配列
//[SerializeField] private int maxSpawnCount; // 敵の最大生成数
//[SerializeField] private float spawnInterval; // 敵を生成する間隔(単位は秒)
// ここまで
[SerializeField] private GameManager gameManager; // GameManagerへの参照
// ここから 削除
//private int spawnedEnemyCount = 0; // これまでに生成された敵の数
//private bool isSpawning = false; // 敵を生成するかどうかを制御するフラグ
// ここまで
// ここから 追加
[SerializeField] private WaveSetting waveSetting; // ウェーブ設定
[SerializeField] private EnemySetting enemySetting; // 敵の設定
private int currentWaveId = 0; // 現在のウェーブID
private List<WaveSetting.WaveData> currentStageWaves; // 現在のステージのウェーブリスト
// ここまで
private void Start()
{
// ここから 削除
//isSpawning = true; // 敵を生成可能にする
//StartCoroutine(ManageEnemySpawning());
// ここまで
// ここから 追加
// 現在のステージに対応するウェーブをフィルタリング
currentStageWaves = waveSetting.WaveDataList.FindAll(wave => wave.StageId == gameManager.CurrentStageId);
if (currentStageWaves.Count == 0)
{
Debug.LogError($"ステージID {gameManager.CurrentStageId} に対応するウェーブが見つかりません。");
return;
}
StartCoroutine(ManageWaves());
// ここまで
}
// ここから 削除
/// <summary>
/// 敵の生成管理
/// </summary>
//private IEnumerator ManageEnemySpawning()
//{
// while (isSpawning) // 敵を生成可能ならば
// {
// yield return new WaitForSeconds(spawnInterval); // 指定した秒数だけ待機
// SpawnEnemy(); // 敵生成
// AddEnemyToList(); // 敵の情報をListに追加
// CheckSpawnLimit(); // 最大生成数を超えたら敵の生成停止
// }
//}
// ここまで
// ここから 追加
/// <summary>
/// ウェーブを管理するコルーチン
/// </summary>
private IEnumerator ManageWaves()
{
while (currentWaveId < currentStageWaves.Count)
{
// 現在のウェーブデータを取得
WaveSetting.WaveData currentWave = currentStageWaves[currentWaveId];
// ウェーブ開始
Debug.Log($"Wave {currentWave.WaveId} 開始");
yield return StartCoroutine(SpawnWave(currentWave));
// 次のウェーブまで30秒待機
yield return new WaitForSeconds(30f);
// 次のウェーブに進む
currentWaveId++;
}
Debug.Log("すべてのウェーブが終了しました!");
}
/// <summary>
/// 指定されたウェーブの敵を生成する
/// </summary>
/// <param name="waveData">ウェーブデータ</param>
private IEnumerator SpawnWave(WaveSetting.WaveData waveData)
{
for (int i = 0; i < waveData.EnemyCount; i++)
{
SpawnEnemy(waveData);
yield return new WaitForSeconds(waveData.SpawnInterval);
}
}
// ここまで
/// <summary>
/// 敵を生成する
/// </summary>
// ここから 追加
/// <param name="waveData">ウェーブデータ</param>
// ここまで
// ここから 修正
//private void SpawnEnemy()
private void SpawnEnemy(WaveSetting.WaveData waveData)
// ここまで
{
// ここから 追加
// EnemyId に基づいて敵データを取得
EnemySetting.EnemyData enemyData = enemySetting.EnemyDataList.Find(e => e.EnemyId == waveData.EnemyId);
if (enemyData == null)
{
Debug.LogError($"EnemyId '{waveData.EnemyId}' に対応する敵データが見つかりません。");
return;
}
// ここまで
// ここから 削除
// ランダムな経路を選択
//PathData selectedPath = pathDataArray[Random.Range(0, pathDataArray.Length)];
// ここまで
// 経路のスタート地点にプレハブから敵を生成
// ここから 修正
//EnemyController enemyController = Instantiate(enemyPrefab, selectedPath.StartPosition.position, Quaternion.identity);
EnemyController enemyController = Instantiate(enemyPrefab, waveData.Path.StartPosition.position, Quaternion.identity);
// ここまで
// ここから 削除
// ランダムな敵を選択
//int enemyId = Random.Range(0, DBManager.Instance.EnemySetting.EnemyDataList.Count);
// ここまで
// 敵データの初期化
// ここから 修正
//enemyController.InitializeEnemy(selectedPath, gameManager, DBManager.Instance.EnemySetting.EnemyDataList[enemyId]);
enemyController.InitializeEnemy(waveData.Path, gameManager, enemyData);
// ここまで
}
// ここから 削除
/// <summary>
/// 敵の情報をListに追加
/// </summary>
//private void AddEnemyToList()
//{
// spawnedEnemyCount++; // 生成した敵の数を増やす
//}
/// <summary>
/// 敵の生成が上限に達したかを確認
/// </summary>
//private void CheckSpawnLimit()
//{
// if (spawnedEnemyCount >= maxSpawnCount) // 敵の最大生成数を超えたら
// {
// isSpawning = false; // 敵を生成不可にする
// }
//}
// ここまで
}
- 以下の変数は不要になるので削除しました。
pathDataArray
: 移動経路情報の配列maxSpawnCount
: 敵の最大生成数spawnInterval
: 敵を生成する間隔spawnedEnemyCount
: これまでに生成された敵の数isSpawning
: 敵を生成するかどうかを制御するフラグ
- 以下の変数を追加しました。
waveSetting
: ウェーブ設定enemySetting
: 敵の設定currentWaveId
: 現在のウェーブIDcurrentStageWaves
: 現在のステージのウェーブリスト
Start()
でManageEnemySpawning()
を呼び出すのをやめて、ManageWaves()
を呼び出しました。また、現在のステージのウェーブリストをcurrentStageWaves
に代入しました。ManageEnemySpawning()
コルーチンは使わなくなったので削除します。- 代わりに
ManageWaves()
コルーチンを追加しました。- このコルーチンは、
currentStageWaves
リストに入っているウェーブ情報に従って30秒おきにウェーブを生成します。 - ウェーブの生成は
SpawnWave()
コルーチンに任せています。
- このコルーチンは、
SpawnWave()
コルーチンを追加しました。- このコルーチンは、引数として渡されたウェーブ情報に従って
SpawnInterval
秒おきに敵を生成します。 - 敵の生成は
SpawnEnemy()
メソッドが行います。
- このコルーチンは、引数として渡されたウェーブ情報に従って
SpawnEnemy()
メソッドを修正しました。- 引数として
waveData
を取るようにしました。 waveData
を元に敵のデータを取得して敵を生成するようにしました。
- 引数として
AddEnemyToList()
メソッドは使わなくなったので削除しました。
EnemySpawnerオブジェクトのインスペクターで「Enemy Spawner (Script) > Wave Setting」に「Data > WaveSetting」を、「Enemy Setting」に「EnemySetting」をドラッグ&ドロップしてアサインします。

以上で、30秒おきにウェーブが発生するようになりました。いえい。
UIの表示
今のところ、ウェーブが発生してもウェーブ関連のUIは動いていません。今、何ウェーブ中の何ウェーブ目なのか、次のウェーブまであと何秒なのかの表示を実装します。
EnemySpawnerスクリプトを修正します。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// ここから 追加
using TMPro; // TextMeshProを使うために必要
// ここまで
public class EnemySpawner : MonoBehaviour
{
[SerializeField] private EnemyController enemyPrefab; // 敵のプレハブ
[SerializeField] private GameManager gameManager; // GameManagerへの参照
[SerializeField] private WaveSetting waveSetting; // ウェーブ設定
[SerializeField] private EnemySetting enemySetting; // 敵の設定
private int currentWaveId = 0; // 現在のウェーブID
private List<WaveSetting.WaveData> currentStageWaves; // 現在のステージのウェーブリスト
// ここから 追加
[SerializeField] private TextMeshProUGUI waveText; // ウェーブテキスト
[SerializeField] private TextMeshProUGUI timeToNextWaveText; // 次のウェーブまでの時間テキスト
private float timeRemaining; // 残り時間を管理するフィールド
// ここまで
private void Start()
{
// ...省略...
}
/// <summary>
/// ウェーブを管理するコルーチン
/// </summary>
private IEnumerator ManageWaves()
{
while (currentWaveId < currentStageWaves.Count)
{
// 現在のウェーブデータを取得
WaveSetting.WaveData currentWave = currentStageWaves[currentWaveId];
// ここから 追加
// ウェーブテキストを更新
waveText.text = $"{currentWaveId + 1}/{currentStageWaves.Count}";
// ここまで
// ウェーブ開始
Debug.Log($"Wave {currentWave.WaveId} 開始");
// ここから 修正
//yield return StartCoroutine(SpawnWave(currentWave));
// 次のウェーブまで30秒待機
//yield return new WaitForSeconds(30f);
// 敵の生成を開始
StartCoroutine(SpawnWave(currentWave));
// カウントダウンを開始
yield return StartCoroutine(StartCountdown(30f));
// ここまで
// 次のウェーブに進む
currentWaveId++;
}
Debug.Log("すべてのウェーブが終了しました!");
}
// ここから 追加
/// <summary>
/// 次のウェーブまでのカウントダウンを開始する
/// </summary>
/// <param name="duration">カウントダウンの秒数</param>
private IEnumerator StartCountdown(float duration)
{
timeRemaining = duration;
while (timeRemaining > 0)
{
timeToNextWaveText.text = $": {Mathf.CeilToInt(timeRemaining)}s";
yield return new WaitForSeconds(1f);
timeRemaining -= 1f;
}
}
// ここまで
// ...省略...
}
- 以下の変数を追加しました。
waveText
: 現在のウェーブ番号を表示するテキストtimeToNextWaveText
: 次のウェーブまでの時間を表示するテキストtimeRemaining
: 残り時間を管理するフィールド
ManageWaves()
でwaveText
を更新します。- 30秒の待機を
StartCountdown()
コルーチンに任せることにしました。 StartCountdown()
コルーチンを追加しました。- 残り時間(
timeRemaining
)を減少させながら、次のウェーブまでの時間(timeToNextWaveText
)を更新します。
- 残り時間(
EnemySpawnerオブジェクトのインスペクターで「Enemy Spawner (Script) > Wave Text」に「WaveText」オブジェクトを、「Time To Next Wave Text」に「TimeToNextWaveText」オブジェクトをドラッグ&ドロップしてアサインします。

次のウェーブボタン
「次のWAVE」ボタンを押したら、30秒を待たずに次のウェーブが開始するようにします。
EnemySpawnerスクリプトを修正します。
using System.Collections;
using System.Collections.Generic; // Listを使うために必要
using UnityEngine;
// ここから 追加
using UnityEngine.EventSystems;
using UnityEngine.UI;
// ここまで
using TMPro; // TextMeshProを使うために必要
public class EnemySpawner : MonoBehaviour
{
[SerializeField] private EnemyController enemyPrefab; // 敵のプレハブ
[SerializeField] private GameManager gameManager; // GameManagerへの参照
[SerializeField] private WaveSetting waveSetting; // ウェーブ設定
[SerializeField] private EnemySetting enemySetting; // 敵の設定
private int currentWaveId = 0; // 現在のウェーブID
private List<WaveSetting.WaveData> currentStageWaves; // 現在のステージのウェーブリスト
[SerializeField] private TextMeshProUGUI waveText; // ウェーブテキスト
[SerializeField] private TextMeshProUGUI timeToNextWaveText; // 次のウェーブまでの時間テキスト
private float timeRemaining; // 残り時間を管理するフィールド
// ここから 追加
[SerializeField] private Button nextWaveButton; // 次のウェーブボタン
private Coroutine countdownCoroutine; // カウントダウンコルーチンの参照
private bool isWaveSkipped = false; // ウェーブがスキップされたかどうかを判定
// ここまで
private void Start()
{
// 現在のステージに対応するウェーブをフィルタリング
currentStageWaves = waveSetting.WaveDataList.FindAll(wave => wave.StageId == gameManager.CurrentStageId);
if (currentStageWaves.Count == 0)
{
Debug.LogError($"ステージID {gameManager.CurrentStageId} に対応するウェーブが見つかりません。");
return;
}
// ここから 追加
// ボタンにクリックイベントを登録
nextWaveButton.onClick.AddListener(SkipToNextWave);
// ここまで
StartCoroutine(ManageWaves());
}
/// <summary>
/// ウェーブを管理するコルーチン
/// </summary>
private IEnumerator ManageWaves()
{
while (currentWaveId < currentStageWaves.Count)
{
// 現在のウェーブデータを取得
WaveSetting.WaveData currentWave = currentStageWaves[currentWaveId];
// ウェーブテキストを更新
waveText.text = $"{currentWaveId + 1}/{currentStageWaves.Count}";
// ここから 追加
// 最後のウェーブの場合、ボタンを無効化
if (currentWaveId == currentStageWaves.Count - 1)
{
nextWaveButton.interactable = false;
}
// ここまで
// ウェーブ開始
Debug.Log($"Wave {currentWave.WaveId} 開始");
// 敵の生成を開始
StartCoroutine(SpawnWave(currentWave));
// カウントダウンを開始
// ここから 修正
//yield return StartCoroutine(StartCountdown(30f));
countdownCoroutine = StartCoroutine(StartCountdown(30f));
// ここまで
// ここから 追加
// カウントダウンが終了するか、ボタンでスキップされるまで待機
while (countdownCoroutine != null && !isWaveSkipped)
{
yield return null;
}
// ここまで
// 次のウェーブに進む
currentWaveId++;
// ここから 追加
isWaveSkipped = false; // スキップ状態をリセット
// ここまで
}
Debug.Log("すべてのウェーブが終了しました!");
}
/// <summary>
/// 次のウェーブまでのカウントダウンを開始する
/// </summary>
/// <param name="duration">カウントダウンの秒数</param>
private IEnumerator StartCountdown(float duration)
{
timeRemaining = duration;
while (timeRemaining > 0)
{
// ここから 追加
if (isWaveSkipped) yield break; // スキップされた場合、カウントダウンを終了
// ここまで
timeToNextWaveText.text = $": {Mathf.CeilToInt(timeRemaining)}s";
yield return new WaitForSeconds(1f);
timeRemaining -= 1f;
}
// ここから 追加
countdownCoroutine = null; // カウントダウンを終了
// ここまで
}
// ...省略...
// ここから 追加
/// <summary>
/// 次のウェーブへスキップする
/// </summary>
private void SkipToNextWave()
{
if (countdownCoroutine != null)
{
StopCoroutine(countdownCoroutine); // カウントダウンを停止
countdownCoroutine = null;
}
isWaveSkipped = true; // スキップフラグを設定
// ボタンの選択状態を解除
EventSystem.current.SetSelectedGameObject(null);
}
// ここまで
}
- 以下の変数を追加しました。
nextWaveButton
: 次のウェーブボタンcountdownCoroutine
: カウントダウンコルーチンの参照isWaveSkipped
: ウェーブがスキップされたかどうかを判定するフラグ
Start()
でnextWaveButton
にクリックイベントを登録しました。ManageWaves()
を修正しました。- 最後のウェーブの場合、ボタンを無効化する処理の追加。
StartCountdown()
コルーチンをcountdownCoroutine
変数に代入。- カウントダウンが終了するか、ボタンでスキップされるまで待機する処理を追加。
isWaveSkipped
をリセット。
StartCountdown()
を修正しました。- スキップされた場合、カウントダウンを終了する処理を追加。
- カウントダウンが終了したときに
countdownCoroutine
にnull
を代入。
SkipToNextWave()
メソッドを追加しました。- ボタンが押されたらカウントダウンを停止して、
countdownCoroutine
にnull
を代入。 isWaveSkipped
にtrue
を代入。- ボタンの選択状態を解除する。
- ボタンが押されたらカウントダウンを停止して、
EnemySpawnerオブジェクトのインスペクターで「Enemy Spawner (Script) > Next Wave Button」にNextWaveButtonオブジェクトをドラッグ&ドロップしてアサインします。

ウェーブ関連の報酬
ウェーブの実装ができたので、ウェーブ関連の報酬を獲得する処理を追加します。
- ウェーブをスキップしたら、次のウェーブまでの残り時間に応じてゴールドを獲得。
- ウェーブ開始時に、現在の城のHPに応じてスコアを獲得。
ウェーブをスキップしたときの報酬
EnemySpawnerスクリプトを修正します。
// ...省略...
public class EnemySpawner : MonoBehaviour
{
// ...省略...
/// <summary>
/// 次のウェーブへスキップする
/// </summary>
private void SkipToNextWave()
{
if (countdownCoroutine != null)
{
StopCoroutine(countdownCoroutine); // カウントダウンを停止
countdownCoroutine = null;
}
isWaveSkipped = true; // スキップフラグを設定
// ここから 追加
// 残り時間に応じてゴールドを獲得
float goldMultiplier = 1f; // 1秒あたりのゴールド獲得量
int goldEarned = Mathf.CeilToInt(timeRemaining * goldMultiplier);
GoldManager.Instance.AddGold(goldEarned);
Debug.Log($"次のウェーブをスキップしました。獲得ゴールド: {goldEarned}");
// ここまで
// ボタンの選択状態を解除
EventSystem.current.SetSelectedGameObject(null);
}
}
ウェーブをスキップしたときに、残り時間1秒につき1ゴールドを獲得する処理を追加しました。
次のウェーブが始まったときの報酬
EnemySpawnerスクリプトを修正します。
// ...省略...
public class EnemySpawner : MonoBehaviour
{
// ...省略...
/// <summary>
/// ウェーブを管理するコルーチン
/// </summary>
private IEnumerator ManageWaves()
{
while (currentWaveId < currentStageWaves.Count)
{
// 現在のウェーブデータを取得
WaveSetting.WaveData currentWave = currentStageWaves[currentWaveId];
// ウェーブテキストを更新
waveText.text = $"{currentWaveId + 1}/{currentStageWaves.Count}";
// 最後のウェーブの場合、ボタンを無効化
if (currentWaveId == currentStageWaves.Count - 1)
{
nextWaveButton.interactable = false;
}
// ここから 追加
// 第1ウェーブ以外の場合、城のHPに応じてスコアを加算
if (currentWaveId > 0)
{
int scoreEarned = FortressController.Instance.CurrentHp * 10; // HP × 10 のスコアを加算
ScoreManager.Instance.AddScore(scoreEarned);
Debug.Log($"ウェーブ {currentWaveId + 1} 開始時にスコア {scoreEarned} を獲得しました。");
}
// ここまで
// ウェーブ開始
Debug.Log($"Wave {currentWave.WaveId} 開始");
// 敵の生成を開始
StartCoroutine(SpawnWave(currentWave));
// カウントダウンを開始
countdownCoroutine = StartCoroutine(StartCountdown(30f));
// カウントダウンが終了するか、ボタンでスキップされるまで待機
while (countdownCoroutine != null && !isWaveSkipped)
{
yield return null;
}
// 次のウェーブに進む
currentWaveId++;
isWaveSkipped = false; // スキップ状態をリセット
}
Debug.Log("すべてのウェーブが終了しました!");
}
// ...省略...
}
第1ウェーブ以外のウェーブが開始したときに、城のHP×10のスコアを獲得する処理を追加しました。
FortressControllerスクリプトを修正します。
using UnityEngine;
using UnityEngine.UI;
public class FortressController : MonoBehaviour
{
// ここから 追加
// シングルトンインスタンス
public static FortressController Instance { get; private set; }
// ここまで
[SerializeField, Header("最大HP")] private int maxHp = 20; // 防衛拠点の最大HP
[SerializeField, Header("現在のHP")] private int currentHp; // 防衛拠点の現在のHP
// ここから 追加
public int CurrentHp => currentHp; // 現在のHPを取得するプロパティ
// ここまで
[SerializeField, Header("HP表示スライダー")] private Slider hpSlider; // HPを表示するスライダー
// ここから 追加
private void Awake()
{
// シングルトンパターンの実装
if (Instance == null)
{
Instance = this;
}
else if (Instance != this)
{
Destroy(gameObject);
}
}
// ここまで
// ...省略...
}
城の現在のHP(currentHp
)をEnemySpawnerから参照するために、シングルトンパターンを実装しました。
動作確認
ゲームを実行して動作確認をします。以下の機能が実装されていることがわかります。
- トップバーに現在のウェーブ番号と次のウェーブまでのカウントダウンが表示される。
- カウントダウンが終わると次のウェーブが始まる。
- 「次のWAVE」ボタンが押されるとカウントダウンの途中でも次のウェーブが始まる。
- ウェーブをスキップしたらゴールドを獲得。
- ウェーブ開始時にスコアを獲得(第1ウェーブを除く)。
さいごに
正直なところ、このウェーブの実装が難関だと思っていたので、うまくいってホッとしております。完成が見えてきましたね。
でわでわ
コメント