ひつじコレクション - 4.フォロワーに追従機能を実装

[ミニゲームを作ってUnityを学ぶ！[ひつじコレクション編]](https://qiita.com/YzRoid/items/8dfd6c168b916ac82a0d)
###**第4回目: フォロワーに追従機能を実装**

前回はフォロワーを作成し、一定時間毎にポップアップする仕組みを実装しました。
今回はUnityのナビゲーションシステムを使ってフォロワーにプレイヤーを追従する機能を実装していきます。

#ナビゲーションシステムとは
Unity公式サイトでは「ナビゲーションシステムによって、シーンのジオメトリから自動で生成されるナビゲーションメッシュを使用して、ゲーム世界の中を知的に動くキャラクターを作成することができます」と記述されています。

**参考：** [Unity公式-ナビゲーションと経路探索](https://docs.unity3d.com/ja/current/Manual/Navigation.html)

これを別の言葉に言い換えると**「オブジェクトを目的地まで最短距離で移動させるよ！」**です。

つまりこのシステムを利用してフォロワーの目的地をプレイヤーの位置に設定しておけば、フォロワーは常に最短距離でプレイヤーに近づき、あたかも追従しているかのように見せることができます。



### ナビゲーションシステムの構成
ナビゲーションシステムは以下4つの要素から成り立っています。

| 要素 | 概要 |
|:-:|:-:|
| NavMesh  | 移動可能な面を表すデータ構造  |
| NavMesh Obstacle  | 移動不可な障害物  |
| NavAgent  | オブジェクトにアタッチすることでNavMeshの情報を元に目的地までのルートを計算し、移動処理を行う  |
| Off-Mesh Link  | NavMeshで定義された移動可能なエリアから抜け出るための道。障害物を迂回するのではなくジャンプで飛び越えるなどの処理で利用 |


### ナビゲーションビュー
Unityの上部メニューにあるWindow - Navigationを選択するとインスペクタの横にナビゲーションビューが表示されます。

![action_sheep_ss_4_1.jpg](https://qiita-image-store.s3.amazonaws.com/0/239877/b9c4dd20-2b0b-087f-609b-95348019b7f4.jpeg)


ナビゲーションシステムの設定は基本的にこのビューを介して行われます。

### サンプルの作成
ナビゲーションシステムの使い方を見るために簡単なサンプルを作成してみます。

+ シーンを新しく作り、名前を「navmeshSample」として保存
+ ゼロポジションにPlaneを配置
+ Planeのインスペクタ上部にあるStaticと表記された右横の三角アイコンからNavigationStaticを選択
+ ナビゲーションビューのBakeタブを選択し、一番下のBakeボタンを押す

![action_sheep_ss_4_2.jpg](https://qiita-image-store.s3.amazonaws.com/0/239877/d412e964-e731-432e-b1b5-0b6255f9d4da.jpeg)

シーンビューでは画像のようにPlaneの端以外が水色に変更されます。
この水色の部分がNavMeshで定義された移動できる面となります。

<Pre>
【NavigationStatic】

この定義がされたオブジェクトはナビゲーションシステムの計算対象となる。

</Pre>

<Pre>
【Bake（ベイク）】

実際に移動可能なエリアを計算し、NavMeshを構築すること。
Bakeボタンを押すことでナビゲーションシステムはNavigationStaticな全オブジェクトを対象にして
移動可能な面を作成する。

</Pre>

次に障害物を配置してみます。

+ ゼロポジションにCubeを配置
+ Planeと同じように、CubeにNavigationStaticを設定
+ さらにAddComponentでNavMeshObstacleをアタッチ
+ ナビゲーションビューのBakeボタンを押す

![action_sheep_ss_4_3.jpg](https://qiita-image-store.s3.amazonaws.com/0/239877/fdb6daf6-341c-9816-1725-95ad863636a4.jpeg)


Cubeが障害物と認識されて周囲が白色になり、移動可能な面から除外されました。
続いてOffMeshLinkを設定してみます。

+ 新しいPlaneを作成し、先ほどのPlaneと隙間が空くように並べて配置
+ 同じくNavigationStaticを設定
+ 2つのPlaneそれぞれに1つずつ、移動可能なエリア内にCylinderを配置
+ 左側のCylinderにAddComponentでOffMeshLinkをアタッチ
+ OffMeshLinkのプロパティStartに自分、Endにもう一方のCylinderを設定
+ ナビゲーションビューのBakeボタンを押す

![action_sheep_ss_4_4.jpg](https://qiita-image-store.s3.amazonaws.com/0/239877/4ba0df11-64d9-b821-513d-665847079f8b.jpeg)


左右のCylinderの間に矢印が表示されました。
目的地までの移動ルートを計算する際にこの矢印を通るルートが最も距離が短いと判断された場合には、矢印のルートを通って移動不可能なエリアを飛び越える（無視する）ことができます。

最後にNavAgentをアタッチしたオブジェクトを使って、実際に移動をさせてみます。

+ 左側の移動可能エリア内にSphereを配置
+ SphereがPlaneにめり込まないようにTransform.Yを0.52に設定
+ 右側の移動可能エリア内に目的地となるCylinderを配置
+ SpereにNavMeshAgentをアタッチ
+ スクリプト「MoveTo」を作成してSphereにアタッチ

```cs:MoveTo.cs

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

public class MoveTo : MonoBehaviour {

    public Transform goal;

    void Start()
    {
        NavMeshAgent agent = GetComponent<NavMeshAgent>();
        agent.destination = goal.position;
    }

}

```

+ MoveToのgoalプロパティに目的地となるCylinderを設定

![action_sheep_ss_4_5.jpg](https://qiita-image-store.s3.amazonaws.com/0/239877/b53d8724-e66d-75ac-4b38-27b7f6bb220e.jpeg)

プロジェクトを実行するとSphereが移動不可エリアを飛び越えて目的地のCylinderまで移動していきます。



# NavMeshを動的に生成する
ナビゲーションシステムの基本を理解したところで早速ゲーム内の処理を実装していきます。
サンプルではあらかじめ生成されているステージに対してBakeを行っていましたが、今回はゲームが開始されてからステージが生成されますので、Bakeをその後に実行する必要があります。

このNavMeshの動的なBakeについてはUnityの開発元「Unity Technologies」が大変使いやすいサンプルを公開していますので、今回はこちらのスクリプトを流用していきたいと思います。

**参照：**　[NavMeshComponents](https://github.com/Unity-Technologies/NavMeshComponents)

+ 上記Unity TechnologiesのGitHubからプロジェクトをダウンロード
+ ダウンロードしたファイルのAssets/Examples/Scriptsを開き、以下2つのスクリプトをインポート

<pre>
1: LocalNavMeshBuilder.cs
2: NavMeshSourceTag.cs
</pre>

+ StageManagerオブジェクトにLocalNavMeshBuilderをアタッチ
+ FloorプレハブにNavMeshSourceTagをアタッチ
+ Bushプレハブの子要素CubeにNavMeshSourceTagをアタッチ

準備が出来たらナビゲーションビューを開いた状態でプロジェクトを実行します。

![action_sheep_ss_4_6.jpg](https://qiita-image-store.s3.amazonaws.com/0/239877/211beb51-986f-3b0a-d698-1a0d9b300ea6.jpeg)

するとSceneビューの表示が上画像のように、ステージ全体に青みがかったような状態になります。
この青色になっている部分がナビゲーションシステムでいうところの移動可能な面となり、動的なBakeが行われていることを確認できます。

**参考：** [HoloLensで実現する動的経路探索](https://qiita.com/morio36/items/d75228d2ccdb9c24574b)

LocalNavMeshBuilderとNavMeshSourceTagについては上記のページで非常にわかりやすく解説されていますので、ご紹介させていただきます。


#Agent Typesの設定
動的なBakeはできましたが現段階ではステージのほとんどが移動可能な状態となっていて、Bushオブジェクトが障害物としての役割を果たしていません。

これはBakeの際に障害物であるBushの高さが足りずに、飛び越えられると判定されていることが原因です。
この問題を解決するために、ナビゲーションビューからAgent typesの設定を行います。

+ ナビゲーションビューのAgentタグを選択
+ 表示されたHumanoidについて以下のように設定

![action_sheep_ss_4_7.jpg](https://qiita-image-store.s3.amazonaws.com/0/239877/1d58b352-5009-57de-257c-cee435c8beef.jpeg)

<Pre>
【Agent Types】

NavMeshAgentがアタッチされるオブジェクトについて、それがどのくらいの大きさで、
どの程度の段差を飛び越えることができ、いくらの斜度ならば昇っていけるかなどの条件を定義する項目。

ナビゲーションシステムのBakeはこのAgentの条件を元にNavMeshを生成する。

</Pre>

下画像のようにナビゲーションビューのBakeタグにもこの条件を設定する場所があります。

![action_sheep_ss_4_8.jpg](https://qiita-image-store.s3.amazonaws.com/0/239877/7f63aa9c-8744-1ea7-6e71-ae91d3187580.jpeg)


この場合は直下のBakeボタンが押されたときのみに使用される条件となっていますので、今回のようにスクリプトからBakeするような場合にはコード上でどのAgentを条件にするか指定しなければなりません。

LocalNavMeshBuilderでそれに当たるのが以下の部分です。

```cs:LocalNavMeshBuilder.cs

    void UpdateNavMesh(bool asyncUpdate = false)
    {
        NavMeshSourceTag.Collect(ref m_Sources);
        var defaultBuildSettings = NavMesh.GetSettingsByID(0);
        var bounds = QuantizedBounds();

        if (asyncUpdate)
            m_Operation = NavMeshBuilder.UpdateNavMeshDataAsync(m_NavMesh, defaultBuildSettings, m_Sources, bounds);
        else
            NavMeshBuilder.UpdateNavMeshData(m_NavMesh, defaultBuildSettings, m_Sources, bounds);
    }

```

NavMesh.GetSettingsByID(0)によってAgentTypesの0番 = Humanoidに定義された条件を取得しています。

![action_sheep_ss_4_9.jpg](https://qiita-image-store.s3.amazonaws.com/0/239877/7b4a53a5-b28b-cc0b-3f23-1ae723461d4d.jpeg)

AgentTypeを設定してからプロジェクトを実行すると、Bushが障害物として認識されたことが確認できます。

<font color="blue">LocalNavMeshBuilderはフレーム毎に処理を行っていますので、プロジェクト実行中にAgentType-Humanoidの値を変えることでリアルタイムにNavMeshの形状が変更されるのを確認することができます。</font>


#フォロワーに移動機能を実装
NavMeshを生成する部分が出来上がりましたので、次はフォロワーにNavMeshAgentを使った移動処理を実装していきます。

+ FollowerプレハブにNavMeshAgentをアタッチ
+ NavMeshAgentの以下パラメータを設定

<pre>

Angular Speed: 360
Auto Braking: チェックを外す
Quality: None

</pre>

+ FollowerModelにコードを追加

```cs:FollowerModel.cs

        void Awake()
        {
            mAgent = GetComponent<NavMeshAgent>();
            InitAnim();
        }

        void Update()
        {
            switch (State)
            {
                case STATE.FOLLOW:
                    UpdateFollow();
                    break;
            }
        }

        private Transform mTarget; // 追従する対象
        private int mOrder; // フォロワーの並び順

        /// <summary>
        /// 指定された対象の追従を開始
        /// </summary>
        public void Follow(Transform target, int order)
        {
            mTarget = target;
            mOrder = order;
            State = STATE.FOLLOW;
        }

        //-----------------
        // アニメーション //
        //---------------------------------------------------------------------------------

        private Animator mAnimator;
        private int mIdIsRun;

        private void InitAnim()
        {
            mAnimator = GetComponent<Animator>();
            mIdIsRun = Animator.StringToHash("IsRun");
        }

        //----------------
        // NavMeshAgent //
        //---------------------------------------------------------------------------------

        private readonly float START_DISTANCE = 1.0f;
        private readonly float STOP_DISTANCE = 0.8f;

        private NavMeshAgent mAgent;

        public void SetSpeed(float spd)
        {
            mAgent.speed = spd;
            mAgent.acceleration = spd * 4.0f;
        }

        private void UpdateFollow()
        {
            mAgent.SetDestination(mTarget.position);

            float remainingDistance = mAgent.remainingDistance;
            if (remainingDistance > START_DISTANCE)
            {
                mAgent.isStopped = false;
                mAnimator.SetBool(mIdIsRun, true);
            }
            else if (remainingDistance <= STOP_DISTANCE)
            {
                mAgent.velocity = Vector3.zero; // 減速ではなくピタっと止める
                mAgent.isStopped = true;
                mAnimator.SetBool(mIdIsRun, false);
            }
        }

```

フォロワーはFollow()によって追従する対象が渡されると、UpdateFollow()によって対象の追従を行います。
UpdateFollow()ではその都度、追従対象の現在の位置を目的地に設定し、目的地までの距離を取得してそれが近すぎればAgentを停止、遠すぎればAgentを再開しています。

また、前回設定したアニメーションについてもそれを制御するコードを追加しています。


#フォロワーを連れていく
続いて、プレイヤー側からフォロワーに対してFollowerModel#Follow()を呼び出し、自身を追従させる仕組みを実装していきます。

### ポップアップポイントの修正
フォロワーを追従させるためには対象のフォロワーに接触する。
という条件が最もわかりやすいのですが、今回はポップアップポイントに接触した際にその場所にフォロワーが出現しているならば、対象のフォロワーを追従させるという仕組みで実装します。

+ 新しく「PopupPoint」というタグを作成
+ PopupPointオブジェクトに上記のタグを設定
+ PopupPointにコードを追加

```cs:PopupPoint.cs

        /// <summary>
        /// フォロワーポップ中にプレイヤーが接触した。
        /// エフェクトを非活性にし、ポップ中のフォロワーを返す
        /// </summary>
        /// <returns></returns>
        public FollowerModel DeleverFollower()
        {
            mEffect.SetActive(false);
            return mFollower;
        }

```

新しくDeleverFollower()を追加しました。
このメソッドはプレイヤーがポップアップポイントに接触した際に呼ばれ、出現中のエフェクトを消すと同時に自身の持っているフォロワーの参照を返します。


### プレイヤーの修正
次に、プレイヤーからPopupPoint#DeleverFollower()およびFollowerModel#Follow()を呼び出す仕組みを実装します。

+ PlayerActionにコードを追加

```cs:PlayerAction.cs

        //-------------
        // 当たり判定 //
        //---------------------------------------------------------------------------------

        private readonly string TAG_POPUP_POINT = "PopupPoint";

        void OnTriggerEnter(Collider other)
        {
            if (State != STATE.IDLE && State != STATE.RUN) return;

            // ポップアップポイントに接触した
            if (other.tag == TAG_POPUP_POINT) TakeFollower(other.GetComponent<PopupPoint>());
        }

        //-------------------
        // フォロワーの追従 //
        //---------------------------------------------------------------------------------

        private List<FollowerModel> mFollowerList = new List<FollowerModel>();

        /// <summary>
        /// 連れているフォロワーの数によって移動速度を再設定
        /// </summary>
        private void SetSpeed()
        {
            int followerCount = mFollowerList.Count;
            mSpeed = MAX_SPEED - DEC_SPEED_VALUE * followerCount;
        }

        /// <summary>
        /// 接触したポップアップポイントに待機中のフォロワーが存在する場合はそのフォロワーを連れていく（追従させる）
        /// </summary>
        /// <param name="popup"></param>
        private void TakeFollower(PopupPoint popup)
        {
            if (!popup.IsExsistFollower()) return;

            // フォロワーに追従させる対象を決定する（すでにフォロワーが追従している場合は一番後ろのフォロワーを対象にする）
            Transform target = mTrans;
            int followerCount = mFollowerList.Count;
            if (followerCount > 0) target = mFollowerList.Last().gameObject.GetComponent<Transform>();

            // フォロワーの追従開始
            FollowerModel follower = popup.DeleverFollower();
            follower.Follow(target, followerCount);

            // フォロワーをリストに追加し、プレイヤーとフォロワーの移動速度を再設定
            mFollowerList.Add(follower);
            SetSpeed();
            foreach (FollowerModel model in mFollowerList)
            {
                model.SetSpeed(mSpeed);
            }
        }

```

ポップアップポイントに接触したタイミングでTakeFollower()が呼ばれ、接触したポップアップポイントにフォロワーが出現している場合はそのフォロワーに対してFollow()を呼び出して追従を開始させています。

このとき、すでに他のフォロワーが追従している場合は一番後ろのフォロワーを追従の対象として渡すことで移動したときにフォロワー同士がきちんと順番を守るように付いてきます。

さらにプレイヤーの速度を追従しているフォロワーの数によって再設定していますが、このときにフォロワーの速度がプレイヤーより早くなってしまうと、プレイヤーがずっと移動しているにもかかわらずフォロワーが移動しては止まり、また移動しては止まるといったひどい挙動になってしまうため、全ての追従フォロワーの速度をプレイヤーと同じになるよう再設定しています。

![action_sheep_ss_4_10.jpg](https://qiita-image-store.s3.amazonaws.com/0/239877/495106fa-f0ca-3655-4ed3-2fab8070d281.jpeg)

プロジェクトを実行して、RPGでいう一列パーティー状態になっていることを確認します。




























---
[次のページに進む](https://qiita.com/YzRoid/items/f3533c9645ed943a13b4)
[イントロダクションに戻る](https://qiita.com/YzRoid/items/8dfd6c168b916ac82a0d)
<BR>
<div><img src="http://unity-chan.com/images/imageLicenseLogo.png" alt="ユニティちゃんライセンス"><p>この作品は<a href="http://unity-chan.com/contents/license_jp/" target="_blank">ユニティちゃんライセンス条項</a>の元に提供されています</p></div>
<Br><Br>