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 で毒沼の広さを決める
  • ルートの Area2DDamageZone.gd をアタッチするか、子ノードとして DamageZone を追加する

パラメータ例:

  • damage_amount = 5.0
  • damage_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: エディタ上で直接接続

  1. PoisonSwamp(または DamageZone を付けたノード)を選択
  2. 「ノード」タブ → damage_applied シグナルを選択
  3. 接続先に 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)ならではの気持ちよさですね。