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 の例として、プレイヤーに近づいたコインが吸い寄せられる シーンを作ってみましょう。
① スクリプトファイルを用意する
MagnetAttractor.gdをプロジェクト内(例:res://components/MagnetAttractor.gd)に保存します。- エディタを再読み込みすると、ノード追加の「スクリプト」タブから
MagnetAttractorが選べるようになります(class_nameのおかげですね)。
② アイテム(コイン)側の設定
まずは、吸い寄せられる対象を用意します。2D のコイン例:
Coin (RigidBody2D) ├── Sprite2D └── CollisionShape2D
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)
- Player シーンを開き、
Playerの子として Node を追加します。 - その Node に
MagnetAttractor.gdをアタッチ(または「ノード追加 > スクリプト」タブから MagnetAttractor を選択)します。 - インスペクタで以下のように設定します:
- 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 の戦い方ですね。
