【Unity】敵AIの作り方|巡回・追跡・攻撃をステートで切り替える

敵がプレイヤーを追いかける所までは、NavMeshを使えば10分ほどで動きます。手が止まるのはその先で、「ずっと追いかけてくる」「壁の向こうからでも見つかる」「見失ったあと棒立ちになる」といった、ゲームとして遊べない動きをどう直すか、なんですね。

この記事では、Unityで敵AIを作る手順を、NavMeshのベイクから順番にまとめました。巡回・追跡・捜索・攻撃の4つの状態(ステート)を切り替えるスクリプトの全文と、視野角と壁で「見える・見えない」を決める判定、うまく動かない時の確認点まで載せています。

確認した環境はUnity 6000.3(Unity 6.3 LTS)とAI Navigationパッケージ2.0です。コードはUnity 6000.3のエンジンのDLLを参照してビルドが通ることを確かめたもので、APIはスクリプトリファレンスで確認しています。

スポンサーリンク

敵AIの作り方の全体像|4つのステートで考える

敵の頭の中を、いまどの「モード」にいるかで分けて考えるのが、ステートマシンという作り方です。今回の敵は次の4つのモードを行き来します。

ステート やること 次へ移る条件
巡回(Patrol) 決めた地点を順番に歩く プレイヤーが見えたら追跡へ
追跡(Chase) プレイヤーに向かって走る 間合いに入ったら攻撃へ、見えなくなったら捜索へ
攻撃(Attack) 止まって一定間隔で攻撃する 離れたら追跡か捜索へ
捜索(Search) 最後に見た場所へ行き、少し待つ 見えたら追跡へ、時間切れで巡回へ

「見失ったら即巡回に戻る」でも動きはします。ただ、プレイヤーが柱の陰に隠れた瞬間に敵がくるっと向きを変えて帰っていくので、どうにも間が抜けて見えるんです。捜索のステートを1つ挟むだけで「さっきまでいた場所を探しに来る」動きになり、かくれんぼの緊張感が出ます。

ステートの書き方には、enumとswitch文で書く方法と、状態ごとにクラスを分けるStateパターンがあります。今回はenumを選びました。4つ程度なら1つのファイルで全体が見渡せて、初心者でも「いまどこの処理が動いているか」を追いやすいからです。状態が10を超えてきたら、クラスに分けるほうが楽になると思います。

準備:Unity 6でNavMeshをベイクする

AI Navigationパッケージを確認する

Unity 2022.2以降、NavMeshの作成はAI Navigationパッケージ(com.unity.ai.navigation)の役目になりました。Window > Package Managerを開き、In ProjectにAI Navigationがあるか見てください。手元にあるUnity 6のプロジェクトでは、どれも最初から2.0系が入っていました。無ければUnity RegistryからAI Navigationを選んでInstallします。

当ブログでも2021年にNavMeshで追いかける記事を書いていますが、そこで使ったNavigationウィンドウのBakeタブでのベイクは古いやり方です。考え方は同じなので、仕組みを知りたい人は読み比べてみてください。

NavMesh Surfaceを置いてBakeする

いまのやり方では、NavMesh Surfaceというコンポーネントがベイクを担当します。

  1. ヒエラルキーで床や地形を含む親オブジェクトを選ぶ(空のオブジェクトでも可)
  2. インスペクターのAdd ComponentからNavigation > NavMesh Surfaceを追加する(GameObject > AI > NavMesh Surfaceでも作れます)
  3. Collect Objectsで対象を決める。迷ったら初期値のAll Game Objectsのままで大丈夫です
  4. Bakeボタンを押す

歩ける場所が青く塗られれば成功です。青が見えない時は、シーンビューのオーバーレイの一覧(シーンビュー上で「`」キー)からAI Navigationを表示させ、SurfacesのShow NavMeshにチェックが入っているか見てください。Show Only Selectedがオンだと、NavMesh Surfaceを選んでいる間しか青が出ません。このAI Navigationオーバーレイは初期状態では隠れているので、最初はここでつまずきやすいです。

壁や箱など、敵に通ってほしくない物は、ベイク時にそこだけ穴が空いた形になります。物を動かしたらBakeを押し直すのを忘れないようにしてください。古いNavMeshのまま再生すると、敵が壁に向かって歩き続けます。

敵にNavMesh Agentを付ける

敵のオブジェクト(まずはカプセルで十分)にAdd Component > Navigation > NavMesh Agentを付けます。速さはスクリプトから切り替えるので、ここで触るのは次の2つだけ。

  • Stopping Distance:目的地のどれだけ手前で止まるか。巡回なら0.5くらい
  • Agent Type:NavMesh Surface側のAgent Typeと同じにする(初期値はどちらもHumanoid)

敵AIのスクリプト全文(C#)

AssetsにEnemyAI.csを作り、中身を次のコードに置き換えます。長く見えますが、半分はコメントと変数の宣言です。

using UnityEngine;
using UnityEngine.AI;

[RequireComponent(typeof(NavMeshAgent))]
public class EnemyAI : MonoBehaviour
{
    // 敵の状態(ステート)
    enum State { Patrol, Chase, Search, Attack }

    [Header("参照")]
    [SerializeField] Transform player;          // 追いかける相手
    [SerializeField] Transform[] patrolPoints;  // 巡回する地点(順番に回る)

    [Header("視界")]
    [SerializeField] float viewDistance = 10f;  // 見える距離
    [SerializeField] float viewAngle = 90f;     // 視野角(左右を合わせた角度)
    [SerializeField] float eyeHeight = 1.5f;    // 目の高さ
    [SerializeField] LayerMask obstacleMask;    // 視線をさえぎる物のレイヤー

    [Header("速さ")]
    [SerializeField] float patrolSpeed = 2f;
    [SerializeField] float chaseSpeed = 4.5f;

    [Header("攻撃")]
    [SerializeField] float attackRange = 1.8f;
    [SerializeField] float attackInterval = 1.2f;
    [SerializeField] int attackDamage = 10;

    [Header("見失った後")]
    [SerializeField] float searchTime = 3f;     // 最後に見た場所で探す秒数

    NavMeshAgent agent;
    State state;
    int pointIndex;
    Vector3 lastSeenPosition;
    float searchTimer;
    float attackTimer;

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

    void Start()
    {
        ChangeState(State.Patrol);
    }

    void Update()
    {
        bool canSee = CanSeePlayer();
        if (canSee) lastSeenPosition = player.position;

        switch (state)
        {
            case State.Patrol: UpdatePatrol(canSee); break;
            case State.Chase:  UpdateChase(canSee);  break;
            case State.Search: UpdateSearch(canSee); break;
            case State.Attack: UpdateAttack(canSee); break;
        }
    }

    // 状態が切り替わった瞬間に1回だけ行う処理
    void ChangeState(State next)
    {
        state = next;
        switch (next)
        {
            case State.Patrol:
                agent.isStopped = false;
                agent.speed = patrolSpeed;
                GoToNextPoint();
                break;
            case State.Chase:
                agent.isStopped = false;
                agent.speed = chaseSpeed;
                break;
            case State.Search:
                agent.isStopped = false;
                agent.speed = patrolSpeed;
                agent.SetDestination(lastSeenPosition);
                searchTimer = searchTime;
                break;
            case State.Attack:
                agent.isStopped = true;
                attackTimer = 0f; // 間合いに入ったらすぐ1発目
                break;
        }
    }

    void UpdatePatrol(bool canSee)
    {
        if (canSee) { ChangeState(State.Chase); return; }
        if (HasArrived()) GoToNextPoint();
    }

    void UpdateChase(bool canSee)
    {
        if (!canSee) { ChangeState(State.Search); return; }

        agent.SetDestination(player.position);
        if (Vector3.Distance(transform.position, player.position) <= attackRange)
        {
            ChangeState(State.Attack);
        }
    }

    void UpdateSearch(bool canSee)
    {
        if (canSee) { ChangeState(State.Chase); return; }

        // 最後に見た場所に着いたら、しばらくその場で待ってから巡回に戻る
        if (HasArrived())
        {
            searchTimer -= Time.deltaTime;
            if (searchTimer <= 0f) ChangeState(State.Patrol);
        }
    }

    void UpdateAttack(bool canSee)
    {
        // 少し離れただけで追跡と攻撃を行き来しないよう、2割の余裕を持たせる
        if (Vector3.Distance(transform.position, player.position) > attackRange * 1.2f)
        {
            ChangeState(canSee ? State.Chase : State.Search);
            return;
        }

        // 止まっている間はエージェントが向きを変えないので、自分でプレイヤーの方を向く
        Vector3 dir = player.position - transform.position;
        dir.y = 0f;
        if (dir.sqrMagnitude > 0.001f)
        {
            Quaternion look = Quaternion.LookRotation(dir);
            transform.rotation = Quaternion.Slerp(transform.rotation, look, 10f * Time.deltaTime);
        }

        attackTimer -= Time.deltaTime;
        if (attackTimer <= 0f)
        {
            attackTimer = attackInterval;
            if (player.TryGetComponent(out PlayerHealth hp)) hp.TakeDamage(attackDamage);
        }
    }

    void GoToNextPoint()
    {
        if (patrolPoints.Length == 0) return;
        agent.SetDestination(patrolPoints[pointIndex].position);
        pointIndex = (pointIndex + 1) % patrolPoints.Length;
    }

    // 経路の計算が終わっていて、目的地の手前まで来たら「着いた」とみなす
    bool HasArrived()
    {
        return !agent.pathPending && agent.remainingDistance <= agent.stoppingDistance + 0.1f;
    }

    bool CanSeePlayer()
    {
        if (player == null) return false;

        Vector3 eye = transform.position + Vector3.up * eyeHeight;
        Vector3 target = player.position + Vector3.up * eyeHeight;
        Vector3 toPlayer = target - eye;
        float distance = toPlayer.magnitude;

        // 1. 距離:遠すぎたら見えない
        if (distance > viewDistance) return false;

        // 2. 角度:追跡中と攻撃中は、真後ろに回られても見失わない
        float halfAngle = (state == State.Chase || state == State.Attack) ? 180f : viewAngle * 0.5f;
        if (Vector3.Angle(transform.forward, toPlayer) > halfAngle) return false;

        // 3. 遮蔽:間に壁があったら見えない
        if (Physics.Raycast(eye, toPlayer.normalized, distance, obstacleMask)) return false;

        return true;
    }

    // 敵を選択している時だけ、シーンビューに視界と攻撃範囲を描く
    void OnDrawGizmosSelected()
    {
        Vector3 eye = transform.position + Vector3.up * eyeHeight;
        Vector3 left = Quaternion.Euler(0f, -viewAngle * 0.5f, 0f) * transform.forward;
        Vector3 right = Quaternion.Euler(0f, viewAngle * 0.5f, 0f) * transform.forward;

        Gizmos.color = Color.yellow;
        Gizmos.DrawWireSphere(transform.position, viewDistance);
        Gizmos.color = Color.cyan;
        Gizmos.DrawLine(eye, eye + left * viewDistance);
        Gizmos.DrawLine(eye, eye + right * viewDistance);
        Gizmos.color = Color.red;
        Gizmos.DrawWireSphere(transform.position, attackRange);
    }
}

攻撃を受ける側として、プレイヤーには次のPlayerHealth.csを付けておきます。HPを減らしてConsoleに出すだけの最小限の中身です。

using UnityEngine;

public class PlayerHealth : MonoBehaviour
{
    [SerializeField] int maxHp = 100;
    int hp;

    void Awake()
    {
        hp = maxHp;
    }

    public void TakeDamage(int amount)
    {
        hp = Mathf.Max(hp - amount, 0);
        Debug.Log("プレイヤーのHP: " + hp);
    }
}

コードのしくみ|ここを押さえると改造しやすい

「入った瞬間」と「いる間」を分ける

ChangeStateは状態が変わった時に1回だけ、UpdatePatrolなどは毎フレーム呼ばれます。速さの切り替えや「最後に見た場所へ向かう」命令は、切り替わった瞬間に1回だけやれば十分。これをUpdate側に書くと、捜索中に毎フレーム目的地を上書きしてしまい、タイマーが進まないといったバグの元になります。

攻撃の効果音や「!」マークを出したい時も、ChangeStateのcase State.Chaseに1行足すだけです。

視界は「距離 → 角度 → 壁」の順に判定する

CanSeePlayerは、軽い判定から順に行って、だめならすぐfalseを返しています。距離の計算はただの引き算、角度はVector3.Angleで前方向とのなす角を出すだけですが、Physics.Raycastは物理の世界に光線を飛ばすので、3つの中ではいちばん重い処理です。遠くのプレイヤーに毎フレーム光線を飛ばす必要はないので、最後に回しています。

Raycastの最後の引数obstacleMaskが、壁だけを相手にするための指定です。光線が壁に当たったら「さえぎられている」と判断します。

もう1つの工夫が、追跡中は角度の制限を外している所。巡回中は前方90度しか見えませんが、一度見つけたあとは、すぐ横をすり抜けられても見失いません。ここを外すと、敵の横を走り抜けるだけで簡単にまけてしまいます。

到着の判定でremainingDistanceだけを見ない

NavMeshAgentのremainingDistanceは、スクリプトリファレンスによると「残りの距離が分からない時はinfinity(無限大)」になります。そしてSetDestinationを呼んだ直後は、経路の計算が終わっていないことがある。そこでHasArrivedでは、pathPending(計算中かどうか)がfalseになってから距離を比べています。これを入れないと、巡回の地点を飛ばしたり、捜索のタイマーが目的地に着く前に進んだりします。

攻撃と追跡のあいだに「遊び」を入れる

攻撃に入る距離はattackRangeちょうど、抜ける距離はattackRangeの1.2倍にしています。同じ距離で出入りさせると、境目にいるプレイヤーに対して、追跡と攻撃が毎フレームのように切り替わり、敵がガタガタ震えて見えるんです。入る条件と出る条件に少し差をつけるのは、ステートマシン全般で使える小技ですね。

インスペクターの設定とGizmosでの確認

スクリプトを書いたら、シーン側を次のように整えます。

  1. 敵にEnemyAIを付け、Player欄にプレイヤーをドラッグする
  2. 空のオブジェクトを3〜4個作って巡回させたい場所に置き、Patrol Pointsに入れる
  3. 壁用のレイヤーを作る(インスペクター右上のLayer > Add Layer…で「Wall」など)。壁や柱のレイヤーをWallにする
  4. EnemyAIのObstacle MaskでWallだけにチェックを入れる
  5. プレイヤーにPlayerHealthを付ける

ヒエラルキーで敵を選ぶと、黄色い円(見える距離)、水色の2本の線(視野角)、赤い円(攻撃の間合い)がシーンビューに出ます。数字だけで調整するより、この線を見ながらviewAngleやviewDistanceを変えるほうが、ずっと早く「ちょうどいい敵」に近づけます。

目の高さeyeHeightは、敵とプレイヤーの基準点(ピボット)が足元にある前提で1.5にしています。Unityの標準のカプセルはピボットが体の中心にあるので、カプセル同士で試す時は0.5くらいに下げてください。そのままだと光線が頭の上を通って、低い壁を素通りしてしまいます。

巡回をランダムにする|NavMesh.SamplePosition

地点を置くのが面倒な時や、徘徊するモンスターを作りたい時は、範囲内のランダムな場所へ歩かせる方法もあります。適当に選んだ座標は空中や壁の中かもしれないので、NavMesh.SamplePositionで「いちばん近い、歩ける点」に寄せてから目的地にします。

using UnityEngine;
using UnityEngine.AI;

[RequireComponent(typeof(NavMeshAgent))]
public class RandomPatrol : MonoBehaviour
{
    [SerializeField] float radius = 8f; // 歩き回る範囲
    NavMeshAgent agent;
    Vector3 home;

    void Awake()
    {
        agent = GetComponent<NavMeshAgent>();
        home = transform.position;
    }

    void Update()
    {
        // 移動中なら何もしない
        if (agent.pathPending || agent.remainingDistance > agent.stoppingDistance + 0.1f) return;

        if (TryGetRandomPoint(home, radius, out Vector3 point)) agent.SetDestination(point);
    }

    // 範囲内のランダムな点を、NavMesh の上に寄せて返す
    bool TryGetRandomPoint(Vector3 center, float range, out Vector3 result)
    {
        for (int i = 0; i < 10; i++)
        {
            Vector3 random = center + Random.insideUnitSphere * range;
            if (NavMesh.SamplePosition(random, out NavMeshHit hit, agent.height * 2f, NavMesh.AllAreas))
            {
                result = hit.position;
                return true;
            }
        }
        result = center;
        return false;
    }
}

探す距離をagent.height * 2fにしているのは、公式リファレンスに「探す半径が大きいとフレームレートが落ちることがあるので、エージェントの高さの2倍を指定する」と書かれているからです。広い範囲から点を探したい時も、半径を大きくするのではなく、推奨の半径で何回か試すように、とあります。上のコードが10回のループになっているのはそのためです。

EnemyAIに組み込むなら、GoToNextPointの中身をこのTryGetRandomPointを使う形に差し替えれば動きます。

敵が動かない・追いかけない時の確認点

「”SetDestination” can only be called on an active agent that has been placed on a NavMesh.」と出る
敵がNavMeshの上にいません。Bakeを押し忘れていないか、敵が床から浮いていないか、NavMesh SurfaceとNavMesh AgentのAgent Typeが同じかを確認します。

近づいても一度も追いかけてこない
Obstacle MaskがEverythingになっていると、光線がプレイヤー自身のコライダーに当たって「さえぎられた」と判定されます。壁のレイヤーだけを選んでください。

壁の向こうからでも見つかる
壁のレイヤーがObstacle Maskに入っていないか、壁にコライダーが付いていません。eyeHeightが高すぎて光線が壁を越えている場合もあります。

巡回の地点で止まったまま動かない
地点がNavMeshの外(壁の中や段差の上)にあると、そこへの経路が作れません。地点を青い範囲の中へ動かします。

ほかのスクリプトでも出るNullReferenceExceptionなら、Player欄が空のままになっていないか見てください。エラーの読み方はこちらにまとめています。

アニメーションを付ける・プレイヤー側を作る

ここまでの敵はカプセルが滑るように動くだけです。キャラクターのモデルに差し替えたら、NavMeshAgentのvelocity.magnitude(いまの速さ)をAnimatorのパラメーターに渡して、待機・歩き・走りを切り替えます。攻撃のアニメーションは、UpdateAttackの中でTakeDamageを呼ぶ所にSetTriggerを足すのが分かりやすい形です。2021年の記事ですが、Animator側の設定は今もほぼ同じ手順でできます。

追いかけられるプレイヤー側をまだ作っていないなら、CharacterControllerで動かすのが手軽です。

手早く済ませたいなら敵AIのアセットもおすすめ

自分で作ると、仕組みが分かるぶん改造は自由です。一方で、物音に反応する・仲間を呼ぶ・遮蔽物に隠れながら撃つ、といった所まで作り込むと、それだけで何週間もかかります。ゲームの本題が別にあるなら、敵AIのアセットに任せるのも立派な選択肢でしょう。

当ブログでは、巡回・攻撃・魔法・射撃まで連載で試した「Blaze AI Engine」を紹介しています。

敵のモデルや地形、エフェクトなど、AI以外の素材もそろえたい人は、人気アセットをジャンル別に集めたこちらの一覧が参考になると思います。

有料のアセットは、Asset Storeのセールで半額前後になることがよくあります。急ぎでなければ、次のセールの時期を確かめてから買うほうがお得です。

まとめ|敵AIは「ステート」と「視界」の2つで決まる

  • NavMeshはNavMesh SurfaceコンポーネントのBakeで作る(Unity 6 / AI Navigation 2.0)
  • 敵の行動を巡回・追跡・攻撃・捜索の4つのステートに分け、ChangeStateで切り替える
  • 視界は距離 → 角度 → Raycast(壁のレイヤーだけ)の順に判定する
  • 到着の判定はpathPendingを見てからremainingDistanceを比べる
  • 攻撃に入る距離と抜ける距離に差をつけると、敵がガタつかない

まずはカプセルの敵1体で、巡回 → 発見 → 追跡 → 見失って捜索、の流れが回るのを見てみてください。そこまで動けば、あとは数字の調整とステートの追加で、いろいろな性格の敵が作れます。

作り込みに時間をかけたくない部分はアセットに任せる手もあります。買う前には、Unity Asset Storeのセール情報で今月の割引を確認しておくと安心です。

著者プロフィール
うんくん

IT系に特化した記事がメインです。(Unity多め。)
Udemy講師もやっています。よろしくお願いします。

※Amazonのアソシエイトとして、当メディアは適格販売により収入を得ています。

うんくんをフォローする
unityunity基本操作
スポンサーリンク
シェアする
うんくんをフォローする