Appearance
第06回 Enemyの状態制御とゲームフィール
前回: 第05回 Enemyの探索・追跡・攻撃 / 次回: 第07回 アイテムとオブジェクトプール
今回の授業内容

enumを使ってEnemyの現在状態を1つだけ表す- 状態に応じて、追跡、攻撃、被弾、死亡の優先順位を制御する
- 被弾時に攻撃判定と演出を中断するリアクションをEnemyへ接続する
- 状態遷移と見た目が一致していることをPlay Modeで確認する
- 敵のパラメーターを1項目ずつ比較し、ゲームフィールを調整する
今回の配布コード
第05回終了時点のコードへ、Enemyの状態管理と被弾リアクションを追加した第06回基本版です。全18ファイルを第06回配布コードとしてまとめています。主な変更ファイルは次の通りです。
配布ファイル一覧
| ファイル | 今回の変更 |
|---|---|
| EnemyState.cs | Enemyの状態を定義する |
| EnemyController.cs | 状態を保持し、行動の優先順位を決める |
| HitReaction.cs | 攻撃中断、色変更、のけぞりを行う |
| CharacterAttack.cs | 攻撃判定と攻撃演出を中断できるようにする |
| AttackEffect.cs | 攻撃演出を外部から停止できるようにする |
| SlashEffect.cs | 剣閃演出を外部から停止できるようにする |
| Enemy.cs | 死亡状態を確認してから破棄できるようにする |
| EnemySpawner.cs | 同時出現数の初期値を調整する |
状態制御で行動の優先順位を決める
現在の敵は、プレイヤーから攻撃されてもひるむことなく追跡と攻撃を続けます。ゲームがリスクとリターンで成り立っていることを考えると、例えば攻撃を受けた際にはひるんだり、後退したりの反応が欲しいところです。
今回は、敵に現在の状態を持たせ、状況に応じて行動を分岐させる実装を行います。
ステートマシンとは
ステートマシンは、対象が取り得る状態と、状態が切り替わる遷移をまとめて扱う仕組みです。特に扱う状態が有限個であるものは有限ステートマシン、またはFSMと呼ばれます。
ゲームでは、同じ対象でも状況によって実行したい処理が変わります。例えば敵はプレイヤーを追跡している間だけ移動し、攻撃中は移動を止め、被弾中は新しい攻撃を始めてはいけません。
こうした動作を「現在はどの状態か」を基準に扱うのがステートマシンです。ゲームではキャラクターの行動やUIの状態管理などに広く使われています。
ステートマシンの構成要素
ステートマシンは、主に次の4つの要素で構成されます。
| 要素 | 意味 | 今回のEnemyでの例 |
|---|---|---|
| 状態 | 現在どの振る舞いを選んでいるか | Idle、Move、Attack、Damage、Dead |
| 状態中の処理 | その状態で実行または許可する処理 | 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;ですが適切に管理しなければ isAttackingとisDamagedが同時に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 |
Idle、Move、Attack | ダメージを受ける | Damage |
Damage | のけぞりが終了する | Idle |
| すべて | HPが0になる | Dead |
通常行動中はターゲットとの距離によってIdle、Move、Attackを切り替えます。被弾と死亡は通常行動へ割り込む、優先度の高い状態です。
図を準備しています…
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
CharacterAttackへCancelAttack()を追加します。
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で被弾のリアクションを行う
HitReactionはDamageReceiver.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の実行中はIsReactingがtrueになるため、EnemyControllerはDamageへ遷移します。
今回は状態制御とのけぞりの導入に集中するため、押し出す方向を-transform.forwardに単純化しています。致死ダメージの場合はDeadが優先されますが、色変更とのけぞりは死亡演出として最後まで再生します。
死亡状態を確認してからEnemyを破棄する
EnemyをHPが0になったフレームで即座に破棄すると、Dead状態を確認できません。短い待機時間を設け、その間にEnemyControllerがDeadへ遷移し、死亡演出を再生できるようにします。
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へコンポーネントを接続する
- Enemy Prefabへ
HitReactionを追加します Character AttackにEnemyのCharacterAttackを設定しますTarget RendererにEnemyのRendererを設定しますEnemyController.characterAttackにEnemyのCharacterAttackを設定しますEnemy.destroyDelayをHitReaction.durationより長い0.5秒程度に設定します- Prefabの変更を保存します
destroyDelayをdurationより長くすることで、死亡時ものけぞりを最後まで再生してからEnemyを破棄できます。
状態遷移をPlay Modeで確認する
| 操作 | 期待する状態と動作 |
|---|---|
| Enemyから離れる | Moveになり、Playerを追跡する |
| 攻撃距離内へ入る | Attackになり、移動を止めて攻撃する |
| 攻撃中のEnemyへ攻撃する | 攻撃判定と演出が消え、Damageになる |
| のけぞり終了まで待つ | Idleへ戻り、追跡を再開する |
| EnemyのHPを0にする | Deadになり、色変更とのけぞりのあとに破棄される |
InspectorでEnemyを選択すると、EnemyController.stateの変化を確認できます。
ゲームフィールを1項目ずつ比較する
複数の値を同時に変えると、どの変更が結果へ影響したのか判断できません。基準値を決め、1回の試行では1項目だけ変更します。
| 値 | 調整する内容 |
|---|---|
moveSpeed | EnemyがPlayerへ近づく速さ |
attackDistance | Enemyが攻撃を開始する距離 |
attackInterval | 次の攻撃を開始できるまでの時間 |
damage | 1回の攻撃で減るHP |
duration | 被弾によって行動できない時間 |
spawnInterval | Enemyを生成する間隔 |
maxAliveCount | 同時に存在できるEnemyの最大数 |
| 試行 | moveSpeed | attackInterval | maxAliveCount | 観察結果 |
|---|---|---|---|---|
| 基準 | 3.0 | 1.0 | 5 | |
| A | 4.0 | 1.0 | 5 | |
| B | 3.0 | 1.5 | 5 |
Enemyの移動速度はPlayerより少し遅い値から始めます。同時出現数は3~5体程度から確認してください。
実習:Enemyの手応えを比較する
- 基準値で1分程度プレイします
moveSpeedだけを変更し、追いつかれやすさを比較します- 基準値へ戻し、
attackIntervalだけを変更します - 基準値へ戻し、
maxAliveCountだけを変更します - 各試行で、倒す、逃げる、被弾する流れを確認します
- 最も不公平に感じた条件と、その理由を記録します
動作しない場合のチェックリスト
のけぞり後に動かなくなる
HitReaction.PlayRoutine()の最後で_routine = nullになっているか確認しますHitReaction.IsReactingがfalseへ戻るか確認しますEnemyControllerでDamageからIdleへ戻しているか確認しますdurationが大きすぎないか確認します
攻撃用Colliderや演出が残る
CancelAttack()がEndAttack()とattackEffect.Stop()を呼んでいるか確認しますEndAttack()がEndHit()を呼んでいるか確認しますAttackEffect.Stop()がSlashEffect.Stop()を呼んでいるか確認しますattackColliderと本体のColliderへ同じColliderを設定していないか確認します
Enemyが攻撃しなくなる
EnemyController.stateがDamageのままになっていないか確認しますHitReaction.IsReactingがfalseへ戻っているか確認しますEnemyController.characterAttackに参照が設定されているか確認しますattackIntervalが大きすぎないか確認します- 対象が攻撃距離内に入っているかSceneビューで確認します
完成条件
- Enemyの状態が
Idle、Move、Attack、Damage、Deadへ変化する - Enemyは攻撃中、被弾中、死亡中に追跡と新しい攻撃を行わない
- 被弾すると攻撃判定と攻撃演出が中断される
- Enemyは
Deadになったあと、死亡演出を再生してから破棄される - パラメーターを1項目ずつ変更し、結果の違いを説明できる
発展課題へ進む
基本実習の完成条件を満たしたら、発展 AnimatorとPlayerStateへ進みます。発展教材では、EnemyをAnimatorへ接続したあと、PlayerStateとPlayerのAnimatorを追加します。
まとめ
- Enemy固有の状態を
EnemyStateとして定義し、EnemyControllerに包含した - 死亡、被弾、攻撃の順に確認し、通常行動へ割り込む状態を優先した
- 被弾時は攻撃判定と攻撃演出を中断し、短時間だけ行動を止めた
- ゲームフィールは基準値から1項目ずつ変更して比較した
次回へ向けて
第07回では、アイテムと回復を追加し、ゲーム内で選択できる行動と目的を増やします。