Parallels Desktop 35%OFF セール

【Unity】タワーディフェンス(19) ゲームの状態管理、スタート・ポーズ機能【クソゲー制作】

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

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

今回はゲームの状態管理機能を実装します。現在のゲームの状態が、タイトル画面なのかプレイ中なのかポーズ中なのかなどを管理することで、コードの見通しがよくなり保守しやすくなります。

目次

タイトル画面の作成

まず、ゲームのタイトル画面を作成します。というのは、状態管理で「タイトル→準備中→プレイ中」みたいな流れを確認するのにタイトル画面が必要だからですね。

STEP

ヒエラルキーで右クリック「UI > Panel」を選択してパネルを作成します。

タワーディフェンス200

Canvasの下にパネルオブジェクトができるので、名前を「TitlePanel」にします。

STEP

TitlePanelのインスペクターで設定をします。

Rect Transform はそのままでOKです。パネルがゲーム画面全体を覆う状態ですね。

「Image > Source Image」「None」に変更します。これでパネルの四隅がほんのり丸くなっている問題を解消できます。「Color」をお好みの色に変更します。

タワーディフェンス201
STEP

TitlePanelの配下に各種オブジェクトを配置して、タイトル画面を作ります。

タワーディフェンス202

ゲームタイトルのテキストとステージ選択ボタンなどを配置しました。

ステージ選択機能の実装

とりあえず、ステージ1の選択ボタンを押したらステージ1が開始するようにします。ちゃんとしたステージ選択機能はあとでやります。

STEP

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;
    // ここから 追加
    [SerializeField] private GameObject titlePanel; // タイトルパネル
    // ここまで

    void Update()
    {
        //...省略...
    }

    // ここから 追加
    /// <summary>
    /// ステージ選択ボタンが押されたときに呼び出されるメソッド
    /// </summary>
    /// <param name="stageId">選択されたステージID</param>
    public void SelectStage(int stageId)
    {
        currentStageId = stageId; // ステージIDを設定

        // タイトルパネルを非表示にする
        if (titlePanel != null)
        {
            titlePanel.SetActive(false);
        }
        else
        {
            Debug.LogWarning("titlePanel が設定されていません。");
        }

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

    /// <summary>
    /// マウスのクリックを検出
    /// </summary>
    private void DetectClick()
    {
        // ...省略...
    }
}
  • titlePanel変数を定義しました。
  • SelectStage()メソッドを追加しました。
    • currentStageIdに選択されたステージ番号を設定し、タイトルパネルを非表示にします。
STEP

GameManagerオブジェクトのインスペクターで「Game Manager (Script) > Title Panel」TitlePanelオブジェクトをドラッグ&ドロップしてアサインします。

タワーディフェンス203
STEP

ステージ選択ボタン(Stage1ButtonStage5Button)の「On Click ()」GameManager.SelectStage()メソッドを設定します。

タワーディフェンス204
  1. 「On Click ()」の「+」をクリックする。
  2. 「None (Object)」にGamemanagerオブジェクトをドラッグ&ドロップする。
  3. 「No Function」をクリックして「GameManager > SelectStage (int)」を選択する。
  4. 引数を設定する。Stage1Buttonは「1」、Stage2Buttonは「2」(以下省略)。

ゲームの状態管理

今回のメインディッシュ、ゲームの状態管理を実装します。ゲームの状態をあらわす定数は以下の6種類にしました。

  • Title (タイトル画面)
  • Preparing (準備中)
  • Playing (プレイ中)
  • Paused (ポーズ中)
  • GameOver (ゲームオーバー)
  • Victory (ステージクリア)

フローチャートで表現するとこんな感じです。

タワーディフェンス205

コードを書いていきましょう。

STEP

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

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

public class GameManager : MonoBehaviour
{
    // ここから 追加
    public enum GameState
    {
        Title,     // タイトル画面
        Preparing, // 準備中
        Playing,   // プレイ中
        Paused,    // 一時停止
        GameOver,  // ゲームオーバー
        Victory    // 勝利
    }
    // ここまで

    [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;
    [SerializeField] private GameObject titlePanel; // タイトルパネル
    // ここから 追加
    public GameState CurrentState = GameState.Title; // 現在のゲーム状態
    [SerializeField] private Button nextWaveButton; // 次のウェーブボタン
    [SerializeField] private Button startPauseButton; // スタート・ポーズボタン
    // ここまで

    // ここから 追加
    private void Start()
    {
        // ボタンのクリックイベントを登録
        startPauseButton.onClick.AddListener(OnStartPauseButtonClicked);
    }
    // ここまで

    void Update()
    {
        // ここから 追加
        if (CurrentState == GameState.Playing && FortressController.Instance.CurrentHp <= 0)
        {
            ChangeState(GameState.GameOver); // 城のHPが0になったらゲームオーバー
        }
        // ここまで

        if (Input.GetMouseButtonDown(0)) // マウスの左ボタンがクリックされたら
        {
            DetectClick();
        }
    }

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

        switch (newState)
        {
            case GameState.Title:
                Debug.Log("ゲーム状態: Title");
                titlePanel.SetActive(true); // タイトルパネルを表示
                nextWaveButton.interactable = false; // 次のウェーブボタンを非活性化
                break;

            case GameState.Preparing:
                Debug.Log("ゲーム状態: Preparing");
                titlePanel.SetActive(false); // タイトルパネルを非表示
                nextWaveButton.interactable = false; // 次のウェーブボタンを非活性化
                break;

            case GameState.Playing:
                Debug.Log("ゲーム状態: Playing");
                nextWaveButton.interactable = true; // 次のウェーブボタンを活性化
                break;

            case GameState.Paused:
                Debug.Log("ゲーム状態: Paused");
                nextWaveButton.interactable = false; // 次のウェーブボタンを非活性化
                break;

            case GameState.GameOver:
                Debug.Log("ゲーム状態: GameOver");
                nextWaveButton.interactable = false; // 次のウェーブボタンを非活性化
                break;

            case GameState.Victory:
                Debug.Log("ゲーム状態: Victory");
                nextWaveButton.interactable = false; // 次のウェーブボタンを非活性化
                break;
        }
    }

    /// <summary>
    /// スタート・ポーズボタンが押されたときの処理
    /// </summary>
    private void OnStartPauseButtonClicked()
    {
        if (CurrentState == GameState.Preparing)
        {
            ChangeState(GameState.Playing); // 準備中 -> プレイ中
        }
        else if (CurrentState == GameState.Playing)
        {
            ChangeState(GameState.Paused); // プレイ中 -> ポーズ
        }
    }

    /// <summary>
    /// 再開ボタンが押されたときの処理
    /// </summary>
    private void OnRestartButtonClicked()
    {
        if (CurrentState == GameState.Paused)
        {
            ChangeState(GameState.Playing); // ポーズ -> プレイ中
        }
    }

    /// <summary>
    /// 最終ウェーブをクリアしたときの処理
    /// </summary>
    public void OnFinalWaveCleared()
    {
        if (CurrentState == GameState.Playing)
        {
            ChangeState(GameState.Victory); // プレイ中 -> 勝利
        }
    }
    // ここまで

    /// <summary>
    /// ステージ選択ボタンが押されたときに呼び出されるメソッド
    /// </summary>
    /// <param name="stageId">選択されたステージID</param>
    public void SelectStage(int stageId)
    {
        currentStageId = stageId; // ステージIDを設定

        // ここから 削除
        // タイトルパネルを非表示にする
        //if (titlePanel != null)
        //{
        //    titlePanel.SetActive(false);
        //}
        //else
        //{
        //    Debug.LogWarning("titlePanel が設定されていません。");
        //}
        // ここまで

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

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

    /// <summary>
    /// マウスのクリックを検出
    /// </summary>
    private void DetectClick()
    {
        // ...省略...
    }
}
  • ゲームの状態をあらわすGameSateを定義しました。列挙型(enum)を使っています。
  • 以下の3つの変数を定義しました。
    • CurrentState: 現在のゲーム状態。初期値はTitle。
    • nextWaveButton: 次のウェーブボタン。
    • startPauseButton: スタート・ポーズボタン。
  • Start()メソッドを追加して、スタート・ポーズボタンのクリックイベントを追加しました。
  • Update()メソッド内に、城のHPが0になったらゲーム状態をGameOverにする処理を追加しました。
  • ChangeState()メソッドを追加しました。ゲーム状態を変更し、パネルの表示・非表示、ボタンの活性・非活性を切り替えます。
  • OnStartPauseButtonClicked()メソッドを追加しました。スタート・ポーズボタンが押されたときにゲーム状態をPlayingまたはPausedに切り替えます。
  • OnRestartButtonClicked()メソッドを追加しました。再開ボタンが押されたときにゲーム状態をPlayingに切り替えます。
  • OnFinalWaveCleared()メソッドを追加しました。最終ウェーブをクリアしたときにゲーム状態をVictoryに切り替えます。
  • SelectStage()メソッドを修正しました。
    • タイトルパネルを非表示にする処理はChangeState()メソッドに任せることにしたので削除します。
    • ゲーム状態をPreparingに切り替える処理を追加しました。
STEP

GameManagerのインスペクターで「Game Manager (Script) > NetWaveButton」NextWaveButtonオブジェクトをドラッグ&ドロップしてアサインします。

「Game Manager (Script) > StartPauseButton」にはStartPauseButtonオブジェクトをアサインします。

タワーディフェンス206

スタート・ポーズ機能の実装

状態がPlayingのとき以外はゲーム時間を止める

ゲーム状態がPlayingのとき以外はゲーム内の時間を止めたい。というわけで、GameManagerスクリプトを修正します。

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

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

    private void Start()
    {
        // ボタンのクリックイベントを登録
        startPauseButton.onClick.AddListener(OnStartPauseButtonClicked);

        // ここから 追加
        // ゲーム開始時に GameState が Title の場合、時間を停止
        if (CurrentState == GameState.Title)
        {
            Time.timeScale = 0; // 時間を停止
        }
        // ここまで
    }

    void Update()
    {
        // ...省略...
    }

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

        switch (newState)
        {
            case GameState.Title:
                Debug.Log("ゲーム状態: Title");
                // ここから 追加
                Time.timeScale = 0; // 時間を停止
                // ここまで
                titlePanel.SetActive(true); // タイトルパネルを表示
                nextWaveButton.interactable = false; // 次のウェーブボタンを非活性化
                break;

            case GameState.Preparing:
                Debug.Log("ゲーム状態: Preparing");
                // ここから 追加
                Time.timeScale = 0; // 時間を停止
                // ここまで
                titlePanel.SetActive(false); // タイトルパネルを非表示
                nextWaveButton.interactable = false; // 次のウェーブボタンを非活性化
                break;

            case GameState.Playing:
                Debug.Log("ゲーム状態: Playing");
                // ここから 追加
                Time.timeScale = 1; // 時間を通常速度に戻す
                // ここまで
                nextWaveButton.interactable = true; // 次のウェーブボタンを活性化
                break;

            case GameState.Paused:
                Debug.Log("ゲーム状態: Paused");
                // ここから 追加
                Time.timeScale = 0; // 時間を停止
                // ここまで
                nextWaveButton.interactable = false; // 次のウェーブボタンを非活性化
                break;

            case GameState.GameOver:
                Debug.Log("ゲーム状態: GameOver");
                // ここから 追加
                Time.timeScale = 0; // 時間を停止
                // ここまで
                nextWaveButton.interactable = false; // 次のウェーブボタンを非活性化
                break;

            case GameState.Victory:
                Debug.Log("ゲーム状態: Victory");
                // ここから 追加
                Time.timeScale = 0; // 時間を停止
                // ここまで
                nextWaveButton.interactable = false; // 次のウェーブボタンを非活性化
                break;
        }
    }

    // ...省略...
}

Time.timeScale = 0でゲーム内時間は停止し、Time.timeScale = 1で通常の速度になります。

ポーズ画面の作成

ゲームがポーズ中のときに表示するパネルを作成します。

STEP

ヒエラルキーで右クリック「UI > Panel」を選択してパネルを作成します。

名前を「PausePanel」に変更します。

STEP

PausePanelのインスペクターで設定をします。

Rect Transform はそのままでOKです。パネルがゲーム画面全体を覆う状態です。ポーズ中はゲーム画面のボタン類を押せないようにしたいので、このようにしました。

「Image > Source Image」「None」に変更し、「Color」をお好みの色に変更します。

STEP

PausePanelの配下に各種オブジェクトを配置して、ポーズ画面を作ります。

タワーディフェンス207
STEP

PausePanelのインスペクターで左上のチェックを外してオブジェクトを非表示にしておきます。

ポーズ画面の表示・非表示

ポーズ中にはポーズ画面を表示し、それ以外の状態のときは表示しないようにコードを修正します。スタート・ポーズボタンのアイコンとテキストの切り替えもします。

STEP

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

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

public class GameManager : MonoBehaviour
{
    // ...省略...
    // ここから 追加
    [SerializeField] private Image startPauseButtonIcon; // スタート・ポーズボタンアイコン
    [SerializeField] private TextMeshProUGUI startPauseButtonText; // スタート・ポーズボタンテキスト
    [SerializeField] private GameObject pausePanel; // ポーズパネル
    [SerializeField] private Button restartButton; // 再開ボタン
    [SerializeField] private Sprite playIcon;  // スタートボタン用のアイコン
    [SerializeField] private Sprite pauseIcon; // ポーズボタン用のアイコン
    // ここまで

    private void Start()
    {
        // ボタンのクリックイベントを登録
        startPauseButton.onClick.AddListener(OnStartPauseButtonClicked);
        // ここから 追加
        restartButton.onClick.AddListener(OnRestartButtonClicked);
        // ここまで

        // ゲーム開始時に GameState が Title の場合、時間を停止
        if (CurrentState == GameState.Title)
        {
            Time.timeScale = 0; // 時間を停止
        }
    }

    void Update()
    {
        // ...省略...
    }

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

        switch (newState)
        {
            case GameState.Title:
                Debug.Log("ゲーム状態: Title");
                Time.timeScale = 0; // 時間を停止
                titlePanel.SetActive(true); // タイトルパネルを表示
                // ここから 追加
                pausePanel.SetActive(false); // ポーズパネルを非表示
                // ここまで
                nextWaveButton.interactable = false; // 次のウェーブボタンを非活性化
                break;

            case GameState.Preparing:
                Debug.Log("ゲーム状態: Preparing");
                Time.timeScale = 0; // 時間を停止
                titlePanel.SetActive(false); // タイトルパネルを非表示
                // ここから 追加
                pausePanel.SetActive(false); // ポーズパネルを非表示
                // ボタンのアイコンとテキストを「スタート」に設定
                startPauseButtonIcon.sprite = playIcon; // playIcon を設定
                startPauseButtonText.text = "スタート";
                // ここまで
                nextWaveButton.interactable = false; // 次のウェーブボタンを非活性化
                break;

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

            case GameState.Paused:
                Debug.Log("ゲーム状態: Paused");
                Time.timeScale = 0; // 時間を停止
                // ここから 追加
                pausePanel.SetActive(true); // ポーズパネルを表示
                // ボタンのアイコンとテキストを「ポーズ」に設定
                startPauseButtonIcon.sprite = pauseIcon; // pauseIcon を設定
                startPauseButtonText.text = "ポーズ";
                // ここまで
                nextWaveButton.interactable = false; // 次のウェーブボタンを非活性化
                break;

            case GameState.GameOver:
                Debug.Log("ゲーム状態: GameOver");
                Time.timeScale = 0; // 時間を停止
                // ここから 追加
                pausePanel.SetActive(false); // ポーズパネルを非表示
                // ここまで
                nextWaveButton.interactable = false; // 次のウェーブボタンを非活性化
                break;

            case GameState.Victory:
                Debug.Log("ゲーム状態: Victory");
                Time.timeScale = 0; // 時間を停止
                // ここから 追加
                pausePanel.SetActive(false); // ポーズパネルを非表示
                // ここまで
                nextWaveButton.interactable = false; // 次のウェーブボタンを非活性化
                break;
        }
    }

    // ...省略...
}
  • 以下の6つの変数を定義しました。
    • startPauseButtonIcon: スタート・ポーズボタンのアイコン。
    • startPauseButtonText: スタート・ポーズボタンのテキスト。
    • pausePanel: ポーズパネル。
    • restartButton: 再開ボタン。
    • playIcon: スタートボタン用のアイコン画像。
    • pauseIcon: ポーズボタン用のアイコン画像。
  • Start()メソッド内に、再開ボタンのクリックイベントを追加しました。
  • ChangeState()メソッド内に、ゲーム状態に応じてポーズパネルの表示非表示、スタート・ポーズボタンのアイコンとテキストを切り替える処理を追加しました。
STEP

GameManagerのインスペクターで、先ほど追加した6つの変数にオブジェクトをドラッグ&ドロップしてアサインします。

タワーディフェンス208

ポーズ画面を最前面に表示する

ポーズ画面はあらゆるオブジェクトを覆うように最前面に表示させたいのですが、今のところそうなっていません。敵のHPバーやフローティングテキストはポーズ画面よりも前に表示されてしまいます。

これは、これらのオブジェクトがプレハブから生成されるためです。プレハブから生成されるオブジェクトはヒエラルキーの下に追加されるので、前面に表示されてしまいます。

この問題を解消するためにGameManagerスクリプトを修正します。

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

    switch (newState)
    {
        // ...省略...

        case GameState.Paused:
            Debug.Log("ゲーム状態: Paused");
            Time.timeScale = 0; // 時間を停止
            pausePanel.SetActive(true); // ポーズパネルを表示
            // ボタンのアイコンとテキストを「ポーズ」に設定
            startPauseButtonIcon.sprite = pauseIcon; // pauseIcon を設定
            startPauseButtonText.text = "ポーズ";
            nextWaveButton.interactable = false; // 次のウェーブボタンを非活性化
            // ここから 追加
            // 最前面に移動
            pausePanel.transform.SetAsLastSibling();
            // ここまで
            break;

        // ...省略...
    }
}

SetAsLastSibling()は、そのオブジェクトをヒエラルキーの兄弟オブジェクトの中で一番最後に移動させるメソッドです。つまり、最前面に表示されるということです。

時間を止めているときもアニメーションは動かす

ゲーム状態がPreparing(準備中)のときに、砲台を設置してもゴールドが減らなかったり、フローティングテキストのアニメーションが動かないという問題が発生しました。これは、DOTweenのデフォルト設定ではTime.timeScaleの影響を受け、アニメーションが停止するためです。

DOTweenのアニメーションがTime.timeScaleの影響を受けないようにします。

STEP

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

/// <summary>
/// フローティングテキストを表示する
/// </summary>
/// <param name="text">表示するテキスト</param>
/// <param name="color">テキストの色</param>
/// <param name="position">ワールド座標での表示位置</param>
/// <param name="offset">表示位置のオフセット</param>
public void ShowFloatingText(string text, Color color, Vector3 position, Vector3 offset)
{
    // ...省略...

    // テキストの設定
    TextMeshProUGUI textMesh = floatingText.GetComponent<TextMeshProUGUI>();
    if (textMesh != null)
    {
        // ...省略...

        // DOTween を使用してアニメーションを設定
        rectTransform.DOMoveY(rectTransform.position.y + 50f, 1.5f) // 上方向に移動
            .SetEase(Ease.OutQuad)
            .SetUpdate(true); // 追加 Unscaled Time を使用
        textMesh.DOFade(0f, 1.5f) // フェードアウト
            .SetEase(Ease.InQuad)
            .SetUpdate(true); // 追加 Unscaled Time を使用
    }
    // ...省略...
}

DOTweenのメソッドチェーンに.SetUpdate(true)を追加すると、Time.timeScaleに依存せずにアニメーションが動作します。

STEP

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

/// <summary>
/// ゴールドを増減する
/// </summary>
/// <param name="amount">増減するゴールド量(正の数で増加、負の数で減少)</param>
/// <returns>ゴールドが足りて処理できた場合は true、足りない場合は false</returns>
private bool ChangeGold(int amount)
{
    // ...省略...

    // DOTween を使用してカウントアップ・カウントダウンアニメーション
    DOTween.To(() => startValue, x =>
    {
        startValue = x;
        if (goldText != null)
        {
            goldText.text = startValue.ToString();
        }
    }, endValue, 0.5f) // 0.5秒でアニメーション
    .SetEase(Ease.OutQuad) // スムーズなアニメーション
    .SetUpdate(true) // 追加 Unscaled Time を使用
    .OnComplete(() =>
    {
        // アニメーション完了後に実際のゴールド値を更新
        SetGold(endValue);
    });

    // ...省略...
}
STEP

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

/// <summary>
/// スコアを加算する
/// </summary>
/// <param name="amount">加算するスコア量</param>
public void AddScore(int amount)
{
    // ...省略...

    // DOTween を使用してカウントアップアニメーション
    DOTween.To(() => startValue, x =>
    {
        startValue = x;
        if (scoreText != null)
        {
            scoreText.text = startValue.ToString();
        }
    }, endValue, 0.5f) // 0.5秒でカウントアップ
    .SetEase(Ease.OutQuad) // スムーズなアニメーション
    .SetUpdate(true) // 追加 Unscaled Time を使用
    .OnComplete(() =>
    {
        // アニメーション完了後に実際のスコア値を更新
        currentScore = endValue;
    });
}

以上で、Time.timeScale = 0のときにもDOTweenのアニメーションが動くようになりました。

さいごに

ゲームの状態管理を実装したことで、今後の開発がやりやすくなったのではないかと思います。いえい。

でわでわ

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

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

シェアしてね

コメント

コメントする

目次