学習記事一覧 · Unity

Unity本格入門 実装編:Chapter 7 敵キャラクターを作って動きを付けよう

題材・出典: 技術評論社刊『作って学べる Unity本格入門[Unity 6対応版]』(賀好 昭仁 著)に基づく学習補助の解説です。書籍の代替提供を目的とせず、コード掲載は学習上必要な範囲にとどめます。本シリーズ目次

対象読者:Unity エディタの基本操作と C# のコードを追いながら読める方
この記事では、いきのこバトル(IkinokoBattle)の敵キャラクター実装を題材に、NavMesh・Raycast・アニメーションイベント・スポーンの仕組みを読み解きます。書籍 Chapter 7(7-1〜7-6) に対応します。


記事の目次

おすすめの読み方

セクション一覧

ポイント一覧


題材の範囲

Chapter 7 のテーマは「敵キャラクターに知性を与える」です。プレイヤーを感知し、追いかけ、攻撃し、倒されたら消える——この一連の流れを IkinokoBattle ではどう実装しているかを書籍の節と対応させながら読みます。

書籍の節 テーマ IkinokoBattle での実装
7-1 NavMesh で追いかける EnemyMove.cs + NavMeshAgent
7-2 範囲検知で襲ってくる CollisionDetector.cs(汎用 UnityEvent)
7-3 視界で判定する EnemyMove.cs + RaycastNonAlloc
7-4 攻撃させる MobAttack.cs + Animation Event
7-5 倒せるようにする MobStatus.Damage() + レイヤー設定
7-6 敵を出現させる Spawner.cs + Coroutine + NavMesh.SamplePosition

この記事でとくに深掘りするポイント:書籍では手順をひとつずつ追いますが、この記事では「なぜ CollisionDetector を別クラスに分けるのか」「RaycastNonAllocRaycastAll の違い」「Quaternion.Euler でランダム方向を生成する数学」に踏み込みます。


プロジェクトの構成(今回のファイル)

ファイル 役割
EnemyMove.cs NavMesh で追跡・Raycast で視野確認
EnemyStatus.cs MobStatus の派生クラス(死亡時に 3 秒後に自己削除)
CollisionDetector.cs Trigger 衝突を UnityEvent に変換する汎用コンポーネント
MobAttack.cs 攻撃判定とアニメーションイベントの処理(第2回でも登場)
MobStatus.cs HP・状態(Normal / Attack / Die)の管理基底クラス
Spawner.cs Coroutine で定期的に敵を NavMesh 上に生成
MobItemDropper.cs 敵を倒したときのアイテムドロップ処理(おまけ)

クラス関係(この記事で登場する範囲)

Spawner                  ← 敵を定期生成する
  └─ Instantiate(enemyPrefab)
       └─ 敵 GameObject(Prefab の複製)
            ├─ EnemyMove         ← 追跡・視野確認
            │    └─ NavMeshAgent (Unity 標準コンポーネント)
            ├─ EnemyStatus       ← HP・状態管理 (MobStatus を継承)
            │    └─ MobStatus    ← 基底クラス
            ├─ MobAttack         ← 攻撃処理
            ├─ MobItemDropper    ← アイテムドロップ
            └─ CollisionDetector ← Trigger 検知 → EnemyMove.OnDetectObject へ委譲

CollisionDetector は敵専用ではなく「Trigger を UnityEvent に変換するだけの汎用クラス」です。誰が何を検知するかは Inspector で自由に接続できます。


コードを全部見てみよう

EnemyMove.cs

using UnityEngine;
using UnityEngine.AI;
[RequireComponent(typeof(NavMeshAgent))]
[RequireComponent(typeof(EnemyStatus))]
public class EnemyMove : MonoBehaviour
{
    [SerializeField] private LayerMask raycastLayerMask;
    private NavMeshAgent _agent;
    private RaycastHit[] _raycastHits = new RaycastHit[10];
    private EnemyStatus _status;
    private void Start()
    {
        _agent = GetComponent<NavMeshAgent>();
        _status = GetComponent<EnemyStatus>();
    }
    public void OnDetectObject(Collider collider)
    {
        if (!_status.IsMovable)
        {
            _agent.isStopped = true;
            return;
        }
        if (collider.CompareTag("Player"))
        {
            var positionDiff = collider.transform.position - transform.position;
            var distance = positionDiff.magnitude;
            var direction = positionDiff.normalized;
            var hitCount = Physics.RaycastNonAlloc(
                transform.position, direction, _raycastHits, distance, raycastLayerMask);
            if (hitCount == 0)
            {
                // プレイヤーは CharacterController なので Raycast にはヒットしない
                // ヒット 0 = 障害物なし = 視界内
                _agent.isStopped = false;
                _agent.destination = collider.transform.position;
            }
            else
            {
                _agent.isStopped = true;
            }
        }
    }
}

CollisionDetector.cs

using System;
using UnityEngine;
using UnityEngine.Events;
[RequireComponent(typeof(Collider))]
public class CollisionDetector : MonoBehaviour
{
    [SerializeField] private TriggerEvent onTriggerEnter = new TriggerEvent();
    [SerializeField] private TriggerEvent onTriggerStay  = new TriggerEvent();
    private void OnTriggerEnter(Collider other)
    {
        onTriggerEnter.Invoke(other);
    }
    private void OnTriggerStay(Collider other)
    {
        onTriggerStay.Invoke(other);
    }
    [Serializable]
    public class TriggerEvent : UnityEvent<Collider> { }
}

Spawner.cs

using System.Collections;
using UnityEngine;
using UnityEngine.AI;
public class Spawner : MonoBehaviour
{
    [SerializeField] private PlayerStatus playerStatus;
    [SerializeField] private GameObject enemyPrefab;
    private void Start()
    {
        StartCoroutine(SpawnLoop());
    }
    private IEnumerator SpawnLoop()
    {
        while (true)
        {
            var distanceVector = new Vector3(10, 0);
            var spawnPositionFromPlayer =
                Quaternion.Euler(0, Random.Range(0, 360f), 0) * distanceVector;
            var spawnPosition = playerStatus.transform.position + spawnPositionFromPlayer;
            NavMeshHit navMeshHit;
            if (NavMesh.SamplePosition(spawnPosition, out navMeshHit, 10, NavMesh.AllAreas))
            {
                Instantiate(enemyPrefab, navMeshHit.position, Quaternion.identity);
            }
            yield return new WaitForSeconds(10);
            if (playerStatus.Life <= 0) break;
        }
    }
}

EnemyStatus.cs

using System.Collections;
using UnityEngine;
using UnityEngine.AI;
[RequireComponent(typeof(NavMeshAgent))]
public class EnemyStatus : MobStatus
{
    private NavMeshAgent _agent;
    protected override void Start()
    {
        base.Start();
        _agent = GetComponent<NavMeshAgent>();
    }
    private void Update()
    {
        _animator.SetFloat("MoveSpeed", _agent.velocity.magnitude);
    }
    protected override void OnDie()
    {
        base.OnDie();
        StartCoroutine(DestroyCoroutine());
    }
    private IEnumerator DestroyCoroutine()
    {
        yield return new WaitForSeconds(3);
        Destroy(gameObject);
    }
}

MobAttack.cs

using System.Collections;
using UnityEngine;
[RequireComponent(typeof(MobStatus))]
public class MobAttack : MonoBehaviour
{
    [SerializeField] private float attackCooldown = 0.5f;
    [SerializeField] private Collider attackCollider;
    [SerializeField] private AudioSource swingSound;
    private MobStatus _status;
    private void Start() { _status = GetComponent<MobStatus>(); }
    public void AttackIfPossible()
    {
        if (!_status.IsAttackable) return;
        _status.GoToAttackStateIfPossible();
    }
    public void OnAttackRangeEnter(Collider collider) { AttackIfPossible(); }
    public void OnAttackStart()
    {
        attackCollider.enabled = true;
        if (swingSound != null)
        {
            swingSound.pitch = Random.Range(0.7f, 1.3f);
            swingSound.Play();
        }
    }
    public void OnHitAttack(Collider collider)
    {
        var targetMob = collider.GetComponent<MobStatus>();
        if (null == targetMob) return;
        targetMob.Damage(1);
    }
    public void OnAttackFinished()
    {
        attackCollider.enabled = false;
        StartCoroutine(CooldownCoroutine());
    }
    private IEnumerator CooldownCoroutine()
    {
        yield return new WaitForSeconds(attackCooldown);
        _status.GoToNormalStateIfPossible();
    }
}

① NavMesh のベイクと NavMeshAgent のパラメータ

NavMesh とは

NavMesh(Navigation Mesh)は「歩ける領域」をメッシュデータとして事前計算(ベイク)したものです。NavMeshAgent コンポーネントはこのデータを参照し、目的地までの最短経路を自動で計算して移動します。

ベイクの手順

  1. 地形・建物など動かない障害物の GameObject を Static(Navigation Static) に設定する
  2. Window → AI → Navigation を開き Bake タブから Bake ボタンをクリック
  3. シーンビュー上に青いメッシュが表示されれば成功(青 = 歩ける領域)

主な NavMeshAgent パラメータ

パラメータ 意味 IkinokoBattle での参考値
Speed 最大移動速度 (m/s) 3.5 前後
Angular Speed 方向転換の速さ (度/秒) 120 前後
Stopping Distance 目的地の手前何 m で停止するか 1.5 前後(攻撃間合い)
Radius エージェントの衝突半径 0.5(他の敵と重ならないように)
Height エージェントの高さ 2.0(地形の段差を通れるか判定)

NavMeshAgent.destination でプレイヤーを追跡する

EnemyMove.OnDetectObject の核心部分です。

_agent.isStopped = false;
_agent.destination = collider.transform.position;

destination にプレイヤーの座標を代入するだけで、NavMeshAgent が自動的に経路計算して移動を始めます。障害物(壁・岩など)を回り込む経路も NavMesh のデータから自動生成されます。

isStopped の役割

状態 isStopped 挙動
追跡中 false destination へ自動移動
障害物あり / 攻撃中 / 死亡 true その場で停止(経路情報は保持)

攻撃中や死亡中は _status.IsMovablefalse になるため、isStopped = true で強制停止しています。

EnemyStatus.Update() でアニメーションを同期

_animator.SetFloat("MoveSpeed", _agent.velocity.magnitude);

NavMeshAgent.velocity は「現在の実際の移動速度ベクトル」です。その大きさ(magnitude)をアニメーターに渡すことで、停止中は待機アニメーション、移動中は歩きアニメーションへ自動で切り替わります。プレイヤーの PlayerController と同じパターンです。


CollisionDetector でトリガー判定を汎用 UnityEvent として委譲する

なぜ別クラスに分けるのか

「敵に近づいたら追いかけてくる」を実現するには、敵の周囲に球状の Trigger Collider を置き、プレイヤーが入ったら検知する必要があります。素直に書くと:

// EnemyMove に直接書くと…
private void OnTriggerStay(Collider other)
{
    // 追跡処理
}

これで動きますが、「検知したら攻撃する」「検知したら SE を鳴らす」など処理が増えると EnemyMove がどんどん肥大化します。

CollisionDetector は「Trigger を検知したら登録されたメソッドを呼ぶ」だけを担います。

[Serializable]
public class TriggerEvent : UnityEvent<Collider> { }
[SerializeField] private TriggerEvent onTriggerStay = new TriggerEvent();
private void OnTriggerStay(Collider other)
{
    onTriggerStay.Invoke(other);   // 登録されたリスナーをすべて呼ぶ
}

Inspector の onTriggerStayEnemyMove.OnDetectObject を登録することで、CollisionDetector は「誰が何をするか」を一切知らずに済みます。

UnityEvent<Collider> を継承したカスタムイベント

UnityEvent<T> は「型付きの Unity イベント」です。[Serializable] を付けた派生クラスを作ることで Inspector に表示できます。引数として Collider を渡すので、呼ばれた側(EnemyMove.OnDetectObject)は「何が当たったか」をそのまま受け取れます。

設計の恩恵CollisionDetector ひとつを「攻撃範囲判定」にも「索敵範囲判定」にも、Inspector の設定を変えるだけで転用できます。コードの変更なしに用途を変えられるのが委譲パターンの強みです。


RaycastNonAlloc で障害物の有無を確認し視野を再現する

Trigger Collider は「球の中にいるかどうか」しか判定できません。壁の裏にいても検知してしまいます。そこで Raycast で「敵からプレイヤーへの直線上に障害物があるか」を確認します。

var positionDiff = collider.transform.position - transform.position;
var distance  = positionDiff.magnitude;
var direction = positionDiff.normalized;
var hitCount = Physics.RaycastNonAlloc(
    transform.position, direction, _raycastHits, distance, raycastLayerMask);
if (hitCount == 0)
{
    // 障害物なし → 視界内 → 追跡
    _agent.destination = collider.transform.position;
}
else
{
    // 障害物あり → 視界外 → 停止
    _agent.isStopped = true;
}

RaycastNonAllocRaycastAll の違い

メソッド 戻り値 メモリ
Physics.RaycastAll RaycastHit[](新規確保) 毎呼び出しヒープにゴミが残る
Physics.RaycastNonAlloc int(ヒット数) 既存配列 _raycastHits に書き込む。GC 発生なし

_raycastHits = new RaycastHit[10] はフィールドで一度だけ確保しておき、毎フレーム再利用します。Update から呼ばれる高頻度な処理では GC 負荷を避けることが重要です。

プレイヤーが Raycast にヒットしない理由

コード内のコメントにある通り、IkinokoBattle のプレイヤーは CharacterController を使っており通常の Collider を持ちません。そのため敵→プレイヤーへの Raycast はプレイヤー自身に当たらず、「ヒット 0 = 障害物なし」という判定が成立します。LayerMask で「障害物レイヤーのみ」を対象にしているため、地面や他の敵には反応しません。


⑤ アニメーションイベントと MobAttack の連携

アニメーションイベントとは

Animator の各クリップに「このフレームでこのメソッドを呼ぶ」というイベントを埋め込む機能です。攻撃アニメーションの「振り下ろす瞬間」にダメージ判定を発動させる、などの用途に使います。

MobAttack のメソッドと呼び出しタイミング

攻撃アニメーション再生の流れ
  ─────────────────────────────────────────────────
  AttackIfPossible()         ← PlayerController や CollisionDetector から呼ばれる
    └─ GoToAttackStateIfPossible()
         └─ SetTrigger("Attack")  → アニメーション再生開始
  
  [アニメーション途中OnAttackStart イベント発火]
    └─ attackCollider.enabled = true   ← 当たり判定 ON
    └─ swingSound.Play()               ← 効果音再生(pitch ランダム)
  
  [当たり判定が当たる → OnHitAttack イベント発火]
    └─ targetMob.Damage(1)             ← ダメージ
  
  [アニメーション末尾 → OnAttackFinished イベント発火]
    └─ attackCollider.enabled = false  ← 当たり判定 OFF
    └─ CooldownCoroutine 開始
         └─ WaitForSeconds(0.5f)
              └─ GoToNormalStateIfPossible()  ← Normal 状態に戻る
  ─────────────────────────────────────────────────

swingSound.pitch = Random.Range(0.7f, 1.3f) の意図

同じ SE でも再生速度(pitch)をわずかにランダムにするだけで「単調さ」が消えます。0.7〜1.3 の範囲なので音程の変化は 30% 以内に収まり、不自然にはなりません。少ないコストで効果音に「揺らぎ」を生む工夫です。


Quaternion.EulerNavMesh.SamplePosition でランダムスポーンを実現する

Spawner のスポーン位置決定ロジックを丁寧に読みます。

var distanceVector = new Vector3(10, 0);
var spawnPositionFromPlayer =
    Quaternion.Euler(0, Random.Range(0, 360f), 0) * distanceVector;
var spawnPosition = playerStatus.transform.position + spawnPositionFromPlayer;

Quaternion.Euler(0, 角度, 0) * ベクトル の意味

Quaternion.Euler(x, y, z) は「X 軸を x 度、Y 軸を y 度、Z 軸を z 度回転させるクォータニオン(回転情報)」を作ります。クォータニオンにベクトルを掛けると「そのベクトルを回転させた新しいベクトル」が得られます。

distanceVector = (10, 0, 0)  ← プレイヤーから X 方向に 10m
Quaternion.Euler(0, 45, 0)   ← Y 軸(上下軸)を 45 度回転
結果 →  (7.07, 0, 7.07)   ← 斜め 45 度の方向に 10m

Random.Range(0, 360f) でランダムな角度を与えることで、プレイヤーを中心とした半径 10m の円周上のランダムな点が得られます。

NavMesh.SamplePosition で着地点を補正する

ランダムで決めた座標が崖の上や建物の中にあると Instantiate できません。

NavMeshHit navMeshHit;
if (NavMesh.SamplePosition(spawnPosition, out navMeshHit, 10, NavMesh.AllAreas))
{
    Instantiate(enemyPrefab, navMeshHit.position, Quaternion.identity);
}

NavMesh.SamplePosition(候補座標, out ヒット情報, 最大探索距離, エリア) は「候補座標から最大探索距離以内で最も近い NavMesh 上の点」を探します。見つかれば true を返し navMeshHit.position に安全なスポーン座標が入ります。見つからなければ何もしません(スポーンをスキップ)。

NavMeshAgent は必ず NavMesh 上でしか動けないため、スポーン座標も NavMesh 上に補正してから Instantiate するのが正しい手順です。


⑦ Coroutine(SpawnLoop)でスポーン間隔をコントロールする

private IEnumerator SpawnLoop()
{
    while (true)
    {
        // ①スポーン位置を計算して Instantiate
        // ...
        yield return new WaitForSeconds(10);   // ② 10 秒待つ
        if (playerStatus.Life <= 0) break;     // ③ プレイヤー死亡でループ終了
    }
}

IEnumeratoryield return の仕組み

Coroutine は「途中で一時停止できるメソッド」です。yield return new WaitForSeconds(10) は「10 秒後に再開してください」という命令で、待機中は他の処理(プレイヤー移動・敵 AI など)が通常通り動き続けます。Update のような毎フレーム呼び出しとは異なり、「◯秒ごとに何かをする」という処理に向いています。

ループ終了の設計

while(true) の無限ループですが、playerStatus.Life <= 0 のとき break でループを脱出します。プレイヤーが死亡した後に敵がスポーンし続けるのを防ぐためです。ゲームオーバー後のシーン遷移と合わせて動作するよう設計されています。

StartCoroutineStart で呼ぶ理由

private void Start()
{
    StartCoroutine(SpawnLoop());
}

Coroutine は StartCoroutine で開始しなければ実行されません。Start は「シーン起動時に 1 回だけ」呼ばれるため、スポーンループをゲーム開始と同時に始める書き方として適切です。


コードの流れを整理しよう

敵キャラクターの生成から行動までの全体フロー

Spawner.Start()
  └─ StartCoroutine(SpawnLoop())
       └─ while(true) ループ
            ├─ Quaternion.Euler でランダム方向を生成
            ├─ NavMesh.SamplePosition で安全な座標を確定
            ├─ Instantiate(enemyPrefab) → 敵 GameObject 生成
            └─ WaitForSeconds(10)10 秒待機 → 繰り返し

敵がプレイヤーを追跡・攻撃するフロー

CollisionDetector(索敵 Trigger Collider)
  └─ OnTriggerStay → onTriggerStay.Invoke(collider)
       └─ EnemyMove.OnDetectObject(collider)
            ├─ IsMovable チェック(攻撃中・死亡中なら停止)
            ├─ CompareTag("Player") チェック
            ├─ RaycastNonAlloc で視野確認
            │    ├─ ヒット 0(視界内)→ isStopped=false / destination=プレイヤー位置
            │    └─ ヒットあり(障害物)→ isStopped=true
            └─ NavMeshAgent が自動で経路計算・移動
CollisionDetector(攻撃 Trigger Collider・近距離)
  └─ OnTriggerEnter → MobAttack.OnAttackRangeEnter(collider)
       └─ AttackIfPossible()
            └─ GoToAttackStateIfPossible()
                 └─ SetTrigger("Attack") → アニメーション再生
                      ├─ OnAttackStart イベント → attackCollider.enabled=true
                      ├─ OnHitAttack イベント  → Damage(1)
                      └─ OnAttackFinished イベント → cooldown → Normal 状態へ

敵の死亡フロー

MobStatus.Damage(damage)
  └─ _life が 0 以下になる
       └─ _state = StateEnum.Die
       └─ SetTrigger("Die") → 死亡アニメーション
       └─ OnDie()(virtual)
            ├─ LifeGaugeContainer.Instance.Remove(this)   ← 基底クラス処理
            └─ StartCoroutine(DestroyCoroutine())          ← EnemyStatus の override
                 └─ WaitForSeconds(3)Destroy(gameObject)

自分でカスタマイズしてみよう!

課題 A:スポーン間隔を難易度に応じて変化させる

現在は固定の 10 秒間隔です。経過時間に応じてスポーン間隔を短くすることでゲームの難易度を上げられます。

private IEnumerator SpawnLoop()
{
    float spawnInterval = 10f;
    while (true)
    {
        // スポーン処理...
        yield return new WaitForSeconds(spawnInterval);
        // 30 秒ごとに 1 秒ずつ間隔を短縮(最短 3 秒)
        spawnInterval = Mathf.Max(3f, spawnInterval - 1f);
        if (playerStatus.Life <= 0) break;
    }
}

課題 B:Raycast の視野角を制限する

現在は「前方180度以内にいればプレイヤーを認識」という実装です。敵の正面方向との角度を計算し、視野角を狭めると「背後から近づくと気づかれない」という要素を追加できます。

// OnDetectObject の RaycastNonAlloc の前に追加
var forward = transform.forward;
var angle = Vector3.Angle(forward, direction);
if (angle > 60f)
{
    // 視野角(60度)の外 → 追いかけない
    _agent.isStopped = true;
    return;
}

課題 C:倒した敵の数をカウントする

MobItemDropper.cs では _status.Life <= 0Update で毎フレーム監視しています。これを応用して倒した敵の数をゲーム全体で管理するクラスに通知する仕組みを作れます。

// EnemyStatus.OnDie() に追加
protected override void OnDie()
{
    base.OnDie();
    GameScore.Instance.AddKillCount();   // スコアに加算(GameScore は別途実装)
    StartCoroutine(DestroyCoroutine());
}

まとめ

ポイント キーワード
① NavMesh をベイクし NavMeshAgent で自動経路探索を有効にする Static フラグ・Bake
destination を更新するだけで追跡が始まる。停止は isStopped NavMeshAgent
CollisionDetector は Trigger を UnityEvent に変換する汎用部品 委譲・再利用性
RaycastNonAlloc で GC を発生させずに視野内を確認する GC フリー・LayerMask
⑤ アニメーションイベントで「振り下ろす瞬間」だけ当たり判定を ON にする Animation Event
Quaternion.Euler * ベクトル で円周上のランダム座標を生成する 回転行列
SamplePosition で NavMesh 上の安全な座標に補正してからスポーン NavMeshHit

Chapter 7 の設計の要点は「各クラスが 1 つの責任に専念している」ことです。EnemyMove は「追うか止まるか」だけ。CollisionDetector は「Trigger を誰かに伝える」だけ。Spawner は「定期的に敵を出す」だけ。それぞれが小さく保たれているため、どれかを変更しても他への影響が最小限に抑えられます。


← シリーズ目次に戻る
← 前の記事:Chapter 6 キャラクターを作ってみよう
次の記事 → 第4回:Chapter 8 ユーザーインタフェースを作ってみよう


最終更新:2026年4月