Godot 4で「押せる箱」や「押せる岩」を作ろうとすると、ついこういう実装をしがちですよね。
- プレイヤー側のスクリプトに「箱を押す処理」を直書きする
- 押せるオブジェクト用に
PushableBox.gd,PushableRock.gdみたいな継承ツリーを増やす - ノード階層を深くして、子ノード側から親の
RigidBody2Dをget_parent()でゴリゴリ触る
動くことは動くんですが、
- プレイヤー側のコードが「押す処理」で肥大化する
- 押せるオブジェクトを増やすたびに、新しいシーンやスクリプトが量産される
- 「この箱は別の挙動にしたい」と思ったときに継承ツリーごと見直しになりがち
…と、継承ベースで組むとどうしてもスケールしづらいんですよね。
そこで今回は、「押せる」という機能だけを独立したコンポーネントとして切り出した PushableObject を用意しました。
親が RigidBody2D や CharacterBody2D であれば、プレイヤーがぶつかった方向の反対側に力(インパルス)を加えて、いい感じに「押せる物体」にしてくれます。
【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(動く敵など)を押す
手順①: プレイヤーにグループを設定
まず、「押す側」のプレイヤーにグループを設定します。
- プレイヤーのノードを選択
- インスペクタ右側の「Node」タブ → 「Groups」タブを開く
- 例として
playerというグループ名を追加
このグループ名は、コンポーネントの pusher_group と一致させます。
手順②: 押されるオブジェクトのシーン構成
押される箱(RigidBody2D)のシーン構成例です。
PushableBox (RigidBody2D) ├── Sprite2D ├── CollisionShape2D └── PushableObject (Area2D) ← このノードにコンポーネントをアタッチ
PushableObject ノードには、CollisionShape2D を付けておくと押し判定が安定します。
PushableObject (Area2D) └── CollisionShape2D
このとき、PushableObject.gd を PushableObject ノードにアタッチしておきましょう。
親は 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()
これを使えば、「押している感」のフィードバックも簡単に追加できます。
コンポーネント指向で組んでおくと、こういった「演出の追加」も局所的に済むので、プロジェクトが大きくなっても管理しやすいですね。
