攻撃判定って、Godot標準のやり方だとちょっと面倒ですよね。

  • プレイヤーや敵ごとに Area2D を生やして
  • 毎回 area_entered シグナルをつないで
  • ダメージ値やノックバック方向をそれぞれのスクリプトに書き散らす

…という構成にすると、キャラが増えるたびに「どこに何を書いたっけ?」状態になりがちです。
さらに、PlayerSwordEnemyClawFireball といった攻撃ごとに別シーン・別スクリプトを作り、そこに直接ロジックを書き込んでしまうと、継承ツリーもノード階層もどんどん深くなっていきます。

そこで今回は、「攻撃判定」をまるっとコンポーネント化した 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)
  1. Player シーンを開く(CharacterBody2D をルートにしたものを想定)。
  2. 子ノードとして Node2D を追加し、HitboxComponent.gd をアタッチ。
  3. さらに別の子ノードとして Node2D を追加し、HurtboxComponent.gd をアタッチ。
  4. HitboxComponent のインスペクタで

    • damage = 15

    • knockback_force = 200

    • team_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)
  1. 敵シーンに HurtboxComponentHitboxComponent をそれぞれ追加。
  2. 敵側の team_id1 に設定(プレイヤーと区別するため)。
  3. Hitbox の collision_layer / mask を、プレイヤーの Hurtbox にだけ当たるように設定。
  4. 体当たり攻撃は常時有効にしたいので、enabled = true のままでOK。

この構成にすると、「プレイヤーと敵のどちらも Hitbox と Hurtbox を持つ」対称的な設計になります。
どちらが攻撃側でも被弾側でも、同じコンポーネントを使い回せるのがポイントですね。


手順③:動くトゲ床に仕込む

続いて、ギミック系の例です。触れたらダメージを与える床を作ってみましょう。

Spikes (StaticBody2D or Node2D)
 ├── Sprite2D
 └── HitboxComponent (Node2D)
      └── HitboxArea (Area2D)
          └── HitboxShape (CollisionShape2D)
  1. トゲ床シーンを作成し、ルートは StaticBody2DNode2D にします。
  2. 子に Sprite2D で見た目を配置。
  3. 子に HitboxComponent を追加し、スクリプトをアタッチ。
  4. Hitbox の damage を 5 などに設定し、team_id を 1(敵側)にするなど、ゲーム仕様に合わせて調整。

これで、プレイヤーが持つ HurtboxComponent に対しても、敵が持つ HurtboxComponent に対しても、同じ仕組みでダメージを送れるようになります。


メリットと応用

HitboxComponent を使うメリットを整理すると:

  • ノード階層が浅く保てる
    攻撃ごとに専用のシーンを作らず、キャラやギミックの下にコンポーネントとして付けるだけで済みます。
  • ロジックの重複を避けられる
    「Area2D を作る」「シグナルをつなぐ」「ダメージ情報を送る」といった共通処理は HitboxComponent に集約。
  • 攻撃のバリエーション追加が楽
    同じコンポーネントに対して damagerehit_cooldown を変えるだけで、弱攻撃・強攻撃・持続ダメージなどを表現できます。
  • レベルデザインがしやすい
    ステージ上のギミック(トゲ、炎、レーザーなど)にもそのまま使えるので、レベルデザイナーが「このノードに HitboxComponent を付ければダメージ床になる」と覚えておくだけでOKです。

継承ベースで PlayerAttackSwordAttackFireSwordAttack…とツリーを伸ばすよりも、
「移動は 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 に任せると、
コンポーネント同士の責務分離がより明確になっていきますね。