【Unity】2D用のNavMeshでTimeMap以外の領域や障害物をベイクする

2D 用の NavMesh の導入方法とタイルマップへの適用方法は記事がいくつかあるので、導入~タイルマップに適用するまでは比較的簡単にできると思います。ただ、実際にトップダウン系のゲームなどで使用する場合、障害物を設置したり経路を動的に足したりできないと実用は厳しいと思います。

そこで今回は以下の3点を紹介します。

  • タイルマップとNavMeshのレイヤーの分け方
  • TimeMap以外のオブジェクトでNavMeshの領域を追加する
  • NavMesh上に障害物配置して通行不可領域を設定する

f:id:Takachan:20220214011942g:plain

確認環境

上記ブランチからソースを取得して、NavMeshComponents/Assets 以下の Gizmos, NavMeshComponents をプロジェクトに取り込んだ後、TimeMapをゲーム上に配置したところから説明を始めます。

タイルマップとNavMeshのレイヤーの分け方

2Dのトップダウンのゲームでタイルマップを使用してキャラクターの移動をコントロールする場合以下の設定を行う事が多いと思います。

  • キャラクターに RigitBody2D + Collder2D
  • タイルマップのキャラクターが移動できない部分にCollder2D

一方、NavMesh2Dの通行可能な範囲の指定は

  • 移動可能な領域をCollider2Dで指定する

となっているため、両方をデフォルトの設定で有効化するとキャラクターがNacMesh用のCollider2Dに衝突して移動できなくなってしまいます。

この場合の設定方法ですが以下のように「プレイヤーが移動するタイルマップ」と「NavMesh用」の2つのタイルマップを作成します。「TileMap」がプレイヤーの移動範囲が設定されたタイルマップで「NavMesh」がNavMeshで使用するタイルマップになります。

f:id:Takachan:20220214012813p:plain

プレイヤーが移動するタイルマップは移動できない範囲にColliderを設定している通常のマップを用意します。この時「TileMap」のゲームオブジェクトのレイヤーは初期値から変更せずに「Default」にしておきます。

f:id:Takachan:20220214013013p:plain

こんな感じで、移動できない範囲が緑色になっています。

次に、NavMesh用のレイヤーですが。前準備として、移動可能な範囲を可視化するために通行可能な場所は「OK」と文字の付いた Collider Type が Grid のタイルを作成します。

f:id:Takachan:20220214013539p:plain

また、移動不能な範囲(これは本当は必要ないですが)も見えるようにするために Collider Type が None のタイルを作成します。

f:id:Takachan:20220214013659p:plain

次にこの「OK」のタイルで移動可能な範囲を塗りつぶします。

f:id:Takachan:20220214013726p:plain

そうしたらもう、この表示が邪魔なので Timemap Renderer のチェックを外しておきます。

f:id:Takachan:20220214014445p:plain

そうすると通行可能な範囲が表示されなくなるので以下のように Collider の緑枠だけが残ります。

f:id:Takachan:20220214014531p:plain

次にプレイヤー用の衝突判定から NavMesh 用のレイヤーを除外する設定を行います。

まず、NavMesh のゲームオブジェクトの Layer を変更します。右側の下矢印をクリックして「Add Layer」を選択します。

f:id:Takachan:20220214014007p:plain

適当な User Later に「TimeMapNavMesh」を入力します(名前はこれじゃなくても任意で大丈夫です。

f:id:Takachan:20220214014120p:plain

作成したレイヤーを NavMesh ゲームオブジェクトに設定します。

f:id:Takachan:20220214014219p:plain

次に Edit > Project Settings > Physics 2D から「Layer Collision Matrix」を以下の通り設定します。

f:id:Takachan:20220214014321p:plain

TimeMapNavMesh の縦の列を全て選択解除します。

これで、NavMeshのColliderとキャラクターが衝突しなくなりました。

余談ですが、この NavMesh 用の Tilemap Collider2D に Composit Collider 2D を追加して、Use By Composote にチェックを入れると NavMesh が生成されなくなるのでコンポーネントを追加してはいけません。

TimeMap以外のオブジェクトでNavMeshの領域を追加する

タイルマップ以外にも任意の範囲を通行可能にする設定です。

f:id:Takachan:20220214014942g:plain

このようにタイルマップ以外のオブジェクトで動的に NavMesh の範囲を増やすことができます。

実装方法ですが、まず任意のゲームオブジェクトに「Box Collider 2D」と「Nav Mesh Source Tag 2D」を追加したものを用意します。ここではBoxCollider の大きさの調整がしやすいように半透明の Sprite Renderer も持ったオブジェクトを作成します。

f:id:Takachan:20220214015236p:plain

これで、任意の位置にこの四角形を配置して「Nav Mesh Builder 2D」を「Bake」するか、Nav Mesh Builder 2D の Update Method を Update か Fix Update に変更してEditor 上でオブジェクトを移動するとベイク範囲にこのオブジェクトの分が足されることが確認できます。

タイルマップの移動範囲の指定だけだとゲームに配置した障害物をエージェントが迂回する表現ができないためこれを実現する実装方法です。

f:id:Takachan:20220214015732g:plain

このように領域を指定してNavMeshから除外するとができるようになります。

これはゲームオブジェクトに「Nav Mesh Obstacle」を設定する事で実現できます。今回も視覚的把握しやすいように Sprite Renderer に Nav Mesh Obstacle を追加しています。インスペクターの内容は以下のようになります。

f:id:Takachan:20220214020259p:plain

最も大切なのは「Carve」にチェックを入れる事です。これをしないとベイクの範囲の反映されません。

f:id:Takachan:20220214020338p:plain

2D だと Z 方向の事を忘れがちですがこのように NavMesh を貫通するように配置してください。

この状態で先ほどと同じように Editor 上で「Nav Mesh Builder 2D」を「Bake」するか、Nav Mesh Builder 2D の Update Method を Update か Fix Update に変更してEditor 上でオブジェクトを移動するとベイク範囲にこのオブジェクトの分が足されることが確認できます。ゲーム中で「Nav Mesh Obstacle」を持つオブジェクトを追加した場合、Manualだと Bake に相当する NavMeshBuilder2D.RebuildNavmesh を呼び出すとスクリプトからでもNavMeshを更新することができます。

【おまけ】プレイヤーを追尾するエージェントの実装

この 2D 用の NavMesh は AI のエージェントは自分で実装しないといけないらしいので冒頭で動いていた追尾してくる白い四角にアタッチしているスクリプトを以下に紹介します。

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

/// <summary>
/// プレイヤーを追尾するダミーの敵キャラクターを表します。
/// </summary>
/// <remarks>
/// NavMeshを使った移動方法のリファレンス実装
/// </remarks>
public class Enemy : MonoBehaviour
{
    //
    // Inspector
    // - - - - - - - - - - - - - - - - - - - -

    // 追尾対象
    [SerializeField] Player _player;
    // 移動速度
    [SerializeField] float _moveSpeed = 1f;
    // どれくらい近づいたら次のポイントに移るか
    [SerializeField] float _minDistance = 0.05f;
    // プレイヤーへの経路再計算をする間隔
    [SerializeField] float _reCalcTime = 0.5f;

    //
    // Fields
    // - - - - - - - - - - - - - - - - - - - -

    // 次の移動先
    private Vector2 _nextPoint;
    
    // キャッシュ類
    private Transform _plyaerTransform;
    private Transform _myTransform;
    
    // AI用
    private NavMeshPath _navMeshPath;
    private Queue<Vector3> _navMeshCorners = new();

    // 計算したときのプレイヤーの位置
    Vector3 _calcedPlayerPos;
    // 次に再計算するまでの時間
    private float _elapsed;

    //
    // Runtime impl
    // - - - - - - - - - - - - - - - - - - - -

    public void Awake()
    {
        _myTransform = transform;
        _plyaerTransform = _player.transform;
        _nextPoint = _myTransform.position;
        _navMeshPath = new NavMeshPath();
    }

    public void Update()
    {
        if (_calcedPlayerPos != _plyaerTransform.localPosition)
        {
            _elapsed += Time.deltaTime;
            if (_elapsed > _reCalcTime)
            {
                _elapsed = 0;
                
                NestStep();
                _calcedPlayerPos = _plyaerTransform.localPosition; // ルート出したときの位置
            }
        }

        Vector2 currentPos = _myTransform.localPosition;
        if (Vector2.Distance(_nextPoint, currentPos) < _minDistance)
        {
            if (_navMeshCorners.Count == 0)
            {
                _nextPoint = _myTransform.localPosition;
                return;
            }
            _nextPoint = _navMeshCorners.Dequeue();
        }

        Vector2 diff = _nextPoint - currentPos;
        if (diff == Vector2.zero)
        {
            return;
        }

        Vector2 step = _moveSpeed * Time.deltaTime * diff.normalized;
        _myTransform.Translate(step);
    }

    private void NestStep()
    {
        // NavMeshで経路を計算する
        // 自分の位置 → プレイヤーの位置
        bool isOk = NavMesh.CalculatePath(_myTransform.position,
            _plyaerTransform.position, NavMesh.AllAreas, _navMeshPath);
        if (!isOk)
        {
            Debug.LogWarning("Failed to NavMesh.CalculatePath.", this);
        }
        
        _navMeshCorners.Clear();
        _navMeshCorners.EnqueueRange(_navMeshPath.corners);
        _nextPoint = _myTransform.localPosition;
    }
}

/// <summary>
/// <see cref="Queue{T}"/> の拡張機能を定義します。
/// </summary>
public static class QueueExtension
{
    public static void EnqueueRange<T>(this Queue<T> self, IEnumerable<T> items)
    {
        foreach (var item in items)
        {
            self.Enqueue(item);
        }
    }
}

プレイヤーが移動したことを0.5秒ごとに検出してルートを再設定するようにして追尾を行っています。

追尾対象は「Player」というここでは紹介していないスクリプトですが Transform 以外参照していないので、使用する際は任意の GameObject に変更できます。また、移動速度やコーナーに到着したとみなす幅、再計算時間はインスペクターから指定できるようにしています。

f:id:Takachan:20220214021742g:plain

実行するとこんな感じになります。

参考資料

この記事は以下を参考にさせて頂きました。

ありがとうございます。

watablog.tech

www.matatabi-ux.com

tsubakit1.hateblo.jp

以上。