Godotでメニュー画面やUIを作っていると、「ボタンが押されたらシーンを切り替えたい」という場面は必ず出てきますよね。
多くの入門記事では、こんな感じの実装が紹介されがちです。

extends Button

func _on_pressed() -> void:
    get_tree().change_scene_to_file("res://scenes/game.tscn")

これでも動きますが、問題はここからです。

  • タイトルボタン、リトライボタン、ステージ選択ボタン…ボタンの数だけスクリプトを量産しがち
  • 「継承」で Button を拡張してしまうと、別の共通処理を入れたいときに継承ツリーがぐちゃぐちゃになる
  • UIデザイナーやレベルデザイナーにとって、シーン遷移の設定がコード依存になってしまう

つまり、「ボタンの見た目」と「シーン遷移のロジック」がベッタリ結合してしまうわけですね。
そこで登場するのが、コンポーネントとしてアタッチするだけでシーン遷移を担当してくれる「SceneChanger」コンポーネントです。

【Godot 4】ボタンはただのボタンに!シーン遷移は「SceneChanger」コンポーネントに丸投げ

今回作る SceneChanger コンポーネントは、

  • 自分の親ノードが Button だったら、その pressed シグナルを自動接続
  • 押されたら、指定したシーンパスに遷移
  • フェード時間などの演出オプションも一応持たせておく(拡張しやすい)

という「ボタンにくっつけるだけ」のシンプルなコンポーネントです。
ボタン側は 一切スクリプトを書かず、SceneChanger をアタッチしてパスを設定するだけでOKにしてしまいましょう。


フルコード:SceneChanger.gd

extends Node
class_name SceneChanger
## 親の Button が押されたときに、指定したシーンへ遷移するコンポーネント。
##
## 想定するノード構成:
## Button
##  └── SceneChanger (このスクリプトをアタッチした Node)
##
## Button 側にはスクリプト不要で、SceneChanger 側の設定だけでシーン遷移が完結します。

@export_file("*.tscn") var target_scene_path: String = ""
## 遷移先のシーンパス。
## 例: res://scenes/game.tscn
## 空文字のままだと押されても何もしません(警告を出すだけ)。

@export var auto_disable_button: bool = true
## 遷移開始時に親の Button を自動的に disabled にするかどうか。
## 二重クリックによる多重ロードを防ぐ目的です。

@export var use_transition: bool = false
## true の場合、簡易フェード演出を入れてからシーンを切り替えます。
## 実際のプロジェクトでは、ここを自前のトランジションマネージャー呼び出しに差し替えるとよいです。

@export_range(0.0, 5.0, 0.1) var fade_time: float = 0.5
## use_transition が true のときのフェード時間(秒)。
## 今回は簡易的に、非同期で待つだけのダミー実装です。

var _button: Button


func _ready() -> void:
    ## 親ノードが Button かどうかをチェックし、シグナルを自動接続します。
    _button = _find_parent_button()
    if _button == null:
        push_warning("SceneChanger: 親に Button が見つかりません。このコンポーネントは Button の子として配置してください。")
        return

    # すでに接続済みでなければ、pressed シグナルを接続
    if not _button.pressed.is_connected(_on_button_pressed):
        _button.pressed.connect(_on_button_pressed)


func _find_parent_button() -> Button:
    ## 直近の親が Button であることを想定しつつ、
    ## 念のため上方向にたどって Button を探します。
    var current: Node = get_parent()
    while current:
        if current is Button:
            return current
        current = current.get_parent()
    return null


func _on_button_pressed() -> void:
    ## Button が押されたときに呼ばれるコールバック。
    if target_scene_path.is_empty():
        push_warning("SceneChanger: target_scene_path が設定されていません。シーン遷移をスキップします。")
        return

    if auto_disable_button and _button:
        _button.disabled = true

    if use_transition:
        # 簡易的なトランジション(フェード)処理。
        # 実プロジェクトでは、ここを TransitionManager 的なシングルトンに置き換えると良いです。
        _change_scene_with_fade()
    else:
        _change_scene_immediately()


func _change_scene_immediately() -> void:
    ## 即座にシーンを切り替えるシンプルな実装。
    var error := get_tree().change_scene_to_file(target_scene_path)
    if error != OK:
        push_error("SceneChanger: シーンの読み込みに失敗しました: %s" % target_scene_path)
        if auto_disable_button and _button:
            _button.disabled = false


func _change_scene_with_fade() -> void:
    ## 疑似的なフェード演出付きシーン遷移。
    ## ここでは単に時間待ちしてからシーンを切り替えるだけのダミー実装です。
    ## 実際にはフェード用 CanvasLayer を制御したり、アニメーションを再生したりします。
    _run_fade_transition()


@func
async func _run_fade_transition() -> void:
    # ここでは "フェード中" という体で、指定時間待ってからシーン遷移します。
    # await を使うので、Godot 4 以降が前提です。
    if fade_time > 0.0:
        await get_tree().create_timer(fade_time).timeout

    var error := get_tree().change_scene_to_file(target_scene_path)
    if error != OK:
        push_error("SceneChanger: シーンの読み込みに失敗しました: %s" % target_scene_path)
        if auto_disable_button and _button:
            _button.disabled = false

使い方の手順

前提として、以下のようなシーンがあるとします。

  • TitleScene(タイトル画面)
  • GameScene(ゲーム本編)

TitleScene の「スタートボタン」を押したら GameScene に遷移したい、という例で解説します。

手順①:コンポーネントスクリプトを作成

  1. 任意のフォルダ(例: res://addons/components/)に SceneChanger.gd を作成
  2. 上記のフルコードをコピペ保存

これで、インスペクタから SceneChanger というスクリプトが選べるようになります。

手順②:ボタンの子として SceneChanger ノードを追加

TitleScene のシーン構成を、例えばこんな感じにします。

TitleScene (Control)
 ├── MarginContainer
 │   └── VBoxContainer
 │       ├── StartButton (Button)
 │       │   └── SceneChanger (Node)  ← このノードにコンポーネントをアタッチ
 │       └── QuitButton (Button)
 └── Background (TextureRect)

StartButton の子として Node を1つ追加し、そのノードに SceneChanger.gd をアタッチします。
(ノード名は SceneChanger としておくと分かりやすいです)

手順③:インスペクタでシーンパスを設定

SceneChanger ノードを選択すると、インスペクタに以下のようなプロパティが見えます。

  • target_scene_path: 遷移先のシーンパス(例: res://scenes/GameScene.tscn
  • auto_disable_button: 押された直後にボタンを無効化するか(多重押し防止)
  • use_transition: フェード演出を挟むか
  • fade_time: フェード時間(秒)

ここでは target_scene_path に GameScene のパスを設定しておきましょう。
これだけで、StartButton にシーン遷移機能が追加されます。

手順④:他のボタンにもコピペで使い回す

たとえば、ゲームオーバー画面からタイトルに戻るボタンにも同じコンポーネントを使えます。

GameOverScene (Control)
 ├── Label
 ├── RetryButton (Button)
 │   └── SceneChanger (Node)
 └── BackToTitleButton (Button)
     └── SceneChanger (Node)
  • RetryButton の SceneChanger: target_scene_path = res://scenes/GameScene.tscn
  • BackToTitleButton の SceneChanger: target_scene_path = res://scenes/TitleScene.tscn

ボタン側には一切スクリプトを書かず、どのボタンがどこに飛ぶかは SceneChanger の設定だけで完結します。
まさに「継承より合成」で、ボタンはただのボタンにしておけるのがポイントですね。


メリットと応用

SceneChanger コンポーネントを導入すると、こんなメリットがあります。

  • UIごとにスクリプトを量産しなくてよい
    → タイトル、ポーズメニュー、オプション画面など、どこでも同じコンポーネントを再利用できます。
  • シーン構造がシンプルで読みやすい
    → 「このボタンはどこに飛ぶの?」が、インスペクタの target_scene_path を見るだけで一目瞭然。
  • ボタンの見た目とロジックが分離
    → アート担当やレベルデザイナーは見た目だけをいじり、エンジニアはコンポーネントをメンテナンスする、という役割分担がしやすくなります。
  • 演出の共通化が簡単
    → フェード演出や SE 再生などを SceneChanger に集約すれば、全ボタンの挙動を一括で変えられます。

特に、Godot の「ノードに直接スクリプトを継承でベタ付けする」スタイルを続けていると、UI周りがスクリプトだらけでカオスになりがちです。
SceneChanger のように 小さくて再利用可能なコンポーネントを積み上げていくと、プロジェクトの見通しがかなり良くなりますね。

改造案:BGM フェードアウトも一緒にやる

例えば、シーン遷移前に BGM をフェードアウトしたい場合は、
Autoload(シングルトン)で AudioManager を用意しておき、SceneChanger から呼ぶようにするときれいにまとまります。

SceneChanger に次のような関数を追加してみましょう。

func _fadeout_bgm_before_change() -> void:
    # AudioManager は Autoload(Project Settings > Autoload)で登録されている想定
    if Engine.has_singleton("AudioManager"):
        var audio_manager = Engine.get_singleton("AudioManager")
        if "fadeout_bgm" in audio_manager:
            audio_manager.fadeout_bgm(fade_time)

そして _run_fade_transition() の先頭で呼び出せば、シーン遷移する全ボタンから自動的に BGM フェードアウトがかかるようになります。

こんな感じで、「ボタンを押したら起きてほしいこと」をどんどん SceneChanger に集約していけば、
UIロジックがバラバラに散らばらず、コンポーネント指向らしい気持ちいい設計になっていきますね。