攻撃判定って、Godot標準のやり方だとちょっと面倒ですよね。
- プレイヤーや敵ごとに
Area2Dを生やして - 毎回
area_enteredシグナルをつないで - ダメージ値やノックバック方向をそれぞれのスクリプトに書き散らす
…という構成にすると、キャラが増えるたびに「どこに何を書いたっけ?」状態になりがちです。
さらに、PlayerSword、EnemyClaw、Fireball といった攻撃ごとに別シーン・別スクリプトを作り、そこに直接ロジックを書き込んでしまうと、継承ツリーもノード階層もどんどん深くなっていきます。
そこで今回は、「攻撃判定」をまるっとコンポーネント化した HitboxComponent を用意します。
どんなノード(プレイヤーでも敵でも飛び道具でも)にポン付けして、「当たったら Hurtbox にダメージ情報を送る」 という役割だけを担当させる構成にしてみましょう。
【Godot 4】当たり判定はコンポーネントに丸投げ!「HitboxComponent」コンポーネント
このコンポーネントの思想はシンプルです。
- HitboxComponent:攻撃側。「当てる」だけ担当
- HurtboxComponent(想定):被弾側。「ダメージを受ける」だけ担当
両者は「ダメージ情報」をやり取りするだけで、お互いの実装詳細は知りません。
これにより、プレイヤー・敵・ギミックなどどこからでも、同じ HitboxComponent を再利用できるようになります。
フルコード:HitboxComponent.gd
extends Node2D
class_name HitboxComponent
"""
攻撃判定用コンポーネント。
内部に Area2D を持ち、重なった Hurtbox にダメージ情報を送信する。
想定する Hurtbox 側のインターフェース:
- Hurtbox が "apply_damage(damage_info: Dictionary)" というメソッドを持っている
もしくは
- "damaged" というシグナルを持っている (emit で受け取る)
ここではメソッド呼び出しを主に想定しつつ、
存在チェックをして安全に呼ぶようにしています。
"""
# ==========================
# エディタから設定できるパラメータ
# ==========================
@export_category("Damage Settings")
## 与えるダメージ量(整数 or 小数)
@export var damage: float = 10.0
## ノックバックの強さ(0 ならノックバックなし)
@export var knockback_force: float = 0.0
## 攻撃属性など(例: "physical", "fire", "ice"...)
@export var damage_type: String = "physical"
## 攻撃した側のチームID(例: 0=プレイヤー, 1=敵)
## Hurtbox 側で「同じチームからの攻撃は無視」などに使える
@export var team_id: int = 0
@export_category("Hitbox Shape")
## ヒットボックスを有効にするかどうか(攻撃のON/OFF)
@export var enabled: bool = true:
set(value):
enabled = value
if is_inside_tree():
_area.monitoring = enabled
_area.monitorable = enabled
## 一度ヒットした Hurtbox に再度当てるまでのクールタイム(秒)
## 0 の場合は毎フレームでも当たる
@export var rehit_cooldown: float = 0.2
## 一度に複数の Hurtbox に当たってよいか
@export var allow_multi_hit: bool = true
@export_category("Debug")
## デバッグ用にヒットログを出すかどうか
@export var debug_print: bool = false
# ==========================
# 内部参照
# ==========================
var _area: Area2D
var _shape: CollisionShape2D
# Hurtbox ごとの「最後に当てた時間」を記録する
var _last_hit_time: Dictionary = {}
# ==========================
# ライフサイクル
# ==========================
func _ready() -> void:
# 自動的に Area2D + CollisionShape2D を用意する構成にします。
# すでに子に Area2D がある場合はそれを使ってもOK。
_area = _find_or_create_area()
_shape = _find_or_create_shape(_area)
# 監視設定
_area.monitoring = enabled
_area.monitorable = enabled
_area.collision_layer = 0 # 実際のゲームでは適宜設定しましょう
_area.collision_mask = 0 # ここもエディタで設定する前提でもOK
# シグナル接続
if not _area.area_entered.is_connected(_on_area_entered):
_area.area_entered.connect(_on_area_entered)
if debug_print:
print("[HitboxComponent] Ready. Owner: ", owner)
# ==========================
# パブリックAPI
# ==========================
## 攻撃情報を Dictionary で返すユーティリティ。
## Hurtbox 側に渡す想定のデータ構造です。
func get_damage_info() -> Dictionary:
return {
"amount": damage,
"knockback_force": knockback_force,
"damage_type": damage_type,
"team_id": team_id,
"source": owner, # 誰の攻撃か
"hitbox": self, # どのHitboxか
}
## 一時的にヒットボックスを有効化
func enable() -> void:
enabled = true
## 一時的にヒットボックスを無効化
func disable() -> void:
enabled = false
## 今までに記録した「最後に当てた時間」をリセットする
## 例: 攻撃アニメーションの開始時に呼ぶと、毎回ちゃんと当たる
func reset_hit_memory() -> void:
_last_hit_time.clear()
# ==========================
# 内部処理
# ==========================
func _find_or_create_area() -> Area2D:
# すでに子に Area2D があるならそれを使う
for child in get_children():
if child is Area2D:
return child
# なければ自動生成
var area := Area2D.new()
area.name = "HitboxArea"
add_child(area)
area.owner = get_tree().edited_scene_root # エディタで保存可能にするため
return area
func _find_or_create_shape(area: Area2D) -> CollisionShape2D:
for child in area.get_children():
if child is CollisionShape2D:
return child
var shape := CollisionShape2D.new()
shape.name = "HitboxShape"
area.add_child(shape)
shape.owner = get_tree().edited_scene_root
# デフォルトで小さめの RectangleShape2D を割り当てておく
var rect := RectangleShape2D.new()
rect.size = Vector2(16, 16)
shape.shape = rect
return shape
# ==========================
# シグナルハンドラ
# ==========================
func _on_area_entered(other_area: Area2D) -> void:
if not enabled:
return
# 自分自身の Area2D に当たった場合などは無視
if other_area == _area:
return
var hurtbox := _extract_hurtbox_from_area(other_area)
if hurtbox == null:
return
# チームチェック(同じチームは無視したい場合)
if "team_id" in hurtbox and hurtbox.team_id == team_id:
return
# 再ヒットクールタイムのチェック
if not _can_hit_again(hurtbox):
return
_register_hit_time(hurtbox)
var damage_info := get_damage_info()
# Hurtbox 側が apply_damage を持っていれば呼ぶ
if hurtbox.has_method("apply_damage"):
hurtbox.apply_damage(damage_info)
elif hurtbox.has_signal("damaged"):
# シグナルだけを提供している場合
hurtbox.emit_signal("damaged", damage_info)
else:
if debug_print:
print("[HitboxComponent] Hurtbox has no apply_damage() or damaged signal: ", hurtbox)
if debug_print:
print("[HitboxComponent] Hit: ", hurtbox, " info: ", damage_info)
# 1体だけに当てたい場合は、ここで一旦無効化してもよい
if not allow_multi_hit:
enabled = false
# ==========================
# ヒット管理
# ==========================
func _can_hit_again(hurtbox: Node) -> bool:
if rehit_cooldown <= 0.0:
return true
var id := _get_hurtbox_id(hurtbox)
var now := Time.get_ticks_msec() / 1000.0
if id in _last_hit_time:
var last_time: float = _last_hit_time[id]
return (now - last_time) >= rehit_cooldown
return true
func _register_hit_time(hurtbox: Node) -> void:
var id := _get_hurtbox_id(hurtbox)
var now := Time.get_ticks_msec() / 1000.0
_last_hit_time[id] = now
func _get_hurtbox_id(hurtbox: Node) -> int:
# ObjectID をそのままキーに使う
return hurtbox.get_instance_id()
func _extract_hurtbox_from_area(other_area: Area2D) -> Node:
# 1. そのものが HurtboxComponent だった場合
if other_area.has_method("apply_damage") or other_area.has_signal("damaged"):
return other_area
# 2. 親に Hurtbox 的なものがいる場合
var parent := other_area.get_parent()
if parent and (parent.has_method("apply_damage") or parent.has_signal("damaged")):
return parent
# 3. さらに上の親も見る(必要に応じて)
if parent and parent.get_parent() and (parent.get_parent().has_method("apply_damage") or parent.get_parent().has_signal("damaged")):
return parent.get_parent()
return null
使い方の手順
ここでは代表的な3パターンを例にします。
- プレイヤーの近接攻撃(剣)
- 敵の体当たり攻撃
- 動くトゲ床(触れたらダメージ)
前提:Hurtbox 側の簡易実装
まずは被弾側のコンポーネント例を用意しておきます(超シンプル版)。
extends Node2D
class_name HurtboxComponent
@export var max_hp: float = 100.0
@export var team_id: int = 0
var current_hp: float
signal damaged(damage_info: Dictionary)
signal died
func _ready() -> void:
current_hp = max_hp
func apply_damage(damage_info: Dictionary) -> void:
var amount := damage_info.get("amount", 0.0)
current_hp -= amount
emit_signal("damaged", damage_info)
if current_hp <= 0.0:
current_hp = 0.0
emit_signal("died")
これをプレイヤーや敵のシーンに付けておけば、HitboxComponent からダメージを受け取れるようになります。
手順①:プレイヤーに HitboxComponent を付ける
例として、剣を振る近接攻撃を作ります。
Player (CharacterBody2D)
├── Sprite2D
├── CollisionShape2D
├── HurtboxComponent (Node2D)
└── HitboxComponent (Node2D)
└── HitboxArea (Area2D)
└── HitboxShape (CollisionShape2D)
- Player シーンを開く(CharacterBody2D をルートにしたものを想定)。
- 子ノードとして
Node2Dを追加し、HitboxComponent.gdをアタッチ。 - さらに別の子ノードとして
Node2Dを追加し、HurtboxComponent.gdをアタッチ。 - HitboxComponent のインスペクタで
damage= 15knockback_force= 200team_id= 0(プレイヤー)
に設定。
剣を振るアニメーションに合わせて、攻撃判定のON/OFFを切り替えたい場合は、プレイヤー側スクリプトからこう呼びます:
# Player.gd (一例)
@onready var hitbox: HitboxComponent = $HitboxComponent
func _physics_process(delta: float) -> void:
if Input.is_action_just_pressed("attack"):
_start_attack()
func _start_attack() -> void:
hitbox.reset_hit_memory()
hitbox.enable()
# 攻撃アニメーションの再生など
$AnimationPlayer.play("attack")
func _on_attack_animation_finished() -> void:
hitbox.disable()
手順②:敵の体当たり攻撃に使う
敵がプレイヤーにぶつかったらダメージ、というよくあるパターンです。
Enemy (CharacterBody2D)
├── Sprite2D
├── CollisionShape2D
├── HurtboxComponent (Node2D)
└── HitboxComponent (Node2D)
└── HitboxArea (Area2D)
└── HitboxShape (CollisionShape2D)
- 敵シーンに
HurtboxComponentとHitboxComponentをそれぞれ追加。 - 敵側の
team_idを1に設定(プレイヤーと区別するため)。 - Hitbox の
collision_layer / maskを、プレイヤーの Hurtbox にだけ当たるように設定。 - 体当たり攻撃は常時有効にしたいので、
enabled = trueのままでOK。
この構成にすると、「プレイヤーと敵のどちらも Hitbox と Hurtbox を持つ」対称的な設計になります。
どちらが攻撃側でも被弾側でも、同じコンポーネントを使い回せるのがポイントですね。
手順③:動くトゲ床に仕込む
続いて、ギミック系の例です。触れたらダメージを与える床を作ってみましょう。
Spikes (StaticBody2D or Node2D)
├── Sprite2D
└── HitboxComponent (Node2D)
└── HitboxArea (Area2D)
└── HitboxShape (CollisionShape2D)
- トゲ床シーンを作成し、ルートは
StaticBody2DかNode2Dにします。 - 子に
Sprite2Dで見た目を配置。 - 子に
HitboxComponentを追加し、スクリプトをアタッチ。 - Hitbox の
damageを 5 などに設定し、team_idを 1(敵側)にするなど、ゲーム仕様に合わせて調整。
これで、プレイヤーが持つ HurtboxComponent に対しても、敵が持つ HurtboxComponent に対しても、同じ仕組みでダメージを送れるようになります。
メリットと応用
HitboxComponent を使うメリットを整理すると:
- ノード階層が浅く保てる
攻撃ごとに専用のシーンを作らず、キャラやギミックの下にコンポーネントとして付けるだけで済みます。 - ロジックの重複を避けられる
「Area2D を作る」「シグナルをつなぐ」「ダメージ情報を送る」といった共通処理は HitboxComponent に集約。 - 攻撃のバリエーション追加が楽
同じコンポーネントに対してdamageやrehit_cooldownを変えるだけで、弱攻撃・強攻撃・持続ダメージなどを表現できます。 - レベルデザインがしやすい
ステージ上のギミック(トゲ、炎、レーザーなど)にもそのまま使えるので、レベルデザイナーが「このノードに HitboxComponent を付ければダメージ床になる」と覚えておくだけでOKです。
継承ベースで PlayerAttack → SwordAttack → FireSwordAttack…とツリーを伸ばすよりも、
「移動は MovementComponent」「攻撃判定は HitboxComponent」「ダメージ処理は HurtboxComponent」と分離しておくほうが、
後からの差し替えや並行開発が圧倒的にやりやすくなります。
改造案:方向付きノックバックを追加する
最後に、ちょっとした改造案です。
「攻撃した方向に応じてノックバックベクトルを計算したい」という場合、HitboxComponent に以下のようなヘルパーを追加できます。
## 攻撃者と被弾者の位置からノックバック方向ベクトルを計算して
## damage_info に含めるユーティリティ
func build_damage_info_with_direction(target_global_position: Vector2) -> Dictionary:
var info := get_damage_info()
var dir := (target_global_position - global_position).normalized()
# 攻撃者からターゲットへの方向とは逆向きに飛ばしたい場合は -dir にする
info["knockback_direction"] = dir
return info
そして _on_area_entered() 内で
var damage_info := build_damage_info_with_direction(hurtbox.global_position)
のように差し替えれば、Hurtbox 側は knockback_direction を使って吹き飛び処理を実装できます。
このように、HitboxComponent 自体は「攻撃イベントを検出して情報を送る」ことに専念させておき、
ノックバックの具体的な挙動やリアクションは Hurtbox 側や専用の ReactionComponent に任せると、
コンポーネント同士の責務分離がより明確になっていきますね。
