Godot で「毒の沼地」や「溶岩床」を作ろうとすると、ついこういう実装をしがちですよね。
- プレイヤー側に
_physics_processで「今ダメージ床の上か?」を毎フレーム判定させる - 各ステージ固有のスクリプトに「この床に触れている間はHPを減らす」ロジックを書いてしまう
- 敵とプレイヤーで別々のダメージ処理を書いて、あとから共通化しようとしてつらくなる
結果として、
- プレイヤー / 敵 / ギミックのスクリプトがどんどん肥大化
- 「この床は 1 秒ごとに 5 ダメージ、この床は 0.5 秒ごとに 2 ダメージ…」みたいなパラメータが散らばる
- シーン階層が深くなり、どこでダメージが発生しているのか追いにくい
こういう「床ごとに違う継続ダメージ」を、プレイヤーや敵に継承で詰め込むのは相性が悪いんですよね。
そこで登場するのが、今回のコンポーネント DamageZone です。
DamageZone コンポーネントを床やエリアにポンと付けておくだけで、「入っている間、毎秒ダメージイベントを発行」する仕組みを、きれいに合成(Composition)で実現できます。
【Godot 4】毒沼も溶岩もこれ一つ!「DamageZone」コンポーネント
今回の DamageZone はこんな思想で作っています。
- 床やエリア側は「ダメージを発行するだけ」=中立的なコンポーネント
- プレイヤー / 敵 / オブジェクト側は「ダメージイベントをどう処理するか」だけを実装
- 両者は シグナル で疎結合につなぐ(お互いを直接知らない)
つまり、継承ではなく「ダメージを発行するエリア」というコンポーネントをシーンに合成するだけで、どこにでも毒沼や溶岩を配置できるようにします。
DamageZone.gd フルコード
extends Area2D
class_name DamageZone
"""
DamageZone (継続ダメージ床) コンポーネント
- プレイヤーや敵など「ダメージを受ける側」がこのエリアに入っている間、
一定間隔で damage_applied シグナルを発行します。
- ダメージの大きさ、間隔、対象フィルタなどは @export で調整できます。
使い方の想定:
- 毒の沼地、溶岩床、電撃フィールド、トゲの霧など
- 2D アクション / RPG での「継続ダメージゾーン」
"""
## --- シグナル -------------------------------------------------------------
## ダメージが適用されるタイミングで発行されるシグナル
## - target: ダメージを受ける対象のノード (Area2D / PhysicsBody2D など)
## - amount: 実際に適用するダメージ量
## - source: ダメージの発生源 (この DamageZone 自身)
signal damage_applied(target: Node, amount: float, source: Node)
## --- エクスポートパラメータ ---------------------------------------------
@export_group("Damage Settings", "damage_")
## 1 回ごとに与えるダメージ量
@export var damage_amount: float = 5.0
## ダメージを与える間隔(秒)
## 例: 1.0 なら 1 秒ごとにダメージ、0.5 なら 0.5 秒ごと
@export var damage_interval: float = 1.0
## エリアに入った瞬間にもダメージを与えるかどうか
@export var damage_on_enter: bool = true
## true の場合、PhysicsBody2D にも Area2D にもダメージを与える
## false の場合、手動でフィルタリングしたいときなどに使う
@export var affect_areas: bool = true
@export var affect_bodies: bool = true
@export_group("Target Filtering", "target_")
## グループ名による対象フィルタ
## 空配列なら「誰でも」対象
## 例: ["player", "enemy"] など
@export var target_groups: Array[String] = []
## --- 内部状態 -------------------------------------------------------------
## 現在このゾーンに滞在している対象のセット
var _targets: = {} # Dictionary を集合として使用: { Node: time_accumulator }
## ダメージ用のタイマーを共有するかどうか
## 各ターゲットごとに時間をカウントする方式なので、
## ゾーン自体に Timer ノードを置く必要はありません。
func _ready() -> void:
## Area2D のシグナルをコード側で接続しておきます
body_entered.connect(_on_body_entered)
body_exited.connect(_on_body_exited)
area_entered.connect(_on_area_entered)
area_exited.connect(_on_area_exited)
if damage_interval <= 0.0:
push_warning("damage_interval が 0 以下です。自動的に 0.1 秒に補正します。")
damage_interval = 0.1
func _physics_process(delta: float) -> void:
## ゾーン内にいる全ターゲットに対して、経過時間を更新し、
## interval を超えたらダメージを発行します。
if _targets.is_empty():
return
var to_remove: Array[Node] = []
for target: Node in _targets.keys():
if not is_instance_valid(target):
## すでに削除されている場合はリストから外す
to_remove.append(target)
continue
var acc: float = _targets[target]
acc += delta
if acc >= damage_interval:
## 必要回数分ダメージを与える(ラグで一気に経過した場合も考慮)
while acc >= damage_interval:
acc -= damage_interval
_apply_damage_to(target)
_targets[target] = acc
for t in to_remove:
_targets.erase(t)
## --- ダメージ適用ロジック ------------------------------------------------
func _apply_damage_to(target: Node) -> void:
## フィルタに合格しない対象はスキップ
if not _is_valid_target(target):
return
## ここでは「ダメージをどう処理するか」は一切知らず、
## ただシグナルを発行するだけにとどめます。
emit_signal("damage_applied", target, damage_amount, self)
## もし「Health」コンポーネントなどがあれば、ここで自動適用してもよいですが、
## 疎結合を保つために、このコンポーネントでは行いません。
## 例:
## if target.has_method("apply_damage"):
## target.apply_damage(damage_amount, self)
func _is_valid_target(target: Node) -> bool:
## Area / Body の種類フィルタ
if target is Area2D and not affect_areas:
return false
if target is PhysicsBody2D and not affect_bodies:
return false
## グループフィルタ
if target_groups.is_empty():
return true
for group_name in target_groups:
if target.is_in_group(group_name):
return true
return false
## --- シグナルハンドラ (Area2D) ------------------------------------------
func _on_body_entered(body: Node) -> void:
if not affect_bodies:
return
_add_target(body, damage_on_enter)
func _on_body_exited(body: Node) -> void:
_remove_target(body)
func _on_area_entered(area: Node) -> void:
if not affect_areas:
return
_add_target(area, damage_on_enter)
func _on_area_exited(area: Node) -> void:
_remove_target(area)
func _add_target(target: Node, apply_immediately: bool) -> void:
if not _is_valid_target(target):
return
## すでに入っている場合はスキップ
if _targets.has(target):
return
## 時間の蓄積を 0 で開始
_targets[target] = 0.0
if apply_immediately:
_apply_damage_to(target)
func _remove_target(target: Node) -> void:
if _targets.has(target):
_targets.erase(target)
使い方の手順
ここでは、典型的な 2D アクションゲームを想定して、プレイヤーが毒の沼地に入ると毎秒ダメージを受ける例で説明します。
手順①: プレイヤー側に「ダメージを受けるコード」を用意する
まずはプレイヤーに HP を持たせて、DamageZone のシグナルを受け取れるようにします。
プレイヤーは CharacterBody2D として、ざっくりこんなスクリプトを用意します。
# Player.gd
extends CharacterBody2D
@export var max_hp: int = 100
var hp: int
func _ready() -> void:
hp = max_hp
func apply_damage(amount: float, source: Node) -> void:
hp -= int(amount)
print("Player took ", amount, " damage from ", source, " HP: ", hp)
if hp <= 0:
_die()
func _die() -> void:
print("Player died")
queue_free()
ここでは apply_damage() というメソッドを用意しておきました。
DamageZone はこのメソッドを直接呼んでもいいですし、シグナルを受け取ってから呼んでもOKです。
手順②: ダメージ床用のシーンを作る
次に、毒の沼地(DamageZone)シーンを 1 つ作っておきます。
PoisonSwamp (Area2D) ├── CollisionShape2D └── DamageZone (スクリプト: DamageZone.gd を Area2D にアタッチ)
実際のノード構成例(コンポーネントとしてアタッチするイメージ)はこんな感じです。
PoisonSwamp (Area2D) ├── Sprite2D ├── CollisionShape2D └── DamageZone (スクリプト付き Node として追加してもOK)
PoisonSwampのルートをArea2DにするCollisionShape2Dで毒沼の広さを決める- ルートの
Area2DにDamageZone.gdをアタッチするか、子ノードとしてDamageZoneを追加する
パラメータ例:
damage_amount = 5.0damage_interval = 1.0(1 秒ごと)damage_on_enter = true(入った瞬間にもダメージ)target_groups = ["player"](プレイヤーだけを対象にする)
手順③: DamageZone とプレイヤーをシグナルでつなぐ
プレイヤーがこの毒沼からダメージを受けるには、damage_applied シグナルをどこかで受け取って apply_damage() を呼べばOKです。
一番シンプルな例として、シーンツリー上で直接接続してみましょう。
シーン構成図(例):
Main (Node2D)
├── Player (CharacterBody2D)
│ ├── Sprite2D
│ ├── CollisionShape2D
│ └── Camera2D
└── PoisonSwamp (Area2D)
├── Sprite2D
├── CollisionShape2D
└── DamageZone (Area2D にスクリプトアタッチ or 子ノード)
接続案 1: エディタ上で直接接続
PoisonSwamp(または DamageZone を付けたノード)を選択- 「ノード」タブ →
damage_appliedシグナルを選択 - 接続先に
Playerを選び、_on_damage_zone_damage_appliedのような関数名で接続
Player.gd に自動でこんな関数が生成されるので、中身を実装します。
func _on_damage_zone_damage_applied(target: Node, amount: float, source: Node) -> void:
# 自分宛てのダメージだけ処理する
if target != self:
return
apply_damage(amount, source)
接続案 2: コードで自動接続(コンポーネント指向っぽく)
プレイヤーが自分に関係ある DamageZone を見つけて、自動的にシグナル接続する方法もあります。
func _ready() -> void:
hp = max_hp
# シーン内の全 DamageZone を探して、自分に対してだけダメージを適用するように接続
for zone in get_tree().get_nodes_in_group("damage_zones"):
if zone is DamageZone:
zone.damage_applied.connect(_on_damage_zone_damage_applied)
func _on_damage_zone_damage_applied(target: Node, amount: float, source: Node) -> void:
if target != self:
return
apply_damage(amount, source)
この場合は、DamageZone 側のノードを "damage_zones" グループに入れておきましょう。
手順④: 敵や動く床にもそのまま流用する
同じ DamageZone コンポーネントを、敵専用の毒ガスや、動く溶岩床にもそのまま使い回せます。
例えば、敵専用の毒ガス:
EnemyPoisonGas (Area2D) ├── Sprite2D ├── CollisionShape2D └── DamageZone (Area2D にアタッチ)
target_groups = ["enemy"]- 敵のスクリプトにも
apply_damage()を実装しておく
動く溶岩床:
MovingLava (Node2D) ├── LavaArea (Area2D) │ ├── CollisionShape2D │ └── DamageZone (Area2D にアタッチ) └── AnimationPlayer
MovingLava 全体をアニメーションで動かすだけで、DamageZone も一緒に移動し、
「動く継続ダメージ床」が簡単に実現できます。
メリットと応用
この DamageZone コンポーネントを使うと、かなりシーン設計が楽になります。
- プレイヤーや敵のスクリプトがスリムになる
「どの床でダメージを受けるか」という情報を一切持たず、
ただapply_damage()を実装しておくだけで済みます。 - レベルデザインが直感的になる
シーン内でDamageZoneをアタッチしたエリアを置くだけで毒沼・溶岩を増やせるので、
スクリプトを触らずにマップをどんどん拡張できます。 - 再利用性が高い
ダメージ量や間隔を@exportで変えるだけで、弱い毒霧・強い溶岩・即死ゾーンなどを量産可能です。 - 疎結合でテストしやすい
DamageZoneは「ダメージイベントを発行するだけ」なので、
テスト用のダミーオブジェクトを作ってログを眺めるだけで動作確認できます。
そして何より、「継承でプレイヤーを肥大化させる」のではなく、「ダメージを発行するエリア」というコンポーネントをどこにでも合成できるのがポイントですね。
改造案: ノックバック付きダメージにする
例えば、「溶岩に触れたらダメージ+上方向にノックバック」させたい場合、
DamageZone 自体はそのままにして、プレイヤー側のハンドラだけ改造するときれいです。
# Player.gd の一部
func _on_damage_zone_damage_applied(target: Node, amount: float, source: Node) -> void:
if target != self:
return
apply_damage(amount, source)
# ノックバックを追加(上方向に跳ねるイメージ)
var knockback_strength := 300.0
if self is CharacterBody2D:
velocity.y = -knockback_strength
こうしておけば、DamageZone はあくまで「継続ダメージを発行するだけ」の中立コンポーネントのまま、
プレイヤー側の挙動(ノックバックする / しない)を自由に差し替えられます。
同じコンポーネントを、敵にはノックバックなしで、プレイヤーにはノックバックありで、
といったように振る舞いを変えられるのも、合成(Composition)ならではの気持ちよさですね。
