ミニゲームを作ってUnityを学ぶ！  [ 3Dマインスイーパー編 - 6. 機能の追加 ]

[ミニゲームを作ってUnityを学ぶ！[3Dマインスイーパー編]](https://qiita.com/YzRoid/items/0e708a561531bf0d384f)
###**第6回目: 機能の追加**
前回まででとりあえずマインスイーパーを一通りプレイすることができるようになりました。
今回はいくつかの機能を追加してより遊びやすくなった「3Dマインスイーパー」を完成させます。



#難易度の切り替え
今の段階ではまだEASYでしかプレイできない難易度に新しくNORMALとHARDを追加し、それを切り替える機能を実装します。

###難易度を保持する

まずはGameControllerにプロパティを追加して現在のゲーム難易度を保持できるようにします。

```cs:GameController.cs

        //-----------------
        // ゲームのレベル //
        //----------------------------------------------------------------------------------------

        public int GameLevel { get; set; }

```

###難易度を変更するメソッド

GameControllerのプロパティ追加にあわせてSceneMainを修正し、同時に難易度を変更するための新しいメソッドを追加します。

```cs:SceneMain.cs

        void Awake()
        {
            // ゲーム全体の初期化
            mGame = GameController.Instance;
            mGame.Init();

            // 初期レベルはイージー
追加        mGame.GameLevel = GameController.LEVEL_EASY;
        }

        private void LoadStage()
        {
変更        int gameLevel = mGame.GameLevel;
            mBlock.CreateField(gameLevel);
            mUi.RenewStartText("START", "blue");
            mState = STATE.WAIT_START;
        }

追加    public void OnSelectLevel(Dropdown dropdown)
        {
            switch (dropdown.value)
            {
                case 0:
                    mGame.GameLevel = GameController.LEVEL_EASY;
                    break;
                case 1:
                    mGame.GameLevel = GameController.LEVEL_NORMAL;
                    break;
                case 2:
                    mGame.GameLevel = GameController.LEVEL_HARD;
                    break;
            }
            ResetGame();
        }

```
難易度の変更や取得はmGameのプロパティGameLevelを利用するよう修正しています。
また難易度の変更の際に実行されるOnSelectLevel()では引数のDropdownからvalueの値を取得して、それによって処理を分岐させています。



###ドロップダウンメニュー

プレイヤーの操作で難易度を変更するためのUIとしてドロップダウンメニューを設置します。

1. Canvas内に「DropdownSelectLevel」という名前でUIのDropdownを作成
+ 下画像1番を参考にDropDownSelectLevelのポジションを設定
+ 下画像2番の赤枠部分について、メニュー項目を3種の難易度になるよう修正
+ 子要素「Label」のポジションとアライメントを下画像3番の内容に設定
+ スタートボタンにメソッドを登録したときと同じように、下画像2番の青枠部分にSceneMain#OnSelectLevel()を登録（引数にはDropdownSelectLevelを設定）

![unity_sweeper_ss_6_1.jpg](https://qiita-image-store.s3.amazonaws.com/0/239877/b1558f0a-cf02-aa65-f274-52ddda23be0e.jpeg)

プロジェクトを実行して、左上に表示されたドロップダウンメニューから難易度を選択することでそれに対応したフィールドが再生成されることを確認してください。

![unity_sweeper_ss_6_2.jpg](https://qiita-image-store.s3.amazonaws.com/0/239877/b73cd288-e894-d14f-40a1-d16b2579fcbc.jpeg)


#問題発生！
新しい難易度を加えたことでフィールドが最大で30x16のブロックで埋め尽くされるようになりました。
それによって以下2つの問題が発生してしまいましたので、次はそれを修正していきます。

+ フィールドが画面内に収まりきらずにクリックできないブロックが出てきてしまった
+ UIとブロックが重なってる状態でUIをクリックするとブロックも同時に反応してしまう



#カメラを操作する機能を実装
フィールドが大きくなったことによって固定カメラではどうしても押せないブロックが出てきてしまいました。
この問題を解消するために、カメラに移動機能を実装します。

+ スクリプト「CameraController」を作成してMain Cameraにアタッチ

```cs:CameraController.cs

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

    public class CameraController : MonoBehaviour
    {
        private Transform mTrans;

        void Awake()
        {
            mTrans = GetComponent<Transform>();
        }

        //-------------
        // 入力の監視 //
        //---------------------------------------------------------------------------------

        public void CheckInput()
        {
            Vector3 velocity = Vector3.zero;
            if (Input.GetKey(KeyCode.W)) velocity.z += 1.0f;
            if (Input.GetKey(KeyCode.A)) velocity.x -= 1.0f;
            if (Input.GetKey(KeyCode.S)) velocity.z -= 1.0f;
            if (Input.GetKey(KeyCode.D)) velocity.x += 1.0f;
            if (velocity.magnitude > 0.0f)
            {
                Move(velocity);
                return;
            }
        }

        //--------
        // 移動 //
        //---------------------------------------------------------------------------------

        [SerializeField]
        private readonly float MOVE_SPEED = 8.0f;
        private float mMinX, mMaxX, mMinZ, mMaxZ; // カメラの可動範囲

        /// <summary>
        /// 難易度によってカメラの稼働範囲を設定する
        /// </summary>
        /// <param name="gameLevel"></param>
        public void SetLimit(int gameLevel)
        {
            switch (gameLevel)
            {
                case GameController.LEVEL_EASY:
                    mMinX = 3.5f;
                    mMaxX = 5.5f;
                    mMinZ = -2.0f;
                    mMaxZ = 2.0f;
                    break;
                case GameController.LEVEL_NORMAL:
                    mMinX = 5.0f;
                    mMaxX = 11.0f;
                    mMinZ = -2.0f;
                    mMaxZ = 9.0f;
                    break;
                default:
                    mMinX = 5.0f;
                    mMaxX = 25.0f;
                    mMinZ = -2.0f;
                    mMaxZ = 9.0f;
                    break;
            }
        }

        /// <summary>
        /// 移動アクション
        /// </summary>
        /// <param name="velocity"></param>
        private void Move(Vector3 velocity)
        {
            Vector3 current = mTrans.position;
            current.x = Mathf.Clamp(current.x + velocity.x * MOVE_SPEED * Time.deltaTime, mMinX, mMaxX);
            current.z = Mathf.Clamp(current.z + velocity.z * MOVE_SPEED * Time.deltaTime, mMinZ, mMaxZ);
            mTrans.position = current;
        }

    }

```
CheckInput()ではWASDキーいずれかが押されている場合にベクトルの値を増減させ、そのベクトルの長さが0より大きい（つまり移動する方向が決定している）場合にMove()を実行します。

そしてMove()ではカメラ自身のTransformポジションを移動させることで視点の移動を行います。
この際にカメラがフィールドを全く映さないような状況を避けるために予めSetLimit()によってカメラが移動できる範囲を設定しています。

続いて、CameraControllerの追加にあわせてSceneMainを修正します。

```cs:SceneMain.cs

        private GameController mGame;

        [SerializeField]
1:      private CameraController mCamera;
        [SerializeField]
        private BlockManager mBlock;
        [SerializeField]
        private UiManager mUi;

        void Update()
        {
            switch (mState)
            {
                // ステージ生成
                case STATE.LOADING:
                    LoadStage();
                    break;
                // スタートボタンが押されるまで何もしないで待機（取り外してOK）
                case STATE.WAIT_START:
                    break;
                // プレイ中：ゲームの終了条件を監視し、終了でない場合はプレイヤーの入力を受け付ける
                case STATE.PLAY:
                    if (mBlock.IsGameClear)
                    {
                        EndGame(true);
                        return;
                    }
                    if (mBlock.IsGameOver)
                    {
                        EndGame(false);
                        return;
                    }
                    mBlock.CheckMouseInput();
追加                mCamera.CheckInput();
                    break;
                // 結果表示中:
                case STATE.RESULT:
                    break;
            }
        }

        private void LoadStage()
        {
            int gameLevel = mGame.GameLevel;
            mBlock.CreateField(gameLevel);
追加        mCamera.SetLimit(gameLevel);
            mUi.RenewStartText("START", "blue");
            mState = STATE.WAIT_START;
        }

```
1: 「Main Camera」をインスペクタから設定

Update()にて、プレイ中にカメラ操作の入力を受け付けるよう修正しています。
またLoadStage()のタイミングで難易度に合わせたカメラの移動範囲を設定しています。

![unity_sweeper_ss_6_3.jpg](https://qiita-image-store.s3.amazonaws.com/0/239877/c79f2bde-b1b8-82b1-0360-864f4a4d9ec8.jpeg)

↑ 難易度Normalのプレイ中にカメラを左下に寄せた図



#クリックイベントを制御する
続いて、UIをクリックしたはずが下のブロックまで反応してしまう状態を解消します。

この問題はプレイヤーの「クリック」という1つのアクションをボタンやドロップダウンといったUIを管理しているEventSystemとブロックを管理しているBlockManagerの両方が受け取ってしまうことが原因ですので、**EventSystemがすでに入力を受け取っている場合はBlockManagerに対する入力を無効化する**という方法で対応します。

+ BlockManager#OnLeftClick()を以下のように修正

```cs:BlockManager.cs

        /// <summary>
        /// 左クリック
        /// 対象ブロックを開く
        /// </summary>
        private void OnLeftClick()
        {
            // UIにマウスポインターが重なっている場合はこちらの処理を無効
追加        if (EventSystem.current.IsPointerOverGameObject()) return;

            Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
            RaycastHit hit;
            if(Physics.Raycast(ray, out hit))
            {
                GameObject go = hit.collider.gameObject;
                if(go.tag == GameController.TAG_BLOCK)
                {
                    // 対象が爆弾ブロックか判定
                    BlockModel target = go.GetComponent<BlockModel>();
                    if (target.HasBomb)
                    {
                        // チェック済ならば何もしない
                        if (target.IsCheck) return;

                        // チェックしていないなら開いてゲームオーバー
                        GameOver(target);
                    }else
                    {
                        // 爆弾でないならば一連の開く処理
                        OpenBlock(target);

                        // ゲームクリアの判定
                        JudgeGameClear();
                    }
                }
            }
        }

```
最初にEventSysem.currentで現在のシーンに配置されているEventSystemの参照を取得し、IsPointerOverGameObject()によってマウスポインターがUIオブジェクトに重なっているかどうかを判定しています。
この結果がtrueだった場合はすでにEventSystemが入力を受け取っていますので、以降のブロックに対する処理をキャンセルします。



<Pre>
EventSystem

ボタンやドロップダウンといったuGui（Hierarchy上で右クリックしたときにUIの項目に出てくるオブジェクト）
を管理し、プレイヤーの入力をそれらに伝える仕組みを持ったオブジェクト。
Canvasをシーンに配置した際に自動生成され、1つのシーンに1つだけ存在することができる。

EventSystemにも入力を受け取る仕組みがUpdate()で実装されているが、このUpdate()は通常のそれよりも
早い順番で呼ばれるため、今回のようにマウスポインターがUIに重なっているかを調べることで入力をすでに
受け取っているか判定することができる。

</Pre>

**参考：** [コンポーネントのイベント実行順についてのTips](http://tsubakit1.hateblo.jp/entry/2017/02/05/003714)
**参考：** [EventSystemのExecutionOrderに勝つ](http://sassembla.github.io/Public/2017:04:02%2017-21-28/2017:04:02%2017-21-28.html)



#タイムを計測する
難易度を増やしたことによって発生した問題は解消できました。
続いて、プレイ開始から終了までのタイムを計測する機能を実装していきます。

![unity_sweeper_ss_6_4.jpg](https://qiita-image-store.s3.amazonaws.com/0/239877/f1e27692-9ad0-27a6-e6a1-f4b8efe8f85c.jpeg)



###時間を表示するUI
ボタンオブジェクトを代用してプレイからの経過時間を示すUIを作成します。

1. Canvas内に「ButtonTime」という名前でButtonを作成
2. ButtonTimeのポジションを以下のように設定

![unity_sweeper_ss_6_5.jpg](https://qiita-image-store.s3.amazonaws.com/0/239877/e4b503f0-81f9-2e61-872c-5a2daecb62d7.jpeg)

+ ButtonTimeの子要素Textについて文字列を以下のように設定

<Pre>
TIME: 00:00
</Pre>


###UiManagerを修正
新しく作成したButtonTimeに対応できるよう、UiManagerにコードを追加します。

```cs:UiManager.cs

        //------------------
        //  経過時間の表示 //
        //---------------------------------------------------------------------------------

        [SerializeField]
1:      private Text mTimeText;

        public void RenewTimeText(int time)
        {
            int minutes = time / 60;
            int seconds = time % 60;
            mTimeText.text = string.Format("TIME: {0:00}:{1:00}", minutes, seconds);
        }

```

1: 「ButtonTime/Text」をインスペクタから設定

RenewTimeText()ではint型で受け取った経過時間を「TIME: 2桁の分:2桁の秒」の文字列に加工してButtonTimeのテキスト部分に反映しています。



###GameTimerの作成
経過時間を管理するためのスクリプト「GameTimer」を新しく作成します。

```cs:GameTimer.cs

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

    public class GameTimer
    {

        private readonly int MAX_TIME = 3599; // 限界は59分59秒
        private int mCurrent;

        public void ResetTime()
        {
            mCurrent = 0;
        }

        public void IncTime()
        {
            mCurrent++;
            if (mCurrent > MAX_TIME) mCurrent = MAX_TIME;
        }

        public int GetTime()
        {
            return mCurrent;
        }

    }

```



###SceneMainを修正
GameTimerを制御して実際にタイム計測ができるよう、SceneMainにコードを追加します。

```cs:SceneMain.cs

        void Awake()
        {
            // ゲーム全体の初期化
            mGame = GameController.Instance;
            mGame.Init();

            // 初期レベルはイージー
            mGame.GameLevel = GameController.LEVEL_EASY;

            // タイマーの生成
追加        mTimer = new GameTimer();
        }

        //---------------------
        // ゲームの開始と終了 //
        //---------------------------------------------------------------------------------

        private void StartGame()
        {
            mUi.RenewStartText("RESET", "red");
追加        StartCoroutine("RenewTime");
            mState = STATE.PLAY;
        }

        private void ResetGame()
        {
追加        StopAllCoroutines();
追加        mTimer.ResetTime();
追加        mUi.RenewTimeText(mTimer.GetTime());
            mUi.RenewStartText("LOADING", "black");
            mUi.HideResultText();
            mState = STATE.LOADING;
        }

        private void EndGame(bool clearFlg)
        {
追加        StopAllCoroutines();
            if (clearFlg)
            {
                mUi.ShowResultText("GAME CLEAR!");
            }
            else
            {
                mUi.ShowResultText("GAME OVER");
            }
            mState = STATE.RESULT;
        }

↓以下を追加

        //------------
        // 時間管理 //
        //---------------------------------------------------------------------------------

        private GameTimer mTimer;

        private IEnumerator RenewTime()
        {
            while (true)
            {
                yield return new WaitForSeconds(1.0f);
                mTimer.IncTime();
                mUi.RenewTimeText(mTimer.GetTime());
            }
        }

```

今回のタイマーにはコルーチンの仕組みを利用しています。

**参考：** [Unityにおけるコルーチンの性質まとめ](https://qiita.com/kazz4423/items/73219068684e87adc87d)

計測を開始するタイミングでコルーチンをスタートさせて1秒毎に経過時間を表すButtonTimerのテキストを更新し、終了する場合はStopAllCoroutines()で現在動いている全てのコルーチンを止めています。



#ゲームの操作方法を表示する
最後に、画面下部に操作方法を表示するUIを設置して3Dマインスイーパーを完成させます。

![unity_sweeper_ss_6_7.jpg](https://qiita-image-store.s3.amazonaws.com/0/239877/a3da0602-ec9f-8f7a-20be-aeec28872a90.jpeg)

1. Canvas内にPanelBottomという名前でPannelを作成
+ PanelにアタッチされたImageのColorを<font color="blue">(R=0, G=0, B=0, A=200)</font>に変更
+ Panelのポジションを以下のように設定

![unity_sweeper_ss_6_8.jpg](https://qiita-image-store.s3.amazonaws.com/0/239877/5ec2f41e-bbae-5875-b365-0815d25fcf6d.jpeg)

1. Panel内にTextを作成
+ TextのColorを<font color="blue">(R=255, G=255, B=255, A=255)</font>に変更
+ Textの赤枠部分とポジションを以下のように設定

![unity_sweeper_ss_6_9.jpg](https://qiita-image-store.s3.amazonaws.com/0/239877/2553ac80-f281-8093-3c40-07f5dac2e37f.jpeg)

<Pre>
[ W/A/S/D ]  Move        [ Left Click ]  Open      [Right Click ]  Marker 
</Pre>



#ゲームの完成

![unity_sweeper_ss_0.jpg](https://qiita-image-store.s3.amazonaws.com/0/239877/5c50492b-3604-1e57-65e4-0541f073a1cf.jpeg)

6回にわたって解説を行ってきた3Dマインスイーパーは以上で完成となります。

前回の[タンクウォーズ編](https://qiita.com/YzRoid/items/544426ceffd4283d6977)に続いて至らない点も多々ありますが、本記事が少しでも参考になれば幸いでございます。
またコードやテキストの不備などございましたら、ぜひぜひお知らせくださいませ。

それでは最後に、プロジェクトを実行して3Dマインスイーパーが完成していることを確認します。

ご覧いただき、ありがとうございました。