【Godot 4】ChainLightning (連鎖雷) コンポーネントの作り方

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

脱・初心者!Godot 4 ゲーム開発の「2歩目」

新品価格
¥831から
(2025/12/13 21:39時点)

Godot4ローグライク入門 ~ダンジョン自動生成~

新品価格
¥831から
(2025/12/13 21:44時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Godot 4 で「チェインライトニング(連鎖雷)」みたいなギミックを作ろうとすると、けっこう面倒ですよね。

  • 弾を発射 → 敵にヒット → 近くの敵を探す → またヒット…という「状態管理」が複雑になりがち
  • プレイヤー、タワー、敵スキルなど「発射元」が増えると、コピペコードが量産される
  • シーン階層の中に「弾ノード」「エフェクト」「ダメージ処理」がベッタリくっついて、継承ツリーも深くなりがち

継承ベースで「LightningProjectile」「EnemyLightning」「TowerLightning」…と増やしていくと、どこを直せばいいのか分からなくなります。
そこで今回は、「どのノードにもポン付けできる ChainLightning コンポーネント」として実装してみましょう。

このコンポーネントは、

  • 敵にヒットしたら、その敵を起点に「近くの別の敵」を探してダメージを連鎖
  • 最大ヒット回数・探索距離・遅延時間などを @export で調整可能
  • 「敵とは何か?」はシグナルとインターフェース的なメソッドで抽象化

という構成にして、発射体やスキル側は「とりあえず当たったら ChainLightning に任せる」だけにします。
継承ではなく「合成(Composition)」で、どのシーンにも同じ連鎖ロジックを再利用できる形ですね。

【Godot 4】ビリビリ連鎖ダメージをコンポーネント化!「ChainLightning」コンポーネント

フルコード(GDScript / Godot 4)


extends Node
class_name ChainLightning
## 連鎖雷コンポーネント
## ・最初のヒット対象から、近くの敵へダメージを連鎖させる
## ・Projectile, Player, Tower など、どのノードにもアタッチして使える想定

## ====== エディタから設定するパラメータ群 ======

@export_category("Chain Settings")

@export_range(1, 32, 1)
var max_jumps: int = 5
## 最大連鎖回数(最初のヒット対象を含まない「追加ジャンプ回数」)
## 例: 5 → 最初の敵 + 5体 = 最大6体にヒット

@export_range(0.0, 1024.0, 1.0)
var jump_radius: float = 256.0
## 次の敵を探す半径(ワールド座標ベース)
## 大きくすると広範囲に連鎖、小さくすると近距離のみ

@export_range(0.0, 1.0, 0.01)
var jump_delay: float = 0.05
## 連鎖1回ごとの遅延(秒)
## 0 にすると一瞬で連鎖、0.05〜0.1 くらいだと「ビリビリ感」が出る

@export var damage_per_hit: float = 10.0
## 1ヒットあたりのダメージ量
## 実際の減算処理は敵側のスクリプトに委ねる(take_damageなど)

@export var max_total_distance: float = 1024.0
## 連鎖全体で進める最大距離
## 「起点からあまり離れすぎない」ようにするための制限
## 0以下なら無制限

@export_category("Target Filtering")

@export var enemy_group_name: StringName = &"enemy"
## 敵を識別するためのグループ名
## このグループに属しているノードだけを連鎖対象とする

@export var require_damage_method: StringName = &"take_damage"
## 連鎖対象に要求するメソッド名
## 例: "take_damage(damage: float, source: Node)" のような関数を敵側に実装しておく

@export var ignore_dead_property: StringName = &"is_dead"
## 敵が「死亡済み」かどうかを示すプロパティ名
## 例: enemy.is_dead == true の敵は連鎖対象から除外
## 空文字列にすると、このチェックは行わない

@export_category("Visual / Debug")

@export var draw_debug_lines: bool = false
## デバッグ用に Line2D を動的生成して、連鎖経路を可視化する
## 本番ビルドでは false 推奨

@export var debug_line_color: Color = Color.YELLOW
@export_range(0.5, 8.0, 0.5)
var debug_line_width: float = 2.0
@export var debug_line_lifetime: float = 0.2
## デバッグラインの寿命(秒)

## ====== シグナル ======

signal chain_started(start_target: Node)
## 最初の敵にヒットして連鎖が開始されたときに発火

signal chain_hit(target: Node, index: int)
## 連鎖中に敵にヒットするたびに発火
## index: 0 = 最初のヒット対象, 1 以降 = 連鎖ジャンプ

signal chain_finished(hit_count: int)
## 連鎖が終了したときに発火(実際にヒットした敵の数を返す)

## ====== 内部状態 ======

var _is_running: bool = false
var _visited_targets: Array[Node] = []
var _total_travel_distance: float = 0.0

## ====== 公開API ======

func is_running() -> bool:
    ## 現在連鎖処理中かどうか
    return _is_running

func reset_chain() -> void:
    ## 連鎖状態をリセット(途中でキャンセルしたいときなど)
    _is_running = false
    _visited_targets.clear()
    _total_travel_distance = 0.0

func start_chain(initial_target: Node, source: Node = null) -> void:
    ## 連鎖雷を開始する
    ## initial_target: 最初にヒットした敵
    ## source: ダメージの「発射元」(プレイヤー、タワーなど)を敵側に渡したい場合に使用
    if initial_target == null:
        push_warning("ChainLightning.start_chain called with null initial_target")
        return
    if _is_running:
        ## 既に連鎖中の場合は、一旦リセットしてから開始してもよい
        reset_chain()

    _is_running = true
    _visited_targets.clear()
    _total_travel_distance = 0.0

    # コルーチンで非同期に連鎖処理を行う
    _run_chain(initial_target, source)


## ====== メイン処理 ======

func _run_chain(initial_target: Node, source: Node) -> void:
    if not _is_valid_target(initial_target):
        _is_running = false
        return

    _visited_targets.append(initial_target)
    chain_started.emit(initial_target)
    chain_hit.emit(initial_target, 0)

    _apply_damage(initial_target, source)

    var current_target := initial_target
    var jump_index := 1

    while jump_index <= max_jumps:
        # 次のターゲットを探索
        var next_target := _find_next_target(current_target)
        if next_target == null:
            break

        # 距離制限チェック
        var segment_distance := _distance_between_nodes(current_target, next_target)
        _total_travel_distance += segment_distance
        if max_total_distance > 0.0 and _total_travel_distance > max_total_distance:
            break

        if jump_delay > 0.0:
            await get_tree().create_timer(jump_delay).timeout

        if not _is_valid_target(next_target):
            break

        # デバッグライン描画
        if draw_debug_lines:
            _draw_debug_line(current_target, next_target)

        _visited_targets.append(next_target)
        chain_hit.emit(next_target, jump_index)
        _apply_damage(next_target, source)

        current_target = next_target
        jump_index += 1

    _is_running = false
    chain_finished.emit(_visited_targets.size())


## ====== ターゲット探索・判定 ======

func _is_valid_target(target: Node) -> bool:
    ## まだ存在しているか
    if target == null:
        return false
    if not is_instance_valid(target):
        return false

    ## グループチェック
    if enemy_group_name != StringName("") and not target.is_in_group(enemy_group_name):
        return false

    ## is_dead プロパティチェック(任意)
    if ignore_dead_property != StringName(""):
        if target.has_method("get") and target.has_property(ignore_dead_property):
            var v = target.get(ignore_dead_property)
            if v == true:
                return false
        elif target.has_property(ignore_dead_property):
            var v2 = target.get(ignore_dead_property)
            if v2 == true:
                return false

    ## すでに連鎖済みなら除外
    if target in _visited_targets:
        return false

    return true


func _find_next_target(from_target: Node) -> Node:
    ## from_target の周囲から、最も近い未訪問の敵を探す
    var space_state := get_world_2d().direct_space_state

    var from_pos := _get_node_global_position(from_target)
    if from_pos == null:
        return null

    # シンプルに「敵グループに属する全ノード」を走査して、距離でフィルタリング
    # 敵の数が多いゲームでは、独自のクアッドツリーや空間パーティションを使うと良い
    var candidates: Array = []
    for enemy in get_tree().get_nodes_in_group(enemy_group_name):
        if not _is_valid_target(enemy):
            continue
        var pos := _get_node_global_position(enemy)
        if pos == null:
            continue
        var dist := from_pos.distance_to(pos)
        if dist <= jump_radius:
            candidates.append({"node": enemy, "dist": dist})

    if candidates.is_empty():
        return null

    # 距離が近い順にソートして最も近い敵を返す
    candidates.sort_custom(func(a, b): return a["dist"] < b["dist"])
    return candidates[0]["node"]


func _get_node_global_position(node: Node) -> Vector2:
    ## Node2D / CharacterBody2D / Area2D などからグローバル座標を取得
    if node is Node2D:
        return (node as Node2D).global_position
    if node is CollisionObject2D:
        return (node as CollisionObject2D).global_position
    # その他の型は Transform2D から位置を拾う(なければ null)
    if node.has_method("get_global_transform"):
        var t = node.get_global_transform()
        if t is Transform2D:
            return (t as Transform2D).origin
    return null


func _distance_between_nodes(a: Node, b: Node) -> float:
    var pa := _get_node_global_position(a)
    var pb := _get_node_global_position(b)
    if pa == null or pb == null:
        return 0.0
    return pa.distance_to(pb)


## ====== ダメージ適用 ======

func _apply_damage(target: Node, source: Node) -> void:
    ## 実際のダメージ処理は、敵側のスクリプトに任せる
    ## 例:
    ##   func take_damage(amount: float, source: Node) -> void:
    ##       health -= amount
    ##       if health 

使い方の手順

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

シーン構成例

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── ChainLightning (Node)   ← コンポーネントとしてアタッチ

LightningProjectile (Area2D)
 ├── Sprite2D
 └── CollisionShape2D

Enemy (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── EnemyHealth (Script)

手順①:敵側に「グループ」と「ダメージメソッド」を用意する

Enemy シーンに以下を設定します。

  1. インスペクタの「Node」タブ → Groups → enemy を追加
  2. スクリプトに take_damage(amount: float, source: Node) を実装

extends CharacterBody2D

@export var max_health: float = 50.0
var health: float
var is_dead: bool = false

func _ready() -> void:
    health = max_health

func take_damage(amount: float, source: Node) -> void:
    if is_dead:
        return
    health -= amount
    print("Enemy %s took %s damage from %s" % [name, amount, source])
    if health <= 0.0:
        die()

func die() -> void:
    is_dead = true
    queue_free()

この take_damage メソッド名と is_dead プロパティ名は、ChainLightning の require_damage_methodignore_dead_property と一致させておきます(デフォルト値のままでOK)。

手順②:Player に ChainLightning コンポーネントをアタッチする

  1. Player シーンを開く
  2. 子ノードとして「Node」を追加し、名前を ChainLightning に変更
  3. このノードに先ほどの ChainLightning.gd をアタッチ
  4. インスペクタで各種パラメータを調整
    • max_jumps = 5
    • jump_radius = 250
    • jump_delay = 0.05
    • damage_per_hit = 15 など

これで Player は「いつでも連鎖雷を撃てる能力」を持ちました。
あとは「最初の敵にヒットした瞬間に start_chain() を呼ぶ」だけです。

手順③:弾(LightningProjectile)から ChainLightning を呼び出す

弾はシンプルに「最初のヒットを検出するだけ」にしておき、連鎖ロジックは一切持たせません。


extends Area2D

@export var speed: float = 600.0
var direction: Vector2 = Vector2.RIGHT
var owner: Node = null  # 発射元(Playerなど)

func _ready() -> void:
    body_entered.connect(_on_body_entered)

func _physics_process(delta: float) -> void:
    position += direction * speed * delta

func _on_body_entered(body: Node) -> void:
    # 敵に当たった?
    if body.is_in_group("enemy"):
        if owner and owner.has_node("ChainLightning"):
            var chain: ChainLightning = owner.get_node("ChainLightning")
            # 連鎖開始:initial_target = body, source = owner
            chain.start_chain(body, owner)
        # 弾自体は消す
        queue_free()

ポイントは、弾が「連鎖の詳細」を一切知らないことです。

  • 弾は「敵に当たったら、Owner の ChainLightning に任せる」だけ
  • 連鎖回数やダメージ量の調整は、Player 側の ChainLightning コンポーネントで完結
  • 別の武器やタワーにも、同じ ChainLightning コンポーネントを再利用できる

手順④:タワーディフェンスの「雷タワー」にも使い回す

同じコンポーネントを、タワーディフェンスの「雷タワー」にもアタッチできます。

LightningTower (Node2D)
 ├── Sprite2D
 ├── Area2D (射程)
 │    └── CollisionShape2D
 └── ChainLightning (Node)

タワー側のスクリプトは、


extends Node2D

@onready var chain: ChainLightning = $ChainLightning

func fire_at(target: Node) -> void:
    # 弾を出さず、直接ターゲットに連鎖雷を起動することもできる
    chain.start_chain(target, self)

という感じで、「とりあえず start_chain だけ呼べばOK」な設計になっています。

メリットと応用

この ChainLightning コンポーネントを使うと、

  • シーン構造がスッキリ
    連鎖ロジックを Projectile / Enemy / Tower などにバラバラに書かなくて済みます。
    Player や Tower は「ChainLightning を子ノードに持っているかどうか」で能力が決まる、というシンプルな構図になります。
  • 使い回しがしやすい
    他のプロジェクトでも、ChainLightning.gd をコピペ → 敵グループ名とダメージメソッド名だけ合わせればそのまま使えます。
  • テストがしやすい
    連鎖ロジックが1か所にまとまっているので、max_jumps / jump_radius / jump_delay をいじりながら挙動を確認しやすいです。
    draw_debug_lines を ON にすれば、エディタ上で「どこからどこへ連鎖しているか」が一目で分かります。
  • 敵の実装を自由にできる
    敵側は take_damage さえ持っていればよく、「HP 管理」「死亡演出」「ノックバック」などは自由に実装できます。
    いわゆるインターフェース的な役割を、メソッド名でゆるく表現している形ですね。

継承で「LightningEnemy」「LightningTower」…と増やしていくより、「ChainLightning という1つの能力」をどのノードにもアタッチできる方が、長期的に見てメンテナンスが楽になります。

改造案:ダメージを連鎖ごとに減衰させる

「連鎖するごとにダメージを減らしたい」という場合は、_run_chain 内で index に応じてダメージを変えるようにしてみましょう。


@export_range(0.0, 1.0, 0.05)
var damage_falloff_per_jump: float = 0.2
## 1回連鎖するごとにダメージを何割減らすか
## 0.2 → 1回目 100%, 2回目 80%, 3回目 64% ...

func _run_chain(initial_target: Node, source: Node) -> void:
    if not _is_valid_target(initial_target):
        _is_running = false
        return

    _visited_targets.append(initial_target)
    chain_started.emit(initial_target)
    chain_hit.emit(initial_target, 0)

    _apply_scaled_damage(initial_target, source, 0)

    var current_target := initial_target
    var jump_index := 1

    while jump_index <= max_jumps:
        var next_target := _find_next_target(current_target)
        if next_target == null:
            break

        var segment_distance := _distance_between_nodes(current_target, next_target)
        _total_travel_distance += segment_distance
        if max_total_distance > 0.0 and _total_travel_distance > max_total_distance:
            break

        if jump_delay > 0.0:
            await get_tree().create_timer(jump_delay).timeout

        if not _is_valid_target(next_target):
            break

        if draw_debug_lines:
            _draw_debug_line(current_target, next_target)

        _visited_targets.append(next_target)
        chain_hit.emit(next_target, jump_index)
        _apply_scaled_damage(next_target, source, jump_index)

        current_target = next_target
        jump_index += 1

    _is_running = false
    chain_finished.emit(_visited_targets.size())


func _apply_scaled_damage(target: Node, source: Node, jump_index: int) -> void:
    var scale := pow(1.0 - damage_falloff_per_jump, jump_index)
    var dmg := damage_per_hit * scale
    if require_damage_method != StringName("") and target.has_method(require_damage_method):
        target.call(require_damage_method, dmg, source)

このように、「ダメージ減衰」「状態異常付与」「属性相性」などのロジックも、ChainLightning コンポーネントの中だけをいじれば済むので、プロジェクト全体の見通しがかなり良くなります。
ぜひ、自分のゲーム仕様に合わせてガンガン改造してみてください。

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

脱・初心者!Godot 4 ゲーム開発の「2歩目」

新品価格
¥831から
(2025/12/13 21:39時点)

Godot4ローグライク入門 ~ダンジョン自動生成~

新品価格
¥831から
(2025/12/13 21:44時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

URLをコピーしました!