Godot 4で敵キャラに「プレイヤーへ接触ダメージ」を実装しようとすると、ついこうなりがちですよね。
- 敵ごとに
Area2Dを生やして、body_enteredシグナルを繋ぐ - 敵スクリプトの中に「移動ロジック」と「接触ダメージロジック」が一緒に書かれてゴチャつく
- 別の敵にも同じ処理を書きたくなってコピペ…あとで直すのが地獄
Godot標準のやり方だと、「敵クラスを継承して、そこに接触ダメージ処理を足していく」という発想になりがちですが、継承を増やすとだんだんツラくなります。
そこで今回は、どの敵にもポン付けできる「ContactDamage」コンポーネントを用意して、合成(Composition)で解決していきましょう。
【Godot 4】触れたら痛い!「ContactDamage」コンポーネント
このコンポーネントは、親ノード(主に敵)にアタッチして使うことを想定しています。
- 親のコリジョンにプレイヤーが触れたら、自動でプレイヤーにダメージを送る
- ダメージ量、クールダウン、対象グループ名などを
@exportで調整可能 - 敵の移動ロジックとは完全に分離できるので、シーン構造がスッキリ
フルコード: ContactDamage.gd
extends Node
class_name ContactDamage
"""
ContactDamage.gd
親(敵)にプレイヤーが触れただけでダメージを与えるコンポーネント。
・親ノードは、PhysicsBody2D / Area2D など物理ボディを想定
・プレイヤー側には「ダメージを受けるメソッド」か「Health などのコンポーネント」がある想定
"""
## ====== 設定パラメータ(インスペクタから編集) ======
@export_group("Damage Settings", "damage_")
## 与えるダメージ量
@export var damage_amount: int = 10
## 連続ヒットを防ぐためのクールダウン時間(秒)
@export var damage_cooldown: float = 0.5
## 同じ対象に再度ダメージを与えるまでのクールダウン(秒)
## 0 以下なら「対象ごとの個別クールダウンなし」(グローバルクールダウンのみ)
@export var per_target_cooldown: float = 0.5
@export_group("Target Filter", "target_")
## ダメージ対象のグループ名(例: "player")
@export var target_group: StringName = "player"
## 対象が持っているべきメソッド名(例: "apply_damage")
## 空文字の場合は「Health コンポーネントを探す」など別の手段で処理する
@export var target_damage_method: StringName = "apply_damage"
## 親ボディに対してどのシグナルを使うか自動判定するか
@export var auto_connect_signals: bool = true
@export_group("Debug")
## デバッグ用に、ダメージ判定時にログを出すかどうか
@export var debug_log: bool = false
## ====== 内部状態 ======
## グローバルクールダウン用タイマー
var _global_cooldown_timer: float = 0.0
## 対象ごとのクールダウン管理: {Node: 残り時間}
var _per_target_cooldowns: Dictionary = {}
func _ready() -> void:
# 親ノードを取得して型チェック
var parent := get_parent()
if parent == null:
push_warning("ContactDamage: 親ノードが存在しません。このコンポーネントは必ず敵などの子として配置してください。")
return
if auto_connect_signals:
_try_auto_connect(parent)
func _physics_process(delta: float) -> void:
# グローバルクールダウンを減少
if _global_cooldown_timer > 0.0:
_global_cooldown_timer -= delta
# 対象ごとのクールダウンを減少
if per_target_cooldown > 0.0 and _per_target_cooldowns.size() > 0:
var to_erase: Array = []
for target: Node in _per_target_cooldowns.keys():
var time_left: float = _per_target_cooldowns[target]
time_left -= delta
if time_left <= 0.0:
to_erase.append(target)
else:
_per_target_cooldowns[target] = time_left
# クールダウン終了した対象を削除
for t in to_erase:
_per_target_cooldowns.erase(t)
## 親ノードの種類に応じて、適切なシグナルに接続する
func _try_auto_connect(parent: Node) -> void:
# 2D 物理ボディ
if parent is Area2D:
var area := parent as Area2D
# body_entered: PhysicsBody2D が入ったとき
if not area.body_entered.is_connected(_on_body_entered):
area.body_entered.connect(_on_body_entered)
# area_entered: Area2D が入ったとき(プレイヤーが Area2D の場合など)
if not area.area_entered.is_connected(_on_area_entered):
area.area_entered.connect(_on_area_entered)
if debug_log:
print("ContactDamage: Connected to Area2D signals on %s" % parent.name)
return
if parent is PhysicsBody2D:
var body := parent as PhysicsBody2D
# Godot 4 の PhysicsBody2D には直接 body_entered はないが、
# もしユーザーがカスタムシグナルを用意していればそれに接続できるようにしておく。
# ここでは何もしないで警告を出すだけにする。
push_warning("ContactDamage: 親が PhysicsBody2D (%s) ですが、衝突シグナルには自動接続できません。Area2D を子に置くか、手動で on_hit() を呼んでください。" % parent.name)
return
# それ以外の型の場合
push_warning("ContactDamage: 親ノード %s は Area2D / PhysicsBody2D ではありません。自動接続は行われません。" % parent.name)
## Area2D.body_entered 用コールバック
func _on_body_entered(body: Node) -> void:
_handle_contact(body)
## Area2D.area_entered 用コールバック
func _on_area_entered(area: Area2D) -> void:
_handle_contact(area)
## 外部から明示的に「このノードに触れた」と通知したいとき用
## 例: 親が CharacterBody2D で、move_and_collide の結果から手動で呼び出す
func on_hit(other: Node) -> void:
_handle_contact(other)
## 実際のダメージ処理の共通部分
func _handle_contact(target: Node) -> void:
if target == null:
return
# グループチェック
if target_group != StringName("") and not target.is_in_group(target_group):
return
# グローバルクールダウン中なら何もしない
if _global_cooldown_timer > 0.0:
if debug_log:
print("ContactDamage: Global cooldown active, skip damage to %s" % target.name)
return
# 対象ごとのクールダウンチェック
if per_target_cooldown > 0.0:
if _per_target_cooldowns.has(target):
if debug_log:
print("ContactDamage: Per-target cooldown active, skip damage to %s" % target.name)
return
# 対象にダメージを適用
var did_damage := _apply_damage_to_target(target)
if did_damage:
# クールダウンをリセット
_global_cooldown_timer = damage_cooldown
if per_target_cooldown > 0.0:
_per_target_cooldowns[target] = per_target_cooldown
## 対象ノードに対して実際にダメージを適用する
func _apply_damage_to_target(target: Node) -> bool:
# まずは指定されたメソッドがあるか確認
if target_damage_method != StringName("") and target.has_method(target_damage_method):
target.call(target_damage_method, damage_amount)
if debug_log:
print("ContactDamage: Called %s(%d) on %s" % [target_damage_method, damage_amount, target.name])
return true
# メソッドが無い場合は、Health というコンポーネントを探す例
# (プロジェクトごとにここは書き換えてOK)
var health := target.get_node_or_null("Health")
if health != null and health.has_method("apply_damage"):
health.call("apply_damage", damage_amount)
if debug_log:
print("ContactDamage: Called Health.apply_damage(%d) on %s" % [damage_amount, target.name])
return true
if debug_log:
push_warning("ContactDamage: %s はダメージ処理メソッドを持っていません。メソッド名: %s" % [target.name, String(target_damage_method)])
return false
使い方の手順
ここでは典型的な「敵に触れるとプレイヤーがダメージを受ける」例で説明します。
前提: プレイヤー側にダメージ処理メソッドを用意する
まずプレイヤー側に、ContactDamage から呼ばれるメソッドを用意します。
# Player.gd (例)
extends CharacterBody2D
var health: int = 100
func apply_damage(amount: int) -> void:
health -= amount
print("Player took %d damage! HP = %d" % [amount, health])
if health <= 0:
print("Player died")
# 死亡処理など
このメソッド名(apply_damage)を、ContactDamage の target_damage_method に設定します。
手順①: 敵シーンに ContactDamage を追加
敵シーンの例:
Enemy (Area2D) ├── Sprite2D ├── CollisionShape2D └── ContactDamage (Node)
- 敵ルートを
Area2Dにして、CollisionShape2Dを設定します。 - 右クリック → 「子ノードを追加」で
Nodeを追加し、ContactDamage.gdをアタッチします。
(もしくはスクリプトをclass_name ContactDamageにしているので、ノードに直接スクリプトをアタッチしてもOKです。)
手順②: ContactDamage のパラメータを設定
インスペクタで ContactDamage ノードを選び、以下のように設定します。
damage_amount: 10(お好みで)damage_cooldown: 0.5(0.5秒ごとにダメージ)per_target_cooldown: 0.5(同じプレイヤーに対しても0.5秒ごと)target_group:"player"target_damage_method:"apply_damage"
プレイヤー側は「グループ: player」に登録しておきましょう。
Player (CharacterBody2D) ← グループ "player" に追加 ├── Sprite2D ├── CollisionShape2D └── Player.gd (スクリプト)
手順③: シーン構成の全体像
プレイヤーと敵が複数いるシーン例:
Main (Node2D)
├── Player (CharacterBody2D)
│ ├── Sprite2D
│ ├── CollisionShape2D
│ └── Player.gd
├── EnemySlime (Area2D)
│ ├── Sprite2D
│ ├── CollisionShape2D
│ └── ContactDamage
└── EnemyBat (Area2D)
├── Sprite2D
├── CollisionShape2D
└── ContactDamage
この構造なら、どの敵にも同じ ContactDamage コンポーネントを付けるだけで、「触れたらダメージ」が自動で動きます。
敵ごとにダメージ量を変えたい場合も、各 ContactDamage の damage_amount を変えるだけでOKです。
手順④: PhysicsBody2D ベースの敵で使う場合
もし敵ルートが CharacterBody2D などで、Area2D を使いたくない場合は、手動で on_hit() を呼ぶ形にできます。
Enemy (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── ContactDamage
# Enemy.gd (例)
extends CharacterBody2D
@onready var contact_damage: ContactDamage = $ContactDamage
func _physics_process(delta: float) -> void:
# いつもの移動処理
var velocity := Vector2.LEFT * 100.0
var collision := move_and_collide(velocity * delta)
if collision:
# 衝突相手に手動で通知
contact_damage.on_hit(collision.get_collider())
こうしておけば、「移動ロジック」は Enemy.gd に、「接触ダメージロジック」は ContactDamage に分離され、コンポーネント指向の恩恵がしっかり出ますね。
メリットと応用
- シーン構造がシンプルになる
敵ごとに「ダメージ処理クラス」を継承して増やしていく必要がなく、ContactDamageノードをポン付けするだけでOKです。 - ロジックの再利用性が高い
スパイク床、トゲ付きの動く床、飛び道具のヒットボックスなどにも、同じコンポーネントをそのまま使い回せます。 - テストしやすい
「接触ダメージ」だけを個別にテストできるので、バグの切り分けが楽になります。 - プロジェクト方針に合わせて拡張しやすい
プレイヤー側の実装が変わっても、target_damage_methodや_apply_damage_to_target()をちょっと書き換えるだけで対応できます。
例えば、ノックバックを追加したいときは、次のように ContactDamage に1つメソッドを足して、_apply_damage_to_target() から呼ぶだけで済みます。
func _apply_knockback(target: Node) -> void:
# 親(敵)からターゲットへの方向にノックバックさせる例
if not target is CharacterBody2D:
return
var body := target as CharacterBody2D
var from_pos := get_parent().global_position
var to_pos := body.global_position
var dir := (to_pos - from_pos).normalized()
var knockback_force := 300.0
# あなたのプロジェクトの CharacterBody2D 実装に合わせて調整してください
body.velocity += dir * knockback_force
このように、「接触ダメージ」という1つの責務をコンポーネントとして切り出しておくと、継承ツリーを増やさずに機能を盛っていけるのが気持ちいいところですね。
ぜひ自分のプロジェクト向けに、ContactDamage をカスタマイズしてみてください。
