Godot 4で「磁石っぽい挙動」を作ろうとすると、つい次のような実装になりがちですよね。

  • 「MagnetBlock」という専用のノードシーンを作り、そこに直接ロジックを書き込む
  • 磁力判定用の `Area2D` を子ノードとしてベタ書き
  • 引き寄せられる側(鉄製オブジェクト)が「MagnetBlock専用のベースクラス」を継承している

こういう継承ベースの実装だと、次のような辛さが出てきます。

  • 「この敵も磁石に反応させたい」と思った時に、ベースクラスを差し替えないといけない
  • 磁力の範囲や強さを変えたいだけなのに、専用シーンを複製して量産してしまう
  • ノード階層が「MagnetBlockRoot → Sprite → Collision → Area2D → CollisionShape2D → スクリプト…」と深くなりがち

そこで今回は、「継承より合成」の方針で、どのノードにもポン付けできるコンポーネントとして、

ONにすると、周囲の鉄製オブジェクトを引き寄せる / 反発する「MagnetBlock」コンポーネント

を実装していきましょう。CharacterBody2D でも StaticBody2D でも、ただノードにアタッチするだけで「磁石ブロック化」できるようにします。


【Godot 4】引き寄せも反発もワンコンポーネントで!「MagnetBlock」コンポーネント

今回作る MagnetBlock は、ざっくり以下の特徴を持つコンポーネントです。

  • どの2Dノードにもアタッチ可能(Node2D / CharacterBody2D / StaticBody2D など)
  • 内蔵の Area2D で「磁力範囲」を自動管理
  • 「鉄製オブジェクト」側は、専用コンポーネントを付けるだけで対応
  • 磁力のON/OFF引き寄せ / 反発距離減衰などをインスペクタから調整可能

磁石に反応する側のコンポーネントも一緒に用意して、双方コンポーネント方式にしておきます。


フルコード:MagnetBlock(磁石側コンポーネント)


# MagnetBlock.gd
# 磁石ブロック用コンポーネント
# どの Node2D 系ノードにもアタッチして使えるようにします。
class_name MagnetBlock
extends Node2D

@export_category("Magnet Settings")

## 磁石が有効かどうか(スイッチON/OFF)
@export var enabled: bool = true

## 引き寄せるか(true) / 反発するか(false)
@export var attract_mode: bool = true

## 磁力の基本強さ(値が大きいほど強く引き寄せ/反発する)
@export var base_force: float = 800.0

## 磁力が届く最大距離(ピクセル)
@export var radius: float = 160.0

## 距離に応じた減衰係数(1.0で線形、2.0で距離の2乗で減衰…)
@export_range(0.1, 4.0, 0.1)
var falloff_power: float = 1.5

## 対象とする「鉄製オブジェクト」のグループ名
## 鉄製オブジェクト側をこのグループに入れておく想定です。
@export var metal_group: StringName = &"metal"

@export_category("Debug")

## デバッグ用:磁力範囲をエディタ/ゲーム中に可視化するか
@export var debug_draw: bool = true

## デバッグ用:磁力の方向ベクトルを線で描画するか
@export var debug_draw_vectors: bool = false


# 内部用:磁力範囲判定用の Area2D と CollisionShape2D
var _area: Area2D
var _shape: CollisionShape2D

# 内部用:現在範囲内にいる「鉄製オブジェクト」
var _metal_bodies: Array[Node2D] = []


func _ready() -> void:
    # 子ノードとして Area2D / CollisionShape2D を自動生成してもいいですが、
    # ここでは「コンポーネント自身の子ノードとして動的に用意する」スタイルにします。
    _setup_area()
    set_process(true)


func _setup_area() -> void:
    # すでに存在していれば再利用
    _area = get_node_or_null("MagnetArea")
    if _area == null:
        _area = Area2D.new()
        _area.name = "MagnetArea"
        add_child(_area)
        _area.owner = get_tree().get_edited_scene_root() if Engine.is_editor_hint() else null

    _shape = _area.get_node_or_null("MagnetShape")
    if _shape == null:
        _shape = CollisionShape2D.new()
        _shape.name = "MagnetShape"
        _area.add_child(_shape)
        _shape.owner = get_tree().get_edited_scene_root() if Engine.is_editor_hint() else null

    # 円形コリジョンで磁力範囲を表現
    var circle := CircleShape2D.new()
    circle.radius = radius
    _shape.shape = circle

    # エリアのモニタリングを有効化
    _area.monitoring = true
    _area.monitorable = true

    # Body の出入りを監視
    _area.body_entered.connect(_on_body_entered)
    _area.body_exited.connect(_on_body_exited)


func _notification(what: int) -> void:
    # インスペクタで radius が変わったときにも反映されるように
    if what == NOTIFICATION_POSTINITIALIZE || what == NOTIFICATION_ENTER_TREE:
        if _shape and _shape.shape is CircleShape2D:
            (_shape.shape as CircleShape2D).radius = radius


func _on_body_entered(body: Node) -> void:
    # metal_group に属している Node2D 系だけを対象にする
    if body is Node2D and body.is_in_group(metal_group):
        _metal_bodies.append(body)


func _on_body_exited(body: Node) -> void:
    if body is Node2D:
        _metal_bodies.erase(body)


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

    if _metal_bodies.is_empty():
        return

    # 磁石のグローバル位置
    var origin: Vector2 = global_position

    for body in _metal_bodies:
        if not is_instance_valid(body):
            continue

        # 対象の位置
        var target_pos: Vector2 = body.global_position
        var dir: Vector2 = target_pos - origin
        var distance: float = dir.length()

        if distance == 0.0:
            continue

        # 範囲外(コリジョンの端を超えている)なら無視
        if distance > radius:
            continue

        # 方向ベクトルを正規化
        dir = dir.normalized()

        # 引き寄せの場合:磁石 → 鉄へ向かうベクトルの逆向き(鉄 → 磁石)に力を加える
        # 反発の場合:そのまま外向き
        var force_dir: Vector2 = -dir if attract_mode else dir

        # 距離に応じて減衰させる(距離が近いほど強く、遠いほど弱く)
        var t: float = clamp(distance / radius, 0.0, 1.0)
        var falloff: float = pow(1.0 - t, falloff_power)

        var force: Vector2 = force_dir * base_force * falloff

        # 対象が「磁力を受け取れるコンポーネント」を持っていれば、そちらに委譲
        # そうでなければ、直接 position を動かす(簡易版)
        if body.has_method("apply_magnetic_force"):
            body.call("apply_magnetic_force", force, delta, self)
        else:
            # 直接位置を動かすのは物理的には正しくないですが、
            # 「とりあえず動かしたい」という用途には十分です。
            body.global_position += force * delta

    if debug_draw:
        queue_redraw()


func _draw() -> void:
    if not debug_draw:
        return

    # 磁力範囲の円を描画
    var color := Color(0.2, 0.8, 1.0, 0.3) if attract_mode else Color(1.0, 0.4, 0.2, 0.3)
    draw_circle(Vector2.ZERO, radius, color)

    # 外枠
    var border_color := Color(0.2, 0.8, 1.0) if attract_mode else Color(1.0, 0.4, 0.2)
    draw_arc(Vector2.ZERO, radius, 0.0, TAU, 48, border_color, 2.0)

    if debug_draw_vectors:
        var origin: Vector2 = Vector2.ZERO
        for body in _metal_bodies:
            if not is_instance_valid(body):
                continue
            var local_pos: Vector2 = to_local(body.global_position)
            draw_line(origin, local_pos, Color.YELLOW, 1.0)

フルコード:MagneticBody(鉄製オブジェクト側コンポーネント)

鉄製オブジェクト側にも、「磁力の受け取り方」だけを担当するコンポーネントを用意しておくと、挙動をきれいに分離できます。


# MagneticBody.gd
# 磁石に反応する「鉄製オブジェクト」用コンポーネント
# 親ノードが Node2D / CharacterBody2D / RigidBody2D などを想定。
class_name MagneticBody
extends Node

@export_category("Magnetic Body Settings")

## このオブジェクトが属するグループ名
## MagnetBlock 側の metal_group と一致させる必要があります。
@export var metal_group: StringName = &"metal"

## 磁力の影響をどれくらい受けるか(質量っぽい係数)
## 値が大きいと「重く」なり、動きにくくなります。
@export_range(0.1, 10.0, 0.1)
var mass: float = 1.0

## 磁力の影響を受けるかどうか(ON/OFF)
@export var magnetic_enabled: bool = true

## 磁力による最大速度(簡易クランプ用)
@export var max_speed: float = 600.0

## 位置直接移動ではなく、「速度ベース」で動かしたい場合に使う内部速度
var _velocity: Vector2 = Vector2.ZERO


func _ready() -> void:
    # 親ノードを metal_group に登録しておく
    if get_parent() and not get_parent().is_in_group(metal_group):
        get_parent().add_to_group(metal_group)


func apply_magnetic_force(force: Vector2, delta: float, source: Node) -> void:
    # MagnetBlock から呼ばれる想定のメソッド
    if not magnetic_enabled:
        return

    var parent := get_parent()
    if parent == null:
        return

    # 簡易的な「加速度 = 力 / 質量」を適用
    var acceleration: Vector2 = force / max(mass, 0.01)
    _velocity += acceleration * delta

    # 最大速度でクランプ
    if _velocity.length() > max_speed:
        _velocity = _velocity.normalized() * max_speed

    # 親ノードの種類に応じて移動方法を変える
    if parent is CharacterBody2D:
        # 既存の velocity に上乗せしたい場合は、用途に応じて調整してください
        parent.velocity += _velocity
        parent.move_and_slide()
    elif parent is RigidBody2D:
        # RigidBody2D の場合は、直接 velocity をいじるよりも add_force の方が自然
        parent.apply_central_force(force)
    elif parent is Node2D:
        parent.global_position += _velocity * delta
    else:
        # その他のノード型は、とりあえず位置を直接動かす
        if "global_position" in parent:
            parent.global_position += _velocity * delta

使い方の手順

  1. コンポーネントスクリプトを用意する
    上記の MagnetBlock.gdMagneticBody.gd をプロジェクト内(例: res://components/)に保存します。
    Godot 4 の class_name を使っているので、スクリプトを保存するとインスペクタの「スクリプトをアタッチ」から直接選べるようになります。
  2. 鉄製オブジェクト側に MagneticBody をアタッチ
    例として、磁石に引き寄せられる「鉄製の箱」を作ってみます。
    MetalBox (CharacterBody2D)
     ├── Sprite2D
     ├── CollisionShape2D
     └── MagneticBody (Node)
        
    • MetalBox(CharacterBody2D)にスプライトと当たり判定を設定。
    • MagneticBody ノードを子として追加し、MagneticBody.gd をアタッチ。
    • グループ名 metal はデフォルトのままでOK(MagnetBlock 側と揃っていればよい)。
  3. 磁石ブロック側に MagnetBlock をアタッチ
    例えば「ステージ上に置いておく磁石ブロック」を StaticBody2D で作るとします。
    MagnetPlatform (StaticBody2D)
     ├── Sprite2D
     ├── CollisionShape2D
     └── MagnetBlock (Node2D)
        
    • MagnetPlatform(StaticBody2D)に見た目とコリジョンを設定。
    • 子ノードとして MagnetBlock(Node2D)を追加し、MagnetBlock.gd をアタッチ。
    • インスペクタから:
      • enabled: true(ON)
      • attract_mode: true なら引き寄せ、false なら反発
      • radius: どれくらいの範囲で効くか(例: 200)
      • base_force: 力の強さ(例: 1000〜2000)
  4. プレイヤーにも磁力を効かせたい場合
    プレイヤーが「鉄製アーマー」を着ている、という設定にして磁力の影響を受けさせたい場合は、プレイヤーシーンに MagneticBody をポン付けするだけです。
    Player (CharacterBody2D)
     ├── Sprite2D
     ├── CollisionShape2D
     └── MagneticBody (Node)
        

    あとは、プレイヤーの挙動スクリプト側で velocity を操作している場合、MagneticBody が足し込んだ速度との兼ね合いを少し調整してあげると自然になります。

このように、磁石側は「MagnetBlock」コンポーネント鉄側は「MagneticBody」コンポーネントという構成にしておくと、

  • どのオブジェクトでも、グループとコンポーネントを付けるだけで磁力対象にできる
  • プレイヤー・敵・動く足場など、既存のクラス構造を壊さずに機能追加できる

というメリットがあります。


メリットと応用

このコンポーネント構成にすることで、次のようなメリットがあります。

  • シーン構造がスッキリ
    磁石用の巨大なベースシーンを作らずに済み、MagnetBlock / MagneticBody をそれぞれ必要なノードに付けるだけで機能追加できます。
    「プレイヤーも敵も動く床も、全部磁石に反応させたい」となっても、既存のクラス階層をいじる必要がありません。
  • レベルデザインが楽
    ステージエディタ的なノリで、「ここに磁石ブロックを一個置いて、radius を 300 にして…」のようにインスペクタからパラメータを調整するだけで、磁石ギミックを量産できます。
  • 挙動の差し替えが簡単
    「ある敵だけは磁力を半分しか受けない」「プレイヤーだけは反発しかしない」など、MagneticBody 側の mass や処理を書き換えるだけで差別化できます。

応用としては、例えば「磁石のON/OFFをスイッチやタイマーで制御する」「プレイヤーが持ち運べる携帯磁石を作る」などが考えられます。

改造案:スイッチで磁石をトグルする

MagnetBlock に簡単なトグルメソッドを追加して、スイッチやボタンから呼び出せるようにしてみましょう。


# MagnetBlock.gd に追記できる改造メソッド例

## 磁石のON/OFFをトグルする
func toggle_magnet() -> void:
    enabled = not enabled
    # 視覚的なフィードバックを入れたい場合はここで処理
    # 例: 親ノードの色を変える
    if get_parent() is Node2D:
        var parent_2d := get_parent() as Node2D
        var modulate_color := Color.WHITE if enabled else Color(0.5, 0.5, 0.5)
        if "modulate" in parent_2d:
            parent_2d.modulate = modulate_color

この toggle_magnet() を、Buttonpressed シグナルや、レバーギミックから呼び出してあげれば、「ステージ中の磁石が一斉にON/OFFする」仕掛けも簡単に作れますね。

継承ベースで作り込むよりも、こうした小さなコンポーネントを組み合わせていく方が、後からの拡張・調整が圧倒的に楽になります。ぜひ、自分のプロジェクト用にカスタマイズしながら使ってみてください。