Appearance
第05回 敵AIとゲームフィール調整
前回: 第04回 攻撃処理と生成
前回の振り返り
攻撃できる敵を生成しました
PlayerAttackをCharacterAttackに共通化し、 PlayerとEnemyの両方が同じ攻撃処理を使えるようにしましたEnemyControllerでAttackTargetを探して近づき、 攻撃距離に入ったらCharacterAttack.Attack()を呼びましたEnemySpawnerはEnemy Prefabの生成だけを担当し、 攻撃対象の判断はEnemy自身に任せました- 今回は、敵の動きと攻撃の手応えを整え、 被弾時ののけぞりと攻撃中断を入れます
今回のテーマ
状態で行動を制御する
攻撃中、被弾中、死亡中に できることを分ける
今回作るもの
ミニステートマシンで手応えを作る
- キャラクターの状態を
Idle、Move、Attack、Damage、Deadに分けます - 攻撃中は移動しないようにします
- 被弾中は移動と攻撃を止めます
- 被弾したら攻撃を中断し、短くのけぞります
- 死亡中は移動、攻撃、被弾リアクションを止めます
- 外部アニメーション素材は使わず、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でも動かせます - しかし数が増えると、どれが優先なのか分かりにくくなります
isAttackingとisDamagedが同時にtrueになった時に、 攻撃を続けるのか、中断するのかが曖昧になります- 今回は、現在状態を1つだけ持ち、 状態ごとにできることを分けます
CharacterStateController
状態と行動可否を管理する
- 状態を持つための小さなComponentを作ります
CanMoveとCanAttackで、今その行動ができるかを外から確認します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のイベントを使う
OnEnableでDamagedを購読します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を見て攻撃が終わったかを判断します EnemyControllerがAttack状態かつIsAttacking == falseになったら、 状態をIdleに戻します- その後、次の距離判定で、まだ攻撃距離なら次の攻撃を待ち、 遠ければ
Moveに戻ります - より厳密に作る場合は、
CharacterAttackに攻撃終了イベントを追加する方法があります
調整する値
まずは少数のパラメーターに絞る
| 値 | 影響 |
|---|---|
moveSpeed | 敵が攻撃対象へ近づく速さ |
attackDistance | 敵が攻撃を始める距離 |
attackInterval | 敵が連続攻撃する間隔 |
damage | 1回の攻撃で減るHP |
duration | のけぞりで行動できない時間 |
spawnInterval | 敵が増える頻度 |
maxAliveCount | 同時に出る敵の最大数 |
全部を同時に変えると原因が分かりにくくなるため、 1つずつ調整して変化を確認します。
調整の目安
理不尽さを減らす
- Enemyの移動速度は、Playerより少し遅いくらいから始めます
- 攻撃距離は、攻撃Colliderの大きさより少し広めにします
- 攻撃間隔は、Playerが離脱できる時間を残します
- のけぞり時間は、長くしすぎると一方的に動けなくなります
- 同時に出る敵の数は、まず3から5体程度で確認します
- 調整は正解を探す作業ではなく、 プレイして不公平に感じる箇所を減らす作業です
実習1
CharacterAttackを中断できるようにする
- 04回で追加した
IsAttackingが使えることを確認します CharacterAttackにCancelAttack()を追加します- 被弾時に攻撃判定Colliderが残らないか確認します
- PlayerとEnemyの両方で、攻撃が今まで通り動くか確認します
実習2
CharacterStateControllerを追加する
CharacterStateenumを作りますCharacterStateControllerを作ります- PlayerとEnemyに
CharacterStateControllerを追加します - Inspectorで現在状態が変わることを確認します
- 状態の目的は、行動できるかどうかを読みやすくすることです
実習3
HitReactionでのけぞりを入れる
- PlayerとEnemyに
HitReactionを追加します DamageReceiverのDamagedイベントで反応するようにします- 被弾時に攻撃が中断されるか確認します
- 被弾時に色が変わり、短く押し出されるか確認します
- のけぞり中に追跡や攻撃が止まるか確認します
実習4
敵AIと数値を調整する
- Enemyの
moveSpeedを調整します attackDistanceと攻撃Colliderの大きさを合わせますattackIntervalを調整し、連続攻撃が理不尽でないか確認します- Spawnerの
spawnIntervalとmaxAliveCountを調整します - 調整後、1分程度プレイして、倒す、逃げる、被弾する流れを確認します
よくあるエラー
のけぞり後に動かなくなる
HitReactionの最後でstate.SetIdle()が呼ばれているか確認しますstate.IsDeadの時にIdleへ戻していないか確認します- Coroutineが途中で止まったままになっていないか確認します
durationが長すぎると、止まっているように見える場合があります
よくあるエラー
攻撃Colliderが残り続ける
CancelAttack()の中でEndAttack()を呼んでいるか確認しますEndAttack()がEndHit()を呼び、Colliderを無効にしているか確認しますattackColliderの参照がInspectorで設定されているか確認します- 攻撃用Colliderと本体Colliderを同じにしていないか確認します
よくあるエラー
敵が攻撃しなくなる
CharacterStateControllerの状態がDamageのまま戻っていない可能性がありますstate.CanAttackがtrueになる状態を確認しますcharacterAttackの参照がEnemyControllerに入っているか確認しますattackIntervalが大きすぎないか確認します- 攻撃距離に入っているかをSceneビューで確認します
今回のポイント
小さな状態管理で手応えを作る
enumで現在状態を1つだけ持ちました- 攻撃中は移動しないようにしました
- 被弾中は攻撃を中断し、短時間行動不能にしました
- 外部アニメーション素材を使わず、色変更とのけぞりで反応を出しました
- 敵の速さ、距離、攻撃間隔、生成数を調整し、 遊びやすさを整えました
今日の完成状態
攻撃と被弾の手応えが出る
- PlayerとEnemyは
CharacterAttackを共有しています - Enemyは
AttackTargetへ近づき、攻撃距離で攻撃します - 攻撃中は移動しません
- 被弾すると攻撃が中断され、短くのけぞります
- のけぞり中は移動も攻撃もできません
- 数値調整により、敵の手応えをInspectorから調整できます
次回へ向けて
アイテムと回復で行動の目的を増やす
- 今回までで、追いかける敵、攻撃、被弾、のけぞりが入りました
- 次回は、回復アイテムや取得物を追加します
- Playerが敵を倒すだけでなく、 危険を避けながらアイテムを取りに行く理由を作ります
- 接触判定、生成、回復処理は、 これまで作った
DamageReceiverとHealthの延長で扱えます