Skip to content

第05回 Enemyの探索・追跡・攻撃 ​

前回: 第04回 衝突判定とダメージ / 次回: 第06回 Enemyの状態制御とゲームフィール

今回の授業内容 ​

敵の攻撃敵の攻撃

  • 第04回で作成したCharacterAttackをEnemyからも利用する
  • Enemyが攻撃対象を探索し、追跡して攻撃する処理を実装する
  • PrefabとInstanceの関係を理解する
  • 複数のSpawn PointからEnemyを一定間隔で生成する
  • 生成数の上限と破棄済みInstanceの管理を実装する

今回の配布コード ​

第04回までの全スクリプトに、共通攻撃、Enemyの追跡、Enemy生成を加えた第05回終了時点の構成です。第04回までに作成したCharacterAttack.csを変更せず、Enemy側にも接続します。

ファイル内容
PlayerController.cs移動、カメラ、共通攻撃の入力
PlayerMove.csプレイヤーの移動と振り向き
CameraMove.csカメラの追従と回転
Health.csHPの管理
DamageReceiver.csダメージの受付
AttackContext.cs攻撃情報
AttackResolver.cs自分自身と同じ陣営を除外する攻撃ルール
CharacterAttack.csPlayerとEnemyで共有する攻撃処理
AttackTarget.cs攻撃対象を示すマーカー
EnemyController.cs対象の探索、追跡、攻撃
EnemySpawner.csEnemy Prefabの生成と生存数管理
Player.csPlayerのHPと表示の接続
Enemy.csEnemyのHPと死亡処理
AttackEffect.cs攻撃演出
SlashEffect.cs剣閃エフェクト
HealthGauge.csHPゲージ

第04回までの構成へEnemyの追跡と生成を加えます。GameObjectの親子関係と、各GameObjectへ追加するComponentを分けて確認します。

GameObjectのHierarchy ​

次の図では、インデントがHierarchy上の親子関係を表します。Componentは含めていません。

Player・Enemy・Enemy SpawnerのHierarchy
  • Player
    • Player Attack
  • Enemy Prefab
    • Enemy Attack Range
  • Enemy Spawner

Playerへ設定するComponent ​

GameObjectComponent役割
PlayerRigidbody物理判定に参加する
PlayerPlayerHPと表示を接続する
PlayerPlayerController入力を読み取る
PlayerPlayerMove移動と回転を行う
PlayerDamageReceiverダメージを受け付ける
PlayerAttackTargetEnemyの攻撃対象になる
Player AttackCharacterAttack攻撃時間と命中判定を管理する
Player AttackTrigger Collider攻撃範囲を表す

Enemy Prefabへ設定するComponent ​

GameObjectComponent役割
Enemy PrefabRigidbody物理判定に参加し、敵同士を押し戻す
Enemy PrefabEnemyHPと死亡処理を接続する
Enemy PrefabEnemyController対象の探索、追跡、攻撃判断を行う
Enemy PrefabDamageReceiverダメージを受け付ける
Enemy Attack RangeCharacterAttack攻撃時間と命中判定を管理する
Enemy Attack RangeTrigger Collider攻撃範囲を表す

Enemyの生成と攻撃対象の探索 ​

Enemy SpawnerはEnemy Prefabの生成だけを担当します。生成されたEnemyは、自分で攻撃対象を探します。

Enemyの生成と攻撃対象の探索
Enemy Spawner
Instanceを生成する
Enemy Prefab
Enemy Instance
攻撃対象を探す
AttackTarget

既存のCharacterAttackをEnemyでも利用する ​

第04回では、Playerの攻撃としてCharacterAttackを実装しました。
Playerの攻撃ですが入力とは切り離されており、攻撃時間、当たり判定、命中済みリスト、ダメージ解決の処理はEnemyの攻撃にも利用できます。
今回はこのCharacterAttackをEnemyの攻撃としても利用し、PlayerとEnemyで共通化します。

CharacterAttackの責務を決める ​

CharacterAttack は、攻撃中だけ攻撃用Colliderを有効にし、Triggerに入った DamageReceiver を検出します。
また、同じ攻撃で同じ相手に何度もダメージを与えないように命中対象を記録します。
ダメージ処理の実際は AttackResolver に任せ、自身は攻撃のタイミングと当たり判定に集中します。

CharacterAttackが扱うこと直接扱わないこと
攻撃時間の管理HP計算
攻撃判定ColliderのON/OFF死亡処理
命中したDamageReceiverの検出追跡AI
AttackResolverへの受け渡しEnemyの生成

必須参照の設定漏れを隠さない

attackCollider は CharacterAttack が動くための必須依存です。
必須であるにも関わらず if (attackCollider != null) で防衛すると、Inspector での設定漏れという本質的な問題を隠してしまいます。
こうした場合はnullチェックでスルーせず、アサーションなどで設定漏れをエラーとして早く気づける形にすることをおすすめします。
ここに限らず 必須依存は設定し忘れた時に早く気づける形にし、null チェックでのスルーは必要な時だけに行うよう心掛けましょう。

勿論、nullチェック自体が悪いわけではありません。
例えば OnTriggerEnter で触れた相手が DamageReceiver を持っていないことはあり得るため、その場合は return して構いません。

設定漏れは

早めに見つけようね

CharacterAttackを実装する ​

次のコードは第04回で完成したCharacterAttackの抜粋です。
攻撃中だけ攻撃判定Colliderを有効にし、命中した DamageReceiver にダメージ処理を渡します。
細かい処理についてはソースコード内のコメントで確認してください。

cs
using System.Collections.Generic;
using UnityEngine;

public class CharacterAttack : MonoBehaviour
{
    [SerializeField] private AttackEffect attackEffect;
    [SerializeField] private Collider attackCollider;
    [SerializeField] private int damage = 1;
    [SerializeField] private float attackDuration = 0.5f;
    [SerializeField] private float hitStartTime = 0.15f;
    [SerializeField] private float hitEndTime = 0.3f;

    // 1回の攻撃で同じ相手に何度も当たらないよう、
    // 命中済みの DamageReceiver を覚えておく。
    private readonly List<DamageReceiver> _hitReceivers = new();

    // 攻撃中かどうか。
    // EnemyController など、外側の処理が行動判断に使えるよう公開する。
    private bool _isAttacking;

    // 攻撃判定Colliderが現在有効かどうか。
    // Collider.enabled を直接読まず、攻撃状態としてコード側でも保持する。
    private bool _isHitActive;

    // 攻撃開始からの経過時間。
    // hitStartTime / hitEndTime / attackDuration の判定に使う。
    private float _attackElapsedTime;

    public bool IsAttacking => _isAttacking;

    private void Awake()
    {
        // 攻撃していない間は、攻撃判定を必ず無効にしておく。
        // attackCollider は必須参照なので、設定漏れはエラーとして気づける状態にする。
        attackCollider.enabled = false;
    }

    private void Update()
    {
        if (!_isAttacking)
            return;

        _attackElapsedTime += Time.deltaTime;

        // 攻撃開始から一定時間後に、攻撃判定を有効にする。
        if (!_isHitActive &&
            _attackElapsedTime >= hitStartTime)
            StartHit();

        // 攻撃判定の有効時間が終わったら、判定だけを先に閉じる。
        if (_isHitActive &&
            _attackElapsedTime >= hitEndTime)
            EndHit();

        // 攻撃全体の時間が終わったら、攻撃状態を終了する。
        if (_attackElapsedTime >= attackDuration)
            EndAttack();
    }

    public void Attack()
    {
        // 攻撃中に再度 Attack() が呼ばれても、攻撃を重ねない。
        if (_isAttacking)
            return;

        // attackEffect は演出用なので、未設定でも攻撃処理は成立させる。
        attackEffect?.Play();

        // 新しい攻撃なので、前回の命中履歴を消す。
        _hitReceivers.Clear();

        _isAttacking = true;
        _isHitActive = false;
        _attackElapsedTime = 0f;
    }

    private void OnTriggerEnter(Collider other)
    {
        // 攻撃判定に入ったColliderの親から、ダメージ受付コンポーネントを探す。
        var receiver =
            other.GetComponentInParent<DamageReceiver>();
        if (receiver == null)
            return;

        // 同じ攻撃中に同じ相手へ複数回ダメージを与えない。
        if (_hitReceivers.Contains(receiver))
            return;

        _hitReceivers.Add(receiver);

        // 攻撃者とダメージ量をまとめ、実際の判定は AttackResolver に任せる。
        var attackContext =
            new AttackContext(gameObject, damage);
        AttackResolver.Resolve(attackContext, receiver);
    }

    private void EndAttack()
    {
        _isAttacking = false;
        EndHit();
    }

    private void StartHit()
    {
        _isHitActive = true;
        attackCollider.enabled = true;
    }

    private void EndHit()
    {
        _isHitActive = false;
        attackCollider.enabled = false;
    }
}

EnemyからのCharacterAttackの利用 ​

Playerの攻撃は、PlayerController が攻撃ボタン入力を受け取り、都度 CharacterAttack.Attack() を呼ぶ形です。

一方、敵の行動はAIで制御される必要があります。
例えばプレイヤーが攻撃範囲に入ったら自動的に攻撃する、といった挙動を実現しなけばなりません。
この場合は攻撃対象を監視・追跡し、攻撃距離に入ったら CharacterAttack.Attack() を呼ぶ形になります。

PlayerController.cscs
public class PlayerController : MonoBehaviour
{
    [SerializeField] private CharacterAttack characterAttack;

    private void Update()
    {
        // 省略: 移動とカメラ操作

        if (_inputActions.Player.Attack.WasPressedThisFrame())
            characterAttack.Attack();
    }
}

AttackTargetをマーカーコンポーネントとして実装する ​

敵の攻撃対象は通常プレイヤーですが、Enemyが Player 型を直接参照すると、EnemyはPlayerに強く依存することになってしまいます。
今回Enemyに必要なのは、相手がPlayerかどうかではなく、相手を攻撃対象として扱ってよいか と 対象がどこにいるか の情報です。
Playerを直接参照しなければ、敵の同士討ちやマルチプレイなど将来的な拡張にも柔軟に対応できます。

今回は、Enemyが攻撃対象にできるObjectに AttackTarget という目印Componentを付けます。
EnemyはScene内の AttackTarget を探し、近い対象を追跡、攻撃します。

AttackTarget は処理をほとんど持たないマーカーComponentです。
PlayerにこのComponentを付けると、Enemyから攻撃対象として見つけられるようになります。
テスト用のCubeに AttackTarget を付ければ、PlayerなしでもEnemyの追跡と攻撃の挙動を確認できます。

cs
using UnityEngine;

public class AttackTarget : MonoBehaviour
{
   // 目印以外の処理は特に持ちません
}

EnemyControllerで探索・追跡・攻撃を制御する ​

EnemyController は、攻撃対象の探索、移動、攻撃判断を担当します。
攻撃対象が遠い間は近づき、攻撃距離に入ったら止まって攻撃します。
実際の当たり判定とダメージ処理は、既に作った CharacterAttack に任せます。

Update内の判断順序 ​

Update では、攻撃対象の探索と攻撃判断を行います。
移動すると決まったら、transform.position を少しずつ動かします。

今回のEnemyは、物理挙動ではなく直接座標を操作して移動します。
ただし、そのままでは敵同士がめり込んでしまうため、Enemy本体に Rigidbody を付けて、Collider同士の衝突で押し戻されるようにします。

必要な参照と設定値 ​

characterAttack はEnemy自身の攻撃処理です。searchRadius は攻撃対象を探す範囲です。moveSpeed と rotateSpeed は移動の調整値です。attackDistance と attackInterval は攻撃判断に使います。

cs
using UnityEngine;

public class EnemyController : MonoBehaviour
{
    [SerializeField] private CharacterAttack characterAttack;
    [SerializeField] private float searchRadius = 30f;
    [SerializeField] private float moveSpeed = 3f;
    [SerializeField] private float attackDistance = 1.5f;
    [SerializeField] private float attackInterval = 1.0f;
    [SerializeField] private float rotateSpeed = 10f;

    private AttackTarget _target;
    private float _lastAttackTime = -999f;

攻撃対象を探して行動を判断する ​

目標が無ければ FindNearestTarget で探します。見つからない時は、そのフレームでは移動しません。攻撃中も移動処理へ進みません。

cs
    private void Update()
    {
        if (_target == null)
            _target = FindNearestTarget();

        if (_target == null)
            return;

        if (characterAttack.IsAttacking)
            return;

距離を見て攻撃する ​

Y方向は無視し、X-Z平面上で近づきます。攻撃距離に入ったら移動せず、攻撃を試みます。まだ遠い場合は、次のスライドで移動方向を決めます。

cs
        var toTarget =
            _target.transform.position - transform.position;
        toTarget.y = 0f;

        if (toTarget.magnitude <= attackDistance)
        {
            TryAttack();
            return;
        }

Transformで移動する ​

攻撃対象の方向へ、transform.position を少しずつ動かします。向きも同じ方向へ回します。TryAttack は攻撃間隔から判断し必要に応じて CharacterAttack.Attack() を呼びます。

cs
        var direction = toTarget.normalized;
        transform.position +=
            direction * moveSpeed * Time.deltaTime;

        var targetRotation =
            Quaternion.LookRotation(direction);
        transform.rotation = Quaternion.Slerp(
            transform.rotation,
            targetRotation,
            rotateSpeed * Time.deltaTime);
    }

    private void TryAttack()
    {
        if (Time.time < _lastAttackTime + attackInterval)
            return;

        characterAttack.Attack();
        _lastAttackTime = Time.time;
    }

最も近い対象を探索する ​

FindNearestTarget はScene内の AttackTarget を調べます。searchRadius より近い対象の中から最も近いものを選びます。やや非効率な処理ですが、ここでは簡単のため FindObjectsByType を使用しています。

cs
    private AttackTarget FindNearestTarget()
    {
        var targets = FindObjectsByType<AttackTarget>(FindObjectsSortMode.None);
        AttackTarget nearest = null;
        var nearestDistance = searchRadius;

        foreach (var target in targets)
        {
            var distance = Vector3.Distance(
                transform.position,
                target.transform.position);

            if (distance >= nearestDistance)
                continue;

            nearest = target;
            nearestDistance = distance;
        }

        return nearest;
    }
}

Prefabのコンポーネントを設定する ​

Player Prefab ​

Player Prefab の InspectorPlayer Prefab の Inspector

Player本体には Rigidbody、Player、PlayerController、PlayerMove、DamageReceiver を設定します。今回追加する AttackTarget によって、Enemyから攻撃対象として見つけられるようになります。Player は HP ゲージを持つ他、敵との攻撃の識別に使われます。前回までに作ったCharacterAttackをそのまま利用します。

Enemy Prefab ​

Enemy Prefab の InspectorEnemy Prefab の Inspector

Enemy本体では、03回で作った Enemy と DamageReceiver をそのまま使います。今回はそこへ EnemyController と Rigidbody を追加し、Colliderも確認します。EnemyController の Character Attack には、Player同様に CharacterAttack を割り当てます。今回の Rigidbody は移動制御ではなく、敵同士を押し戻すために使います。Freeze Position Y と Freeze Rotation XYZ を確認してください。

本体Colliderと攻撃Colliderを分ける ​

Enemy本体のColliderは、Playerの攻撃を受けたり、物理的な大きさを表したりするために使います。一方で攻撃Colliderは、Enemyが攻撃中だけ有効にするTriggerです。Playerの攻撃判定と同じ考え方で、Enemyにも攻撃用の子Objectを用意します。2つを同じColliderにすると、移動中の接触と攻撃中の命中判定が混ざってしまいます。接触ダメージを与える種類の敵なら一緒でも構いませんが、今回は分割しています。

Rigidbodyで敵同士のめり込みを減らす ​

Enemy Rigidbody の InspectorEnemy Rigidbody の Inspector

今回のEnemy移動は transform.position で行うため、移動処理のために Rigidbody は使いません。ただし敵同士が互いにめり込むのを避けるため今回はEnemy本体に Rigidbody を付けます。X/Z方向は固定せず、物理的に押し戻せる余地を残します。

Enemyが滑る場合はDampingを調整する ​

デフォルトの設定では、衝突で生まれた速度が残り、Enemyが少しずつ滑っていくことがあります。これを防ぐために、Linear Damping を大きめに設定して速度を減衰させるのがおすすめです。Unityのバージョンによっては Drag という名前で表示されます。03回の Rigidbody はTrigger判定を成立させるためでしたが、今回は敵同士を押し戻すために使用しています。

PrefabからEnemyを生成する ​

PrefabとInstance ​

Prefab は、GameObjectの構成を保存した設計図です。関連するオブジェクトとComponentの構成をまとめて保存できます。Instance は、Scene上に存在する実体です。Prefabから複製され、位置や状態を個別に持ちます。Enemyのように同じ構成のObjectを何度も使う場合、Prefabを1つ作っておき、必要なタイミングでInstanceを生成します。今回の Spawner は敵の生成しか扱いませんが、何らかの設定値を付与するのに好適な場所です。

Spawn Point ​

敵を出したい場所に空のGameObjectを置き、Spawn Point として使います。座標をコードに直接書くのではなく、Scene上で位置を設定するのがおすすめです。動作的にはやや非効率ですが、ゲームバランスの調整がしやすくなります。ゲームバランスに関わる位置は、なるべくInspectorやScene上で調整できる形にして、ゲームエンジンUnityの強みを活かす判断は決して間違いではありません。今回は複数のSpawn Pointを用意し、そのうちのランダムな場所から敵を出しています。

Prefabと生成位置を参照する ​

enemyPrefab は生成するEnemyのPrefabです。spawnPoints は生成位置の候補です。maxAliveCount で生成しすぎを防ぎます。Spawnerは生成だけを担当し、追跡対象の判断はEnemy側に任せます。03回で作った Enemy の型として持つことで、行動AIではなく敵そのものを生成対象として扱います。

cs
using System.Collections.Generic;
using UnityEngine;

public class EnemySpawner : MonoBehaviour
{
    [SerializeField] private Enemy enemyPrefab;
    [SerializeField] private Transform[] spawnPoints;
    [SerializeField] private float spawnInterval = 1.5f;
    [SerializeField] private int maxAliveCount = 20;

    private readonly List<Enemy> _aliveEnemies = new();
    private float _spawnTimer;

一定間隔で生成を試みる ​

Update で経過時間を数え、指定間隔で Spawn を呼びます。破棄済みの敵がListに残っている場合は先に取り除きます。最大数に達している場合、それ以上は生成しません。

cs
    private void Update()
    {
        CleanupDestroyedEnemies();

        _spawnTimer += Time.deltaTime;
        if (_spawnTimer < spawnInterval)
            return;

        _spawnTimer = 0f;
        Spawn();
    }

生成したEnemyを管理する ​

ランダムなSpawn Pointを選んで Enemyを生成します。生成したEnemyをListに登録し、現在数を管理します。

cs
    private void Spawn()
    {
        if (_aliveEnemies.Count >= maxAliveCount)
            return;

        var spawnPoint =
            spawnPoints[Random.Range(0, spawnPoints.Length)];
        var enemy = Instantiate(
            enemyPrefab,
            spawnPoint.position,
            spawnPoint.rotation);

        _aliveEnemies.Add(enemy);
    }

破棄済みのEnemyをListから外す ​

生成した敵を List に控えて生成した敵の数を管理していますが Destroy されたObjectは Listから自動で削除されるわけではありません。そのままでは敵が減っていても _aliveEnemies.Count が減らず、生成上限に達したままになります。RemoveAll を使い、条件に合う要素をまとめて Listから取り除きます。enemy => enemy == null は 「enemy が null なら対象」という条件です。

cs
    private void CleanupDestroyedEnemies()
    {
        _aliveEnemies.RemoveAll(enemy => enemy == null);
    }

実習1:Player側のCharacterAttackを確認する ​

  1. 第04回のCharacterAttack.csがProjectにあることを確認する
  2. Playerの攻撃判定ColliderがCharacterAttackに設定されていることを確認する
  3. PlayerControllerが同じCharacterAttackを参照していることを確認する
  4. Play ModeでPlayerの攻撃とダメージが前回どおり動くことを確認する
  5. Playして、Playerの直接攻撃が前回と同じように動くか確認

実習2:Enemyに追跡と攻撃を追加する ​

  1. Playerに AttackTarget を追加

  2. Enemy本体に EnemyController を追加

  3. Enemyに Rigidbody を追加し、 敵同士のめり込み対策を設定 Is Kinematic: Off、 Use Gravity: Off、 Linear Damping: 大きめ、 Constraintsを確認

  4. Enemyの子Objectとして Enemy Attack Range を作成、 CharacterAttack を追加

  5. Enemy Attack Range に Trigger Collider を設定

  6. CharacterAttack の Attack Collider に 攻撃用Colliderを設定

  7. EnemyController の Character Attack に CharacterAttack を設定

実習3:EnemySpawnerでEnemyを生成する ​

  1. EnemyをPrefab化します
  2. Enemy Spawner を作り、EnemySpawner を追加します
  3. Enemy Prefab に、ProjectビューのEnemy Prefabを設定します
  4. Spawn Points 配列へ、Scene上のSpawn Pointを登録します
  5. 生成されたEnemyが AttackTarget へ近づき、攻撃するか確認します

動作しない場合のチェックリスト ​

Enemyが追いかけてこない ​

  • Playerに AttackTarget が付いているか確認します
  • Enemyの Search Radius が小さすぎないか確認します
  • Enemyに EnemyController が付いているか確認します
  • moveSpeed が小さすぎないか確認します

Enemyが攻撃してこない ​

  • Enemy Attack Range に CharacterAttack が付いているか確認します
  • EnemyController の Character Attack に参照が入っているか確認します
  • CharacterAttack の Attack Collider に攻撃用Colliderが設定されているか確認します
  • 攻撃用Colliderの Is Trigger が有効になっているか確認します
  • 攻撃対象に DamageReceiver が付いているか確認します

生成された敵にPlayerの攻撃が当たらない ​

  • Enemy PrefabにColliderが付いているか確認します
  • Enemy本体、または親Objectに DamageReceiver が付いているか確認します
  • Player側の CharacterAttack の攻撃判定Colliderが、 攻撃中に有効になっているか確認します
  • PlayerとEnemyのLayer同士が接触しない設定になっていないか確認します
  • Prefab化する前のScene上のEnemyだけにComponentを追加していて、 Prefab側に反映されていない場合があります

完成条件 ​

  • Playerは前回と同じ直接攻撃を使えます
  • Enemyは AttackTarget へ近づき、攻撃距離で止まります
  • Enemyは一定間隔で攻撃対象へ攻撃できます
  • EnemySpawnerがEnemy Prefabを一定間隔で生成します
  • Playerの直接攻撃で、生成されたEnemyにもダメージを与えられます
  • HPが0になったEnemyはSceneから破棄されます

まとめ ​

  • 第04回で共通名にしたCharacterAttackをPlayerとEnemyの両方から利用した
  • Playerは入力から、Enemyは行動判断から同じ攻撃処理を呼ぶ
  • EnemyはAttackTargetを探索して追跡と攻撃を行う
  • EnemySpawnerはEnemy Prefabの生成と生存数だけを管理する
  • 生成されたEnemyも追跡、攻撃、被ダメージ、死亡破棄まで動作する