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 シーンに以下を設定します。
- インスペクタの「Node」タブ → Groups →
enemyを追加 - スクリプトに
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_method と ignore_dead_property と一致させておきます(デフォルト値のままでOK)。
手順②:Player に ChainLightning コンポーネントをアタッチする
- Player シーンを開く
- 子ノードとして「Node」を追加し、名前を
ChainLightningに変更 - このノードに先ほどの
ChainLightning.gdをアタッチ - インスペクタで各種パラメータを調整
max_jumps = 5jump_radius = 250jump_delay = 0.05damage_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 コンポーネントの中だけをいじれば済むので、プロジェクト全体の見通しがかなり良くなります。
ぜひ、自分のゲーム仕様に合わせてガンガン改造してみてください。




