【Godot】Godotの攻撃判定入門:1回の攻撃で同じ相手に何度もダメージが入るのを止める

作成: 2026-09-22最終更新: 2026-10-10

1回の攻撃で同じ相手に1回だけダメージを入れるように、当てた相手を覚える形でArea2Dの攻撃判定を作る方法を解説します。

剣の振りや薙ぎ払いのような攻撃は、範囲に入った相手それぞれに、1回の攻撃につき1回だけダメージを入れるのが基本です。Godot では、Area2D の当たり判定と、この攻撃で当てた相手を覚えるリストで作ります。

範囲に入った瞬間のシグナルでそのままHPを減らすと、1回の攻撃で同じスライムのHPが3回減ることがあります。逆に、同じ場所で2回目を振っても当たらないこともあります。

この記事では、シグナルの回数ではなく「この攻撃で当てた相手」を数える形にして、相手ごとにダメージを1回だけ入れる方法を解説します。

剣の範囲に入ったスライム2体に、1回の攻撃でダメージが1回ずつだけ入り、始めも採用も2になる

この記事でわかること

  • area_entered が数えているものと、同じ相手にダメージが重なる3つの入口
  • 「1回の攻撃 × 相手1体」を単位にして、当てた相手を覚える形
  • 2回目の攻撃が当たらなくなる理由と、すでに重なっている相手の拾い方

動作確認環境: Godot 4.7

Sponsored

area_entered が数えるもの

Area2D の area_entered は、2つの範囲が重なり始めたときに1回だけ来ます。重なっているあいだ毎フレーム届くのではなく、始まりの1点だけです。

いま重なっているかどうかは、get_overlapping_areas() や overlaps_area() で調べます。こちらは始まりではなく、直近の物理更新(既定で毎秒60回)で分かっている重なりを答えます。入った瞬間と入っているあいだの作り分けは Area2Dの記事 で扱っています。

area_enteredは重なり始めの1点で1回だけ来て、get_overlapping_areas()は重なっているあいだ答え続ける

シグナルが1回なら、そこでダメージを1回出せば、入るダメージも1回で済むはずです。ところが実際には3回ぶん減ります。

ずれているのは、数えている場所です。area_entered が数えるのは、攻撃の回数でも相手の数でもなく、重なりが新しく検出された回数です。「1回の攻撃につき、同じ相手へは1回まで」という数え方は、シグナルの側には存在しません。それは攻撃する側が自分で持つ必要があります。

他のエンジンでいうと: 重なりを検出する用途では、Godot の Area2D は Unity の Collider2D で「Is Trigger」を有効にしたもの、Unreal Engine の Overlap 応答を設定したコリジョンコンポーネントに近い役割です。「1回の攻撃で相手ごとに1回」を自前で持つ必要があるところも同じです。

ダメージが重なる3つの入口

メトロイドヴァニアの剣、ベルトスクロールの殴り、見下ろしARPGの薙ぎ払い——どれも同じ3つのどこかで詰まります。

同じ相手にダメージが重なる入口は、出て入り直す・口が2つ・毎フレーム減らすの3つ

1つ目は、重なりが切れて入り直していることです。剣の判定が体から一度出て、また戻ってくると、そのたびに新しい重なりとして検出されます。弧を描く軌道では実際に起きます。アニメーションのうち攻撃が当たるコマ(有効フレーム)だけ判定をオンにする作りでも起きます。オフのあいだに重なりが終わったことになり、オンに戻して再び重なりが見つかると、入り直しとして数え直されるからです。

2つ目は、同じ接触を2か所で受け取っていることです。攻撃側の Area2D と、相手のHurtBox(ダメージを受け取る側の Area2D。被弾範囲とも呼ぶ)は、どちらも area_entered を持っています。両方でHPを別々に減らすと、1回の接触で2回ダメージが出ます。敵の当たり判定が頭と胴で2つに分かれていて、それぞれが別の Area2D になっている場合も同じです。area_shape_entered は、形状(Area2D の子の CollisionShape2D)の組ごとに来ます。Area2D が1つでも、形状が2つあれば2回届きます。

3つ目は、重なりの状態を毎フレームのダメージにしていることです。_physics_process() の中で重なりの一覧を回し、その場でHPを減らす作りです。これはシグナルの話ではなく、物理の更新ごとにダメージを出しているだけです。既定の毎秒60回なら、0.5秒重なるあいだに約30回入ります。

3つとも、HPを減らす出口を1つにまとめ、そこで当てた相手を数えれば止まります。

Sponsored

当てた相手のリスト

入口を塞ぐより、出口を1つにするほうが確実です。ダメージを出す直前に「この攻撃で、この相手にはもう当てたか」を確かめ、当てていなければ記録してから減らします。

var is_attacking := false
var hit_targets: Array[Node] = []

func _try_hit(area: Area2D) -> void:
	if not is_attacking:
		return                          # 攻撃していないあいだの接触は無視する
	var box := area as HurtBox          # HurtBox でなければ null になる
	if box == null or box.target == null:
		return
	if hit_targets.has(box.target):     # この攻撃ではもう当てた
		return
	hit_targets.append(box.target)      # 先に記録してから減らす
	box.target.take_damage(10)
入口がいくつあっても、当てた相手のリストを通れるのは最初の1回だけで、HPへ届くのも1回

入口がいくつあっても構いません。同じリストを通るかぎり、先へ進むのは最初の1回だけです。条件は、HPを減らす行がこの関数の中にしかないことです。HurtBox側でも別にHPを減らしていると、その分はリストを通らずに素通りします。

覚えるのはHurtBoxではなく、HPを持っている本体にします。頭と胴で当たり判定が分かれている敵に対して、HurtBoxを記録する作りだと、2つとも別々の相手として通ってしまいます。

では、HurtBoxから本体へはどうやって辿るか。area.get_parent() が正しいのは、HurtBoxが本体の直接の子になっているときだけです。area.owner はシーンを保存するときの所有関係を表すもので、HPを持っているノードを指す保証はありません。行き先はHurtBox自身に持たせます。

class_name HurtBox
extends Area2D

@export var target: Node   # HPを持っている本体。インスペクタで入れる

1行目の class_name HurtBox で、このスクリプトに型の名前が付きます。_try_hit() の area as HurtBox は、受け取った Area2D をこの型として扱い直す書き方です。このスクリプトが付いていない Area2D なら null になるので、box == null の行で弾けます。

HurtBoxに行き先を持たせると頭と胴の2つが同じ本体を指すが、get_parent()は途中のBodyで止まる

リストを空にするのは、攻撃を始める直前です。area_exited で忘れる作りにすると、同じ振りのなかで剣が体から出て入り直したときに、2回目のダメージが通ります。

func _start_attack() -> void:
	is_attacking = true
	hit_targets.clear()     # ここが「1回の攻撃」の区切り
area_exitedで忘れると入り直しで2回目が入り、攻撃開始で空にすると1回の振りで1回だけ入る

すでに重なっている相手の拾い方

ここまでで、同じ相手にダメージが重なることは止まります。ところが、同じ場所で2回目を振ると、今度はまったく当たりません。

area_entered は、重なりが始まったときにしか来ません。1回目からずっと範囲に入っている相手には、2回目でも新しいシグナルは届きません。リストを空にしても、そこへ流し込むものが無ければ何も起きません。

重なり続けている相手には2回目のarea_enteredが来ないので、攻撃を始めた時点でいま重なっている相手を確かめて拾う

シグナルが教えてくれるのは、途中から入ってきた相手です。すでに入っている相手のほうは、攻撃を始めた瞬間に get_overlapping_areas() で一覧をもらって、1体ずつ確かめます。

func _start_attack() -> void:
	is_attacking = true
	hit_targets.clear()
	for area in sword_arc.get_overlapping_areas():
		_try_hit(area)      # すでに範囲内にいる相手も確かめる

これで入口が、シグナルと開始時の確認の2つになりました。それでも、どちらも同じ _try_hit() を通り、同じリストを見ているので、ダメージは1回のままです。

Sponsored

実践:スライム2体を1回の攻撃でまとめて切る

見下ろしARPGの回転斬りや、ベルトスクロールの薙ぎ払いのように、1振りで何体かを巻き込む攻撃を作ります。新しいシーンに立ち止まったスライムを2体並べ、その真ん中から攻撃して、2体とも100から90へ一度だけ減れば成功です。

画面には2つの数を並べて出します。area_entered が来た回数(始め)と、ダメージを実際に採用した回数(採用)です。

Playerの剣の円(半径120)に、左右へ100離れたスライム2体のHurtBox(半径24)が重なる

① 置くもの

Main(Node2D)の下に、次の4つを置きます。

ノード位置子ノード
Player(Node2D)(320, 300)子に SwordArc(Area2D)、その子の CollisionShape2D は円の半径120
Slime1(Node2D)(220, 300)子に HurtBox(Area2D)と HpLabel(Label、位置 (0, -40))。HurtBox の子の CollisionShape2D は円の半径24
Slime2(Node2D)(420, 300)Slime1 と同じ構成
HUD(CanvasLayer)—子に Label を2つ。EnterLabel を (16, 16)、HitLabel を (160, 16)

CanvasLayer は、カメラが動いても画面の同じ位置に表示を留めておくためのノードです。SwordArc・EnterLabel・HitLabel は、右クリックから「Access as Unique Name」をオンにします。名前の横に % が付いて、そのまま掴めるようになります。絵のノードは置いていません。実行する前にエディタ上部の Debug から Visible Collision Shapes をオンにすると、剣とスライムの円が見えます。

② 組み立て

HurtBox に付けるスクリプトは、行き先を1つ持つだけです。

# hurt_box.gd
class_name HurtBox
extends Area2D

@export var target: Node   # HPを持っている本体。インスペクタでスライム本体を入れる

インスペクタで、それぞれの HurtBox の Target に自分の本体(Slime1・Slime2)をドラッグします。本体の2体には、同じ slime.gd を付けます。

# slime.gd
extends Node2D

@export var max_hp := 100

var hp := 0

@onready var hp_label: Label = $HpLabel

func _ready() -> void:
	hp = max_hp
	_show_hp()

func take_damage(amount: int) -> void:
	hp = maxi(hp - amount, 0)
	_show_hp()

func _show_hp() -> void:
	hp_label.text = str(hp)

Player に付けるのが本体です。

# player.gd
extends Node2D

const SPEED := 200.0
const DAMAGE := 10
const SWING_TIME := 0.3

var is_attacking := false
var hit_targets: Array[Node] = []
var enter_count := 0
var hit_count := 0

@onready var sword_arc: Area2D = %SwordArc
@onready var enter_label: Label = %EnterLabel
@onready var hit_label: Label = %HitLabel

func _ready() -> void:
	sword_arc.area_entered.connect(_on_arc_area_entered)   # 繋ぐのはここ1回だけ
	_update_hud()

func _process(delta: float) -> void:
	position.x += Input.get_axis("ui_left", "ui_right") * SPEED * delta
	if Input.is_action_just_pressed("ui_accept"):
		_start_attack()

func _start_attack() -> void:
	if is_attacking:
		return
	is_attacking = true
	hit_targets.clear()                          # この攻撃の記録を空にする
	for area in sword_arc.get_overlapping_areas():
		_try_hit(area)                           # すでに範囲内にいる相手も確かめる
	await get_tree().create_timer(SWING_TIME).timeout   # 振り終わるまで0.3秒待つ
	is_attacking = false

func _on_arc_area_entered(area: Area2D) -> void:
	enter_count += 1                             # 攻撃中かどうかに関わらず数える
	_update_hud()
	_try_hit(area)                               # 振りの途中で入ってきた相手

func _try_hit(area: Area2D) -> void:
	if not is_attacking:
		return
	var box := area as HurtBox
	if box == null or box.target == null:
		return
	if hit_targets.has(box.target):
		return
	hit_targets.append(box.target)
	hit_count += 1
	box.target.take_damage(DAMAGE)
	_update_hud()

func _update_hud() -> void:
	enter_label.text = "始め: %d" % enter_count
	hit_label.text = "採用: %d" % hit_count

③ 動かして確かめる

起動直後に始めは2になり、押すたびに採用だけが2ずつ増えて、始めは2のまま動かない

実行したら、まず何も押さずに画面を見ます。「始め」が2になります。area_entered は攻撃を待たずに、範囲が重なった時点で届いているからです。「採用」が0なのは、is_attacking が false のあいだ、_try_hit() の先頭で弾いているためです。

表示が「始め: 2 / 採用: 0」になったら、スペースキーを1回押します。HPが2体とも90になり、「採用」が2に増えます。動いたのは開始時の確認のほうで、シグナルは来ていないので「始め」は2のままです。押しても「採用」が0のままなら、HurtBox の Target が空か、hurt_box.gd が付いていません。どちらも _try_hit() の途中で黙って return するので、エラーは出ません。

半秒ほど待ってから、もう一度押します。攻撃中の入力は _start_attack() の先頭で捨てます。0.3秒(SWING_TIME)より早く押しても、何も起きません。

HPが80になり、「採用」は4になります。「始め」は最初の2から一度も動きません。4回のダメージは、すべて開始時の確認から入っています。シグナルだけに頼っていたら、この置き方では何度押しても当たりませんでした。

最後に、右矢印キーを押しっぱなしにします。Player のXが564を超えると、剣の円が2体の円から両方離れます。左矢印キーで元の位置まで戻ると、「始め」が4に増えます。入り直しはシグナルの側が数えるからです。攻撃していないので、「採用」は4のままです。

ポイントは2つです。

  • 入口を減らすのではなく、出口で絞る: area_entered も開始時の確認も、同じ _try_hit() へ流します。入口が2つに増えてもダメージが2倍にならないのは、hit_targets を見てから記録しているからです
  • 記録するのはHPを持っている本体: HurtBox の target を経由すると、当たり判定が頭と胴に分かれても1体として数えられます。判定を増やすときに書き換える場所が増えません

被弾側の短い無敵時間や、味方の攻撃を味方に当てない設定は、Collision LayerとMaskの記事 で扱っています。

うまくいかないときは

症状原因直し方
攻撃ボタンを押していないのに、敵が近づいただけでHPが減るarea_entered は攻撃を待たずに来る。受け取った関数がそのままHPを減らしている_try_hit() の先頭で is_attacking を見る。ビームのように入口を増やしたときも同じ行を置く
自分のゲームの敵(CharacterBody2D)に剣を重ねても何も起きないarea_entered と get_overlapping_areas() が拾うのは Area2D だけ。ボディは数えない敵の子に被弾用の Area2D(HurtBox)を置く。ボディを直接拾うなら body_entered と get_overlapping_bodies()
プレイヤーに被弾用の HurtBox を足したら、攻撃するたびに自分のHPも減る剣の円は自分の中心にあり、自分の HurtBox とも重なっている。Layer と Mask が既定の1同士だと、開始時の確認に自分も入る自分と敵の HurtBox の Layer を分け、SwordArc の Mask には敵の番号だけを入れる。分け方は Collision LayerとMaskの記事
「始め」も「採用」も0のままSwordArc の Collision Mask に、HurtBox の Collision Layer が入っていない。どちらかのLayerを既定の1から変えたときに起きるインスペクタの SwordArc の Collision → Mask で、HurtBox の Layer と同じ番号のマスをオンにする

おまけ:先に知っておくと良いこと

  • 押しているあいだ当て続ける攻撃: ビームや炎のように一定の間隔で当てるなら、相手ごとに「次に当てられる時刻」を辞書で持ちます。重なりっぱなしの相手にはシグナルが届かないので、Timer で0.1秒ごとに get_overlapping_areas() を見に行きます。範囲に入っているあいだダメージを出す作りは 毒沼の例 にあります
  • 接続は _ready() で1回だけ: 攻撃のたびに同じ関数を connect() しても、2回目はエラーになって増えません。その場で書く名前のない関数(ラムダ式)を毎回つなぐと接続は増えますが、_try_hit() を通るかぎり2本目はリストで止まります
  • 判定のオンオフは物理の処理のあとで: monitoring や CollisionShape2D の disabled は、area_entered の中から直接変えるとエラーになります。set_deferred("monitoring", false) のように後へ回します。オンにした直後の get_overlapping_areas() は、まだ空のことがあります

まとめ

  • 数えているのは重なりの回数: area_entered は、重なりが新しく検出された回数。攻撃の回数でも、当てた相手の数でもない
  • 重なる入口は3つ: 出て入り直す、同じ接触を2か所で受け取る、重なりを毎フレームのダメージにする
  • 減らす行は1つの関数に: 「この攻撃で、この相手にはもう当てたか」を確かめてから記録する
  • 記録は本体で、攻撃の直前に空に: 記録するのはHPを持っている本体。リストは攻撃を始める直前に空にする
  • 開始時に重なりを確かめる: 重なり続けている相手にはシグナルが来ないので、get_overlapping_areas() で確かめる。入口が2つでも、ダメージは1回のまま

当たった瞬間の手応えは ヒットストップの記事、当たったあとの吹き飛びは ノックバックの記事 にあります。いま作っている攻撃は、1振りで何体まで当たっていいものでしょうか。

次に読む学習パスの次の記事
物理・移動

GodotのRayCast2D入門:崖の手前で引き返し、壁越しには気づかない敵を作る

この記事を読む
Godot このセクションのノート104