Godot 4で「押せる箱」や「押せる岩」を作ろうとすると、ついこういう実装をしがちですよね。

  • プレイヤー側のスクリプトに「箱を押す処理」を直書きする
  • 押せるオブジェクト用に PushableBox.gd, PushableRock.gd みたいな継承ツリーを増やす
  • ノード階層を深くして、子ノード側から親の RigidBody2Dget_parent() でゴリゴリ触る

動くことは動くんですが、

  • プレイヤー側のコードが「押す処理」で肥大化する
  • 押せるオブジェクトを増やすたびに、新しいシーンやスクリプトが量産される
  • 「この箱は別の挙動にしたい」と思ったときに継承ツリーごと見直しになりがち

…と、継承ベースで組むとどうしてもスケールしづらいんですよね。

そこで今回は、「押せる」という機能だけを独立したコンポーネントとして切り出した PushableObject を用意しました。
親が RigidBody2DCharacterBody2D であれば、プレイヤーがぶつかった方向の反対側に力(インパルス)を加えて、いい感じに「押せる物体」にしてくれます。

【Godot 4】何でも「押せるオブジェクト」に変身!「PushableObject」コンポーネント

このコンポーネントは、親ノードにアタッチして使います。
「押される側のオブジェクト」にこのスクリプトを付けておくと、プレイヤーが衝突したときに自動で押し返す力を加えてくれます。

  • 親が RigidBody2D の場合: apply_impulse() で物理的に押す
  • 親が CharacterBody2D の場合: velocity を書き換えて押す(Kinematic っぽい動き)

プレイヤー側は「普通に移動して物理的に衝突するだけ」でOK。
「押す処理」は全部このコンポーネントに閉じ込めましょう。


PushableObject.gd フルコード


extends Area2D
class_name PushableObject
"""
親ノード(RigidBody2D / CharacterBody2D)を
「押せるオブジェクト」に変えるコンポーネント。

・プレイヤー等がこのArea2Dに侵入 & 衝突したとき
  → 親ノードに「反対方向」の力を加えて押す。

想定:
  - このノードは「押されるオブジェクト」の子に配置する
  - 親は RigidBody2D または CharacterBody2D を想定
"""

@export_category("基本設定")
## 押す対象となる親ノードを自動検出するかどうか
@export var auto_detect_parent_body: bool = true

## 手動で押す対象を指定したい場合に使う(親以外も指定可能)
@export var target_body: NodePath

## プレイヤーなど「押す側」の判定に使うグループ名
## ここに含まれるノードのみが「押す側」として扱われる
@export var pusher_group: StringName = &"player"

@export_category("押し出しパラメータ")
## 押す強さ(インパルス or 速度のスカラー)
@export var push_strength: float = 400.0

## 押す方向を決めるときに、どれくらい「水平方向」に寄せるか
## 1.0 = 完全に水平方向に補正(左右のみ)
## 0.0 = 一切補正しない(斜め方向もそのまま押す)
@export_range(0.0, 1.0, 0.05)
@export var horizontal_bias: float = 0.8

## プレイヤーとの距離がどれくらい近いときに押し判定とみなすか
@export var max_push_distance: float = 32.0

@export_category("CharacterBody2D 用")
## CharacterBody2D に対しては velocity を直接書き換える。
## その際に、既存のX方向の速度をどれくらい残すか(0.0〜1.0)
@export_range(0.0, 1.0, 0.05)
@export var keep_existing_velocity_x: float = 0.3

## 押しベクトルを徐々に補間して、ガクつきを抑えるかどうか
@export var smooth_velocity: bool = true

## smooth_velocity が true のときの補間係数(0〜1)
@export_range(0.0, 1.0, 0.05)
@export var velocity_lerp_factor: float = 0.3

var _body_to_push: Node = null


func _ready() -> void:
    # 衝突検知のためのシグナル接続
    body_entered.connect(_on_body_entered)
    body_exited.connect(_on_body_exited)

    # 押す対象の自動検出
    if auto_detect_parent_body:
        _detect_parent_body()
    else:
        _resolve_target_body_path()


func _detect_parent_body() -> void:
    # 親チェーンを上に辿って、最初に見つかった
    # RigidBody2D or CharacterBody2D を押す対象とする
    var p: Node = get_parent()
    while p:
        if p is RigidBody2D or p is CharacterBody2D:
            _body_to_push = p
            break
        p = p.get_parent()


func _resolve_target_body_path() -> void:
    if target_body.is_empty():
        return
    var node := get_node_or_null(target_body)
    if node and (node is RigidBody2D or node is CharacterBody2D):
        _body_to_push = node
    else:
        push_warning("target_body は RigidBody2D か CharacterBody2D を指している必要があります。")


func _on_body_entered(body: Node) -> void:
    # 押す側(プレイヤーなど)が侵入したときの処理
    if not _body_to_push:
        return

    # グループでフィルタリング
    if not body.is_in_group(pusher_group):
        return

    # 距離チェック(近すぎるときだけ押す)
    var distance := global_position.distance_to(body.global_position)
    if distance > max_push_distance:
        return

    _apply_push_from(body)


func _on_body_exited(body: Node) -> void:
    # 今回は「離れたとき」に特別な処理はしないが、
    # 将来的に「押されているフラグ」を消すなどに使える。
    pass


func _apply_push_from(pusher: Node2D) -> void:
    # 押す側と押される側の位置関係から押し出し方向を決める
    var from_dir: Vector2 = (global_position - pusher.global_position).normalized()
    if from_dir == Vector2.ZERO:
        return

    # 「反対側に押す」= pusher から見て外側方向
    var push_dir: Vector2 = from_dir

    # 水平方向に寄せる補正(主に2D横スクロール向け)
    if horizontal_bias > 0.0:
        var horizontal := Vector2(sign(push_dir.x), 0.0)
        push_dir = push_dir.lerp(horizontal, horizontal_bias).normalized()

    var magnitude := push_strength

    if _body_to_push is RigidBody2D:
        _push_rigidbody(_body_to_push as RigidBody2D, push_dir * magnitude)
    elif _body_to_push is CharacterBody2D:
        _push_characterbody(_body_to_push as CharacterBody2D, push_dir * magnitude)


func _push_rigidbody(rb: RigidBody2D, impulse: Vector2) -> void:
    # RigidBody2D は物理インパルスで押す
    # 中心にインパルスを加えるので、回転は発生しない
    rb.apply_impulse(impulse)


func _push_characterbody(cb: CharacterBody2D, push_vector: Vector2) -> void:
    # CharacterBody2D は velocity を直接いじる
    var v := cb.velocity

    # 既存のX速度を一部だけ残して、新しい押しベクトルを合成
    v.x = v.x * keep_existing_velocity_x + push_vector.x

    # Y方向は重力などに任せる場合が多いので、必要に応じて調整
    # 今回は、斜め押しの場合のみY方向も少し加える例
    v.y += push_vector.y * 0.2

    if smooth_velocity:
        cb.velocity = cb.velocity.lerp(v, velocity_lerp_factor)
    else:
        cb.velocity = v

使い方の手順

ここでは 2D プロジェクトを前提に、代表的なパターンを2つ紹介します。

  • ① プレイヤー(CharacterBody2D)が箱(RigidBody2D)を押す
  • ② プレイヤー(CharacterBody2D)が別の CharacterBody2D(動く敵など)を押す

手順①: プレイヤーにグループを設定

まず、「押す側」のプレイヤーにグループを設定します。

  1. プレイヤーのノードを選択
  2. インスペクタ右側の「Node」タブ → 「Groups」タブを開く
  3. 例として player というグループ名を追加

このグループ名は、コンポーネントの pusher_group と一致させます。

手順②: 押されるオブジェクトのシーン構成

押される箱(RigidBody2D)のシーン構成例です。

PushableBox (RigidBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── PushableObject (Area2D)  ← このノードにコンポーネントをアタッチ

PushableObject ノードには、CollisionShape2D を付けておくと押し判定が安定します。

PushableObject (Area2D)
 └── CollisionShape2D

このとき、PushableObject.gdPushableObject ノードにアタッチしておきましょう。
親は RigidBody2D なので、auto_detect_parent_body はデフォルトの true のままでOKです。

手順③: プレイヤーシーンの構成例

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── Camera2D

プレイヤーの移動処理は、通常どおり move_and_slide() などで実装しておきます。
特別な「押す処理」は一切書かず、ただ歩いて箱にぶつかるだけで押せるようになります。

手順④: CharacterBody2D を押したい場合

例えば、プレイヤーが敵キャラを「押し返す」ようなシチュエーションを作りたい場合も、同じコンポーネントが使えます。

Enemy (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── PushableObject (Area2D)

この場合も auto_detect_parent_body = true にしておけば、親の Enemy (CharacterBody2D) を自動で押す対象として認識してくれます。
敵側の _physics_process() では、通常どおり move_and_slide()velocity を使うようにしておけば、押された速度もそのまま反映されます。


メリットと応用

この PushableObject コンポーネントを使うと、色々と嬉しい点があります。

  • 押せるかどうかを「スクリプトの種類」ではなく「コンポーネントの有無」で決められる
  • プレイヤー側のコードに「押す処理」を一切書かなくてよいので、責務がスッキリ分離される
  • 箱でも岩でも敵でも動く床でも、「押される側」にこれを付けるだけで共通の挙動を再利用できる
  • ノード階層を深くせずに、「押す機能」だけをシンプルな Area2D コンポーネントとして差し込める

つまり、「押せる」という機能を継承ではなく合成(Composition)で表現しているわけですね。
押せる箱、押せる敵、押せるスイッチ…全部同じコンポーネントでOKです。

応用例

  • 押したときに「ゴロゴロ…」というSEを鳴らす
  • 一定距離以上押されたらスイッチがONになるパズル
  • プレイヤー以外(例: 巨大なボス)だけが押せるオブジェクトを作る(pusher_group を変えるだけ)

改造案: 押されたときにSEを鳴らす

最後に、ちょっとした改造案です。
押された瞬間に一度だけSEを再生したい場合、以下のような関数を追加して、_apply_push_from() の最後から呼び出すと良いです。


@export_category("演出")
@export var push_sound: AudioStream
@export var min_interval_between_sounds: float = 0.15

var _last_push_sound_time: float = -100.0


func _play_push_sound() -> void:
    if not push_sound:
        return

    var now := Time.get_ticks_msec() / 1000.0
    if now - _last_push_sound_time < min_interval_between_sounds:
        return

    _last_push_sound_time = now

    var player := AudioStreamPlayer2D.new()
    add_child(player)
    player.stream = push_sound
    player.finished.connect(player.queue_free)
    player.play()

これを使えば、「押している感」のフィードバックも簡単に追加できます。
コンポーネント指向で組んでおくと、こういった「演出の追加」も局所的に済むので、プロジェクトが大きくなっても管理しやすいですね。