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
}
使い方の手順
ここでは「プレイヤーの雷撃弾が敵に当たったら連鎖する」例で説明します。
-
敵プレハブに Damageable を付ける
- 敵プレハブ(Enemy など)を開く
Damageableコンポーネントを追加- HP(maxHealth)を好みの値に設定
- 必要であれば敵用の Layer(例: Enemy)や Tag(例: Enemy)を設定
-
シーン上の「雷スキル管理オブジェクト」に ChainLightning を付ける
- 空の GameObject を作成(名前例:
ChainLightningController) ChainLightningコンポーネントを追加- enemyLayerMask に敵のレイヤーを設定
- enemyTag を使いたい場合は、敵の Tag と合わせて設定
- 視覚効果を付けたい場合は LineRenderer プレハブを作成して lineRendererPrefab に割り当て
- 空の GameObject を作成(名前例:
-
雷撃弾スクリプトから 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); } } -
テスト:プレイヤーから雷撃弾を発射してみる
例として、スペースキーで前方に雷撃弾を撃つシンプルなテスト用コンポーネントを紹介します。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 クラスに機能を足し続けるのではなく、
今回のような小さなコンポーネントを積み重ねて、拡張しやすいプロジェクトを目指していきましょう。
