Godotで「アイテムがプレイヤーに吸い寄せられる」ギミックを作ろうとすると、ありがちな実装はこんな感じですよね。

  • プレイヤー側のスクリプトで、毎フレーム get_tree().get_nodes_in_group("items") を回して距離チェック
  • アイテム側のスクリプトに「プレイヤーを探して向かっていく」処理をベタ書き
  • プレイヤーを継承した別キャラにも同じ処理をコピペ…

こうなると、

  • プレイヤーのスクリプトが肥大化する
  • 「このキャラは磁石ON、このキャラはOFF」の切り替えがやりづらい
  • アイテムの種類が増えると、どこで何を吸い寄せているのか分かりづらい

と、継承ベース&深いノード階層の典型的なつらみが出てきます。

そこで今回は、親ノードにアタッチするだけで「周囲のアイテム(RigidBody)を吸い寄せる」コンポーネント、MagnetAttractor を用意しました。
プレイヤーでも敵でも動く床でも、「磁石化したいノードにポン付け」するだけでOKな、完全コンポーネント指向のアプローチです。

【Godot 4】近くのアイテムをズバッと吸引!「MagnetAttractor」コンポーネント

以下が、コピペでそのまま使える MagnetAttractor.gd のフルコードです。


extends Node
class_name MagnetAttractor
## MagnetAttractor
## 親ノードの周囲にある RigidBody2D / RigidBody3D を
## 親ノードの位置に向かって吸い寄せるコンポーネント。
##
## - 2D/3D 両対応
## - 対象はグループでフィルタ
## - 距離に応じて力をスケール
## - ON/OFF を簡単に切り替え可能

@export_category("Basic Settings")
@export var enabled: bool = true:
	set(value):
		enabled = value
		# デバッグ表示などをここで切り替えてもよい

## 2D か 3D かを選択します。
## - 2D: 親は Node2D / CharacterBody2D / RigidBody2D など
## - 3D: 親は Node3D / CharacterBody3D / RigidBody3D など
@export_enum("DIM_2D", "DIM_3D")
var dimension: int = 0  # 0: 2D, 1: 3D

## 吸い寄せる対象のグループ名。
## アイテム側を "magnet_item" などのグループに入れておきましょう。
@export var target_group: StringName = &"magnet_item"

## 吸引の有効半径(ピクセル or ユニット)。
## この半径以内にある対象だけに力を加えます。
@export var radius: float = 256.0

## 基本の吸引力(力の強さ)。
## 数値が大きいほど、より強く引き寄せられます。
@export var base_force: float = 500.0

## 対象との距離に応じた減衰係数。
## 1.0 なら距離に反比例、0.0 なら距離に関係なく一定の力。
@export_range(0.0, 2.0, 0.05)
var distance_falloff: float = 1.0

## 対象に付与する力の上限値。
## 物理が暴れすぎる場合は小さめに設定しましょう。
@export var max_force: float = 1500.0

## 毎フレーム処理ではなく、一定間隔で処理したい場合のタイマー間隔(秒)。
## 0.0 の場合は _physics_process() 毎に処理します。
@export var update_interval: float = 0.0

@export_category("Debug")
## 有効半径を簡易的に可視化するかどうか(2D のみ)。
@export var debug_draw_2d: bool = false

## 内部用: 経過時間のカウンタ
var _time_accum: float = 0.0


func _ready() -> void:
	# 親ノードが 2D か 3D か、軽くチェック(厳密ではないが事故防止用)
	if dimension == 0:
		if not owner or not owner is Node2D:
			push_warning("MagnetAttractor (2D) が Node2D 系以外にアタッチされています。想定外の挙動になるかもしれません。")
	else:
		if not owner or not owner is Node3D:
			push_warning("MagnetAttractor (3D) が Node3D 系以外にアタッチされています。想定外の挙動になるかもしれません。")


func _physics_process(delta: float) -> void:
	if not enabled:
		return

	# 更新間隔が指定されている場合は、一定間隔ごとにのみ処理
	if update_interval > 0.0:
		_time_accum += delta
		if _time_accum < update_interval:
			return
		_time_accum = 0.0

	if dimension == 0:
		_apply_attraction_2d()
	else:
		_apply_attraction_3d()


func _apply_attraction_2d() -> void:
	if not owner or not owner is Node2D:
		return

	var center: Vector2 = (owner as Node2D).global_position
	# 対象グループに属するノードをすべて取得
	var nodes := get_tree().get_nodes_in_group(target_group)

	for node in nodes:
		# RigidBody2D のみを対象にする
		if not node is RigidBody2D:
			continue

		var body := node as RigidBody2D
		if not body.is_inside_tree():
			continue

		var dir: Vector2 = center - body.global_position
		var dist: float = dir.length()

		if dist <= 0.001:
			continue
		if dist > radius:
			continue  # 半径外は無視

		dir = dir.normalized()

		# 距離に応じたスケーリング
		var force_mag := base_force
		if distance_falloff > 0.0:
			# 距離に反比例するような簡易モデル
			force_mag *= 1.0 / pow(dist / radius, distance_falloff)

		# 力の上限を適用
		force_mag = clamp(force_mag, 0.0, max_force)

		var force_vec: Vector2 = dir * force_mag

		# RigidBody2D には add_central_force で力を加える
		body.apply_central_force(force_vec)


func _apply_attraction_3d() -> void:
	if not owner or not owner is Node3D:
		return

	var center: Vector3 = (owner as Node3D).global_position
	var nodes := get_tree().get_nodes_in_group(target_group)

	for node in nodes:
		if not node is RigidBody3D:
			continue

		var body := node as RigidBody3D
		if not body.is_inside_tree():
			continue

		var dir: Vector3 = center - body.global_position
		var dist: float = dir.length()

		if dist <= 0.001:
			continue
		if dist > radius:
			continue

		dir = dir.normalized()

		var force_mag := base_force
		if distance_falloff > 0.0:
			force_mag *= 1.0 / pow(dist / radius, distance_falloff)

		force_mag = clamp(force_mag, 0.0, max_force)

		var force_vec: Vector3 = dir * force_mag

		body.apply_central_force(force_vec)


func _draw() -> void:
	# 2D の場合のみ、簡易的なデバッグ表示
	if dimension == 0 and debug_draw_2d and owner and owner is Node2D:
		draw_circle(Vector2.ZERO, radius, Color(0.2, 0.8, 1.0, 0.15))
		draw_circle(Vector2.ZERO, radius, Color(0.2, 0.8, 1.0, 0.8))


func _notification(what: int) -> void:
	# 2D デバッグ描画のため、親の位置に追従して再描画
	if what == NOTIFICATION_TRANSFORM_CHANGED and dimension == 0 and debug_draw_2d:
		queue_redraw()

使い方の手順

ここでは 2D の例として、プレイヤーに近づいたコインが吸い寄せられる シーンを作ってみましょう。

① スクリプトファイルを用意する

  1. MagnetAttractor.gd をプロジェクト内(例: res://components/MagnetAttractor.gd)に保存します。
  2. エディタを再読み込みすると、ノード追加の「スクリプト」タブから MagnetAttractor が選べるようになります(class_name のおかげですね)。

② アイテム(コイン)側の設定

まずは、吸い寄せられる対象を用意します。2D のコイン例:

Coin (RigidBody2D)
 ├── Sprite2D
 └── CollisionShape2D
  1. Coin(RigidBody2D)に、以下のようなスクリプトをアタッチします(最低限でOK)。

extends RigidBody2D

func _ready() -> void:
	# MagnetAttractor の対象にするためのグループに入れる
	add_to_group("magnet_item")

これで、target_group = "magnet_item" の MagnetAttractor から吸引対象として認識されるようになります。

③ プレイヤー側に MagnetAttractor コンポーネントを付ける

プレイヤーのシーン構成を、例えばこんな感じにします:

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── MagnetAttractor (Node)
  1. Player シーンを開き、Player の子として Node を追加します。
  2. その Node に MagnetAttractor.gd をアタッチ(または「ノード追加 > スクリプト」タブから MagnetAttractor を選択)します。
  3. インスペクタで以下のように設定します:
    • enabled: チェック ON
    • dimension: DIM_2D
    • target_group: "magnet_item"
    • radius: 256.0(好みで調整)
    • base_force: 800.0 くらいから試す
    • distance_falloff: 1.0(距離に応じて弱くなる)
    • max_force: 1500.0
    • update_interval: 0.0(毎フレーム)
    • debug_draw_2d: 動作確認時に ON にすると半径が見えて便利

これで、プレイヤーの周囲にある Coin(RigidBody2D)が、物理的な力としてプレイヤーに向かって吸い寄せられるようになります。

④ 他の用途への応用例

同じコンポーネントを、ほぼ設定だけで別用途にも使えます。

  • 敵に吸い寄せられる「燃料セル」
    • 敵シーンに MagnetAttractor を追加
    • 燃料セル(RigidBody2D)を fuel_item グループに入れる
    • 敵側の MagnetAttractor の target_group"fuel_item" に変更
  • 動く床に吸い寄せられる箱
    • 動く床(Node2D / KinematicBody2D など)の子に MagnetAttractor
    • 箱(RigidBody2D)を crate グループに入れる
    • 床の MagnetAttractor の radius を広めにして、箱が乗りやすくする

3D でも同じ考え方です。例えば:

Player3D (CharacterBody3D)
 ├── MeshInstance3D
 ├── CollisionShape3D
 └── MagnetAttractor (Node)
  • dimension = DIM_3D に変更
  • 対象は RigidBody3D で、同じくグループ名でフィルタ

メリットと応用

この MagnetAttractor コンポーネントを使う最大のメリットは、「磁石」ロジックを完全に親ノードから分離できることです。

  • Player のスクリプトは「移動・攻撃・入力処理」に集中できる
  • 「磁石化する / しない」は コンポーネントを付けるかどうかだけで決まる
  • 敵、NPC、ギミックなど、どんなノードにも 同じコンポーネントを再利用できる
  • グループ名で対象を変えるだけで、「コイン専用」「経験値オーブ専用」などを簡単に作れる

レベルデザイン的にも、

  • 「ここにいる敵だけはアイテムを吸い寄せる」
  • 「このエリアの床は、プレイヤーを中央に集める」

といったギミックを、シーンツリー上でコンポーネントをペタペタ貼るだけで表現できるので、スクリプトをいじらずにゲーム性を盛り込めるのが嬉しいですね。

ちょっとした改造案:ON/OFF を自動で切り替える

例えば「プレイヤーがダメージを受けている間は磁石OFF」にしたい場合、MagnetAttractor に簡単な API を足して、プレイヤー側から呼び出すのもアリです。


# MagnetAttractor.gd 内に追記
func set_temporary_enabled(value: bool, duration: float = 0.0) -> void:
	enabled = value
	if duration > 0.0 and value:
		# duration 秒後に自動で OFF にする例
		var timer := get_tree().create_timer(duration)
		timer.timeout.connect(func():
			enabled = false)

これで、プレイヤー側から


# Player.gd 例
@onready var magnet: MagnetAttractor = $MagnetAttractor

func _on_damage_taken() -> void:
	# 3 秒間だけ磁石を ON にする(あるいは逆に OFF にする)
	magnet.set_temporary_enabled(true, 3.0)

のように呼び出せば、「ダメージ後だけアイテムを強く吸い寄せる」といった一時的なブーストも、継承に頼らずにコンポーネントとして実現できます。

こんな感じで、「磁石」という概念を 1 ファイルに閉じ込めて、必要なノードにだけアタッチするのが、まさに「継承より合成」な Godot の戦い方ですね。