Skip to content

第04回 攻撃処理と生成

前回: 第03回 衝突判定とダメージ / 次回: 第05回 敵AIとゲームフィール調整

前回の振り返り

Playerの直接攻撃を作りました

前回作成したプレイヤー攻撃と敵のHP表示

  • PlayerAttack で攻撃中だけ Collider を有効に して、Trigger に入った相手を検出しました
  • DamageReceiver で、攻撃を受けた時にダメージを受ける処理を作りました
  • AttackResolver で攻撃者と被弾者の関係を 判断し、ゲーム仕様に沿ったダメージの解決を集中管理する形としました
  • 今回はこれらを Enemy も使えるようにし、 Player と戦闘できるようにします

今回のテーマ

攻撃能力のある敵を生成する

攻撃処理を共通化 ついでに追跡も

今回作るもの

攻撃対象を探して攻撃するEnemy

  • 前回作った PlayerAttack を、 Player専用ではない攻撃コンポーネントに 共通化します
  • Enemyは攻撃対象へ近づき、攻撃できる距離で 停止、一定間隔で攻撃します
  • これらの機能を実装したEnemyをPrefab化し、 Spawnerから複数生成します
text
Player
  Rigidbody
  Player
  PlayerController
  PlayerMove
  DamageReceiver
  AttackTarget
  Player Attack
    CharacterAttack

Enemy Prefab
  Rigidbody
  Enemy(03回で作成済み)
  EnemyController
  DamageReceiver
  Enemy Attack Range
    CharacterAttack

EnemySpawner
  Enemy Prefab を生成
  追跡対象はEnemy自身が探す

今回の到達目標

攻撃処理の共通化と敵の追跡攻撃

  • PlayerAttackCharacterAttack として共通化します
  • Playerの攻撃入力から CharacterAttack.Attack() を呼びます
  • Enemyにも CharacterAttack を持たせ、同じ攻撃処理を使います
  • Enemyが攻撃対象を探し、対象へ向かって移動する最低限の追跡処理を作ります
  • Enemyは攻撃距離に入ると止まり、一定間隔で攻撃します
  • Enemy Prefabを EnemySpawner から生成します
  • Spawnerは生成だけを担当し、追跡対象の決定はEnemy側に任せます

攻撃処理の共通化

あえて Player 専用とする理由がない?

  • 前回の PlayerAttack は、Playerが使う前提で名前を付けました
  • しかし中身を見ると、処理の多くはPlayer専用ではありません 攻撃時間、当たり判定、命中済みリスト、ダメージ解決は Enemyの攻撃にもそのまま使えます
  • Player専用ではない処理なら、名前と参照の持ち方を整理して、 PlayerとEnemyの両方から使える部品にした方が自然かもしれません
  • 共通化が常に最適とは限りませんが 今回は PlayerAttackCharacterAttack として共通化します

CharacterAttack の実装

攻撃判定だけを扱う

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

CharacterAttackが直接やらないこと
  HP計算
  死亡処理
  追跡AI
  生成処理

注意: nullチェックについて

必須の依存を防衛的に握りつぶさない

  • attackColliderCharacterAttack が動くための必須依存です ここを if (attackCollider != null) で黙って飛ばすと、 Inspector での設定漏れという本質的な問題を隠してしまいます
  • ここに限らず 必須依存は設定し忘れた時に早く気づける形にし、 null チェックでのスルーは必要な時だけに行うよう心掛けてください
  • nullチェック自体が悪いわけではありません 例えば OnTriggerEnter で触れた相手が DamageReceiver を持っていないことは あり得るため、その場合は return して構いません

名前を変える時の注意

ファイル名とクラス名を揃える

  • Unityの MonoBehaviour を扱うクラス管理では、 ファイル名とクラス名を一致させるのが基本です
  • クラス名 PlayerAttackCharacterAttack に変更する場合、 ファイル名も CharacterAttack.cs に変更します
  • Player側は、PlayerController の参照先を PlayerAttack から CharacterAttack に差し替えます
  • すでにInspectorで PlayerAttack を参照している場合、 変更後に参照が外れていないかを確認してください

CharacterAttack.cs (1/5)

前回のPlayerAttackを共通化する

  • Player 専用から Character 共通に変更され ますが、ほとんどの処理は前回と同じです
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;

    private readonly List<DamageReceiver> _hitReceivers = new();

CharacterAttack.cs (2/5)

攻撃状態を保持する

  • IsAttacking は、自身が攻撃中かどうかを 表すプロパティです
  • _isHitActive は、攻撃判定Colliderが 有効になっているかを表します
  • _attackElapsedTime は、攻撃開始からの 経過時間を数えます
  • Awake では、攻撃判定Colliderを最初は 無効にしておきます
cs
    private bool _isAttacking;
    private bool _isHitActive;
    private float _attackElapsedTime;

    public bool IsAttacking => _isAttacking;

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

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

        _attackElapsedTime += Time.deltaTime;

CharacterAttack.cs (3/5)

攻撃時間の更新と攻撃開始

  • CharacterAttack.Attack() で攻撃を開始します
  • 攻撃中だけ _attackElapsedTime を進めます
  • hitStartTime で当たり判定を有効にし、 hitEndTime で無効にします
  • attackDuration を過ぎたら攻撃を終了します
  • attackEffect は演出用なので、設定されていなくても 攻撃処理自体は成立します
cs
        if (!_isHitActive &&
            _attackElapsedTime >= hitStartTime)
            StartHit();

        if (_isHitActive &&
            _attackElapsedTime >= hitEndTime)
            EndHit();

        if (_attackElapsedTime >= attackDuration)
            EndAttack();
    }

    public void Attack()
    {
        if (_isAttacking)
            return;

        attackEffect?.Play();
        _hitReceivers.Clear();
        _isAttacking = true;
        _isHitActive = false;
        _attackElapsedTime = 0f;
    }

CharacterAttack.cs (4/5)

ダメージはAttackResolverが解決

  • 1回の攻撃で、同じ相手へ何度もダメージを 与えないようにします
  • 攻撃者とダメージ量を AttackContext に まとめ、AttackResolver.Resolve() で ダメージ処理を行います
  • 自分自身への攻撃除外なども AttackResolver 側で判断します
cs
    private void OnTriggerEnter(Collider other)
    {
        var receiver =
            other.GetComponentInParent<DamageReceiver>();
        if (receiver == null)
            return;

        if (_hitReceivers.Contains(receiver))
            return;

        _hitReceivers.Add(receiver);

        var attackContext =
            new AttackContext(gameObject, damage);
        AttackResolver.Resolve(attackContext, receiver);
    }

AttackResolverの更新

Enemy同士の相打ちを防ぐ

  • 03回の AttackResolver は、自分自身への ダメージだけを除外していました
  • 今回はEnemyも攻撃するため、そのままだと Enemyの攻撃が別のEnemyにも当たってしまいます
  • 既存の Player と、03回で作った Enemy クラスを使い、 同じ種類の相手にはダメージを与えないようにします
  • Resolve 内に味方同士の判定を追加し、 IsSameSideAttackResolver クラス内の別メソッドとして定義します
cs
public static class AttackResolver
{
    public static void Resolve(
        AttackContext attackContext,
        DamageReceiver receiver)
    {
        if (receiver.transform ==
            attackContext.Attacker.transform ||
            receiver.transform.IsChildOf(
                attackContext.Attacker.transform))
            return;

        if (IsSameSide(
            attackContext.Attacker,
            receiver.gameObject))
            return;

        if (!receiver.CanReceiveDamage)
            return;

        receiver.ReceiveDamage(attackContext.Damage);
    }

    private static bool IsSameSide(
        GameObject attacker,
        GameObject receiver)
    {
        if (attacker.GetComponentInParent<Player>() != null &&
            receiver.GetComponentInParent<Player>() != null)
            return true;

        if (attacker.GetComponentInParent<Enemy>() != null &&
            receiver.GetComponentInParent<Enemy>() != null)
            return true;

        return false;
    }
}

CharacterAttack.cs (5/5)

攻撃判定をON/OFFする

  • StartHit は、攻撃判定を有効に EndHit は、攻撃判定を無効にします
  • EndAttack では攻撃状態を解除し 判定も必ず停止します
  • 個々の変数を直接操作せず、必要な操作を メソッドにまとめることで、状態の不整合を 防ぎましょう
cs
    private void EndAttack()
    {
        _isAttacking = false;
        EndHit();
    }

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

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

Player側の変更

PlayerControllerからCharacterAttackを呼ぶ

  • Playerの操作入力は、これまで通り PlayerController が受け取ります
  • 攻撃ボタンが押されたら、CharacterAttack.Attack() を呼びます
  • 変更点は、参照する型が PlayerAttack から CharacterAttack になることだけです
  • Playerの子Objectにある攻撃判定Colliderの 設定も前回と同じです
cs
public class PlayerController : MonoBehaviour
{
    [SerializeField] private CharacterAttack characterAttack;

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

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

攻撃対象の見つけ方

Playerを直接参照しない

  • Enemyが Player 型を直接参照すると、 EnemyはPlayerに強く依存することになってしまいます
  • 今回Enemyに必要なのは、相手がPlayerかどうかではなく、 相手を攻撃対象として扱ってよいか対象がどこにいるか の情報です
  • そこで、攻撃対象にできるObjectへ AttackTarget という目印Componentを付けます
  • EnemyはScene内の AttackTarget を探し、 近い対象を追跡、攻撃します

AttackTarget.cs

攻撃対象であることを示す目印

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

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

EnemyControllerの実装方針

探す、近づく、攻撃する

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

EnemyControllerの処理

Update内でシンプルに動かす

  • Update では、攻撃対象の探索と攻撃判断を行います 移動すると決まったら、transform.position を少しずつ動かします
  • 今回のEnemyは、物理挙動ではなく直接座標を操作して移動します 移動処理のために Rigidbody は使いません
  • ただし、そのままでは敵同士がめり込むため、 Enemy本体に Rigidbody を付けます
  • Rigidbody は敵同士を押し戻すために使い、 EnemyControllerから速度を直接操作しません

EnemyController.cs (1/4)

必要な参照と設定値

  • characterAttack はEnemy自身の攻撃処理です
  • searchRadius は攻撃対象を探す範囲です
  • moveSpeedrotateSpeed は移動の調整値です
  • attackDistanceattackInterval は攻撃判断に使います
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;

EnemyController.cs (2/4)

Updateで行動を判断する

  • 目標が無ければ FindNearestTarget で探します
  • 見つからない時は、そのフレームでは移動しません
  • 攻撃中も移動処理へ進みません
cs
    private void Update()
    {
        if (_target == null)
            _target = FindNearestTarget();

        if (_target == null)
            return;

        if (characterAttack.IsAttacking)
            return;

EnemyController.cs (3/4)

距離を見て攻撃する

  • Y方向は無視し、X-Z平面上で近づきます
  • 攻撃距離に入ったら移動せず、攻撃を試みます
  • まだ遠い場合は、次のスライドで移動方向を決めます
cs
        var toTarget =
            _target.transform.position - transform.position;
        toTarget.y = 0f;

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

EnemyController.cs (4/4)

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;
    }

EnemyController.cs 補足

対象探索

  • FindNearestTarget はScene内の AttackTarget を調べます
  • searchRadius より近い対象の中から 最も近いものを選びます やや非効率な処理ですが、ここでは簡単のため FindObjectsByType を使用しています
cs
    private AttackTarget FindNearestTarget()
    {
        var targets =
            FindObjectsByType<AttackTarget>();
        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;
    }
}

Player Prefabの Inspector

Player Prefab の Inspector

  • Player本体には RigidbodyPlayerPlayerControllerPlayerMoveDamageReceiver を設定します
  • 今回追加する AttackTarget によって、 Enemyから攻撃対象として見つけられるようになります
  • Player は HP ゲージを持つ他、 敵との攻撃の識別に使われます
  • 前回作った PlayerAttackCharacterAttack に 変更されます

Enemy PrefabのInspector

Enemy Prefab の Inspector

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

攻撃Colliderの注意

本体Colliderと攻撃Colliderを分ける

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

EnemyのRigidbody設定

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

Enemy Rigidbody の Inspector

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

EnemyのRigidbody設定の注意

Enemyが少しずつ滑っていく

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

Prefab と Instance

設計図と実体

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

Spawn Point

生成位置を表す空のGameObject

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

EnemySpawner.cs (1/3)

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;

EnemySpawner.cs (2/3)

一定間隔で生成を試みる

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

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

        _spawnTimer = 0f;
        Spawn();
    }

EnemySpawner.cs (3/3)

生成した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);
    }

破棄済みの敵をListから外す

Destroyされた参照を取り除く

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

実習1

PlayerAttackをCharacterAttackへ変更する

  • PlayerAttack.csCharacterAttack.cs に変更 クラス名も CharacterAttack に変更
  • Playerの攻撃判定Colliderを CharacterAttack に設定
  • PlayerController の参照を CharacterAttack に変更
  • Playして、Playerの直接攻撃が前回と同じように動くか確認

実習2

Enemyに追跡と攻撃を追加する

  • Playerに AttackTarget を追加

  • Enemy本体に EnemyController を追加

  • Enemyに Rigidbody を追加し、 敵同士のめり込み対策を設定 Is Kinematic: OffUse Gravity: OffLinear Damping: 大きめ、 Constraintsを確認

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

  • Enemy Attack Range に Trigger Collider を設定

  • CharacterAttackAttack Collider に 攻撃用Colliderを設定

  • EnemyControllerCharacter AttackCharacterAttack を設定

実習3

EnemySpawnerで攻撃できる敵を生成する

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

よくあるエラー (1/2)

Enemyが追いかけてこない

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

よくあるエラー (2/2)

Enemyが攻撃してこない

  • Enemy Attack RangeCharacterAttack が付いているか確認します
  • EnemyControllerCharacter Attack に参照が入っているか確認します
  • CharacterAttackAttack 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で共有できる

  • Player専用だった PlayerAttackCharacterAttack に共通化しました
  • Playerは入力から CharacterAttack.Attack() を呼びます
  • Enemyは EnemyController から CharacterAttack.Attack() を呼びます
  • Enemyは AttackTarget を探して、攻撃対象を自己判断します
  • EnemySpawnerはPrefabの生成だけを担当します
  • 生成されたEnemyも、追跡、攻撃、被ダメージ、死亡破棄まで動きます

今日の完成状態

攻撃する敵がゲーム中に増える

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

AttackTarget.cs

cs
using UnityEngine;

// 攻撃対象であることを示す目印
public class AttackTarget : MonoBehaviour
{
   // 目印以外の処理は特に持ちません
}

EnemyController.cs (1/2)

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;
cs
    private void Update()
    {
        if (_target == null)
            _target = FindNearestTarget();

        if (_target == null)
            return;

        if (characterAttack.IsAttacking)
            return;

        var toTarget =
            _target.transform.position - transform.position;

        toTarget.y = 0f;

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

EnemyController.cs (2/2)

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;
    }
cs
    private AttackTarget FindNearestTarget()
    {
        var targets = FindObjectsByType<AttackTarget>();
        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;
    }
}

EnemySpawner.cs

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;

    private void Update()
    {
        CleanupDestroyedEnemies();
        _spawnTimer += Time.deltaTime;
        if (_spawnTimer < spawnInterval)
            return;
        _spawnTimer = 0f;
        Spawn();
    }
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);
    }

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

余裕があれば

自由に改善してみましょう

  • 敵の攻撃とプレイヤーの攻撃を差別化してみましょう 色を変える、速度を変える、範囲を変えるなどから始めるのがおすすめです
  • プレイヤーの攻撃中の動作を止めてみましょう 現在は攻撃中に移動できてしまい、的が定めづらいかもしれません 完全に停止させるのではなく、減速するのも良いでしょう
  • プレイヤーの攻撃受け付けに先行入力を入れてみましょう 攻撃の終端に入力がされた場合はすかさず次の攻撃に移ると プレイヤーの操作感が良くなります

次回へ向けて

敵AIとゲームフィールを調整する

  • 今回は、Enemyが AttackTarget を探して攻撃する最低限の Bot の動作を作りました
  • 次回は、攻撃距離、攻撃間隔、移動速度、HP、ダメージ量に加えて、 被弾時ののけぞりと攻撃中断を入れます
  • 敵AIの調整とゲームフィールの改善を行い、 プレイヤーが戦っていて手応えを感じられるようにします

おつかれさまでした!

次回予告 第05回 敵AIとゲームフィール調整

のけぞりと攻撃中断で ゲームに手応えを 与える