Unityを触り始めた頃、「とりあえず全部 Update に書いておけば動くし楽!」となりがちですよね。
プレイヤーの移動、入力処理、エフェクト制御、敵へのダメージ計算……全部が1つの巨大スクリプトに入り込んでしまうと、ちょっとした仕様変更でもコード全体を追いかける必要が出てきてしまいます。

特に「攻撃ロジック」は肥大化しやすい領域です。
近接攻撃、遠距離攻撃、状態異常、連鎖ダメージなどを1つのスクリプトに詰め込むと、デバッグも再利用もつらくなります。

そこで今回は、「連鎖する雷攻撃」というよくあるギミックを、ひとつの責務に絞ったコンポーネントとして切り出してみましょう。
敵にヒットした後、近くの別の敵に雷が飛び移り、さらにまた次の敵へ……という挙動を、「ChainLightning(連鎖雷)」コンポーネントとして実装していきます。

【Unity】敵から敵へバチバチ連鎖!「ChainLightning」コンポーネント

このコンポーネントは

  • 最初にヒットした敵から
  • 一定範囲内の別の敵を探して
  • 指定回数だけダメージを連鎖させる

というシンプルな責務だけを持つように設計します。
「誰が撃ったか」「どのタイミングで発動するか」などは別コンポーネントに任せることで、攻撃ロジックをきれいに分割できます。


フルコード:ChainLightning.cs


using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// ダメージを受けられる対象にアタッチする簡易インターフェース的コンポーネント。
/// 非常にシンプルな実装にしておき、ゲーム側で拡張しやすくしています。
/// </summary>
public class Damageable : MonoBehaviour
{
    [SerializeField] private float maxHealth = 100f;
    [SerializeField] private float currentHealth = 100f;

    // 死亡時に何かしたい場合のイベント用(今回は簡易ログのみに使用)
    public System.Action<Damageable> OnDead;

    private void Awake()
    {
        // シーン開始時に最大HPで初期化
        currentHealth = maxHealth;
    }

    /// <summary>
    /// ダメージを受ける共通入口。
    /// この関数を ChainLightning から呼び出します。
    /// </summary>
    public void ApplyDamage(float amount)
    {
        currentHealth -= amount;
        if (currentHealth <= 0f)
        {
            currentHealth = 0f;
            Debug.Log($"{name} は倒れた!");
            OnDead?.Invoke(this);
        }
        else
        {
            Debug.Log($"{name} は {amount} ダメージを受けた。残りHP: {currentHealth}");
        }
    }
}

/// <summary>
/// 連鎖雷の本体コンポーネント。
/// 1体目の敵にヒットしたあと、近くの別の敵を探索して順番にダメージを与えます。
/// 
/// 責務:
/// - 連鎖のロジック(対象探索、回数管理、ディレイ)
/// - 実際のダメージ適用(Damageable を呼ぶ)
/// 
/// 「いつ発動するか」は他コンポーネント(弾丸、スキルボタンなど)から
/// StartChain を呼び出すことで制御します。
/// </summary>
public class ChainLightning : MonoBehaviour
{
    [Header("連鎖設定")]
    [SerializeField, Tooltip("1回のヒットで与えるダメージ量")]
    private float damagePerHit = 20f;

    [SerializeField, Tooltip("最大連鎖数(最初の1体目も含む回数)")]
    private int maxChains = 5;

    [SerializeField, Tooltip("1回の連鎖ごとに次の敵を探す半径")]
    private float searchRadius = 8f;

    [SerializeField, Tooltip("同じ敵に複数回ヒットさせないかどうか")]
    private bool avoidDuplicateTargets = true;

    [SerializeField, Tooltip("連鎖間のディレイ(秒)。0で即時連鎖")]
    private float chainDelay = 0.1f;

    [Header("対象フィルタ")]
    [SerializeField, Tooltip("敵が所属しているレイヤーを指定すると無駄な判定を減らせます")]
    private LayerMask enemyLayerMask = ~0; // デフォルトは全レイヤー

    [SerializeField, Tooltip("敵オブジェクトに付いている Damageable のタグなどでフィルタしたい場合に使用")]
    private string enemyTag = "";

    [Header("ビジュアル(任意)")]
    [SerializeField, Tooltip("連鎖の間をつなぐ LineRenderer(任意)。指定しなければビジュアルなしで動作します")]
    private LineRenderer lineRendererPrefab;

    [SerializeField, Tooltip("LineRenderer の寿命(秒)")]
    private float lineLifeTime = 0.2f;

    // 連鎖中に使い回すワークエリア
    private readonly List<Damageable> _hitTargets = new List<Damageable>();

    /// <summary>
    /// 連鎖を開始する入口。
    /// 1体目の敵(初撃で当たった敵など)を指定して呼び出します。
    /// 
    /// 例:
    ///   var damageable = hitCollider.GetComponent<Damageable>();
    ///   chainLightning.StartChain(damageable);
    /// </summary>
    /// <param name="firstTarget">連鎖の起点となる敵(Damageable)</param>
    public void StartChain(Damageable firstTarget)
    {
        if (firstTarget == null)
        {
            Debug.LogWarning("ChainLightning.StartChain に null が渡されました。");
            return;
        }

        // すでに連鎖中の情報をリセット
        _hitTargets.Clear();

        // コルーチンで連鎖処理を開始
        StartCoroutine(ChainRoutine(firstTarget));
    }

    /// <summary>
    /// 実際の連鎖処理を行うコルーチン。
    /// </summary>
    private IEnumerator ChainRoutine(Damageable firstTarget)
    {
        Damageable currentTarget = firstTarget;
        Vector3 previousPosition = currentTarget.transform.position;

        for (int i = 0; i < maxChains; i++)
        {
            if (currentTarget == null)
            {
                // 対象が何らかの理由で消えていた場合は終了
                yield break;
            }

            // ダメージ適用
            currentTarget.ApplyDamage(damagePerHit);
            _hitTargets.Add(currentTarget);

            // 次の対象を探す
            Damageable nextTarget = FindNextTarget(currentTarget);

            // ビジュアル(LineRenderer)を出したい場合
            if (lineRendererPrefab != null)
            {
                SpawnLine(previousPosition, currentTarget.transform.position);
            }

            // 次がいなければここで終了
            if (nextTarget == null)
            {
                yield break;
            }

            // 次のループの準備
            previousPosition = currentTarget.transform.position;
            currentTarget = nextTarget;

            // ディレイが設定されていれば待つ
            if (chainDelay > 0f)
            {
                yield return new WaitForSeconds(chainDelay);
            }
        }
    }

    /// <summary>
    /// 現在の対象から近い順に次の敵を探す。
    /// 条件:
    /// - searchRadius 以内
    /// - enemyLayerMask に含まれる
    /// - enemyTag が設定されていれば、そのタグを持つ
    /// - avoidDuplicateTargets が true の場合、すでにヒットした敵は除外
    /// </summary>
    private Damageable FindNextTarget(Damageable current)
    {
        Vector3 center = current.transform.position;

        // OverlapSphere で周囲のコライダーを一括取得
        Collider[] hits = Physics.OverlapSphere(center, searchRadius, enemyLayerMask);

        Damageable bestCandidate = null;
        float bestDistanceSqr = float.MaxValue;

        foreach (var hit in hits)
        {
            // 自分自身は除外
            if (hit.attachedRigidbody != null &&
                hit.attachedRigidbody.gameObject == current.gameObject)
            {
                continue;
            }

            // タグフィルタ
            if (!string.IsNullOrEmpty(enemyTag) && !hit.CompareTag(enemyTag))
            {
                continue;
            }

            Damageable damageable = hit.GetComponentInParent<Damageable>();
            if (damageable == null)
            {
                continue;
            }

            // すでにヒット済みの敵を避けるかどうか
            if (avoidDuplicateTargets && _hitTargets.Contains(damageable))
            {
                continue;
            }

            // 距離チェック
            float distSqr = (damageable.transform.position - center).sqrMagnitude;
            if (distSqr < bestDistanceSqr)
            {
                bestDistanceSqr = distSqr;
                bestCandidate = damageable;
            }
        }

        return bestCandidate;
    }

    /// <summary>
    /// 連鎖のビジュアル用 LineRenderer を生成して、一定時間後に破棄します。
    /// </summary>
    private void SpawnLine(Vector3 from, Vector3 to)
    {
        LineRenderer line = Instantiate(lineRendererPrefab, Vector3.zero, Quaternion.identity);
        line.positionCount = 2;
        line.SetPosition(0, from);
        line.SetPosition(1, to);

        // 寿命タイマー付きの自動破棄
        Destroy(line.gameObject, lineLifeTime);
    }

    // --- デバッグ用 Gizmos 描画(エディタで連鎖半径を確認したいときに便利) ---
#if UNITY_EDITOR
    private void OnDrawGizmosSelected()
    {
        // ここでは「このオブジェクトの位置」を起点に半径を描いています。
        // 実際の探索は各ターゲットの位置から行われます。
        Gizmos.color = new Color(0.2f, 0.8f, 1f, 0.3f);
        Gizmos.DrawWireSphere(transform.position, searchRadius);
    }
#endif
}

使い方の手順

ここでは「プレイヤーの雷撃弾が敵に当たったら連鎖する」例で説明します。

  1. 敵プレハブに Damageable を付ける
    • 敵プレハブ(Enemy など)を開く
    • Damageable コンポーネントを追加
    • HP(maxHealth)を好みの値に設定
    • 必要であれば敵用の Layer(例: Enemy)や Tag(例: Enemy)を設定
  2. シーン上の「雷スキル管理オブジェクト」に ChainLightning を付ける
    • 空の GameObject を作成(名前例: ChainLightningController
    • ChainLightning コンポーネントを追加
    • enemyLayerMask に敵のレイヤーを設定
    • enemyTag を使いたい場合は、敵の Tag と合わせて設定
    • 視覚効果を付けたい場合は LineRenderer プレハブを作成して lineRendererPrefab に割り当て
  3. 雷撃弾スクリプトから StartChain を呼び出す
    例として、単純な弾丸コンポーネントを用意します。
    弾が敵に当たった瞬間に ChainLightning.StartChain を呼ぶだけです。
    
    using UnityEngine;
    
    /// <summary>
    /// 非常にシンプルな雷撃弾の例。
    /// Rigidbody + Collider (IsTrigger でも通常衝突でもOK) を付けて使用します。
    /// </summary>
    [RequireComponent(typeof(Rigidbody))]
    public class LightningProjectile : MonoBehaviour
    {
        [SerializeField] private float speed = 20f;
        [SerializeField] private float lifeTime = 5f;
    
        private Rigidbody _rb;
        private ChainLightning _chainLightning;
    
        private void Awake()
        {
            _rb = GetComponent<Rigidbody>();
            // シーン上の ChainLightning を探す(1つだけ存在する想定)
            _chainLightning = FindObjectOfType<ChainLightning>();
        }
    
        private void Start()
        {
            // 前方に発射
            _rb.velocity = transform.forward * speed;
            Destroy(gameObject, lifeTime);
        }
    
        private void OnCollisionEnter(Collision collision)
        {
            TryTriggerChain(collision.collider);
        }
    
        private void OnTriggerEnter(Collider other)
        {
            TryTriggerChain(other);
        }
    
        private void TryTriggerChain(Collider hit)
        {
            if (_chainLightning == null)
            {
                Debug.LogWarning("ChainLightning がシーンに見つかりません。");
                return;
            }
    
            Damageable damageable = hit.GetComponentInParent<Damageable>();
            if (damageable == null)
            {
                // 敵でなければ何もしない
                return;
            }
    
            // ここで連鎖開始!
            _chainLightning.StartChain(damageable);
    
            // 弾丸は役目を終えたので破棄
            Destroy(gameObject);
        }
    }
        
  4. テスト:プレイヤーから雷撃弾を発射してみる
    例として、スペースキーで前方に雷撃弾を撃つシンプルなテスト用コンポーネントを紹介します。
    
    using UnityEngine;
    
    /// <summary>
    /// 非常にシンプルな「前方に弾を撃つだけ」のテスト用コンポーネント。
    /// プレイヤーやカメラに付けて使えます。
    /// </summary>
    public class SimpleShooter : MonoBehaviour
    {
        [SerializeField] private LightningProjectile projectilePrefab;
        [SerializeField] private Transform muzzle; // 発射位置
        [SerializeField] private float fireInterval = 0.2f;
    
        private float _lastFireTime;
    
        private void Update()
        {
            // スペースキーで発射(旧InputSystemベースの簡易実装)
            if (Input.GetKey(KeyCode.Space))
            {
                if (Time.time - _lastFireTime >= fireInterval)
                {
                    Fire();
                    _lastFireTime = Time.time;
                }
            }
        }
    
        private void Fire()
        {
            if (projectilePrefab == null || muzzle == null)
            {
                Debug.LogWarning("SimpleShooter の設定が不完全です。");
                return;
            }
    
            Instantiate(projectilePrefab, muzzle.position, muzzle.rotation);
        }
    }
        

    この状態で、敵を複数体並べておき、雷撃弾を1体に当てると、近くの敵へ次々に連鎖していくはずです。

同じ考え方で、

  • ボスの特殊スキルとしてプレイヤー側に連鎖させる
  • 動く床に乗った敵同士を連鎖させる(足場ギミックとして)
  • タワーディフェンスのタワーから撃つ連鎖雷

などにも簡単に流用できます。


メリットと応用

この「ChainLightning」コンポーネントを使うメリットは、主に次のようなものがあります。

  • 攻撃ロジックを分離できる
    弾丸コンポーネントは「移動して当たる」だけ、
    ChainLightning は「当たった後の連鎖処理」だけ、と責務を分けられます。
    これにより、弾丸を別タイプ(火球、氷弾など)に差し替えても、連鎖ロジックはそのまま使い回せます。
  • プレハブの再利用性が高まる
    敵側は Damageable さえ持っていれば、どんな敵でも連鎖の対象にできます。
    新しい敵プレハブを追加しても、「Damageable を付ける + レイヤー/タグ設定」だけで連鎖雷対応完了です。
  • レベルデザインが楽になる
    連鎖半径(searchRadius)や最大連鎖数(maxChains)をインスペクターから調整するだけで、
    「敵を密集させると気持ちよく連鎖する」「バラけさせると発動しにくい」といったレベルデザインがやりやすくなります。
  • 視覚効果の差し替えが簡単
    LineRenderer を差し替えたり、SpawnLine 内でパーティクルを出したりするだけで、
    見た目をガラッと変えられます。ロジックとビジュアルが分離されているので、アーティストとの分業もしやすいです。

さらに、応用として「連鎖ごとにダメージを減衰させる」改造をしてみましょう。
例えば、最初は100ダメージ、次は80、次は64…のようにだんだん弱くなっていくイメージです。

以下は ChainLightning 内に追加・変更できる簡単な改造案です。


    // 追加フィールド(減衰率)
    [SerializeField, Tooltip("連鎖ごとにダメージを何倍にするか。1未満で徐々に減衰")]
    private float damageFalloff = 0.8f;

    // ChainRoutine を少し改造
    private IEnumerator ChainRoutine(Damageable firstTarget)
    {
        Damageable currentTarget = firstTarget;
        Vector3 previousPosition = currentTarget.transform.position;

        float currentDamage = damagePerHit;

        for (int i = 0; i < maxChains; i++)
        {
            if (currentTarget == null)
            {
                yield break;
            }

            // 減衰したダメージを適用
            currentTarget.ApplyDamage(currentDamage);
            _hitTargets.Add(currentTarget);

            Damageable nextTarget = FindNextTarget(currentTarget);

            if (lineRendererPrefab != null)
            {
                SpawnLine(previousPosition, currentTarget.transform.position);
            }

            if (nextTarget == null)
            {
                yield break;
            }

            previousPosition = currentTarget.transform.position;
            currentTarget = nextTarget;

            // 次の連鎖に向けてダメージを減衰
            currentDamage *= damageFalloff;

            if (chainDelay > 0f)
            {
                yield return new WaitForSeconds(chainDelay);
            }
        }
    }

このように、コンポーネントを小さな責務に分けておけば、
「ダメージ減衰を入れたい」「スタン効果も乗せたい」「特定の属性の敵だけ連鎖させたい」といった要望にも、
連鎖ロジック部分だけをピンポイントで改造して対応できます。

巨大な God クラスに機能を足し続けるのではなく、
今回のような小さなコンポーネントを積み重ねて、拡張しやすいプロジェクトを目指していきましょう。