Skip to content

第06回 Enemyの状態制御とゲームフィール

前回: 第05回 Enemyの探索・追跡・攻撃 / 次回: 第07回 アイテムとオブジェクトプール

今回の授業内容

複数のEnemyと戦い、被弾状態を確認しているゲーム画面

  • enumを使ってEnemyの現在状態を1つだけ表す
  • 状態に応じて、追跡、攻撃、被弾、死亡の優先順位を制御する
  • 被弾時に攻撃判定と演出を中断するリアクションをEnemyへ接続する
  • 状態遷移と見た目が一致していることをPlay Modeで確認する
  • 敵のパラメーターを1項目ずつ比較し、ゲームフィールを調整する

今回の配布コード

第05回終了時点のコードへ、Enemyの状態管理と被弾リアクションを追加した第06回基本版です。全18ファイルを第06回配布コードとしてまとめています。主な変更ファイルは次の通りです。

配布ファイル一覧
ファイル今回の変更
EnemyState.csEnemyの状態を定義する
EnemyController.cs状態を保持し、行動の優先順位を決める
HitReaction.cs攻撃中断、色変更、のけぞりを行う
CharacterAttack.cs攻撃判定と攻撃演出を中断できるようにする
AttackEffect.cs攻撃演出を外部から停止できるようにする
SlashEffect.cs剣閃演出を外部から停止できるようにする
Enemy.cs死亡状態を確認してから破棄できるようにする
EnemySpawner.cs同時出現数の初期値を調整する

状態制御で行動の優先順位を決める

現在の敵は、プレイヤーから攻撃されてもひるむことなく追跡と攻撃を続けます。ゲームがリスクとリターンで成り立っていることを考えると、例えば攻撃を受けた際にはひるんだり、後退したりの反応が欲しいところです。

今回は、敵に現在の状態を持たせ、状況に応じて行動を分岐させる実装を行います。

ステートマシンとは

ステートマシンは、対象が取り得る状態と、状態が切り替わる遷移をまとめて扱う仕組みです。特に扱う状態が有限個であるものは有限ステートマシン、またはFSMと呼ばれます。

ゲームでは、同じ対象でも状況によって実行したい処理が変わります。例えば敵はプレイヤーを追跡している間だけ移動し、攻撃中は移動を止め、被弾中は新しい攻撃を始めてはいけません。

こうした動作を「現在はどの状態か」を基準に扱うのがステートマシンです。ゲームではキャラクターの行動やUIの状態管理などに広く使われています。

ステートマシンの構成要素

ステートマシンは、主に次の4つの要素で構成されます。

要素意味今回のEnemyでの例
状態現在どの振る舞いを選んでいるかIdleMoveAttackDamageDead
状態中の処理その状態で実行または許可する処理Move中にPlayerへ接近する
遷移条件状態を切り替えるきっかけ攻撃距離へ入る、ダメージを受ける、HPが0になる
遷移現在状態を別の状態へ変更することMoveからAttackへ変更する

例えば「ダメージを受ける」は遷移条件であり、「Damageへ移る」のが遷移です。遷移の条件と遷移の結果を分けることで、状態の切り替えを柔軟に扱えるようになります。

EnemyStateを宣言するだけではステートマシンにはなりません。EnemyControllerが現在状態を保持し、遷移条件を判定し、状態に応じた行動を選ぶことで、1つのステートマシンとして動作します。

今回の実装では、Update()が毎フレーム遷移条件を確認します。死亡や被弾のような優先度の高い条件を先に判定し、現在のフレームで実行できる行動を1つに絞ります。

この回で扱う実装範囲

複雑な状態遷移を扱う場合は、状態ごとにクラスを分け、遷移処理を汎用化するなど、より高度なステートマシンが必要です。今回は状態数が少ないため、enumを使った最小構成で基本概念を確認するに留めます。

敵の現在の状態を表すEnemyState

例えば、以下のようにboolを書き連ねて状態を表すことも可能です。

cs
private bool isAttacking;
private bool isDamaged;
private bool isDead;

ですが適切に管理しなければ isAttackingisDamagedが同時にtrueになる可能性があるなど、状態の矛盾が起きやすくなります。扱う状態が増えるにつれて矛盾の管理は難しくなり、いずれ管理不可能なほど複雑になってしまいます。

多くの場合で実際に必要なのは、ごく単純な現在の状態だけです。今回はenumを使い、Enemyの現在状態を1つだけ保持するEnemyStateを実装します。

cs
// Enemyが現在行っている振る舞い
public enum EnemyState
{
    Idle,
    Move,
    Attack,
    Damage,
    Dead
}

今回のEnemyでは、それぞれの状態で行う処理と制限する処理を次のように定めます。

状態行う処理制限する処理
Idle対象の探索、距離判定なし
Move対象の追跡なし
Attack攻撃処理の終了を待つ移動、新しい攻撃
Damage被弾リアクションの終了を待つ移動、攻撃
Dead死亡演出の終了と破棄を待つ追跡、攻撃

また、以下のような遷移の条件を定めます。

現在状態条件次の状態
Idle対象が見つかり、攻撃距離の外にいるMove
Idle対象が攻撃距離内にいて、攻撃間隔を満たすAttack
Move対象を見失うIdle
Move攻撃距離内にいるが、攻撃間隔を満たしていないIdle
Move対象が攻撃距離内に入り、攻撃間隔を満たすAttack
Attack攻撃処理が終了するIdle
IdleMoveAttackダメージを受けるDamage
Damageのけぞりが終了するIdle
すべてHPが0になるDead

通常行動中はターゲットとの距離によってIdleMoveAttackを切り替えます。被弾と死亡は通常行動へ割り込む、優先度の高い状態です。

図を準備しています…

EnemyControllerで状態と遷移を扱う

状態と遷移処理をEnemyController.csへまとめて実装します。まずクラス全体を確認し、その後で状態遷移の要点を分解して確認します。

cs
using UnityEngine;

// 攻撃対象を探して近づき、攻撃距離で攻撃する
[RequireComponent(typeof(DamageReceiver))]
[RequireComponent(typeof(HitReaction))]
public class EnemyController : MonoBehaviour
{
    [Tooltip("敵の現在の状態")]
    [SerializeField] private EnemyState state = EnemyState.Idle;

    [Tooltip("敵自身の攻撃処理")]
    [SerializeField] private CharacterAttack characterAttack;

    [Tooltip("攻撃対象を探す範囲")]
    [SerializeField] private float searchRadius = 30f;

    [Tooltip("攻撃対象へ近づく速度")]
    [SerializeField] private float moveSpeed = 3f;

    [Tooltip("この距離以内に入ったら攻撃する")]
    [SerializeField] private float attackDistance = 1.5f;

    [Tooltip("攻撃を繰り返す間隔")]
    [SerializeField] private float attackInterval = 1f;

    [Tooltip("攻撃対象へ向きを変える速さ")]
    [SerializeField] private float rotateSpeed = 10f;

    private AttackTarget _target;
    private DamageReceiver _damageReceiver;
    private HitReaction _hitReaction;
    private float _lastAttackTime = -999f;

    public EnemyState State => state;

    private bool CanAct =>
        state == EnemyState.Idle ||
        state == EnemyState.Move;

    // 状態判定に必要なコンポーネントを取得する
    private void Awake()
    {
        _damageReceiver = GetComponent<DamageReceiver>();
        _hitReaction = GetComponent<HitReaction>();
    }

    // 優先度に従って状態を更新し、現在可能な行動を実行する
    private void Update()
    {
        if (_damageReceiver.IsDead)
        {
            SetState(EnemyState.Dead);
            return;
        }

        if (_hitReaction.IsReacting)
        {
            SetState(EnemyState.Damage);
            return;
        }

        if (state == EnemyState.Damage)
            SetState(EnemyState.Idle);

        if (state == EnemyState.Attack &&
            !characterAttack.IsAttacking)
            SetState(EnemyState.Idle);

        if (!CanAct)
            return;

        if (characterAttack.IsAttacking)
            return;

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

        if (_target == null)
        {
            SetState(EnemyState.Idle);
            return;
        }

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

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

        SetState(EnemyState.Move);
        MoveToTarget(toTarget.normalized);
    }

    // 攻撃間隔を満たしていれば攻撃を開始する
    private void TryAttack()
    {
        if (Time.time < _lastAttackTime + attackInterval)
        {
            SetState(EnemyState.Idle);
            return;
        }

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

    // 現在の状態を指定された状態へ変更する
    private void SetState(EnemyState nextState)
    {
        if (state == nextState)
            return;

        state = nextState;
    }

    // 指定された方向へ移動しながら向きを変える
    private void MoveToTarget(Vector3 direction)
    {
        transform.position +=
            direction * moveSpeed * Time.deltaTime;

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

    // 探索範囲内から最も近い攻撃対象を取得する
    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;
    }
}

状態遷移の優先順位

Update()の先頭では、死亡、被弾、攻撃の順に優先度の高い状態を確認します。HPが0ならDead、被弾リアクション中ならDamageを適用し、returnによって以降の追跡と攻撃を実行しません。

CanActは、自身が能動的に動けるかを表すプロパティです。IdleまたはMoveのときだけ、対象の探索、距離判定、移動、新しい攻撃を実行します。

距離に応じてMoveとAttackへ遷移する

対象が攻撃距離の外にいればMoveへ遷移して接近します。攻撃距離内ではTryAttack()を呼び、攻撃間隔を満たす場合だけAttackへ遷移します。攻撃間隔を満たしていない間はIdleとなり、その場で次の攻撃を待ちます。

状態変更はSetState()へ集約し、同じ状態を指定した場合は何も変更しません。

攻撃判定と攻撃演出を中断する

攻撃を中断させられた際はColliderを無効にします。剣や剣閃が残ると見た目では攻撃が続いているように見えてしまいますので、CancelAttack()では、攻撃判定と攻撃演出の両方を停止します。

CharacterAttack.cs

CharacterAttackCancelAttack()を追加します。

cs
public class CharacterAttack : MonoBehaviour
{
    // 省略: 既存のフィールドと攻撃処理

    // 進行中の攻撃判定と攻撃演出を中断する
    public void CancelAttack()
    {
        if (!_isAttacking)
            return;

        EndAttack();
        attackEffect?.Stop();
    }

    // 省略: 既存の当たり判定処理
}

AttackEffect.cs

AttackEffect.Stop()は実行中のCoroutineを止め、剣と剣閃を非表示にします。

cs
public class AttackEffect : MonoBehaviour
{
    // 省略: 既存のフィールドと再生処理

    // 再生中の剣と剣閃の演出を停止する
    public void Stop()
    {
        if (_playCoroutine != null)
            StopCoroutine(_playCoroutine);

        _playCoroutine = null;
        slashEffect?.Stop();
        SetVisible(false);
    }

    // 省略: 既存のCoroutineと表示処理
}

SlashEffect.cs

SlashEffectにもStop()を追加し、表示とマテリアルの値を初期状態へ戻します。

cs
public class SlashEffect : MonoBehaviour
{
    // 省略: 既存のフィールドと再生処理

    // 再生中の剣閃を停止して初期表示へ戻す
    public void Stop()
    {
        if (_playCoroutine != null)
            StopCoroutine(_playCoroutine);

        _playCoroutine = null;
        SetVisible(false);
        SetMagnitude(0f);
    }

    // 省略: 既存のCoroutineと表示処理
}

HitReactionで被弾のリアクションを行う

HitReactionDamageReceiver.Damagedを購読し、被弾時に進行中の攻撃を中断します。その後、短時間だけ色を変え、向いている方向とは反対へ押し出します。基本実習では、このコンポーネントをEnemyへ追加します。

cs
using System.Collections;
using UnityEngine;

// 被弾時の攻撃中断、色変更、のけぞりを管理する
[RequireComponent(typeof(DamageReceiver))]
public class HitReaction : MonoBehaviour
{
    [Tooltip("被弾時に中断する攻撃処理")]
    [SerializeField] private CharacterAttack characterAttack;

    [Tooltip("被弾時に色を変えるRenderer")]
    [SerializeField] private Renderer targetRenderer;

    [Tooltip("被弾中の色")]
    [SerializeField] private Color hitColor = Color.red;

    [Tooltip("被弾リアクションの時間")]
    [SerializeField] private float duration = 0.2f;

    [Tooltip("のけぞる速さ")]
    [SerializeField] private float knockbackPower = 4f;

    public bool IsReacting => _routine != null;

    private DamageReceiver _receiver;
    private Material _material;
    private Color _defaultColor;
    private Coroutine _routine;

    // 被弾処理に必要なコンポーネントと初期色を取得する
    private void Awake()
    {
        _receiver = GetComponent<DamageReceiver>();

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

    // 有効になったときにダメージイベントの購読を開始する
    private void OnEnable()
    {
        _receiver.Damaged += OnDamaged;
    }

    // イベント購読と実行中のリアクションを解除する
    private void OnDisable()
    {
        _receiver.Damaged -= OnDamaged;

        if (_routine != null)
            StopCoroutine(_routine);

        RestoreAppearance();
        _routine = null;
    }

    // 攻撃を中断し、被弾リアクションを開始する
    private void OnDamaged(int damage, int maxHealth)
    {
        if (_routine != null)
            StopCoroutine(_routine);

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

    // 一定時間、色変更とのけぞりを実行する
    private IEnumerator PlayRoutine()
    {
        if (_material != null)
            _material.color = hitColor;

        var knockbackDirection = -transform.forward;
        var elapsedTime = 0f;

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

        RestoreAppearance();
        _routine = null;
    }

    // Rendererの色を被弾前の状態へ戻す
    private void RestoreAppearance()
    {
        if (_material != null)
            _material.color = _defaultColor;
    }

    // Renderer用に生成したMaterialを破棄する
    private void OnDestroy()
    {
        if (_material != null)
            Destroy(_material);
    }
}

ダメージイベントを購読する

OnEnable()DamageReceiver.Damagedを購読し、OnDisable()で解除します。無効化された場合はCoroutineを停止し、色とIsReactingの状態も初期化します。Renderer.materialで生成したMaterialは、Enemyの破棄時にOnDestroy()で破棄します。

攻撃を中断してのけぞる

OnDamaged()は進行中の攻撃を中断し、PlayRoutine()を開始します。Coroutineの実行中はIsReactingtrueになるため、EnemyControllerDamageへ遷移します。

今回は状態制御とのけぞりの導入に集中するため、押し出す方向を-transform.forwardに単純化しています。致死ダメージの場合はDeadが優先されますが、色変更とのけぞりは死亡演出として最後まで再生します。

死亡状態を確認してからEnemyを破棄する

EnemyをHPが0になったフレームで即座に破棄すると、Dead状態を確認できません。短い待機時間を設け、その間にEnemyControllerDeadへ遷移し、死亡演出を再生できるようにします。

cs
using UnityEngine;

// 攻撃の的になる最小構成の敵
[RequireComponent(typeof(DamageReceiver))]
public class Enemy : MonoBehaviour
{
    [Tooltip("この敵の体力を表示するゲージ")]
    [SerializeField] private HealthGauge healthGauge;

    [Tooltip("死亡してから破棄するまでの時間")]
    [SerializeField] private float destroyDelay = 0.5f;

    private DamageReceiver _damageReceiver;
    private bool _destroyScheduled;

    // HPの状態を取得するためのコンポーネントを取得する
    private void Awake()
    {
        _damageReceiver = GetComponent<DamageReceiver>();
    }

    // 有効になったときにHP変更イベントの購読を開始する
    private void OnEnable()
    {
        _damageReceiver = GetComponent<DamageReceiver>();
        _damageReceiver.Damaged += OnDamaged;
        _damageReceiver.Healed += OnHealed;
    }

    // 無効になったときにHP変更イベントの購読を解除する
    private void OnDisable()
    {
        _damageReceiver.Damaged -= OnDamaged;
        _damageReceiver.Healed -= OnHealed;
    }

    // ゲージを減らし、死亡していれば破棄を予約する
    private void OnDamaged(int damage, int maxHealth)
    {
        healthGauge.TakeDamage((float)damage / maxHealth);

        if (_damageReceiver.IsDead && !_destroyScheduled)
        {
            _destroyScheduled = true;
            Destroy(gameObject, destroyDelay);
        }
    }

    // 回復量をHPゲージへ反映する
    private void OnHealed(int heal, int maxHealth)
    {
        healthGauge.Heal((float)heal / maxHealth);
    }
}

Enemy Prefabへコンポーネントを接続する

  1. Enemy PrefabへHitReactionを追加します
  2. Character AttackにEnemyのCharacterAttackを設定します
  3. Target RendererにEnemyのRendererを設定します
  4. EnemyController.characterAttackにEnemyのCharacterAttackを設定します
  5. Enemy.destroyDelayHitReaction.durationより長い0.5秒程度に設定します
  6. Prefabの変更を保存します

destroyDelaydurationより長くすることで、死亡時ものけぞりを最後まで再生してからEnemyを破棄できます。

状態遷移をPlay Modeで確認する

操作期待する状態と動作
Enemyから離れるMoveになり、Playerを追跡する
攻撃距離内へ入るAttackになり、移動を止めて攻撃する
攻撃中のEnemyへ攻撃する攻撃判定と演出が消え、Damageになる
のけぞり終了まで待つIdleへ戻り、追跡を再開する
EnemyのHPを0にするDeadになり、色変更とのけぞりのあとに破棄される

InspectorでEnemyを選択すると、EnemyController.stateの変化を確認できます。

ゲームフィールを1項目ずつ比較する

複数の値を同時に変えると、どの変更が結果へ影響したのか判断できません。基準値を決め、1回の試行では1項目だけ変更します。

調整する内容
moveSpeedEnemyがPlayerへ近づく速さ
attackDistanceEnemyが攻撃を開始する距離
attackInterval次の攻撃を開始できるまでの時間
damage1回の攻撃で減るHP
duration被弾によって行動できない時間
spawnIntervalEnemyを生成する間隔
maxAliveCount同時に存在できるEnemyの最大数
試行moveSpeedattackIntervalmaxAliveCount観察結果
基準3.01.05
A4.01.05
B3.01.55

Enemyの移動速度はPlayerより少し遅い値から始めます。同時出現数は3~5体程度から確認してください。

実習:Enemyの手応えを比較する

  1. 基準値で1分程度プレイします
  2. moveSpeedだけを変更し、追いつかれやすさを比較します
  3. 基準値へ戻し、attackIntervalだけを変更します
  4. 基準値へ戻し、maxAliveCountだけを変更します
  5. 各試行で、倒す、逃げる、被弾する流れを確認します
  6. 最も不公平に感じた条件と、その理由を記録します

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

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

  • HitReaction.PlayRoutine()の最後で_routine = nullになっているか確認します
  • HitReaction.IsReactingfalseへ戻るか確認します
  • EnemyControllerDamageからIdleへ戻しているか確認します
  • durationが大きすぎないか確認します

攻撃用Colliderや演出が残る

  • CancelAttack()EndAttack()attackEffect.Stop()を呼んでいるか確認します
  • EndAttack()EndHit()を呼んでいるか確認します
  • AttackEffect.Stop()SlashEffect.Stop()を呼んでいるか確認します
  • attackColliderと本体のColliderへ同じColliderを設定していないか確認します

Enemyが攻撃しなくなる

  • EnemyController.stateDamageのままになっていないか確認します
  • HitReaction.IsReactingfalseへ戻っているか確認します
  • EnemyController.characterAttackに参照が設定されているか確認します
  • attackIntervalが大きすぎないか確認します
  • 対象が攻撃距離内に入っているかSceneビューで確認します

完成条件

  • Enemyの状態がIdleMoveAttackDamageDeadへ変化する
  • Enemyは攻撃中、被弾中、死亡中に追跡と新しい攻撃を行わない
  • 被弾すると攻撃判定と攻撃演出が中断される
  • EnemyはDeadになったあと、死亡演出を再生してから破棄される
  • パラメーターを1項目ずつ変更し、結果の違いを説明できる

発展課題へ進む

基本実習の完成条件を満たしたら、発展 AnimatorとPlayerStateへ進みます。発展教材では、EnemyをAnimatorへ接続したあと、PlayerStateとPlayerのAnimatorを追加します。

まとめ

  • Enemy固有の状態をEnemyStateとして定義し、EnemyControllerに包含した
  • 死亡、被弾、攻撃の順に確認し、通常行動へ割り込む状態を優先した
  • 被弾時は攻撃判定と攻撃演出を中断し、短時間だけ行動を止めた
  • ゲームフィールは基準値から1項目ずつ変更して比較した

次回へ向けて

第07回では、アイテムと回復を追加し、ゲーム内で選択できる行動と目的を増やします。