Godotでアクションゲームを作り始めると、だいたいこういう流れになりがちですよね。

  • プレイヤーシーンの中に Area2D を直接置いて「被ダメージ判定」を書く
  • 敵シーンの中にも同じような Area2D+スクリプトを書いて…
  • ボスシーンではさらに特殊な処理を継承で上書きして…

結果として、

  • 「このキャラはどこでダメージを受けているのか?」がシーンを開かないと分からない
  • プレイヤーと敵でほぼ同じ被ダメージ処理をコピペしていて、修正がつらい
  • ノード階層が深くなり、Area2Dのコールバックがあちこちに散らばる

といった「典型的Godotあるある」にハマります。

そこで今回は、「被ダメージ判定」を丸ごとコンポーネント化して、どんなキャラにもポン付けできる HurtboxComponent を作ってみましょう。
「継承でプレイヤー基底クラスに書く」のではなく、「HealthManager」と「HurtboxComponent」を合成するスタイルですね。

【Godot 4】当たり判定はコンポーネントに丸投げ!「HurtboxComponent」コンポーネント

HurtboxComponent はざっくり言うと、

  • 内部に Area2D を持つ(あるいは自分自身が Area2D として振る舞う)
  • 敵の Hitbox(こちらも Area2D 想定)と重なったら
  • 同じシーン内の HealthManager を探して HP を減らす

という「被ダメージ判定だけ」を担当するコンポーネントです。

プレイヤーでも敵でも動く床でも、「ダメージを受ける側」にこれをアタッチしておけば、
「Hitbox と重なったら勝手に HP を減らしてくれる箱」として機能します。


フルコード: HurtboxComponent.gd


extends Node2D
class_name HurtboxComponent
## HurtboxComponent
## ・自分の子にある Area2D を使って「被ダメージ判定」を行うコンポーネント
## ・敵側の Hitbox(Area2D) と重なったら、同一シーン内の HealthManager にダメージを通知する

@export var area_path: NodePath = NodePath("Area2D")
## 被ダメージ判定に使う Area2D へのパス
## デフォルトでは自分の直下にある "Area2D" を想定
## シーン構成に合わせてインスペクタから変更できます

@export var health_manager_path: NodePath
## ダメージを適用する HealthManager へのパス
## 空の場合は、親ノードから自動で探しにいきます

@export var hitbox_group_name: String = "hitbox"
## 「攻撃判定側(Hitbox)」が所属するグループ名
## 例: 敵の攻撃判定 Area2D に "hitbox" グループを付けておく

@export var damage_cooldown: float = 0.2
## 連続ヒットを防ぐためのクールタイム(秒)
## 0 にすると毎フレームダメージが入ります(多段ヒットにしたい場合など)

@export var debug_log: bool = false
## true にすると、ダメージイベントを print でログ出力します

var _area: Area2D
var _health_manager: Node
var _last_damage_time := -INF


func _ready() -> void:
    # Area2D を取得
    if area_path.is_empty():
        push_warning("HurtboxComponent: `area_path` が未設定です。子ノードに Area2D を置き、パスを設定してください。")
    else:
        _area = get_node_or_null(area_path) as Area2D
        if _area == null:
            push_warning("HurtboxComponent: area_path = '%s' に Area2D が見つかりません。" % area_path)
        else:
            # Area2D のシグナルを接続
            _area.body_entered.connect(_on_body_entered)
            _area.area_entered.connect(_on_area_entered)

    # HealthManager を取得
    if health_manager_path.is_empty():
        # パス未指定なら、親からそれっぽいノードを自動探索
        _health_manager = _find_health_manager_in_parents()
        if _health_manager == null:
            push_warning("HurtboxComponent: HealthManager が見つかりませんでした。`health_manager_path` を設定するか、親に HealthManager を付けてください。")
    else:
        _health_manager = get_node_or_null(health_manager_path)
        if _health_manager == null:
            push_warning("HurtboxComponent: health_manager_path = '%s' にノードが見つかりません。" % health_manager_path)

    if _health_manager != null and not _health_manager.has_method("apply_damage"):
        push_warning("HurtboxComponent: 参照している HealthManager に `func apply_damage(amount: int, source: Node)` がありません。")


func _on_body_entered(body: Node) -> void:
    # RigidBody2D / CharacterBody2D などの「ボディ」が侵入したとき
    if not _can_take_damage():
        return
    if not _is_hitbox(body):
        return
    _apply_damage_from(body)


func _on_area_entered(area: Area2D) -> void:
    # Area2D が侵入したとき
    if not _can_take_damage():
        return
    if not _is_hitbox(area):
        return
    _apply_damage_from(area)


func _can_take_damage() -> bool:
    # クールタイム中かどうかを判定
    if damage_cooldown <= 0.0:
        return true
    var now := Time.get_ticks_msec() / 1000.0
    if now - _last_damage_time >= damage_cooldown:
        _last_damage_time = now
        return true
    return false


func _is_hitbox(node: Node) -> bool:
    # 相手が「攻撃判定グループ」に属しているかどうか
    return node.is_in_group(hitbox_group_name)


func _apply_damage_from(source: Node) -> void:
    if _health_manager == null:
        if debug_log:
            print("HurtboxComponent: HealthManager が設定されていないためダメージを適用できません。")
        return

    # ダメージ量を決定する
    # 1) source が `damage` プロパティを持っていればそれを使う
    # 2) なければデフォルトで 1 ダメージ
    var damage_amount := 1
    if "damage" in source:
        damage_amount = int(source.damage)

    if debug_log:
        print("HurtboxComponent: %s から %d ダメージを受けました。" % [source.name, damage_amount])

    # HealthManager にダメージを適用
    # 想定されるシグネチャ: func apply_damage(amount: int, source: Node) -> void
    if _health_manager.has_method("apply_damage"):
        _health_manager.call("apply_damage", damage_amount, source)


func _find_health_manager_in_parents() -> Node:
    # 親方向に遡って `HealthManager` らしきノードを探す
    var current := get_parent()
    while current:
        # 名前でゆるく判定("HealthManager" を含む)
        if "HealthManager" in current.name or current.get_class() == "HealthManager":
            return current
        # メソッドを持っているかどうかで判定
        if current.has_method("apply_damage"):
            return current
        current = current.get_parent()
    return null

HealthManager のシンプル実装例

上のコンポーネントが期待しているのは「apply_damage(amount, source) を持つノード」です。
最低限、こんな感じの HealthManager を用意しておきましょう。


extends Node
class_name HealthManager

@export var max_hp: int = 10
var current_hp: int

signal died
signal damaged(amount: int, source: Node)

func _ready() -> void:
    current_hp = max_hp


func apply_damage(amount: int, source: Node) -> void:
    current_hp = max(current_hp - amount, 0)
    emit_signal("damaged", amount, source)

    if current_hp == 0:
        emit_signal("died")

あとは、キャラ側で dieddamaged シグナルを拾ってアニメーションやエフェクトを再生すればOKです。


使い方の手順

  1. HealthManager をキャラのルート(または親)にアタッチ
  2. HurtboxComponent を追加し、子に Area2D + CollisionShape2D を置く
  3. 敵の Hitbox 側に「hitbox」グループと damage プロパティを用意
  4. シーンを再生して、Hitbox が重なったときに HP が減ることを確認

例1: プレイヤーに Hurtbox を付ける

典型的な 2D アクションゲームのプレイヤーシーン例です。

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── HealthManager (Node)
 └── HurtboxComponent (Node2D)
      └── Area2D
          └── CollisionShape2D

この構成で、以下のように設定します。

  • HealthManager に上のサンプルスクリプトをアタッチ
  • HurtboxComponent に今回のスクリプトをアタッチ
  • HurtboxComponent.area_path はデフォルトの "Area2D" のままでOK
  • HurtboxComponent.health_manager_path は空でもOK(親から自動探索)
  • HurtboxComponent.hitbox_group_name"hitbox" のまま

敵の攻撃判定(Hitbox)は例えばこんな感じにします:

Enemy (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── Hitbox (Area2D)
 │    └── CollisionShape2D
 └── HealthManager (Node) ※敵にも HP がある場合

Hitbox の設定:

  • インスペクタの「ノード」タブ → 「グループ」で hitbox グループを追加
  • スクリプトで damage プロパティを用意(例: 1〜5 など)

# Enemy の Hitbox 用スクリプト例
extends Area2D

@export var damage: int = 2

これで、Enemy.Hitbox の Area2D が Player の Hurtbox の Area2D に重なった瞬間
HurtboxComponentHealthManager.apply_damage() を呼び出し、HPが減ります。

例2: 動くトゲ床にも Hurtbox を使う

「動く床だけど、敵の攻撃で壊れる」みたいなギミックにもそのまま使えます。

BreakablePlatform (Node2D)
 ├── Sprite2D
 ├── HealthManager (Node)
 └── HurtboxComponent (Node2D)
      └── Area2D
          └── CollisionShape2D

HealthManager.died シグナルで「床を消す」だけです。


# BreakablePlatform.gd
extends Node2D

@onready var health: HealthManager = $HealthManager

func _ready() -> void:
    health.died.connect(_on_died)

func _on_died() -> void:
    queue_free() # 壊れる床

プレイヤーと同じ HurtboxComponent をそのまま流用している点がポイントですね。


メリットと応用

HurtboxComponent を使うメリットはかなり分かりやすいです。

  • 「被ダメージの責務」が1つのコンポーネントにまとまる
    各キャラのスクリプトから _on_area_entered などの判定ロジックを追い出せます。
  • シーン構造がフラットで見通しが良くなる
    「このキャラはダメージを受ける?」→ HurtboxComponent が付いているかどうかを見るだけ。
  • プレイヤー / 敵 / ギミックで完全に使い回し
    どれも「HealthManager + HurtboxComponent」を合成するだけで済みます。
  • テストがしやすい
    HurtboxComponent 単体で Hitbox と HealthManager の挙動を検証できます。

Godot の「なんでもノード継承で作りたくなる罠」から抜け出して、
「Health 管理」と「被ダメージ判定」をそれぞれコンポーネントとして合成することで、
将来の拡張(シールド、バリア、属性ダメージなど)がかなり楽になります。

改造案: 無敵時間(i-frame)を HealthManager と連携

例えば「ダメージを受けたら一時的に無敵になる」処理を、
HurtboxComponent 側に少し追加しても良いです。


# HurtboxComponent に追記する例(簡易 i-frame)
var _invincible: bool = false

func set_invincible(enabled: bool) -> void:
    _invincible = enabled
    if _area:
        _area.monitoring = not enabled  # 無敵中は当たり判定を止める


func _apply_damage_from(source: Node) -> void:
    if _invincible:
        return

    if _health_manager == null:
        return

    var damage_amount := 1
    if "damage" in source:
        damage_amount = int(source.damage)

    if _health_manager.has_method("apply_damage"):
        _health_manager.call("apply_damage", damage_amount, source)

    # ダメージを受けた後、0.5秒だけ無敵にする例
    set_invincible(true)
    await get_tree().create_timer(0.5).timeout
    set_invincible(false)

もちろん、本格的にやるなら「無敵状態管理コンポーネント」を別に切り出しても良いですが、
まずはこんな風に「被ダメージ判定コンポーネント」を起点にして機能を増やしていくと、
継承地獄に落ちずに済むのでおすすめです。

ぜひ、自分のプロジェクトのプレイヤー・敵・ギミックにこの HurtboxComponent をポン付けして、
「被ダメージ処理をコンポーネントに丸投げする気持ちよさ」を体験してみてください。