Godot 4で「押せる岩」を作ろうとすると、まず思い浮かぶのは専用の CharacterBody2D シーンを作って、そこからプレイヤーや敵のロジックをちょっと拝借して…みたいな「継承ベース」の発想だと思います。ですが、そうすると:

  • 岩ごとに専用シーンを作る必要がある
  • プレイヤーと岩で似たような移動コードをコピペしがち
  • 「この岩だけちょっと重くしたい」「この岩だけ一方向にしか動かしたくない」などの調整がしづらい

といった「クラス継承のつらみ」が出てきます。さらに、Godot標準のチュートリアルに沿って作ると、ノード階層もどんどん深くなりがちですよね。

そこで今回は、どんな CharacterBody2D にもポン付けできる「押せる岩」コンポーネント PushableBlock を用意しました。
プレイヤーがぶつかって押したときだけ動く、いわゆるゼルダ系の「押せるブロック」をコンポーネントとして実装していきましょう。

【Godot 4】プレイヤーに押されて動く!「PushableBlock」コンポーネント

このコンポーネントは:

  • 親ノードが CharacterBody2D であることを前提にした「移動制御」パーツ
  • プレイヤーとの接触方向を判定し、押された方向にだけ動く
  • 重さ・最大速度・摩擦などを @export で調整可能

という設計になっています。プレイヤー本体にはほぼ手を入れず、「押せるオブジェクト側」にロジックを閉じ込める構成ですね。


フルコード:PushableBlock.gd


extends Node
class_name PushableBlock
## 親が CharacterBody2D であることを前提にした
## 「押されて動くブロック」コンポーネント。
##
## 親ノード:
##   - CharacterBody2D を想定
## 必要なもの:
##   - 親に CollisionShape2D などのコリジョン
##   - プレイヤー側は CharacterBody2D で、velocity を使って移動していること

@export_group("基本設定")
## プレイヤーとみなすボディのグループ名
@export var player_group: StringName = &"player"

## ブロックの「重さ」。大きいほど押しても動きにくい
@export var mass: float = 1.5

## 押されたときの加速度の強さ
@export var push_force: float = 1200.0

## ブロックが到達できる最大速度
@export var max_speed: float = 80.0

## ブロックが自然に止まるまでの減速(摩擦)
@export var friction: float = 8.0

@export_group("方向制限")
## true の方向には動かさない(例: 片方向だけに動く岩)
@export var lock_horizontal: bool = false
@export var lock_vertical: bool = false

@export_group("押し判定")
## プレイヤーの速度がこれ以上のとき「押している」とみなす
@export var min_push_speed: float = 40.0

## プレイヤーのどの方向成分を押し方向とするかの許容角度(度数法)
## 0 に近いほど「真正面から押さないと動かない」
@export_range(5.0, 90.0, 1.0)
var push_angle_tolerance_deg: float = 35.0

## 押されていないときにブロックを完全停止させる速度しきい値
@export var stop_threshold: float = 5.0

# 内部状態
var _body: CharacterBody2D
var _velocity: Vector2 = Vector2.ZERO
var _push_dir: Vector2 = Vector2.ZERO

func _ready() -> void:
    # 親が CharacterBody2D であることをチェック
    _body = get_parent() as CharacterBody2D
    if _body == null:
        push_error("PushableBlock must be a child of CharacterBody2D.")
        set_process(false)
        return

    # 衝突検知のために contact_monitor を有効化
    _body.contact_monitor = true
    # 1 フレームあたりの最大接触数(押される相手はプレイヤー1人想定なので少なくてOK)
    _body.max_contacts_reported = 4

func _physics_process(delta: float) -> void:
    if _body == null:
        return

    _update_push_direction()
    _apply_push_force(delta)
    _apply_friction(delta)
    _move_block()

func _update_push_direction() -> void:
    ## プレイヤーとの接触方向を調べて「押されている方向」を計算する

    _push_dir = Vector2.ZERO

    # 衝突しているボディを取得
    var count := _body.get_slide_collision_count()
    if count == 0:
        return

    var best_push: Vector2 = Vector2.ZERO
    var best_speed: float = 0.0

    for i in count:
        var collision := _body.get_slide_collision(i)
        var other := collision.get_collider()

        if other == null:
            continue

        # プレイヤー判定(グループで判定)
        if other is PhysicsBody2D and other.is_in_group(player_group):
            # プレイヤーの速度を取得(CharacterBody2D 前提)
            var player_body := other as CharacterBody2D
            if player_body == null:
                continue

            var v: Vector2 = player_body.velocity
            var speed := v.length()
            if speed < min_push_speed:
                continue

            # プレイヤーの移動方向
            var move_dir := v.normalized()

            # 衝突法線(ブロックから見た「押される方向」は -normal)
            var normal: Vector2 = collision.get_normal()
            var push_dir_candidate := -normal.normalized()

            # プレイヤーの移動方向と「押し方向」がある程度揃っているか確認
            var angle_deg := rad_to_deg(move_dir.angle_to(push_dir_candidate).abs())
            if angle_deg > push_angle_tolerance_deg:
                continue

            # 一番強く押しているプレイヤーを採用
            if speed > best_speed:
                best_speed = speed
                best_push = push_dir_candidate

    _push_dir = best_push

func _apply_push_force(delta: float) -> void:
    ## 押されている方向に加速度を加える

    if _push_dir == Vector2.ZERO:
        return

    var accel := _push_dir * (push_force / max(mass, 0.01))
    _velocity += accel * delta

    # 方向ロック
    if lock_horizontal:
        _velocity.x = 0.0
    if lock_vertical:
        _velocity.y = 0.0

    # 最大速度制限
    if _velocity.length() > max_speed:
        _velocity = _velocity.normalized() * max_speed

func _apply_friction(delta: float) -> void:
    ## 押されていないときは摩擦でだんだん止まる

    if _push_dir != Vector2.ZERO:
        # 押されているときは摩擦を弱めたい場合はここを調整
        return

    if _velocity.length() <= stop_threshold:
        _velocity = Vector2.ZERO
        return

    var friction_force := friction * delta
    var v_dir := _velocity.normalized()
    var v_mag := _velocity.length()

    v_mag = max(v_mag - friction_force, 0.0)
    _velocity = v_dir * v_mag

func _move_block() -> void:
    ## 実際にブロックを動かす。親の velocity を上書きして move_and_slide を呼ぶ。

    _body.velocity = _velocity
    _body.move_and_slide()
    # move_and_slide 後の velocity を再取得(壁にぶつかったときなどの反映)
    _velocity = _body.velocity

## --- 便利関数:コードからブロックを「押す」こともできる例 ------------------

func nudge(direction: Vector2, strength: float = 1.0) -> void:
    ## 外部から軽く押したいとき用のユーティリティ(スイッチで動く岩など)
    if direction == Vector2.ZERO:
        return
    var dir := direction.normalized()
    _velocity += dir * (push_force / max(mass, 0.01)) * strength * get_physics_process_delta_time()

使い方の手順

ここでは典型的な例として「プレイヤーがぶつかると押せる岩」を作ってみます。

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

  1. プレイヤーのシーンを開きます(例:Player.tscn)。
  2. PlayerCharacterBody2D)ノードを選択し、右側の「ノード」タブ > 「グループ」を開きます。
  3. player というグループ名を追加します。
    (コンポーネント側の player_group デフォルトが "player" なので合わせています)

プレイヤーが CharacterBody2D で、velocity を使って移動していればOKです。

手順②:押せる岩シーンを作る

  1. 新規シーンを作成し、ルートノードに CharacterBody2D を追加します。
  2. 名前を PushableRock などに変更します。
  3. 子ノードとして以下を追加します:
    • Sprite2D(岩の見た目)
    • CollisionShape2D(岩の当たり判定)
    • PushableBlock(今作ったコンポーネント)
  4. PushableBlock ノードに、先ほどのスクリプト PushableBlock.gd をアタッチします。

シーン構成図はこんな感じになります:

PushableRock (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── PushableBlock (Node)

このように、「岩の移動ロジック」は PushableBlock に閉じ込めておき、親の CharacterBody2D 自体にはスクリプトを貼らない構成にするのがポイントです。

手順③:パラメータを調整する

PushableBlock ノードを選択すると、インスペクタに以下のようなパラメータが出てきます:

  • player_group:プレイヤーが所属するグループ名(デフォルト player
  • mass:岩の重さ。大きいほど押しても動きにくくなります
  • push_force:押されたときの力の強さ
  • max_speed:岩が出せる最高速度
  • friction:止まるまでの減速量
  • lock_horizontal:左右方向の移動を禁止(縦方向だけに動く岩など)
  • lock_vertical:上下方向の移動を禁止(横方向だけに動く岩など)
  • min_push_speed:この速度以上でぶつかったときだけ「押している」と判定
  • push_angle_tolerance_deg:どれくらい正面から押したら動くか(角度の許容)
  • stop_threshold:この速度以下になったら完全停止とみなす

例えば「かなり重い岩」にしたい場合:

  • mass = 3.0
  • push_force = 1000.0
  • max_speed = 40.0

などとすれば、グッと押さないと動かない感じになります。

手順④:ステージに配置してテストする

メインシーン(例:Main.tscn)を開き、プレイヤーと押せる岩を両方インスタンスして配置します:

Main (Node2D)
 ├── Player (CharacterBody2D)
 │    ├── Sprite2D
 │    ├── CollisionShape2D
 │    └── PlayerController (Script)
 └── PushableRock (CharacterBody2D)
      ├── Sprite2D
      ├── CollisionShape2D
      └── PushableBlock (Node)

ゲームを実行して、プレイヤーを岩にぶつけてみてください。
一定以上の速度で正面から押すと、岩が CharacterBody2D としてスルスルと押されていくはずです。

プレイヤー側は「いつもの移動コード」のままでOKで、PushableBlock は「押される側」にだけ付いているのがポイントです。継承ではなく「合成」で押せる岩を表現できていますね。


メリットと応用

この PushableBlock コンポーネントを使うことで、いくつか嬉しい点があります。

1. シーン構造がシンプルなまま「押せるオブジェクト」を量産できる

岩、木箱、氷ブロックなど、見た目やコリジョンサイズだけ違う「押せるオブジェクト」を作る場合:

  • ルートに CharacterBody2D
  • 見た目用の Sprite2D
  • 当たり判定用の CollisionShape2D
  • 挙動用の PushableBlock コンポーネント

というパターンで統一できます。
「押せるロジック」は全部 PushableBlock に閉じ込めてあるので、各シーンに余計なスクリプトを増やさなくて済みます。

2. プレイヤーコードを汚さない

「押す処理」をプレイヤー側に書き始めると:

  • プレイヤーが「押せるもの」と「押せないもの」を判定しないといけない
  • 押しロジックがプレイヤーの移動コードに混じってカオスになりがち

といった問題が出てきます。
今回のコンポーネントは「押される側」にだけロジックを持たせているので、プレイヤーは「ただ移動しているだけ」でOKです。
押せるかどうかはオブジェクト側が勝手に判定してくれます。

3. パズルギミックへの応用が簡単

たとえば:

  • 一方向にしか動かせない岩(lock_vertical = true で横方向限定など)
  • 氷の床の上でスーッと滑り続けるブロック(friction を小さくする)
  • スイッチを押すと勝手に少し動く岩(nudge() を呼ぶ)

など、パラメータとちょっとしたコード追加でバリエーションを増やせます。
同じ PushableBlock コンポーネントを使い回しつつ、インスタンスごとに設定を変えるだけでレベルデザインができるのが「合成」の強みですね。

改造案:スイッチで自動的に押し出す

例えば「床スイッチを踏むと岩が右に一マス分だけスライドする」みたいなギミックを作りたい場合、PushableBlock にこんな関数を追加してもいいですね:


func slide_one_tile(tile_size: float = 32.0, direction: Vector2 = Vector2.RIGHT) -> void:
    ## グリッド1マス分だけスライドさせる簡易ユーティリティ
    ## (実際には到達チェックなどを足すとパズルゲーム向きになります)
    if direction == Vector2.ZERO:
        return

    # 目標位置を計算
    var target_pos := _body.global_position + direction.normalized() * tile_size

    # シンプルに瞬間移動でもいいし、Tween でアニメーションしてもOK
    var tween := create_tween()
    tween.tween_property(
        _body,
        "global_position",
        target_pos,
        0.2
    ).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)

このように、押されて動くロジックはそのままに、「スクリプトからも動かせるAPI」を少しずつ足していくと、
パズル用のギミックコンポーネントとしてかなり汎用的に使い回せるようになります。

継承ベースで巨大な PlayerAndPushableAndMovableBlock みたいなクラスを作るより、
こうした小さなコンポーネントを組み合わせていく方が、後からの改造や再利用が圧倒的に楽になりますね。ぜひ自分のプロジェクト流にカスタマイズしてみてください。