Parallels Desktop 35%OFF セール

【Unity】タワーディフェンス(21) ステージの実装【クソゲー制作】

タワーディフェンスを作る(21)

Unity初心者が2Dタワーディフェンスゲームを制作しています。

今回は、複数のステージを実装して、タイトル画面からステージを選択できるようにします。

環境
  • Mac mini (M1, 2020)
  • Unity 2022.3.36f1
目次

ステージマップの作成

このゲームは5つのステージで構成しようと思っています。今のところ、ステージ1のマップしかありませんのでステージ2〜5のマップを作成します。

STEP

まず、城オブジェクト(Fortress)がMapオブジェクトの外にあるので、配下に移動します。

タワーディフェンス216
STEP

Mapオブジェクトの名前を「Map1」に変更して、ステージ1のマップだとわかるようにします。

STEP

Map1オブジェクトを複製して、名前を「Map2」に変更します。これがステージ2のマップになります。

タワーディフェンス217
STEP

Map1を一旦非表示にして、Map2のタイルを修正してマップを作っていきます。

マップの作り方を完全に忘れてしまっていたので過去記事を参照しながらやりました。

  1. タイル画像のインポート
  2. Tile Paletteに画像を登録
  3. Grid_BaseのTilemapに下地となるタイルを配置
  4. Grid_WayのTilemapに道や障害物のタイルを配置
  5. 城(Fortress)の位置を調整

こんな感じのマップができました。ステージ2は敵が2ヶ所から発生するみたいです。

タワーディフェンス218

同じように、ステージ3、4、5のマップも作ります。

STEP

ステージに関する情報はScriptableObjectで管理しようと思います。で、マップオブジェクトをそのScriptableObjectにアサインしたいのですが、できません。ScriptableObjectにはヒエラルキー上のオブジェクトはアサインできないという制約があるようなのです。

この問題を回避するために、マップオブジェクトをプレハブ化します。

タワーディフェンス219

ヒエラルキーのMap1〜Map5は削除します。

STEP

マップオブジェクトをプレハブ化したことにより、マップの配下にあるFortressオブジェクトもプレハブになりました。FortressにアタッチしているFortressControllerhpSliderへのアサインが無効になってしまうので、スクリプトでアサインします。

FortressControllerスクリプトを修正します。

using UnityEngine;
using UnityEngine.UI;

public class FortressController : MonoBehaviour
{
    // ...省略...

    private void Awake()
    {
        // シングルトンパターンの実装
        if (Instance == null)
        {
            Instance = this;
        }
        else if (Instance != this)
        {
            Destroy(gameObject);
        }

        // ここから 追加
        // HPスライダーをアサイン
        if (hpSlider == null)
        {
            var sliderObj = GameObject.Find("FortressHpSlider");
            if (sliderObj != null)
            {
                hpSlider = sliderObj.GetComponent<Slider>();
            }
        }
        // ここまで
    }

    // ...省略...
}

敵の移動経路の作成

ステージ2〜5用の敵の移動経路を作成します。

これまた、作り方を忘れていたので過去記事を参照しました。

  1. 「Assets > Prefabs > Path_1-1」をヒエラルキーにドラッグ&ドロップしてPathオブジェクトを作成
  2. Pathオブジェクトを右クリックして「Prefab > Unpack Completely」を選択して、プレハブとの紐付けを解除
  3. Pathオブジェクトの配下にあるPosition*オブジェクトを追加、削除、位置調整する
  4. インスペクターの「Path Data (Script) > Path Points」にPosition*オブジェクトをアサインして経路を作る

完成したPathオブジェクトを「Assets/Prefabs」にドラッグ&ドロップしてプレハブ化し、名前を「Path_2-1」のように修正します。

ヒエラルキーのPathオブジェクトは削除します。

ウェーブ情報の追加

ステージ2〜5のウェーブ情報を設定します。

「Assets > Data > WaveSetting」のインスペクターでWave Data Listに各ステージのウェーブ情報を追加します。

ステージ選択機能の実装

ステージデータをScriptableObjectで管理する

STEP

StageSettingスクリプトを作成します。

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

[CreateAssetMenu(fileName = "StageSetting", menuName = "ScriptableObject/Stage Setting")]
public class StageSetting : ScriptableObject
{
    // ステージのデータを格納するリスト
    [SerializeField] private List<StageData> stageDataList = new List<StageData>();
    public List<StageData> StageDataList => stageDataList;

    // ステージのデータ構造
    [Serializable]
    public class StageData
    {
        [SerializeField] private int stageId; // ステージのID
        public int StageId => stageId;

        [SerializeField] private GameObject stageMap; // ステージのマップ
        public GameObject StageMap => stageMap;
    }
}
STEP

DBManagerスクリプトを修正します。

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

public class DBManager : MonoBehaviour
{
    // ...省略...

    // ここから 追加
    // ステージの設定データを保持する変数
    [SerializeField] private StageSetting stageSetting;
    public StageSetting StageSetting => stageSetting;
    // ここまで

    void Awake()
    {
        // ...省略...
    }
}
STEP

「Assets > Data」下で右クリック「Create > ScriptableObject > Stage Setting」を選択して、StageSettingアセットを作成します。

タワーディフェンス220
STEP

StageSettingのインスペクターで「Stage Data List」に項目を追加して、ステージ1〜5のStage IdStage Mapを設定します。

タワーディフェンス221

ステージの選択

タイトル画面のステージ選択ボタンを押すと選択したステージが開始するようにします。

STEP

GameManagerスクリプトを修正します。

using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI; // Buttonクラスを使用するために必要
using TMPro; // TextMeshProを使うために必要

public class GameManager : MonoBehaviour
{
    // ...省略...
    // ここから 追加
    [SerializeField] private TextMeshProUGUI stageText; // ステージIDのテキスト
    [SerializeField] private StageSetting stageSetting; // StageSettingの参照
    private GameObject currentStageMap; // 現在表示中のマップ
    public TurretGenerator turretGenerator; // TurretGeneratorの参照
    // ここまで

    // ...省略...

    /// <summary>
    /// ゲーム状態を変更する
    /// </summary>
    /// <param name="newState">新しいゲーム状態</param>
    public void ChangeState(GameState newState)
    {
        CurrentState = newState;

        switch (newState)
        {
            case GameState.Title:
                // ...省略...

            case GameState.Preparing:
                // ...省略...

            case GameState.Playing:
                Debug.Log("ゲーム状態: Playing");
                Time.timeScale = 1; // 時間を通常速度に戻す
                pausePanel.SetActive(false); // ポーズパネルを非表示
                gameOverPanel.SetActive(false); // ゲームオーバーパネルを非表示
                // ボタンのアイコンとテキストを「ポーズ」に設定
                startPauseButtonIcon.sprite = pauseIcon; // pauseIcon を設定
                startPauseButtonText.text = "ポーズ";
                nextWaveButton.interactable = true; // 次のウェーブボタンを活性化
                stageClearPanel.SetActive(false); // ステージクリアパネルを非表示
                // ここから 追加
                // ウェーブ開始
                var spawner = EnemySpawner.Instance;
                if (spawner != null)
                {
                    spawner.StartWaves();
                }
                // ここまで
                break;

            case GameState.Paused:
                // ...省略...

            case GameState.GameOver:
                // ...省略...

            case GameState.Victory:
                // ...省略...
        }
    }

    // ...省略...

    /// <summary>
    /// ステージを選択する
    /// </summary>
    /// <param name="stageId">選択するステージのID</param>
    public void SelectStage(int stageId)
    {
        currentStageId = stageId; // ステージIDを設定

        Debug.Log($"ステージ {stageId} が選択されました。");

        // ここから 追加
        // ステージIDを表示
        if (stageText != null)
        {
            stageText.text = stageId.ToString();
        }

        // 既存のマップを削除
        if (currentStageMap != null)
        {
            Destroy(currentStageMap);
            currentStageMap = null;
        }

        // ステージデータから該当マップを取得して表示
        var stageData = stageSetting.StageDataList.Find(data => data.StageId == stageId);
        if (stageData != null && stageData.StageMap != null)
        {
            currentStageMap = Instantiate(stageData.StageMap);

            // Gridコンポーネントをセット
            var grid = currentStageMap.GetComponentInChildren<Grid>();
            if (grid != null && turretGenerator != null)
            {
                turretGenerator.grid = grid;
            }

            // 建築不可能なタイルマップの設定
            // Grid_Wayオブジェクトを探す
            var gridWay = currentStageMap.transform.Find("Grid_Way");
            if (gridWay != null)
            {
                // Grid_Wayの子のTilemapオブジェクトを探す
                var tilemapObj = gridWay.transform.Find("Tilemap");
                if (tilemapObj != null)
                {
                    var tilemap = tilemapObj.GetComponent<UnityEngine.Tilemaps.Tilemap>();
                    if (tilemap != null && turretGenerator != null)
                    {
                        turretGenerator.unbuildableTilemap = tilemap;
                    }
                }
            }
        }
        else
        {
            Debug.LogWarning($"ステージID {stageId} に対応するマップが見つかりません。");
        }

        // EnemySpawnerのウェーブリストを更新
        var spawner = EnemySpawner.Instance;
        if (spawner != null)
        {
            spawner.SetStageWaves(stageId);
        }
        // ここまで

        // ゲーム状態を Title から Preparing に変更
        if (CurrentState == GameState.Title)
        {
            ChangeState(GameState.Preparing);
        }
    }

    // ...省略...
}
  • 4つの変数を定義しました。
    • stageText: ゲーム画面上でステージ番号を表示するTMPオブジェクト。
    • stageSetting: StageSettingスクリプタブルオブジェクトの参照。
    • currentStageMap: 現在のマップ。
    • turretGenerator: TurretGeneratorスクリプトの参照。
  • ChangeState()内で、ゲーム状態がPlayingのときにウェーブを開始する処理を追加しました。
  • SelectStage()に、以下の処理を追加しました。
    • ゲーム画面にステージ番号を表示する。
    • 直前にプレイしていたステージのマップを削除。
    • これからプレイするステージのマップを表示。
      • Gridコンポーネント(turretGenerator.grid)をセット。
      • 建築不可能なタイルマップ(turretGenerator.unbuildableTilemap)の設定。
STEP

EnemySpawnerスクリプトを修正します。

using System.Collections;
using System.Collections.Generic; // Listを使うために必要
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
using TMPro; // TextMeshProを使うために必要

public class EnemySpawner : MonoBehaviour
{
    // ...省略...

    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>
    /// <param name="stageId">ステージID</param>
    public void SetStageWaves(int stageId)
    {
        currentWaveId = 0;
        currentStageWaves = waveSetting.WaveDataList.FindAll(wave => wave.StageId == stageId);
    }

    /// <summary>
    /// ウェーブを開始する
    /// </summary>
    public void StartWaves()
    {
        StopAllCoroutines(); // 念のため前のコルーチンを停止
        StartCoroutine(ManageWaves());
    }
    // ここまで
}
  • Start()から、以下の処理を削除しました。
    • 現在のステージに対応するウェーブを探す処理を削除しました。この処理はSetStageWaves()に移動します。
    • ManageWaves()コルーチンを開始する処理を削除しました。この処理はStartWaves()に移動します。
  • SetStageWaves()メソッドを追加しました。GameManager.SelectStageCoroutineから呼び出されます。
  • StartWaves()メソッドを追加しました。GameManager.ChangeStateでゲーム状態がPlayingのときに呼び出されます。
STEP

TurretGeneratorスクリプトを修正します。

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Tilemaps;
using UnityEngine.EventSystems;

public class TurretGenerator : MonoBehaviour
{
    // ...省略...
    // ここから 修正
    //[SerializeField] private Grid grid; // グリッドコンポーネント
    [SerializeField] public Grid grid; // グリッドコンポーネント
    //[SerializeField] private Tilemap unbuildableTilemap; // 建築不可能なタイルマップ
    [SerializeField] public Tilemap unbuildableTilemap; // 建築不可能なタイルマップ
    // ここまで

    // ...省略...
}

GameManagerからアクセスできるように、gridunbuildableTilemapのアクセス修飾子をprivateからpublicに変更しました。

STEP

GameManagerのインスペクターで以下の変数にオブジェクトをアサインします。

  • 「Game Manager (Script) > Stage Text」StageTextオブジェクトをアサイン。
  • 「Game Manager (Script) > Stage Setting」StageSettingクリプタブルオブジェクトをアサイン。
  • 「Game Manager (Script) > Turret Generator」TurretGeneratorオブジェクトをアサイン。

ゲームの状態をリセット

現状では、ステージ1をクリアしたあとにステージ2を開始した場合に、マップは更新されますが、それ以外のオブジェクトはステージ1をクリアした直後の状態のまま残ってしまいます。これらを初期化してキレイな状態で次のステージをプレイできるようにします。

STEP

GameManagerスクリプトを修正します。

using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI; // Buttonクラスを使用するために必要
using TMPro; // TextMeshProを使うために必要

public class GameManager : MonoBehaviour
{
    // ...省略...

    /// <summary>
    /// ステージを選択する
    /// </summary>
    /// <param name="stageId">選択するステージのID</param>
    public void SelectStage(int stageId)
    {
        // ...省略...

        // ここから 追加
        // ゲーム全体の初期化
        InitializeGame();
        // ここまで

        // ゲーム状態を Title から Preparing に変更
        if (CurrentState == GameState.Title)
        {
            ChangeState(GameState.Preparing);
        }
    }

    // ここから 追加
    /// <summary>
    /// ゲーム全体の初期化(スコア・ゴールド・城HP・敵・砲台などのリセット)
    /// </summary>
    private void InitializeGame()
    {
        // スコア初期化
        if (ScoreManager.Instance != null)
            ScoreManager.Instance.ResetScore();

        // ゴールド初期化
        if (GoldManager.Instance != null)
            GoldManager.Instance.ResetGold();

        // 城HP初期化
        if (FortressController.Instance != null)
            FortressController.Instance.ResetHp();

        // 敵の生成をリセット
        var spawner = FindObjectOfType<EnemySpawner>();
        if (spawner != null)
            spawner.ResetSpawner();

        // 砲台の設置情報をリセット
        var turretGenerator = FindObjectOfType<TurretGenerator>();
        if (turretGenerator != null)
            turretGenerator.ResetTurretPlacements();

        // tileHighlighterを非表示
        if (tileHighlighter != null)
            tileHighlighter.SetActive(false);

        // マップ上の敵・HPバー・砲台・インジケータ削除
        foreach (var enemy in GameObject.FindGameObjectsWithTag("Enemy"))
            Destroy(enemy);
        foreach (var hpBar in GameObject.FindGameObjectsWithTag("HpBar"))
            Destroy(hpBar);
        foreach (var turret in GameObject.FindGameObjectsWithTag("Turret"))
            Destroy(turret);
        foreach (var indicator in GameObject.FindGameObjectsWithTag("Indicator"))
            Destroy(indicator);
    }
    // ここまで

    // ...省略...
}
  • SelectStage()内で、ゲームの初期化をするメソッドInitializeGame()を実行します。
  • InitializeGame()メソッドを追加します。
    • スコアの初期化
    • ゴールドの初期化
    • 城のHPの初期化
    • 敵の生成の初期化
    • 砲台の設置情報の初期化
    • タイルの強調表示の初期化
    • 敵、敵のHPバー、砲台、砲台のインジケータを削除
STEP

EnemySpawnerスクリプトを修正します。

using System.Collections;
using System.Collections.Generic; // Listを使うために必要
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
using TMPro; // TextMeshProを使うために必要

public class EnemySpawner : MonoBehaviour
{
    // ...省略...

    // ここから 追加
    /// <summary>
    /// 敵の生成をリセットする
    /// </summary>
    public void ResetSpawner()
    {
        StopAllCoroutines(); // すべてのコルーチンを停止

        currentWaveId = 0; // 現在のウェーブ番号をリセット
        aliveEnemyCount = 0; // 生存している敵の数をリセット
        timeRemaining = 0f; // 次のウェーブまでの残り時間をリセット
        isWaveSkipped = false; // ウェーブをスキップしたかどうかのフラグを初期化

        // ウェーブ表示用のテキストが設定されていれば更新
        if (waveText != null)
            waveText.text = currentStageWaves != null && currentStageWaves.Count > 0
                ? $"{currentWaveId + 1}/{currentStageWaves.Count}"
                : "";

        // 次のウェーブまでのカウントダウンテキストが設定されていれば更新
        if (timeToNextWaveText != null)
            timeToNextWaveText.text = $": {Mathf.CeilToInt(timeRemaining)}s";

        // カウントダウン用のコルーチンが動作していれば停止し、参照を解除
        if (countdownCoroutine != null)
        {
            StopCoroutine(countdownCoroutine);
            countdownCoroutine = null;
        }
    }
    // ここまで
}

敵の生成関連の変数をリセットするResetSpawner()メソッドを追加します。

STEP

FortressControllerスクリプトを修正します。

using UnityEngine;
using UnityEngine.UI;

public class FortressController : MonoBehaviour
{
    // ...省略...

    // ここから 追加
    /// <summary>
    /// 防衛拠点のHPをリセットする
    /// </summary>
    public void ResetHp()
    {
        currentHp = maxHp;
        UpdateHpSlider();
    }
    // ここまで
}

城のHPをリセットするResetHp()メソッドを追加しました。

STEP

GoldManagerスクリプトを修正します。

using UnityEngine;
using TMPro; // TextMeshPro を使うために必要
using DG.Tweening; // DOTween を使うために必要

public class GoldManager : MonoBehaviour
{
    // ...省略...

    // ここから 追加
    /// <summary>
    /// ゴールドをリセットする
    /// </summary>
    public void ResetGold()
    {
        currentGold = initialGold;
        UpdateGoldDisplay();
    }
    // ここまで
}

現在のゴールドをリセットするResetGold()メソッドを追加しました。

STEP

ScoreManagerスクリプトを修正します。

using UnityEngine;
using TMPro; // TextMeshPro を使うために必要
using DG.Tweening; // DOTween を使うために必要

public class ScoreManager : MonoBehaviour
{
    // ...省略...

    // ここから 追加
    /// <summary>
    /// スコアをリセットする
    /// </summary>
    public void ResetScore()
    {
        currentScore = 0;
        UpdateScoreDisplay();
    }
    // ここまで
}

現在のスコアをリセットするResetScore()メソッドを追加しました。

STEP

TurretGeneratorスクリプトを修正します。

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Tilemaps;
using UnityEngine.EventSystems;

public class TurretGenerator : MonoBehaviour
{
    // ...省略...

    // ここから 追加
    /// <summary>
    /// すべての砲台設置状態をリセット
    /// </summary>
    public void ResetTurretPlacements()
    {
        placedTurretCells.Clear();
    }
    // ここまで
}

砲台が配置されているセルのリストをリセットするResetTurretPlacements()メソッドを追加しました。

STEP

敵、敵のHPバー、砲台、砲台のインジケータにタグを付けます。

これらはすべてプレハブから生成されるので、「Assets > Prefabs」の中にあるプレハブをダブルクリックして、プレハブの編集モードに入ってからタグを設定します。

プレハブ付加するタグ
EnemyEnemy
HpBarHpBar
TurretTurret
UpgradeIndicatorCanvasIndicator

Enemyにはすでにタグが付いていたので、他の3つのタグを追加しました。

不具合の修正

以上で完了と思ったのですが、実行してみるといくつか不具合があったので修正していきます。

同じステージを繰り返しプレイすると城が消える

同じステージを繰り返しプレイすると、2回目以降に城が表示されないという問題が発生しました。

自力で解決できそうにないので、AI様に聞くことにしました。私はVSCodeGitHub Copilotを入れております。

この現象の主な原因は、同じステージを連続して開始した場合、前回生成したFortress(やMapプレハブ)が正しく削除されていないためです。 UnityのDestroy()は即時ではなくフレームの最後でオブジェクトを削除するため、 「同じプレハブをInstantiateしても、まだ前回のオブジェクトが残っている」状態になり、 新しいFortressが生成されないことがよくあります。

GitHub Copilot様のお言葉

なるほど、こんなん自力で解決できるわけありませんね。

ステージ選択ボタンが押されたときの処理をコルーチンにして、マップ削除後に1フレーム待つようにすれば解決するようです。やってみます。

using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI; // Buttonクラスを使用するために必要
// ここから 追加
using System.Collections; // IEnumeratorを使用するために必要
// ここまで
using TMPro; // TextMeshProを使うために必要

public class GameManager : MonoBehaviour
{
    // ...省略...

    // ここから 追加
    /// <summary>
    /// ステージ選択ボタンが押されたときの処理
    /// </summary>
    /// <param name="stageId">選択されたステージID</param>
    public void SelectStage(int stageId)
    {
        StartCoroutine(SelectStageCoroutine(stageId));
    }
    // ここまで

    // ここから 修正
    /// <summary>
    /// ステージを選択する
    /// </summary>
    /// <param name="stageId">選択するステージのID</param>
    //public void SelectStage(int stageId)
    private IEnumerator SelectStageCoroutine(int stageId)
    // ここまで
    {
        currentStageId = stageId; // ステージIDを設定

        Debug.Log($"ステージ {stageId} が選択されました。");

        // ステージIDを表示
        if (stageText != null)
        {
            stageText.text = stageId.ToString();
        }

        // 既存のマップを削除
        if (currentStageMap != null)
        {
            Destroy(currentStageMap);
            currentStageMap = null;
            // ここから 追加
            yield return null; // 1フレーム待つ
            // ここまで
        }

        // ...省略...
    }

    // ...省略...
}
  • ステージ選択ボタンが押されたときに実行されるメソッドSelectStage()を作り直しました。SelectStageCoroutine()をスタートするだけの簡単なメソッドです。
  • 既存のSelectStage()メソッドは、SelectStageCoroutine()に名前を変えてコルーチン化します。
    • 既存のマップを削除する処理の後ろにyield return nullを追加して1フレーム待ちます。

以上でうまくいきました。いえい。

最終ウェーブのあとにウェーブが発生してしまう

最終ウェーブの発生後にゲームをポーズして再開すると、またウェーブが発生してしまうという問題が発生しました。AI先生に聞いてみましょう。

この現象は、GameStateがPausedからPlayingに戻るたびにspawner.StartWaves()が呼ばれているためです。 その結果、最終ウェーブ後でも再度ウェーブが始まってしまいます。

GitHub Copilot様のお言葉
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI; // Buttonクラスを使用するために必要
using System.Collections; // IEnumeratorを使用するために必要
using TMPro; // TextMeshProを使うために必要

public class GameManager : MonoBehaviour
{
    // ...省略...
    // ここから 追加
    private GameState previousState = GameState.Title; // 前のゲーム状態を保存するための変数
    // ここまで

    // ...省略...

    /// <summary>
    /// ゲーム状態を変更する
    /// </summary>
    /// <param name="newState">新しいゲーム状態</param>
    public void ChangeState(GameState newState)
    {
        // ここから 追加
        previousState = CurrentState; // 前の状態を保存
        // ここまで
        CurrentState = newState;

        switch (newState)
        {
            case GameState.Title:
                // ...省略...

            case GameState.Preparing:
                // ...省略...

            case GameState.Playing:
                Debug.Log("ゲーム状態: Playing");
                Time.timeScale = 1; // 時間を通常速度に戻す
                pausePanel.SetActive(false); // ポーズパネルを非表示
                gameOverPanel.SetActive(false); // ゲームオーバーパネルを非表示
                // ボタンのアイコンとテキストを「ポーズ」に設定
                startPauseButtonIcon.sprite = pauseIcon; // pauseIcon を設定
                startPauseButtonText.text = "ポーズ";
                nextWaveButton.interactable = true; // 次のウェーブボタンを活性化
                stageClearPanel.SetActive(false); // ステージクリアパネルを非表示
                // ここから 修正
                // Preparing -> Playing のときだけウェーブ開始
                if (previousState == GameState.Preparing) // 追加
                { // 追加
                    var spawner = EnemySpawner.Instance;
                    if (spawner != null)
                    {
                        spawner.StartWaves();
                    }
                } // 追加
                // ここまで
                break;

            case GameState.Paused:
                // ...省略...

            case GameState.GameOver:
                // ...省略...

            case GameState.Victory:
                // ...省略...
        }
    }

    // ...省略...
}

ゲーム状態がPlayingに変わると常にウェーブを開始していたのを、PreparingからPlayingに変わったときだけ開始するようにしました。

準備中はボタンを非活性化したい

これは不具合ではないのですが、準備中画面で砲台の強化・削除ボタンが押されると厄介なので、ボタンを押せないようにします。

GameManagerスクリプトを修正します。

using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI; // Buttonクラスを使用するために必要
using System.Collections; // IEnumeratorを使用するために必要
using TMPro; // TextMeshProを使うために必要

public class GameManager : MonoBehaviour
{
    // ...省略...
    // ここから 追加
    public static GameManager Instance { get; private set; }
    // ここまで

    // ここから 追加
    // シングルトンパターンを実装
    private void Awake()
    {
        if (Instance == null)
        {
            Instance = this;
        }
        else if (Instance != this)
        {
            Destroy(gameObject);
        }
    }
    // ここまで

    // ...省略...
}

TurretControllerスクリプトを修正します。

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

public class TurretController : MonoBehaviour
{
    // ...省略...

    /// <summary>
    /// 砲台がクリックされたときの処理
    /// </summary>
    public void OnTurretClicked()
    {
        SideBarManager sideBarManager = FindObjectOfType<SideBarManager>(); // SideBarManagerを取得

        // ここから 追加
        var state = GameManager.Instance.CurrentState;
        bool isPlaying = state == GameManager.GameState.Playing;
        bool isPreparing = state == GameManager.GameState.Preparing;
        // ここまで

        // ここから 修正
        // 砲台情報パネルの表示
        //sideBarManager.ShowTurretInfo(currentTurretData, currentTurretLevelData, true, this);
        // 砲台情報パネルの表示(売却ボタンはプレイ中のみ有効)
        sideBarManager.ShowTurretInfo(currentTurretData, currentTurretLevelData, isPlaying, this);

        // 次のレベルのデータを取得
        TurretSetting.TurretLevelData nextLevelData = DBManager.Instance.TurretSetting.GetTurretData(currentTurretData.TurretId, currentTurretLevelData.Level + 1);
        if (nextLevelData != null) // 次のレベルが存在する場合
        {
            //sideBarManager.ShowTurretUpgradeInfo(currentTurretData, currentTurretLevelData, true); // 強化情報パネルを表示
            // 砲台強化情報パネルの表示
            sideBarManager.ShowTurretUpgradeInfo(currentTurretData, currentTurretLevelData, isPlaying);
            if (isPreparing)
            {
                sideBarManager.SetUpgradeButtonInteractable(false); // 準備中は必ず無効化
            }
        }
        // ここまで
        // ...省略...
    }
}

ステージのアンロック機能の実装

ステージ1をクリアしたらステージ2がアンロックされ、ステージ2をクリアしたらステージ3がアンロックされるようにします。

UserDataスクリプト

まず、各ステージのクリア済みフラグを入れておくリストを作成します。ついでに、各ステージのハイスコアも保存できるようにしました。

STEP

新規C#スクリプトを作成し、名前を「UserData」にします。

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

/// <summary>
/// 各ステージのハイスコアとクリア情報を格納するデータ構造
/// </summary>
[Serializable]
public class StageUserData
{
    public int stageId;
    public int highScore;
    public bool isCleared;
}

/// <summary>
/// ユーザーの進行状況を管理するクラス
/// </summary>
public class UserData : MonoBehaviour
{
    // 各ステージのデータリスト
    public List<StageUserData> stageUserDataList = new List<StageUserData>();
    public static UserData Instance { get; private set; }

    // シングルトンパターンを実装
    private void Awake()
    {
        if (Instance == null)
            Instance = this;
        else if (Instance != this)
            Destroy(gameObject);
    }

    /// <summary>
    /// ステージIDからデータを取得
    /// </summary>
    public StageUserData GetStageData(int stageId)
    {
        var data = stageUserDataList.Find(d => d.stageId == stageId);
        return data;
    }

    /// <summary>
    /// ハイスコアを更新
    /// </summary>
    public void UpdateHighScore(int stageId, int score)
    {
        var data = GetStageData(stageId);
        if (score > data.highScore)
            data.highScore = score;
    }

    /// <summary>
    /// クリア情報を更新
    /// </summary>
    public void SetStageCleared(int stageId)
    {
        var data = GetStageData(stageId);
        data.isCleared = true;
    }
}
  • StageUserDataクラスを定義し、各ステージに対して、ステージID、ハイスコア、クリアフラグを持たせます。
  • stageUserDataListはステージごとのデータを保持するリストです。
  • シングルトンパターンを実装します。
  • GetStageData()は、指定されたステージIDに対応する StageUserData をリストから探して返すメソッドです。
  • UpdateHighScore()は、指定ステージのハイスコアを更新するメソッドです。
  • SetStageCleared()は、指定ステージにクリア済みフラグを設定します。
STEP

ヒエラルキーに空オブジェクトを作成して、名前を「UserData」にします。

そして、UserDataオブジェクトにUserDataスクリプトをアタッチします。

STEP

UserDataオブジェクトのインスペクターで「User Data (Script) > Stage User Data List」に項目を5つ追加します。

各エレメントの「Stage Id」に1〜5を設定します。

タワーディフェンス222

ハイスコアとクリア済みフラグの保存

ステージクリアしたらクリア済みフラグを設定するようにスクリプトを修正します。ゲームオーバーまたはステージクリアしたときにハイスコアを更新する処理も入れます。

GameManagerスクリプトを修正します。

using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI; // Buttonクラスを使用するために必要
using System.Collections; // IEnumeratorを使用するために必要
using TMPro; // TextMeshProを使うために必要

public class GameManager : MonoBehaviour
{
    // ...省略...

    /// <summary>
    /// ゲーム状態を変更する
    /// </summary>
    /// <param name="newState">新しいゲーム状態</param>
    public void ChangeState(GameState newState)
    {
        previousState = CurrentState; // 前の状態を保存
        CurrentState = newState;

        switch (newState)
        {
            case GameState.Title:
                // ...省略...

            case GameState.Preparing:
                // ...省略...

            case GameState.Playing:
                // ...省略...

            case GameState.Paused:
                // ...省略...

            case GameState.GameOver:
                Debug.Log("ゲーム状態: GameOver");
                Time.timeScale = 0; // 時間を停止
                pausePanel.SetActive(false); // ポーズパネルを非表示
                gameOverPanel.SetActive(true); // ゲームオーバーパネルを表示
                gameOverPanel.transform.SetAsLastSibling(); // ゲームオーバーパネルを最前面に移動
                nextWaveButton.interactable = false; // 次のウェーブボタンを非活性化
                stageClearPanel.SetActive(false); // ステージクリアパネルを非表示
                // ここから 追加
                // ハイスコア記録
                if (UserData.Instance != null && ScoreManager.Instance != null)
                {
                    UserData.Instance.UpdateHighScore(currentStageId, ScoreManager.Instance.CurrentScore);
                }
                // ここまで
                break;

            case GameState.Victory:
                Debug.Log("ゲーム状態: Victory");
                Time.timeScale = 0; // 時間を停止
                pausePanel.SetActive(false); // ポーズパネルを非表示
                gameOverPanel.SetActive(false); // ゲームオーバーパネルを非表示
                nextWaveButton.interactable = false; // 次のウェーブボタンを非活性化
                stageClearPanel.SetActive(true); // ステージクリアパネルを表示
                stageClearPanel.transform.SetAsLastSibling(); // ステージクリアパネルを最前面に移動
                if (scoreText != null && ScoreManager.Instance != null)
                    scoreText.text = ScoreManager.Instance.CurrentScore.ToString(); // スコア表示
                // ここから 追加
                // ハイスコアとクリアフラグ記録
                if (UserData.Instance != null && ScoreManager.Instance != null)
                {
                    UserData.Instance.UpdateHighScore(currentStageId, ScoreManager.Instance.CurrentScore);
                    UserData.Instance.SetStageCleared(currentStageId);
                }
                // ここまで
                break;
        }
    }

    // ...省略...
}

ステージのアンロック

ステージ選択ボタンの有効/無効をクリアフラグに応じて切り替えます。

STEP

GameManagerスクリプトを修正します。

using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI; // Buttonクラスを使用するために必要
using System.Collections; // IEnumeratorを使用するために必要
using TMPro; // TextMeshProを使うために必要

public class GameManager : MonoBehaviour
{
    // ...省略...
    // ここから 追加
    [SerializeField] private Button[] stageSelectButtons; // ステージ選択ボタン1〜5
    [SerializeField] private Sprite stageSelectButtonLockedIcon; // ステージ選択ボタンのロックアイコン
    [SerializeField] private Sprite stageSelectButtonUnlockedIcon; // ステージ選択ボタンのアンロックアイコン
    // ここまで

    // ...省略...

    /// <summary>
    /// ゲーム状態を変更する
    /// </summary>
    /// <param name="newState">新しいゲーム状態</param>
    public void ChangeState(GameState newState)
    {
        previousState = CurrentState; // 前の状態を保存
        CurrentState = newState;

        switch (newState)
        {
            case GameState.Title:
                Debug.Log("ゲーム状態: Title");
                Time.timeScale = 0; // 時間を停止
                titlePanel.SetActive(true); // タイトルパネルを表示
                titlePanel.transform.SetAsLastSibling(); // タイトルパネルを最前面に移動
                pausePanel.SetActive(false); // ポーズパネルを非表示
                gameOverPanel.SetActive(false); // ゲームオーバーパネルを非表示
                nextWaveButton.interactable = false; // 次のウェーブボタンを非活性化
                stageClearPanel.SetActive(false); // ステージクリアパネルを非表示
                // ここから 追加
                UpdateStageSelectButtons(); // ステージ選択ボタンの有効/無効を更新
                // ここまで
                break;

            case GameState.Preparing:
                // ...省略...

            case GameState.Playing:
                // ...省略...

            case GameState.Paused:
                // ...省略...

            case GameState.GameOver:
                // ...省略...

            case GameState.Victory:
                // ...省略...
        }
    }

    // ...省略...

    // ここから 追加
    /// <summary>
    /// ステージ選択ボタンの有効/無効をクリア情報に応じて切り替える
    /// </summary>
    private void UpdateStageSelectButtons()
    {
        if (stageSelectButtons == null || stageSelectButtons.Length == 0) return;

        // UserDataの参照をキャッシュ
        var userData = UserData.Instance;

        for (int i = 0; i < stageSelectButtons.Length; i++)
        {
            var button = stageSelectButtons[i];
            bool interactable = false;
            bool unlocked = false;
            int highScore = 0;

            // ステージIDはボタン配列のインデックス+1
            int stageId = i + 1;

            // ステージ1は常に選択可能
            if (i == 0)
            {
                interactable = true;
                unlocked = true;
            }
            else if (userData != null)
            {
                // 前のステージがクリア済みならアンロック
                var prevData = userData.GetStageData(stageId - 1);
                unlocked = prevData != null && prevData.isCleared;
                interactable = unlocked;
            }

            // ハイスコア取得
            if (userData != null)
            {
                var stageData = userData.GetStageData(stageId);
                if (stageData != null)
                {
                    highScore = stageData.highScore;
                }
            }

            button.interactable = interactable;

            // LockImageのアイコンと透明度
            var lockImage = button.transform.Find("LockImage")?.GetComponent<Image>();
            if (lockImage != null)
            {
                lockImage.sprite = unlocked ? stageSelectButtonUnlockedIcon : stageSelectButtonLockedIcon;
                var c = lockImage.color;
                c.a = unlocked ? 1f : 0.3f;
                lockImage.color = c;
            }

            // テキスト透明度
            var labels = button.GetComponentsInChildren<TextMeshProUGUI>();
            foreach (var label in labels)
            {
                var c = label.color;
                c.a = unlocked ? 1f : 0.3f;
                label.color = c;
            }

            // ハイスコア表示
            var highScoreText = button.transform.Find("HighScoreText")?.GetComponent<TextMeshProUGUI>();
            if (highScoreText != null)
            {
                highScoreText.text = $"HIGH SCORE {highScore}";
            }
        }
    }
    // ここまで
}
  • 以下の3つの変数を定義しました。
    • stageSelectButtons: ステージ選択ボタンを格納する配列。
    • stageSelectButtonLockedIcon: ステージ選択ボタンのロックアイコン。
    • stageSelectButtonUnlockedIcon: ステージ選択ボタンのアンロックアイコン。
  • ChangeState()内で、ゲーム状態がTitleのときに、ステージ選択ボタンの有効/無効を更新する処理を追加しました。
  • UpdateStageSelectButtons()メソッドを追加しました。
    • 前のステージのクリア状況に応じて、各ステージ選択ボタンを有効/無効化します。
    • 同様にボタンのロックアイコンを切り替えます。
    • 同様にボタンのテキストの透明度を切り替えます。
    • 各ステージのハイスコアを表示します。
STEP

GameManagerのインスペクターで「GameManager (Script) > Stage Select Buttons」に要素を5つ追加して、Stage1ButtonStage5Buttonをアサインします。

また、「Stage Select Button Locked Icon」「Stage Select Button Unlocked Icon」lock画像、unlock画像をアサインします。

タワーディフェンス223

さいごに

今回はステージを選択する機能を実装しました。

しかし、クリア済み情報をセーブする機能がないので、ゲームを終了するとステージのアンロックが初期化されてしまいます。次回はこれをなんとかしましょう。

でわでわ

タワーディフェンスを作る(21)

この記事が気に入ったら
いいね または フォローしてね!

シェアしてね

コメント

コメントする

目次