Skip to content

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

前回: 第04回 攻撃処理と生成

前回の振り返り

攻撃できる敵を生成しました

  • PlayerAttackCharacterAttack に共通化し、 PlayerとEnemyの両方が同じ攻撃処理を使えるようにしました
  • EnemyControllerAttackTarget を探して近づき、 攻撃距離に入ったら CharacterAttack.Attack() を呼びました
  • EnemySpawner はEnemy Prefabの生成だけを担当し、 攻撃対象の判断はEnemy自身に任せました
  • 今回は、敵の動きと攻撃の手応えを整え、 被弾時ののけぞりと攻撃中断を入れます

今回のテーマ

状態で行動を制御する

攻撃中、被弾中、死亡中に できることを分ける

今回作るもの

ミニステートマシンで手応えを作る

  • キャラクターの状態を IdleMoveAttackDamageDead に分けます
  • 攻撃中は移動しないようにします
  • 被弾中は移動と攻撃を止めます
  • 被弾したら攻撃を中断し、短くのけぞります
  • 死亡中は移動、攻撃、被弾リアクションを止めます
  • 外部アニメーション素材は使わず、Transform移動と色変更で反応を見せます
text
通常時
  追跡する
  攻撃距離なら攻撃する

攻撃中
  移動しない
  攻撃判定を出す

被弾中
  攻撃を中断する
  のけぞる
  短時間だけ行動できない

今回の到達目標

状態、のけぞり、数値調整を接続する

  • enum でキャラクターの現在状態を表します
  • 状態ごとに移動や攻撃を許可するかを分けます
  • CharacterAttack に攻撃中かどうかと攻撃中断を追加します
  • DamageReceiver.Damaged を受け取り、のけぞりを開始します
  • のけぞり中は追跡と攻撃を止めます
  • 攻撃距離、攻撃間隔、移動速度、生成間隔を調整します
  • 複雑なビヘイビアツリーやStateパターンには踏み込みません

なぜ状態が必要なのか

何でも同時にできるとゲームが壊れる

  • 攻撃中に移動し続けると、攻撃判定だけが相手に張り付き、 プレイヤーから見ると避けにくい攻撃になります
  • 被弾中にすぐ次の攻撃を出せると、攻撃を当てた手応えが弱くなります
  • 死亡した直後にも移動や攻撃が動くと、意図しないダメージが発生します
  • 今そのキャラクターが何をしているかを1つの状態として持つと、 行動の優先順位を説明しやすくなります

今回扱わないこと

初心者が混乱しやすい部分は避ける

  • ビヘイビアツリーは扱いません
  • Stateパターンや状態ごとのクラス分割は扱いません
  • Animator Controllerの複雑な遷移は扱いません
  • Humanoid Animationや外部モーション素材は使いません
  • Animation Eventで攻撃判定を制御する方法も今回は扱いません
  • 今回は、コードで状態を決め、 見た目は短いリアクションで伝えることに集中します

状態の種類

enumで今の状態を1つだけ持つ

  • Idle は、止まっている状態です
  • Move は、移動している状態です
  • Attack は、攻撃している状態です
  • Damage は、のけぞりなどで行動できない状態です
  • Dead は、死亡して何もできない状態です
cs
public enum CharacterState
{
    Idle,
    Move,
    Attack,
    Damage,
    Dead
}

フラグを増やしすぎない

boolが増えると優先順位が見えにくい

cs
bool isAttacking;
bool isDamaged;
bool isDead;
bool canMove;
bool canAttack;
  • 小さい実装なら bool でも動かせます
  • しかし数が増えると、どれが優先なのか分かりにくくなります
  • isAttackingisDamaged が同時に true になった時に、 攻撃を続けるのか、中断するのかが曖昧になります
  • 今回は、現在状態を1つだけ持ち、 状態ごとにできることを分けます

CharacterStateController

状態と行動可否を管理する

  • 状態を持つための小さなComponentを作ります
  • CanMoveCanAttack で、今その行動ができるかを外から確認します
  • SetState で状態を変更します
  • 状態遷移の専門クラスではなく、まずは状態を読みやすくするための部品です
cs
using UnityEngine;

public class CharacterStateController : MonoBehaviour
{
    [SerializeField] private CharacterState state;

    public CharacterState State => state;
    public bool IsDead => state == CharacterState.Dead;
    public bool CanMove =>
        state == CharacterState.Idle ||
        state == CharacterState.Move;
    public bool CanAttack =>
        state == CharacterState.Idle ||
        state == CharacterState.Move;

CharacterStateController 続き

状態を変更する入口

  • 同じ状態へ変える場合は何もしません
  • まずは状態の変更だけを行います
  • 今後、状態が変わった瞬間に音やエフェクトを出したくなったら、 この入口に処理を足せます
cs
    public void SetState(CharacterState nextState)
    {
        if (state == nextState)
            return;

        state = nextState;
    }

    public void SetIdle()
    {
        SetState(CharacterState.Idle);
    }

    public void SetDead()
    {
        SetState(CharacterState.Dead);
    }
}

04回への影響

CharacterAttackは少しだけ拡張する

  • 第04回の CharacterAttack は、攻撃を開始して、 時間経過で攻撃判定をON/OFFするところまでを扱いました
  • 第05回では、被弾時に攻撃を止める必要があります
  • IsAttacking は第04回で攻撃中の移動停止に使いました
  • 今回はさらに CancelAttack() を追加します
  • これは04回の処理を壊す変更ではなく、 04回の攻撃処理に「外から中断できる入口」を足す変更です

CharacterAttackの追加

攻撃を外から中断できるようにする

  • IsAttacking は、EnemyControllerが攻撃中の移動を止めるために使っています
  • CancelAttack() があると、被弾時に攻撃判定を消せます
  • EndAttack() は既存の内部処理として使います
  • 05回では、04回の CharacterAttack に次の処理を追加します
cs
public void CancelAttack()
{
    if (!_isAttacking)
        return;

    EndAttack();
}

攻撃中は移動しない

攻撃の予備動作と隙を作る

  • 攻撃中も移動できると、攻撃判定が相手に追従しすぎます
  • 攻撃中は止めることで、避ける余地と攻撃後の隙ができます
  • CharacterAttack.IsAttacking を使えば、 移動側の処理で攻撃中かどうかを判断できます
  • 今回はEnemy側に入れますが、Playerにも応用できます

のけぞりの役割

ダメージを受けたことを伝える

  • のけぞりは、キャラクターアニメーションがなくても作れます
  • 被弾した瞬間に、相手から少し離れる方向へ押し出します
  • 短い時間だけ行動できないようにすると、 攻撃を当てた手応えが出ます
  • 被弾中は攻撃を中断し、移動も止めます
  • やりすぎると操作不能時間が長くなるため、 授業では短い時間に留めます

HitReaction.cs (1/4)

被弾時の見た目と状態を担当する

  • DamageReceiver.Damaged を受け取り、のけぞりを再生します
  • CharacterStateController を使って Damage 状態にします
  • CharacterAttack を中断します
  • Transformを短時間だけ動かして押し出します
cs
using System.Collections;
using UnityEngine;

[RequireComponent(typeof(DamageReceiver))]
public class HitReaction : MonoBehaviour
{
    [SerializeField] private CharacterStateController state;
    [SerializeField] private CharacterAttack characterAttack;
    [SerializeField] private Renderer targetRenderer;
    [SerializeField] private Color hitColor = Color.red;
    [SerializeField] private float duration = 0.2f;
    [SerializeField] private float knockbackPower = 4f;

HitReaction.cs (2/4)

DamageReceiverのイベントを使う

  • OnEnableDamaged を購読します
  • OnDisable で解除します
  • 生成と破棄を繰り返すEnemyでは、イベント解除を忘れないようにします
  • 被弾時は、攻撃してきた方向が必要です 今回は簡単に、相手方向は transform.forward の逆方向として扱います
cs
    private DamageReceiver _receiver;
    private Color _defaultColor;
    private Coroutine _routine;

    private void Awake()
    {
        _receiver = GetComponent<DamageReceiver>();
        if (targetRenderer != null)
            _defaultColor = targetRenderer.material.color;
    }

    private void OnEnable()
    {
        _receiver.Damaged += OnDamaged;
    }

    private void OnDisable()
    {
        _receiver.Damaged -= OnDamaged;
    }

HitReaction.cs (3/4)

被弾時に攻撃を中断する

  • すでにのけぞり中なら、前のCoroutineを止めます
  • 攻撃中なら CancelAttack() で中断します
  • 状態を Damage にして、追跡や攻撃を止めます
  • 短い色変更で、被弾したことを見た目に出します
cs
    private void OnDamaged(int damage, int maxHealth)
    {
        if (_routine != null)
            StopCoroutine(_routine);

        characterAttack?.CancelAttack();
        _routine = StartCoroutine(PlayRoutine());
    }

    private IEnumerator PlayRoutine()
    {
        state?.SetState(CharacterState.Damage);

        if (targetRenderer != null)
            targetRenderer.material.color = hitColor;

HitReaction.cs (4/4)

短く押し出して戻す

  • 短い時間だけ transform.position を動かします
  • yield return null で毎フレーム少しずつ押し出します
  • 終わったら色を戻し、状態を Idle に戻します
  • 死亡処理が別に入る場合は、死亡中に Idle へ戻さないようにします
cs
        var knockbackDirection = -transform.forward;
        var elapsedTime = 0f;

        while (elapsedTime < duration)
        {
            transform.position +=
                knockbackDirection * knockbackPower * Time.deltaTime;
            elapsedTime += Time.deltaTime;
            yield return null;
        }

        if (targetRenderer != null)
            targetRenderer.material.color = _defaultColor;

        if (state != null && !state.IsDead)
            state.SetIdle();

        _routine = null;
    }
}

のけぞり方向について

今回は単純化する

  • 本来は「攻撃してきた相手から離れる方向」に押すのが自然です
  • そのためには、攻撃情報に攻撃者の位置や方向を含める必要があります
  • ただし今回は、状態制御とのけぞりの導入が目的です
  • まずは -transform.forward 方向へ押す単純な実装にします
  • 余裕があれば、AttackContext に攻撃方向を追加して発展できます

EnemyController.cs 更新 (1/3)

状態と攻撃を参照する

  • state を追加します
  • characterAttack は前回から引き続き使います
  • 追跡対象は04回と同じく AttackTarget から探します
  • 攻撃中、被弾中、死亡中は移動を止めます
  • 追跡処理自体は04回のままです
cs
public class EnemyController : MonoBehaviour
{
    [SerializeField] private CharacterStateController state;
    [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/3)

行動できない状態なら止まる

  • 死亡中なら何もしません
  • 攻撃状態だが攻撃処理が終わっていれば、Idle に戻します
  • 被弾中なら、のけぞり処理に任せます
  • 攻撃中なら移動せず、攻撃が終わるのを待ちます
  • これだけでも、攻撃と移動が混ざる問題を減らせます
cs
    private void Update()
    {
        if (state != null && state.IsDead)
        {
            return;
        }

        if (state != null &&
            state.State == CharacterState.Attack &&
            !characterAttack.IsAttacking)
        {
            state.SetIdle();
        }

        if (state != null && !state.CanMove)
        {
            return;
        }

        if (characterAttack.IsAttacking)
        {
            return;
        }

        if (_target == null)
            _target = FindNearestTarget();

        if (_target == null)
        {
            return;
        }

EnemyController.cs 更新 (3/3)

移動と攻撃で状態を変える

  • 移動している間は Move にします
  • 攻撃する時は Attack にします
  • 攻撃できない距離なら追跡します
  • 状態の名前があることで、何をしている最中かが読みやすくなります
cs
        // 省略: AttackTarget探索と距離計算

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

        state?.SetState(CharacterState.Move);
        MoveToTarget(toTarget.normalized);
    }

    private void TryAttack()
    {
        if (state != null && !state.CanAttack)
            return;

        if (Time.time < _lastAttackTime + attackInterval)
            return;

        state?.SetState(CharacterState.Attack);
        characterAttack.Attack();
        _lastAttackTime = Time.time;
    }

Attack終了後の状態

攻撃終了をどう知るか

  • 04回の CharacterAttack は、攻撃終了を内部で処理しています
  • 今回は攻撃終了イベントまでは作らず、 CharacterAttack.IsAttacking を見て攻撃が終わったかを判断します
  • EnemyControllerAttack 状態かつ IsAttacking == false になったら、 状態を Idle に戻します
  • その後、次の距離判定で、まだ攻撃距離なら次の攻撃を待ち、 遠ければ Move に戻ります
  • より厳密に作る場合は、CharacterAttack に攻撃終了イベントを追加する方法があります

調整する値

まずは少数のパラメーターに絞る

影響
moveSpeed敵が攻撃対象へ近づく速さ
attackDistance敵が攻撃を始める距離
attackInterval敵が連続攻撃する間隔
damage1回の攻撃で減るHP
durationのけぞりで行動できない時間
spawnInterval敵が増える頻度
maxAliveCount同時に出る敵の最大数

全部を同時に変えると原因が分かりにくくなるため、 1つずつ調整して変化を確認します。

調整の目安

理不尽さを減らす

  • Enemyの移動速度は、Playerより少し遅いくらいから始めます
  • 攻撃距離は、攻撃Colliderの大きさより少し広めにします
  • 攻撃間隔は、Playerが離脱できる時間を残します
  • のけぞり時間は、長くしすぎると一方的に動けなくなります
  • 同時に出る敵の数は、まず3から5体程度で確認します
  • 調整は正解を探す作業ではなく、 プレイして不公平に感じる箇所を減らす作業です

実習1

CharacterAttackを中断できるようにする

  • 04回で追加した IsAttacking が使えることを確認します
  • CharacterAttackCancelAttack() を追加します
  • 被弾時に攻撃判定Colliderが残らないか確認します
  • PlayerとEnemyの両方で、攻撃が今まで通り動くか確認します

実習2

CharacterStateControllerを追加する

  • CharacterState enumを作ります
  • CharacterStateController を作ります
  • PlayerとEnemyに CharacterStateController を追加します
  • Inspectorで現在状態が変わることを確認します
  • 状態の目的は、行動できるかどうかを読みやすくすることです

実習3

HitReactionでのけぞりを入れる

  • PlayerとEnemyに HitReaction を追加します
  • DamageReceiverDamaged イベントで反応するようにします
  • 被弾時に攻撃が中断されるか確認します
  • 被弾時に色が変わり、短く押し出されるか確認します
  • のけぞり中に追跡や攻撃が止まるか確認します

実習4

敵AIと数値を調整する

  • Enemyの moveSpeed を調整します
  • attackDistance と攻撃Colliderの大きさを合わせます
  • attackInterval を調整し、連続攻撃が理不尽でないか確認します
  • Spawnerの spawnIntervalmaxAliveCount を調整します
  • 調整後、1分程度プレイして、倒す、逃げる、被弾する流れを確認します

よくあるエラー

のけぞり後に動かなくなる

  • HitReaction の最後で state.SetIdle() が呼ばれているか確認します
  • state.IsDead の時に Idle へ戻していないか確認します
  • Coroutineが途中で止まったままになっていないか確認します
  • duration が長すぎると、止まっているように見える場合があります

よくあるエラー

攻撃Colliderが残り続ける

  • CancelAttack() の中で EndAttack() を呼んでいるか確認します
  • EndAttack()EndHit() を呼び、Colliderを無効にしているか確認します
  • attackCollider の参照がInspectorで設定されているか確認します
  • 攻撃用Colliderと本体Colliderを同じにしていないか確認します

よくあるエラー

敵が攻撃しなくなる

  • CharacterStateController の状態が Damage のまま戻っていない可能性があります
  • state.CanAttacktrue になる状態を確認します
  • characterAttack の参照が EnemyController に入っているか確認します
  • attackInterval が大きすぎないか確認します
  • 攻撃距離に入っているかをSceneビューで確認します

今回のポイント

小さな状態管理で手応えを作る

  • enum で現在状態を1つだけ持ちました
  • 攻撃中は移動しないようにしました
  • 被弾中は攻撃を中断し、短時間行動不能にしました
  • 外部アニメーション素材を使わず、色変更とのけぞりで反応を出しました
  • 敵の速さ、距離、攻撃間隔、生成数を調整し、 遊びやすさを整えました

今日の完成状態

攻撃と被弾の手応えが出る

  • PlayerとEnemyは CharacterAttack を共有しています
  • Enemyは AttackTarget へ近づき、攻撃距離で攻撃します
  • 攻撃中は移動しません
  • 被弾すると攻撃が中断され、短くのけぞります
  • のけぞり中は移動も攻撃もできません
  • 数値調整により、敵の手応えをInspectorから調整できます

次回へ向けて

アイテムと回復で行動の目的を増やす

  • 今回までで、追いかける敵、攻撃、被弾、のけぞりが入りました
  • 次回は、回復アイテムや取得物を追加します
  • Playerが敵を倒すだけでなく、 危険を避けながらアイテムを取りに行く理由を作ります
  • 接触判定、生成、回復処理は、 これまで作った DamageReceiverHealth の延長で扱えます

おつかれさまでした!

次回予告 第06回 アイテムと回復

逃げる理由と 取りに行く理由を作る