【Cocos Creator 3.8】ChainLightning(連鎖雷)の実装:敵にヒット後、自動で近くの敵へ次々とダメージを連鎖させる汎用スクリプト

このガイドでは、1つの敵にヒットしたあと、周囲の敵へ一定回数まで自動で雷が飛び移る「連鎖雷」コンポーネントを実装します。
攻撃オブジェクト(弾・スキルエフェクト・近接攻撃の当たり判定など)にアタッチするだけで、「最初に当たった敵から、射程内の敵へ順番にダメージが連鎖する」挙動を実現できます。

敵管理用のGameManagerなどに依存せず、「敵」ノードに enemyTag で指定したタグ名を付けるだけで動くように設計します。


コンポーネントの設計方針

1. 機能要件の整理

  • 攻撃側ノード(弾・スキル・近接攻撃など)にアタッチして使用する。
  • 最初の敵ヒットは、以下の2パターンをサポートする:
    • 自ノードの位置から一番近い敵を自動で検索して最初のターゲットにする
    • 外部から最初のターゲットを指定する(メソッド呼び出し)
  • 最初のターゲットにダメージを与えた後、その敵の周囲から次の敵を探し、雷を連鎖させる
  • 連鎖は最大ヒット数(または最大連鎖回数)までで終了する。
  • 同じ敵には1回だけヒットする(既にヒット済みの敵はスキップ)。
  • 敵の検索は ワールド座標空間 で行い、enemyTagmaxChainDistance を用いてフィルタリングする。
  • 各ヒット間に わずかなディレイ を入れて、連鎖している感じを演出できるようにする。
  • ダメージは カスタムイベントログ で通知し、敵側の実装に依存しない。
  • (オプション)雷のエフェクト表示用に、Line-like 表現のための Lineノード(Sprite など) を指定できるようにし、あればそれを伸縮・回転して表示する。

敵側のスクリプトや GameManager などの外部依存を一切なくすため、「敵の判定」=タグと距離による検索に限定し、ダメージ処理はイベント通知に留めます。
敵ノードが 'TakeDamage' などのイベントをリッスンしていればダメージを受けられますし、そうでなくてもコンソールログで動作を確認できます。

2. インスペクタで設定可能なプロパティ設計

以下のプロパティを用意します。

  • enemyTag: string
    • 敵ノードに付与するタグ名。
    • このタグと一致するノードのみを「敵」とみなして連鎖対象にする。
    • 例: "Enemy"
  • damage: number
    • 1ヒットあたりのダメージ量。
    • イベントパラメータとして敵側に渡される。
    • 例: 10
  • maxHits: number
    • 最大ヒット数(最初のターゲットを含む)。
    • 例: 5 にすると、最大 5 体の敵にヒットした時点で連鎖終了。
  • maxChainDistance: number
    • 1回の連鎖で次の敵を探す最大距離(ワールド座標上の距離)。
    • この距離より遠い敵は次のターゲット候補にならない。
    • 例: 400
  • searchRadius: number
    • 最初のターゲットを「自動検索」する際の探索半径。
    • 自ノードの位置からこの半径内で最も近い敵を最初のターゲットにする。
    • 例: 600
  • autoStart: boolean
    • true: start() 時に自動で最初のターゲットを検索して連鎖を開始する。
    • false: 外部から beginChainFrom(node) を呼び出したときだけ動作する。
  • hitInterval: number
    • 各ヒット間のディレイ時間(秒)。
    • 0 にすると即座に次の敵へ連鎖する。
    • 例: 0.08
  • destroyOnComplete: boolean
    • 連鎖処理が完了したときに、この攻撃ノードを自動で destroy() するかどうか。
    • 弾や一時的なエフェクトの場合は true 推奨。
  • linePrefab: Prefab | null
    • 雷のビームを表現するためのプレハブ(任意)。
    • 例: 細長い Sprite を用意し、これを回転・スケールして「線」に見せる。
    • 指定されていれば、各ヒット間にこのプレハブをインスタンス化して表示する。
  • lineDuration: number
    • 1本の雷ビームを表示しておく時間(秒)。
    • linePrefab を使う場合のみ有効。
  • debugLog: boolean
    • true の場合、連鎖の進行状況やエラーを console.log で詳細に出力する。

また、防御的な実装として、以下の点に注意します。

  • enemyTag が空文字の場合はエラーログを出し、連鎖を開始しない。
  • 敵が1体も見つからなかった場合は、その旨をログに出して終了する。
  • linePrefab が指定されている場合、インスタンス生成時にエラーがあればログ出力する。

TypeScriptコードの実装


import { _decorator, Component, Node, Vec3, math, Prefab, instantiate, tween, Tween, director } from 'cc';
const { ccclass, property } = _decorator;

/**
 * ChainLightning
 * - 指定した最初のターゲット、または自ノード周辺から見つけた敵を起点に
 *   近くの敵へ順番に雷を連鎖させるコンポーネント。
 * - 敵は Node.tag === enemyTag で判定する。
 * - ダメージは敵ノードに対して "TakeDamage" カスタムイベントを emit する。
 */
@ccclass('ChainLightning')
export class ChainLightning extends Component {

    @property({
        tooltip: '敵ノードに設定されているタグ名。このタグと一致するノードのみを連鎖対象とします。',
    })
    public enemyTag: string = 'Enemy';

    @property({
        tooltip: '1ヒットあたりのダメージ量。敵ノードには "TakeDamage" イベントで通知されます。',
    })
    public damage: number = 10;

    @property({
        tooltip: '最大ヒット数(最初のターゲットを含む)。この回数に達すると連鎖を終了します。',
        min: 1,
    })
    public maxHits: number = 5;

    @property({
        tooltip: '1回の連鎖で次の敵を探す最大距離(ワールド座標上の距離)。',
        min: 0,
    })
    public maxChainDistance: number = 400;

    @property({
        tooltip: 'autoStart が true のとき、最初のターゲットを自ノード周辺から検索する半径。',
        min: 0,
    })
    public searchRadius: number = 600;

    @property({
        tooltip: 'true の場合、start() 時に自動で最初のターゲットを検索して連鎖を開始します。',
    })
    public autoStart: boolean = true;

    @property({
        tooltip: '各ヒット間のディレイ時間(秒)。0 にすると即座に次の敵へ連鎖します。',
        min: 0,
    })
    public hitInterval: number = 0.08;

    @property({
        tooltip: '連鎖処理が完了したときに、このノードを自動で destroy() するかどうか。',
    })
    public destroyOnComplete: boolean = true;

    @property({
        type: Prefab,
        tooltip: '雷ビームを表現するためのプレハブ(任意)。\n細長い Sprite などを想定しています。指定されていれば、各ヒット間にインスタンスを生成して表示します。',
    })
    public linePrefab: Prefab | null = null;

    @property({
        tooltip: '1本の雷ビームを表示しておく時間(秒)。linePrefab を使用する場合のみ有効です。',
        min: 0,
    })
    public lineDuration: number = 0.12;

    @property({
        tooltip: 'true の場合、連鎖の進行状況やエラーを console.log で詳細に出力します。',
    })
    public debugLog: boolean = false;

    // 内部状態管理
    private _isRunning = false;
    private _hitTargets: Node[] = [];
    private _currentHitCount = 0;
    private _activeTweens: Tween<any>[] = [];

    onLoad() {
        // 特に必須コンポーネントはないが、enemyTag のチェックなどを行う
        if (!this.enemyTag || this.enemyTag.trim().length === 0) {
            console.error('[ChainLightning] enemyTag が設定されていません。このコンポーネントは動作しません。', this.node.name);
        }
    }

    start() {
        if (this.autoStart) {
            this.tryAutoStartChain();
        }
    }

    onDisable() {
        // ノードが無効化されたら進行中の tween を全て停止する
        this._stopAllTweens();
        this._isRunning = false;
    }

    onDestroy() {
        this._stopAllTweens();
    }

    /**
     * autoStart が true のときに呼び出される、自動連鎖開始処理。
     */
    private tryAutoStartChain() {
        if (!this._validateConfig()) {
            return;
        }

        const firstTarget = this._findClosestEnemyAroundNode(this.node, this.searchRadius);
        if (!firstTarget) {
            if (this.debugLog) {
                console.warn('[ChainLightning] 自動開始しようとしましたが、searchRadius 内に敵が見つかりませんでした。', this.node.name);
            }
            this._onChainComplete();
            return;
        }

        this.beginChainFrom(firstTarget);
    }

    /**
     * 連鎖雷を特定の最初のターゲットから開始する。
     * 外部スクリプトからも呼び出し可能。
     */
    public beginChainFrom(firstTarget: Node) {
        if (!this._validateConfig()) {
            return;
        }

        if (!firstTarget || !firstTarget.isValid) {
            console.error('[ChainLightning] beginChainFrom に無効なターゲットが渡されました。');
            return;
        }

        if (this._isRunning) {
            if (this.debugLog) {
                console.warn('[ChainLightning] すでに連鎖処理が実行中です。二重起動は無視されます。');
            }
            return;
        }

        this._isRunning = true;
        this._hitTargets.length = 0;
        this._currentHitCount = 0;

        if (this.debugLog) {
            console.log('[ChainLightning] 連鎖開始: first target =', firstTarget.name);
        }

        // 最初のヒット処理を開始
        this._chainFromTarget(firstTarget);
    }

    /**
     * 設定値の妥当性チェック。
     */
    private _validateConfig(): boolean {
        if (!this.enemyTag || this.enemyTag.trim().length === 0) {
            console.error('[ChainLightning] enemyTag が空です。敵ノードのタグを設定してください。');
            return false;
        }
        if (this.maxHits <= 0) {
            console.error('[ChainLightning] maxHits が 0 以下です。1 以上の値を設定してください。');
            return false;
        }
        if (this.maxChainDistance < 0) {
            console.error('[ChainLightning] maxChainDistance が負の値です。0 以上の値を設定してください。');
            return false;
        }
        return true;
    }

    /**
     * 一つのターゲットから次のターゲットへ連鎖させるメイン処理。
     */
    private _chainFromTarget(currentTarget: Node) {
        if (!currentTarget || !currentTarget.isValid) {
            if (this.debugLog) {
                console.warn('[ChainLightning] currentTarget が無効です。連鎖を終了します。');
            }
            this._onChainComplete();
            return;
        }

        this._currentHitCount++;
        this._hitTargets.push(currentTarget);

        // ダメージ適用(イベント通知)
        this._applyDamage(currentTarget);

        if (this.debugLog) {
            console.log(`[ChainLightning] Hit #${this._currentHitCount} :`, currentTarget.name);
        }

        // 最大ヒット数に達したら終了
        if (this._currentHitCount >= this.maxHits) {
            if (this.debugLog) {
                console.log('[ChainLightning] maxHits に達したため連鎖終了。');
            }
            this._onChainComplete();
            return;
        }

        // 次のターゲットを検索
        const nextTarget = this._findNextTarget(currentTarget);
        if (!nextTarget) {
            if (this.debugLog) {
                console.log('[ChainLightning] 次のターゲットが見つからないため連鎖終了。');
            }
            this._onChainComplete();
            return;
        }

        // 雷ビームの表示(任意)
        this._spawnLineBetween(currentTarget, nextTarget);

        // ディレイ後に次のターゲットへ連鎖
        if (this.hitInterval > 0) {
            const t = tween({})
                .delay(this.hitInterval)
                .call(() => {
                    this._removeTween(t);
                    if (!this._isRunning) return;
                    this._chainFromTarget(nextTarget);
                });
            this._activeTweens.push(t);
            t.start();
        } else {
            // 即座に次へ
            this._chainFromTarget(nextTarget);
        }
    }

    /**
     * 敵ノードにダメージを適用する。
     * - "TakeDamage" イベントを emit する。
     * - debugLog が true の場合は console.log も出力する。
     */
    private _applyDamage(target: Node) {
        if (!target.isValid) return;

        // カスタムイベントでダメージ通知
        target.emit('TakeDamage', {
            amount: this.damage,
            source: this.node,
            type: 'ChainLightning',
        });

        if (this.debugLog) {
            console.log(`[ChainLightning] ${target.name} に ${this.damage} ダメージを適用 (イベント: TakeDamage)`);
        }
    }

    /**
     * currentTarget から一定距離内にいる、まだヒットしていない最も近い敵を探す。
     */
    private _findNextTarget(currentTarget: Node): Node | null {
        const allEnemies = this._findAllEnemies();
        if (allEnemies.length === 0) return null;

        const currentWorldPos = new Vec3();
        currentTarget.getWorldPosition(currentWorldPos);

        let bestTarget: Node | null = null;
        let bestDistSq = Number.MAX_VALUE;

        for (const enemy of allEnemies) {
            if (!enemy.isValid) continue;
            if (this._hitTargets.includes(enemy)) continue; // 既にヒット済み

            const pos = new Vec3();
            enemy.getWorldPosition(pos);
            const distSq = Vec3.squaredDistance(currentWorldPos, pos);

            if (distSq > this.maxChainDistance * this.maxChainDistance) {
                continue; // 射程外
            }

            if (distSq < bestDistSq) {
                bestDistSq = distSq;
                bestTarget = enemy;
            }
        }

        return bestTarget;
    }

    /**
     * 自ノードの周辺から searchRadius 内で一番近い敵を探す。
     * autoStart 用の最初のターゲット検索に使用。
     */
    private _findClosestEnemyAroundNode(originNode: Node, radius: number): Node | null {
        const allEnemies = this._findAllEnemies();
        if (allEnemies.length === 0) return null;

        const originPos = new Vec3();
        originNode.getWorldPosition(originPos);

        let bestTarget: Node | null = null;
        let bestDistSq = Number.MAX_VALUE;
        const radiusSq = radius * radius;

        for (const enemy of allEnemies) {
            if (!enemy.isValid) continue;

            const pos = new Vec3();
            enemy.getWorldPosition(pos);
            const distSq = Vec3.squaredDistance(originPos, pos);

            if (distSq > radiusSq) continue;

            if (distSq < bestDistSq) {
                bestDistSq = distSq;
                bestTarget = enemy;
            }
        }

        return bestTarget;
    }

    /**
     * シーン内の全ノードから、enemyTag を持つノードを収集する。
     * - director.getScene() から再帰的に探索する。
     * - 外部の GameManager 等に依存しない汎用的な検索。
     */
    private _findAllEnemies(): Node[] {
        const scene = director.getScene();
        if (!scene) {
            console.error('[ChainLightning] シーンが取得できませんでした。');
            return [];
        }
        const result: Node[] = [];
        this._collectEnemiesRecursive(scene, result);
        return result;
    }

    private _collectEnemiesRecursive(node: Node, out: Node[]) {
        if (node.tag === this.enemyTag) {
            out.push(node);
        }
        for (const child of node.children) {
            this._collectEnemiesRecursive(child, out);
        }
    }

    /**
     * 2つのノードの間に雷ビーム用の linePrefab を表示する(任意)。
     */
    private _spawnLineBetween(from: Node, to: Node) {
        if (!this.linePrefab) return;
        if (!from.isValid || !to.isValid) return;

        const scene = director.getScene();
        if (!scene) return;

        const lineNode = instantiate(this.linePrefab);
        if (!lineNode) {
            console.error('[ChainLightning] linePrefab のインスタンス化に失敗しました。');
            return;
        }

        // シーンのルートに配置(UI 系なら Canvas 配下など、用途に合わせて変更可能)
        scene.addChild(lineNode);

        const fromPos = new Vec3();
        const toPos = new Vec3();
        from.getWorldPosition(fromPos);
        to.getWorldPosition(toPos);

        // 中点に配置
        const midPos = new Vec3(
            (fromPos.x + toPos.x) * 0.5,
            (fromPos.y + toPos.y) * 0.5,
            (fromPos.z + toPos.z) * 0.5,
        );
        lineNode.setWorldPosition(midPos);

        // 距離と角度を計算して、線を伸ばす
        const dir = new Vec3();
        Vec3.subtract(dir, toPos, fromPos);
        const length = Vec3.len(dir);

        // Y 軸方向を基準とした回転(2D 前提の簡易実装)
        const angleRad = Math.atan2(dir.y, dir.x);
        const angleDeg = math.toDegree(angleRad);
        lineNode.setRotationFromEuler(0, 0, angleDeg);

        // X スケールを長さに合わせる想定(linePrefab 側のサイズに合わせて調整)
        const originalScale = lineNode.scale.clone();
        if (length > 0.001) {
            lineNode.setScale(length / 100, originalScale.y, originalScale.z);
        }

        // 一定時間後に自動で破棄
        const t = tween(lineNode)
            .delay(this.lineDuration)
            .call(() => {
                this._removeTween(t);
                if (lineNode.isValid) {
                    lineNode.destroy();
                }
            });
        this._activeTweens.push(t);
        t.start();
    }

    /**
     * 連鎖がすべて終了したときに呼び出される。
     */
    private _onChainComplete() {
        this._isRunning = false;
        if (this.debugLog) {
            console.log('[ChainLightning] 連鎖処理が完了しました。総ヒット数:', this._currentHitCount);
        }
        if (this.destroyOnComplete && this.node.isValid) {
            this.node.destroy();
        }
    }

    /**
     * 登録済みの Tween を停止し、配列から除去する。
     */
    private _stopAllTweens() {
        for (const t of this._activeTweens) {
            t.stop();
        }
        this._activeTweens.length = 0;
    }

    private _removeTween(t: Tween<any>) {
        const idx = this._activeTweens.indexOf(t);
        if (idx !== -1) {
            this._activeTweens.splice(idx, 1);
        }
    }
}

コードの主要ポイント解説

  • onLoad
    • enemyTag が空でないかチェックし、問題があればエラーログを出します。
    • 他に必須コンポーネントはないため、ここでの検証は最低限です。
  • start
    • autoStarttrue の場合、tryAutoStartChain() を呼び出します。
    • 自ノード周辺(searchRadius)から最初のターゲットを検索し、見つかれば連鎖を開始します。
  • beginChainFrom(firstTarget)
    • 外部から「最初のターゲット」を指定して連鎖を開始したい場合に呼び出します。
    • 設定値の検証・重複起動の防止・内部状態の初期化を行ったのち、_chainFromTarget を呼びます。
  • _chainFromTarget(currentTarget)
    • 連鎖のメインループです。
    • 現在のターゲットにダメージを適用し、maxHits に達していなければ次のターゲットを検索します。
    • 次のターゲットが見つからなければ連鎖終了。
    • hitInterval に応じて、ディレイ付きまたは即時で次へ連鎖します。
  • _findAllEnemies / _collectEnemiesRecursive
    • director.getScene() からシーン全体を再帰的に探索し、node.tag === enemyTag のノードを収集します。
    • GameManager などの外部スクリプトに依存せず、「タグ」だけで敵を判定する設計です。
  • _spawnLineBetween(from, to)
    • linePrefab が指定されている場合のみ呼ばれます。
    • 2つのノードのワールド座標から中点・角度・距離を計算し、線状のビームを表示します。
    • lineDuration 秒後に自動的に破棄されます。
  • _applyDamage(target)
    • 敵ノードに対して TakeDamage イベントを emit します。
    • 敵側スクリプトは、必要に応じて node.on('TakeDamage', ...) で受け取り、HP 減少などを実装できます。

使用手順と動作確認

1. スクリプトファイルの作成

  1. Assets パネルで右クリック → Create → TypeScript を選択します。
  2. ファイル名を ChainLightning.ts にします。
  3. 自動生成されたコードをすべて削除し、本記事の 「TypeScriptコードの実装」 セクションのコードを丸ごと貼り付けて保存します。

2. 敵ノードの用意(タグ設定が重要)

このコンポーネントは「敵タグ」で敵を判定するため、まず敵ノードを準備します。

  1. Hierarchy パネルで右クリック → Create → 2D Object → Sprite などを選択し、敵用ノードを複数作成します。
    • 例: Enemy_1, Enemy_2, Enemy_3
  2. 各敵ノードを選択し、Inspector の Tag フィールドを Enemy に設定します。
    • Tag フィールドが見当たらない場合は、Node のプロパティ欄をスクロールして確認してください。
    • 本スクリプトのデフォルト enemyTag"Enemy" なので、ここを揃えることが重要です。
  3. テストしやすいように、敵ノードを適度に離して配置しておきます(例: 200〜300px 間隔)。

(任意)敵側のダメージ処理を確認したい場合は、シンプルなコンポーネントを追加してログを出すと分かりやすくなります。


// 簡易的なダメージ表示用コンポーネント例(EnemyDamageLogger.ts)
import { _decorator, Component, Node } from 'cc';
const { ccclass } = _decorator;

@ccclass('EnemyDamageLogger')
export class EnemyDamageLogger extends Component {
    onLoad() {
        this.node.on('TakeDamage', (data: any) => {
            console.log(`[EnemyDamageLogger] ${this.node.name} がダメージを受けました:`, data);
        });
    }
}

このスクリプトを敵ノードにアタッチしておくと、連鎖雷からのダメージイベントがコンソールに表示されます。

3. 連鎖雷を発生させるノードの作成

  1. Hierarchy パネルで右クリック → Create → 2D Object → Empty Node を選択し、ノード名を ChainLightningTester などにします。
  2. ChainLightningTester ノードを選択し、Inspector の Add Component → Custom → ChainLightning を選択してコンポーネントを追加します。
  3. Inspector で ChainLightning の各プロパティを設定します。
    • Enemy Tag: Enemy(敵ノードの Tag と一致させる)
    • Damage: 10(お好みで)
    • Max Hits: 5
    • Max Chain Distance: 400
    • Search Radius: 600
    • Auto Start: (チェック ON)
    • Hit Interval: 0.08
    • Destroy On Complete: (テスト用ノードなら OFF でも可)
    • Line Prefab: (後述、まずは null のままで OK)
    • Line Duration: 0.12
    • Debug Log: (挙動確認のため ON 推奨)
  4. ChainLightningTester ノードを、敵たちの近くに配置します(searchRadius 内に少なくとも1体は入るように)。

4. プレビューで自動連鎖を確認する

  1. エディタ右上の ▶(Play) ボタンを押して、エディタ内プレビューまたはブラウザプレビューを開始します。
  2. ゲーム開始直後、ChainLightningTester の位置から最も近い敵が最初のターゲットとして選ばれ、そこから周囲の敵へ順番に雷が連鎖していきます。
  3. コンソール(Console パネル)で以下のようなログが出ていることを確認します。
    • [ChainLightning] 連鎖開始: first target = Enemy_2
    • [ChainLightning] Hit #1 : Enemy_2
    • [ChainLightning] Enemy_2 に 10 ダメージを適用 (イベント: TakeDamage)
    • …(連鎖が続く)…
    • [ChainLightning] 連鎖処理が完了しました。総ヒット数: 4
  4. 敵ノードに EnemyDamageLogger を付けている場合は、そのログも合わせて表示されます。

5. 雷ビームのエフェクトを追加する(任意)

見た目を分かりやすくするため、雷ビーム用の Line プレハブを作成して linePrefab に設定してみましょう。

  1. Assets パネルで右クリック → Create → 2D Object → Sprite を選択し、名前を LightningLine にします。
  2. Inspector で Sprite に細長いテクスチャを設定するか、単色の白い画像を指定して Width を大きく / Height を小さく 調整して「線」に見えるようにします。
    • 例: Width: 100, Height: 8
  3. LightningLine ノードを選択した状態で、Assets パネルにドラッグ&ドロップし、Prefab 化します。
  4. Hierarchy 上の LightningLine ノードは削除して構いません(Prefab が残っていればOK)。
  5. ChainLightningTester ノードを選択し、Inspector の Line Prefab に先ほど作成した LightningLine プレハブをドラッグ&ドロップします。
  6. Line Duration0.1〜0.2 秒程度に設定します。
  7. 再度プレイして、連鎖のたびに敵同士を結ぶビームが一瞬表示されることを確認します。

6. スクリプトから任意タイミングで連鎖を開始する(応用)

autoStart を OFF にし、外部スクリプトから beginChainFrom() を呼び出すことで、「弾が特定の敵に当たった瞬間から連鎖開始」といった挙動を作れます。

  1. ChainLightningTester ノードの Auto Start のチェックを外します。
  2. 例えば「弾」ノードに以下のようなスクリプトを付け、敵に当たったときに呼び出します。

// Bullet.ts の一部サンプル
import { _decorator, Component, Node, Collider2D, Contact2DType, IPhysics2DContact } from 'cc';
import { ChainLightning } from './ChainLightning';
const { ccclass, property } = _decorator;

@ccclass('Bullet')
export class Bullet extends Component {

    @property(ChainLightning)
    chainLightning: ChainLightning | null = null;

    onLoad() {
        const col = this.getComponent(Collider2D);
        if (col) {
            col.on(Contact2DType.BEGIN_CONTACT, this.onBeginContact, this);
        }
    }

    private onBeginContact(selfCol: Collider2D, otherCol: Collider2D, contact: IPhysics2DContact | null) {
        const otherNode = otherCol.node;

        // ここでは単純に敵タグで判定
        if (otherNode.tag === 'Enemy' && this.chainLightning) {
            this.chainLightning.beginChainFrom(otherNode);
        }
    }
}

このようにすれば、「弾が当たった敵から連鎖雷をスタート」という使い方も簡単に実現できます。


まとめ

この ChainLightning コンポーネントは、

  • 敵管理用の GameManager や外部シングルトンに一切依存せず、
  • 敵ノードの Tag と距離情報だけで連鎖対象を自動判定し、
  • ダメージは カスタムイベントで通知するだけのシンプルな設計

になっているため、

  • 2D アクションゲームのスキル(チェインライトニング、チェインフレイムなど)
  • タワーディフェンスの「雷塔」や「範囲連鎖攻撃タワー」
  • RPG の範囲魔法・状態異常の伝播表現

など、さまざまなジャンルに そのまま再利用できます。

調整可能なプロパティ(最大ヒット数・連鎖距離・ディレイ・ビームエフェクトなど)もすべてインスペクタから変更できるため、デザイナーやレベルデザイナーがコードに触れずにバランス調整できる点も大きなメリットです。

このコンポーネントをベースに、

  • 属性ごとに異なるエフェクトプレハブを使い分ける
  • 同じ敵に複数回ヒットさせるモードを追加する
  • 敵の「優先度」や「HP」などを見て次のターゲットを選ぶ

といった拡張も容易に行えます。
まずは本記事の実装をそのまま導入し、シーン内に敵ノードとテストノードを配置して、「アタッチするだけで連鎖雷が動く」感覚を体験してみてください。