Unity本格入門 実装編:Chapter 7 敵キャラクターを作って動きを付けよう
題材・出典: 技術評論社刊『作って学べる Unity本格入門[Unity 6対応版]』(賀好 昭仁 著)に基づく学習補助の解説です。書籍の代替提供を目的とせず、コード掲載は学習上必要な範囲にとどめます。本シリーズ目次
対象読者:Unity エディタの基本操作と C# のコードを追いながら読める方
この記事では、いきのこバトル(IkinokoBattle)の敵キャラクター実装を題材に、NavMesh・Raycast・アニメーションイベント・スポーンの仕組みを読み解きます。書籍 Chapter 7(7-1〜7-6) に対応します。
記事の目次
おすすめの読み方
セクション一覧
- 題材の範囲
- プロジェクトの構成(今回のファイル)
- クラス関係(この記事で登場する範囲)
- コードを全部見てみよう
- ポイント解説(①〜⑦)
- コードの流れを整理しよう
- 自分でカスタマイズしてみよう!
- まとめ
ポイント一覧
- ① NavMesh のベイクと
NavMeshAgentのパラメータ - ②
NavMeshAgent.destinationでプレイヤーを追跡する - ③
CollisionDetectorでトリガー判定を汎用 UnityEvent として委譲する - ④
RaycastNonAllocで障害物の有無を確認し視野を再現する - ⑤ アニメーションイベントと
MobAttackの連携 - ⑥
Quaternion.EulerとNavMesh.SamplePositionでランダムスポーンを実現する - ⑦ Coroutine(
SpawnLoop)でスポーン間隔をコントロールする
題材の範囲
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 を別クラスに分けるのか」「RaycastNonAlloc と RaycastAll の違い」「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 コンポーネントはこのデータを参照し、目的地までの最短経路を自動で計算して移動します。
ベイクの手順
- 地形・建物など動かない障害物の GameObject を Static(Navigation Static) に設定する
Window → AI → Navigationを開きBakeタブからBakeボタンをクリック- シーンビュー上に青いメッシュが表示されれば成功(青 = 歩ける領域)
主な 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.IsMovable が false になるため、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 の onTriggerStay に EnemyMove.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;
}RaycastNonAlloc と RaycastAll の違い
| メソッド | 戻り値 | メモリ |
|---|---|---|
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.Euler と NavMesh.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 度の方向に 10mRandom.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; // ③ プレイヤー死亡でループ終了
}
}IEnumerator と yield return の仕組み
Coroutine は「途中で一時停止できるメソッド」です。yield return new WaitForSeconds(10) は「10 秒後に再開してください」という命令で、待機中は他の処理(プレイヤー移動・敵 AI など)が通常通り動き続けます。Update のような毎フレーム呼び出しとは異なり、「◯秒ごとに何かをする」という処理に向いています。
ループ終了の設計
while(true) の無限ループですが、playerStatus.Life <= 0 のとき break でループを脱出します。プレイヤーが死亡した後に敵がスポーンし続けるのを防ぐためです。ゲームオーバー後のシーン遷移と合わせて動作するよう設計されています。
StartCoroutine を Start で呼ぶ理由
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 <= 0 を Update で毎フレーム監視しています。これを応用して倒した敵の数をゲーム全体で管理するクラスに通知する仕組みを作れます。
// 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月