Unityを触り始めた頃、「敵のAIも当たり判定もアニメーションも、とりあえず全部Updateに書いちゃえ!」となりがちですよね。最初は動きますが、時間が経つと
- 1つのスクリプトが数百行〜千行になり、どこを直せばいいか分からない
- 似たような敵をもう1体作りたいだけなのに、巨大なクラスをコピペしてバグが量産される
- 「待ち伏せする敵」「巡回する敵」「追いかける敵」が全部if文だらけで混在する
といった地獄にハマりやすいです。
そこで今回は、「待ち伏せしていて、プレイヤーが近づくと正体を現して攻撃する」という振る舞いだけを切り出したコンポーネント Ambusher を作ってみましょう。
「見た目の切り替え」「プレイヤーとの距離判定」「攻撃タイミング」を1つの責務としてまとめた、小さめのコンポーネントにしておくことで、他の敵AIと気持ちよく組み合わせやすくなります。
【Unity】近づくと正体を現す待ち伏せAI!「Ambusher」コンポーネント
フルコード
using UnityEngine;
/// <summary>
/// プレイヤーが一定距離まで近づくと、擬態状態から正体を現し、
/// 一定間隔で攻撃アニメーション(またはイベント)を発生させるコンポーネント。
///
/// ・見た目の切り替え(擬態 <-> 本体)
/// ・プレイヤーとの距離判定
/// ・攻撃タイミング管理
///
/// だけに責務を絞った「待ち伏せ」専用コンポーネントです。
/// </summary>
[DisallowMultipleComponent]
public class Ambusher : MonoBehaviour
{
// --- 参照系 ---
[Header("ターゲット設定")]
[Tooltip("待ち伏せを解除する対象(通常はプレイヤー)。未指定ならTag \"Player\" を自動検索します。")]
[SerializeField] private Transform target;
[Header("見た目の切り替え")]
[Tooltip("擬態状態のときに表示するオブジェクト(岩モデルなど)。")]
[SerializeField] private GameObject disguisedVisual;
[Tooltip("正体を現したときに表示するオブジェクト(本体モデルなど)。")]
[SerializeField] private GameObject revealedVisual;
[Header("待ち伏せパラメータ")]
[Tooltip("この距離以内にターゲットが入ると、擬態を解除して正体を現します。")]
[SerializeField] private float revealDistance = 5f;
[Tooltip("正体を現してから、最初の攻撃を行うまでの待ち時間。")]
[SerializeField] private float firstAttackDelay = 0.5f;
[Tooltip("2回目以降の攻撃間隔(秒)。")]
[SerializeField] private float attackInterval = 2f;
[Header("攻撃パラメータ")]
[Tooltip("攻撃が届く最大距離。ターゲットがこれより遠い場合は攻撃しません。")]
[SerializeField] private float attackRange = 2f;
[Tooltip("攻撃が命中したときに与えるダメージ。実際のダメージ適用はOnAttackメソッド内で行います。")]
[SerializeField] private int attackDamage = 10;
[Header("デバッグ")]
[Tooltip("シーンビューに感知距離と攻撃距離のGizmosを表示します。")]
[SerializeField] private bool drawGizmos = true;
// --- 内部状態 ---
// 現在、擬態状態かどうか
private bool isDisguised = true;
// 待ち伏せが解除されている(=一度でも正体を現した)かどうか
private bool hasRevealed = false;
// 次に攻撃できる時刻(Time.time と比較)
private float nextAttackTime = Mathf.Infinity;
private void Awake()
{
// targetが未設定なら、Tag "Player" を自動で探す簡易フォールバック
if (target == null)
{
GameObject playerObj = GameObject.FindGameObjectWithTag("Player");
if (playerObj != null)
{
target = playerObj.transform;
}
}
// 初期状態では擬態を有効化しておく
SetDisguisedState(true);
}
private void Update()
{
// ターゲットが存在しない場合は何もしない
if (target == null) return;
// すでに正体を現しているかどうかで処理を分岐
if (!hasRevealed)
{
CheckRevealCondition();
}
else
{
HandleAttack();
}
}
/// <summary>
/// ターゲットとの距離をチェックし、一定距離以内なら擬態を解除します。
/// </summary>
private void CheckRevealCondition()
{
float distance = Vector3.Distance(transform.position, target.position);
// ターゲットがrevealDistance以内に入ったら正体を現す
if (distance <= revealDistance)
{
Reveal();
}
}
/// <summary>
/// 擬態状態から正体を現す。
/// 見た目の切り替えと、攻撃タイマーの初期化を行います。
/// </summary>
private void Reveal()
{
if (hasRevealed) return; // 二重呼び出し防止
hasRevealed = true;
SetDisguisedState(false);
// 最初の攻撃タイミングをセット
nextAttackTime = Time.time + firstAttackDelay;
// ここでアニメーションやSEを鳴らすのもOK
// 例: Animatorを使う場合
// animator.SetTrigger("Reveal");
}
/// <summary>
/// 擬態状態のON/OFFを切り替えるヘルパー。
/// 擬態用モデルと本体モデルの有効/無効をここで一括管理します。
/// </summary>
/// <param name="disguised">trueなら擬態状態、falseなら正体を現した状態</param>
private void SetDisguisedState(bool disguised)
{
isDisguised = disguised;
if (disguisedVisual != null)
{
disguisedVisual.SetActive(disguised);
}
if (revealedVisual != null)
{
revealedVisual.SetActive(!disguised);
}
}
/// <summary>
/// 正体を現した後の攻撃ロジック。
/// 一定間隔でターゲットとの距離をチェックし、攻撃可能なら攻撃処理を呼び出します。
/// </summary>
private void HandleAttack()
{
// まだ攻撃可能な時間になっていなければ何もしない
if (Time.time < nextAttackTime) return;
// ターゲットとの距離チェック
float distance = Vector3.Distance(transform.position, target.position);
if (distance <= attackRange)
{
// 攻撃実行
OnAttack();
}
// 次の攻撃タイミングを更新
nextAttackTime = Time.time + attackInterval;
}
/// <summary>
/// 実際の攻撃処理。
/// ここでは「ターゲット側にIDamageableインターフェースがあればダメージを与える」
/// というシンプルな処理にしてあります。
///
/// プロジェクトに合わせて、アニメーション再生や弾の生成などを追加してください。
/// </summary>
private void OnAttack()
{
// ここで攻撃アニメーションを再生してもOK
// 例: animator.SetTrigger("Attack");
// シンプルなダメージ適用例
// プレイヤー側に「IDamageable」インターフェースを実装したコンポーネントがあればダメージを与える
IDamageable damageable = target.GetComponent<IDamageable>();
if (damageable != null)
{
damageable.TakeDamage(attackDamage);
}
// もしIDamageableがなければ、ここでDebugログを出しておくとデバッグしやすいです
// Debug.Log($"{name} attacked {target.name} for {attackDamage} damage.");
}
/// <summary>
/// エディタ上で感知距離・攻撃距離を可視化するためのGizmos描画。
/// レベルデザイン時に非常に便利です。
/// </summary>
private void OnDrawGizmosSelected()
{
if (!drawGizmos) return;
// 感知距離(revealDistance)を黄色で表示
Gizmos.color = Color.yellow;
Gizmos.DrawWireSphere(transform.position, revealDistance);
// 攻撃距離(attackRange)を赤で表示
Gizmos.color = Color.red;
Gizmos.DrawWireSphere(transform.position, attackRange);
}
}
/// <summary>
/// ダメージを受け取れる対象が実装すべきインターフェース。
/// Ambusherはこのインターフェースだけを知っていればよいので、
/// プレイヤーや敵の実装詳細から疎結合にできます。
/// </summary>
public interface IDamageable
{
void TakeDamage(int damage);
}
/// <summary>
/// 簡易的なダメージ受け取りサンプル。
/// プレイヤーや敵にアタッチして動作確認に使えます。
/// 実プロジェクトでは、既存のHP管理コンポーネントにIDamageableを実装する形がオススメです。
/// </summary>
public class SimpleHealth : MonoBehaviour, IDamageable
{
[Tooltip("最大HP。0以下になるとDestroyします。")]
[SerializeField] private int maxHealth = 50;
[Tooltip("現在のHP(デバッグ用にインスペクター表示)。")]
[SerializeField] private int currentHealth;
private void Awake()
{
currentHealth = maxHealth;
}
public void TakeDamage(int damage)
{
currentHealth -= damage;
Debug.Log($"{name} took {damage} damage. HP: {currentHealth}");
if (currentHealth <= 0)
{
Die();
}
}
private void Die()
{
Debug.Log($"{name} died.");
Destroy(gameObject);
}
}
使い方の手順
-
待ち伏せ用プレハブを作る
- 空のGameObjectを作成し、名前を
Ambusher_StoneEnemyなどにします。 - その子オブジェクトとして
- 岩に擬態している見た目用オブジェクト(例:
StoneVisual) - 正体を現したときの本体モデル(例:
MonsterVisual)
を用意します。
- 岩に擬態している見た目用オブジェクト(例:
- 岩モデルとモンスターのモデルには、それぞれMeshRendererやSkinnedMeshRendererなど通常の見た目用コンポーネントを付けておきます。
- 空のGameObjectを作成し、名前を
-
Ambusherコンポーネントをアタッチする
- 親オブジェクト(
Ambusher_StoneEnemy)にAmbusherコンポーネントを追加します。 - インスペクターで
- Disguised Visual に岩モデルのGameObject(
StoneVisual) - Revealed Visual にモンスターのGameObject(
MonsterVisual)
をドラッグ&ドロップで設定します。
- Disguised Visual に岩モデルのGameObject(
- Target は空でOKです。Tagが
Playerのオブジェクトを自動で探してくれます。
もしプレイヤーオブジェクトのTagが違う場合は、プレイヤーのTransformを直接セットしましょう。
- 親オブジェクト(
-
プレイヤー側にダメージ受け取り処理を付ける
- プレイヤーのGameObjectに、サンプルとして
SimpleHealthコンポーネントをアタッチします。 - これでプレイヤーは
IDamageableを実装した扱いになり、Ambusherの攻撃が当たるとHPが減ります。 - すでに独自のHP管理スクリプトがある場合は、そのスクリプトに
IDamageableを実装し、TakeDamage内でHPを減らす処理を書きましょう。
- プレイヤーのGameObjectに、サンプルとして
-
パラメータを調整してテストする
例えばプレイヤーキャラがいるシーンで:- Ambusherプレハブをプレイヤーの通り道に配置します(岩っぽい場所に置くと雰囲気が出ます)。
- Reveal Distance を 5〜8 くらいに設定すると、「近づいたらガバッと動き出す」感が出ます。
- Attack Range は 1.5〜2.5 くらいにして、近接攻撃っぽくします。
- First Attack Delay を 0.5〜1.0 にすると、「飛び出してから一瞬ためて攻撃する」演出にできます。
- シーンビューでAmbusherを選択すると、感知距離と攻撃距離がGizmosとして表示されるので、レベルデザイン時にとても便利です。
例としては、こんな使い方がしやすいです。
- プレイヤーの進行方向の岩場に配置して、「この岩、なんか怪しいな…」と思ったら案の定モンスターだった、という演出。
- ダンジョンの曲がり角の手前に置いておき、角を曲がった瞬間に飛び出してくる待ち伏せ敵。
- 動く床やエレベーターの上に配置し、プレイヤーが乗って一定距離に近づいたら動き出すトラップ的な使い方。
メリットと応用
Ambusherコンポーネントを使うことで、
- 「待ち伏せする」という振る舞いだけを1つのコンポーネントに閉じ込められるので、
- 移動ロジック(パトロールや追尾)は別コンポーネント
- 攻撃の種類(近接・飛び道具・範囲攻撃)はさらに別コンポーネント
といった分割がしやすくなります。
- プレハブとして一度作っておけば、シーン上にポンポン置くだけで「待ち伏せポイント」が量産できます。
- Gizmosで感知距離・攻撃距離が見えるので、レベルデザイナーが「ここに敵を置いたらどこで反応するか」を視覚的に調整しやすいです。
- ダメージ処理は
IDamageableインターフェースに依存しているだけなので、プレイヤー実装を差し替えてもAmbusher側は一切変更不要です。
このように、待ち伏せという1つの責務に絞ったコンポーネントにしておくと、プレハブの組み合わせでゲームのバリエーションを増やしやすくなります。
改造案:一度だけ攻撃して消える「使い捨てトラップ」にする
例えば「1回だけ飛び出して攻撃したら消える岩トラップ」にしたい場合は、OnAttackの最後にこんな処理を追加できます。
/// <summary>
/// 1回攻撃したら自壊するバリエーション例。
/// 通常のOnAttackの末尾に呼び出すだけでもOKです。
/// </summary>
private void SelfDestructAfterAttack()
{
// ここでエフェクトや破壊アニメーションを再生してもよい
// 例: animator.SetTrigger("Die");
// 一定時間後に自分自身を削除(0なら即時)
Destroy(gameObject, 0.5f);
}
実際には、OnAttack() の末尾で SelfDestructAfterAttack(); を呼ぶだけで、「一撃必殺の待ち伏せ岩」になります。
このように、小さな関数単位で改造ポイントを追加していくと、Godクラス化を防ぎつつ、多彩な敵バリエーションを作りやすくなりますね。
