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")
あとは、キャラ側で died や damaged シグナルを拾ってアニメーションやエフェクトを再生すればOKです。
使い方の手順
- HealthManager をキャラのルート(または親)にアタッチ
- HurtboxComponent を追加し、子に Area2D + CollisionShape2D を置く
- 敵の Hitbox 側に「hitbox」グループと damage プロパティを用意
- シーンを再生して、Hitbox が重なったときに HP が減ることを確認
例1: プレイヤーに Hurtbox を付ける
典型的な 2D アクションゲームのプレイヤーシーン例です。
Player (CharacterBody2D)
├── Sprite2D
├── CollisionShape2D
├── HealthManager (Node)
└── HurtboxComponent (Node2D)
└── Area2D
└── CollisionShape2D
この構成で、以下のように設定します。
HealthManagerに上のサンプルスクリプトをアタッチHurtboxComponentに今回のスクリプトをアタッチHurtboxComponent.area_pathはデフォルトの"Area2D"のままでOKHurtboxComponent.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 に重なった瞬間、HurtboxComponent が HealthManager.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 をポン付けして、
「被ダメージ処理をコンポーネントに丸投げする気持ちよさ」を体験してみてください。
