UI周りをゲームパッド対応しようとすると、地味に面倒なのが「フォーカス管理」ですね。
Godot 4標準でも focus_neighbor_*focus_next / focus_previous などの仕組みはありますが、

  • ポーズメニューを開いた瞬間に、特定のボタンへ自動でフォーカスを当てたい
  • メニューを閉じてまた開いたら、毎回「一番上のボタン」からフォーカスを始めたい
  • シーンごとにちょっとずつ違うUI構成で、毎回スクリプトを書き直したくない

…といった要望を、毎回「親コントロールの _ready に書く」「メニューごとにスクリプトを継承して書く」と、だんだんスクリプトが肥大化していきます。
さらに、UIシーンをプレイヤーや敵と同じように「継承ベース」で作ると、

  • 少しUI構成を変えたいだけで、ベースシーンをいじる羽目になる
  • メニューごとに _unhandled_input などの処理がコピペされていく

といった「継承のつらみ」が出てきがちです。

そこでこの記事では、「UIが開いたら自動で親のボタンにフォーカスを当てる」だけに責務を絞った小さなコンポーネント
FocusTrap コンポーネントを用意して、どのメニューにもポン付けできる形にしてみましょう。

【Godot 4】開いた瞬間にフォーカスバッチリ!「FocusTrap」コンポーネント

FocusTrap は、ざっくり言うと「親ノードのフォーカス制御を肩代わりする小さなノード」です。

  • 親が Control 系(例:PanelVBoxContainer)であることを前提に
  • ゲームパッド操作時に、UIが開いた瞬間に指定したボタンへ自動フォーカス
  • オプションで「開くたびに毎回フォーカスし直す」「一度だけフォーカスする」を選択可能
  • 「最初の有効なボタンを自動検出」もOK

という機能を持っています。
親シーンにべったり書いていたフォーカス制御を、ひとつのコンポーネントに合成するイメージですね。


フルソースコード(GDScript / Godot 4)


## FocusTrap.gd
## UIコンテナにアタッチして使う「フォーカス制御」用コンポーネント。
## 親Controlが表示されたタイミングで、指定したボタンにフォーカスを当てます。

extends Node
class_name FocusTrap
## ↑ 他のシーンから「FocusTrap」として追加できるようにする

## ---------------------------------------------------------
## 設定パラメータ(インスペクタから編集可能)
## ---------------------------------------------------------

## フォーカスを当てたいターゲットボタン
## - 未設定の場合は「親以下から最初に見つかったボタン」を自動検出します。
@export var target_button: BaseButton

## UIが「開かれた」とみなすトリガー方法
## - "ready"       : _ready() のタイミングで一度だけフォーカス
## - "visibility"  : 親Controlの visible が true になったときにフォーカス
## - "tree_enter"  : 親Controlがシーンツリーに入るたびにフォーカス
@export_enum("ready", "visibility", "tree_enter")
var trigger_mode: String = "visibility"

## 親Controlが非表示 → 再表示されたときに、毎回フォーカスし直すかどうか
## - trigger_mode = "visibility" のときのみ有効
@export var refocus_on_every_show: bool = true

## ゲームパッド入力が使われているときだけフォーカスするか
## - true  : 直近でゲームパッド入力があった場合のみ自動フォーカス
## - false : 入力方法に関係なく毎回フォーカス
@export var only_when_gamepad_used: bool = true

## 自動検出時に、無効化されたボタン(disabled = true)をスキップするかどうか
@export var skip_disabled_buttons: bool = true

## デバッグログを出すかどうか
@export var debug_log: bool = false


## ---------------------------------------------------------
## 内部状態
## ---------------------------------------------------------

var _parent_control: Control
var _has_focused_once: bool = false
var _last_input_device: String = "mouse_keyboard"  ## "gamepad" or "mouse_keyboard"


func _ready() -> void:
    ## 親が Control であることを前提とする
    _parent_control = get_parent() as Control
    if _parent_control == null:
        push_warning("FocusTrap: 親ノードが Control ではありません。このコンポーネントは Control の子として使ってください。")
        return

    ## 入力デバイスの種類を追跡するため、Input のイベントを購読
    ## ※ _unhandled_input でもよいが、ここでは生の _input を使用
    get_tree().root.connect("input_event", Callable(self, "_on_root_input_event"))

    match trigger_mode:
        "ready":
            _try_focus("trigger:ready")
        "visibility":
            ## 親Controlの visibility 変更を監視
            _parent_control.visibility_changed.connect(_on_parent_visibility_changed)
        "tree_enter":
            ## シーンツリーに入るたびにフォーカス
            _parent_control.tree_entered.connect(_on_parent_tree_entered)

    if debug_log:
        print("FocusTrap: initialized with trigger_mode=%s" % trigger_mode)


## ---------------------------------------------------------
## 入力デバイスの追跡
## ---------------------------------------------------------

func _on_root_input_event(_viewport: Viewport, event: InputEvent, _shape_idx: int) -> void:
    ## ゲームパッド由来のイベントかどうかをざっくり判定
    if event is InputEventJoypadButton or event is InputEventJoypadMotion:
        _last_input_device = "gamepad"
    elif event is InputEventKey or event is InputEventMouseButton or event is InputEventMouseMotion:
        _last_input_device = "mouse_keyboard"


## ---------------------------------------------------------
## トリガーイベントのハンドラ
## ---------------------------------------------------------

func _on_parent_visibility_changed() -> void:
    if not is_instance_valid(_parent_control):
        return

    if _parent_control.visible:
        ## 表示されたとき
        if refocus_on_every_show:
            _has_focused_once = false
        _try_focus("trigger:visibility")
    else:
        ## 非表示になったときは特に何もしない
        pass


func _on_parent_tree_entered() -> void:
    _has_focused_once = false
    _try_focus("trigger:tree_enter")


## ---------------------------------------------------------
## フォーカス処理本体
## ---------------------------------------------------------

func _try_focus(reason: String) -> void:
    if not is_instance_valid(_parent_control):
        return

    if only_when_gamepad_used and _last_input_device != "gamepad":
        if debug_log:
            print("FocusTrap: skip focus because last input device is not gamepad (%s)" % reason)
        return

    if _has_focused_once and trigger_mode == "ready":
        ## ready モードは一度だけ
        return

    var button := _resolve_target_button()
    if button == null:
        if debug_log:
            print("FocusTrap: target button not found (%s)" % reason)
        return

    if not button.is_visible_in_tree():
        if debug_log:
            print("FocusTrap: target button is not visible in tree (%s)" % reason)
        return

    if skip_disabled_buttons and button.disabled:
        if debug_log:
            print("FocusTrap: target button is disabled (%s)" % reason)
        return

    ## 実際にフォーカスを与える
    button.grab_focus()
    _has_focused_once = true

    if debug_log:
        print("FocusTrap: focused %s (%s)" % [button.name, reason])


## ---------------------------------------------------------
## ターゲットボタンの解決
## ---------------------------------------------------------

func _resolve_target_button() -> BaseButton:
    ## 明示的に指定されていればそれを使う
    if is_instance_valid(target_button):
        return target_button

    ## 未指定の場合は、親Control以下から「最初に見つかった BaseButton」を探す
    var queue: Array[Node] = [_parent_control]
    while not queue.is_empty():
        var node := queue.pop_front()
        if node is BaseButton:
            var btn := node as BaseButton
            if skip_disabled_buttons and btn.disabled:
                ## 無効なボタンはスキップ
                pass
            else:
                return btn

        ## 子を探索キューに追加
        for child in node.get_children():
            if child is Node:
                queue.append(child)

    return null

使い方の手順

ここでは、典型的な「ポーズメニュー」や「オプションメニュー」での使い方を例にしてみます。

手順①:コンポーネントスクリプトを用意する

  1. 上記の FocusTrap.gd を新規スクリプトとして保存します。
    例: res://components/ui/FocusTrap.gd
  2. Godotエディタで開き、エラーがないことを確認しておきましょう。

手順②:ポーズメニューのシーンに追加する

例として、以下のようなシーン構成のポーズメニューを考えます。

PauseMenu (Control)
 ├── Panel
 │    └── VBoxContainer
 │         ├── ResumeButton (Button)
 │         ├── OptionsButton (Button)
 │         └── QuitButton (Button)
 └── FocusTrap (Node)
  1. PauseMenu シーンを開く
  2. ルートの PauseMenu (Control) の子として Node を追加
  3. その NodeFocusTrap.gd をアタッチ
  4. インスペクタで以下のように設定
    • target_button: ResumeButton(または未指定で自動検出に任せる)
    • trigger_mode: "visibility"
    • refocus_on_every_show: true(ポーズを開くたびにフォーカスしたい)
    • only_when_gamepad_used: true(ゲームパッド操作時だけ自動フォーカス)
    • debug_log: 必要に応じて true

これで、PauseMenu.visible = true にしたタイミングで、ゲームパッド操作中なら ResumeButton に自動でフォーカスが当たるようになります。

手順③:プレイヤーからポーズメニューを開く例

プレイヤー側では「ポーズメニューの表示 / 非表示」だけを意識し、フォーカスは FocusTrap に任せましょう。
シーン構成例:

Main (Node)
 ├── Player (CharacterBody2D)
 ├── PauseMenu (Control)
 │    ├── Panel
 │    │    └── VBoxContainer
 │    │         ├── ResumeButton (Button)
 │    │         ├── OptionsButton (Button)
 │    │         └── QuitButton (Button)
 │    └── FocusTrap (Node)
 └── ...

プレイヤーやメインシーン側のスクリプト例:


# Main.gd など
extends Node

@onready var pause_menu: Control = $PauseMenu

func _ready() -> void:
    pause_menu.visible = false

func _unhandled_input(event: InputEvent) -> void:
    if event.is_action_pressed("ui_cancel"):
        if pause_menu.visible:
            _close_pause_menu()
        else:
            _open_pause_menu()

func _open_pause_menu() -> void:
    get_tree().paused = true
    pause_menu.visible = true
    ## ここでフォーカス処理は書かない!
    ## FocusTrap が visibility_changed をフックして勝手にやってくれます。

func _close_pause_menu() -> void:
    pause_menu.visible = false
    get_tree().paused = false

こうしておくと、メニューを開く側のコードは「表示・非表示」だけに集中できて、
どのボタンにフォーカスを当てるか、いつ当てるかといったロジックはコンポーネントに丸投げできます。

手順④:別のUI(オプション画面、ショップ画面など)にも再利用

同じ FocusTrap を、オプション画面やショップ画面にもそのまま使い回せます。
例えばオプションメニュー:

OptionsMenu (Control)
 ├── Panel
 │    └── VBoxContainer
 │         ├── BackButton (Button)
 │         ├── AudioButton (Button)
 │         └── VideoButton (Button)
 └── FocusTrap (Node)

このシーンでも、FocusTraptarget_buttonBackButton にしておけば、
ゲームパッド操作時にオプションを開いた瞬間、BackButton にフォーカスが当たるようになります。


メリットと応用

FocusTrap を使うことで、UIのフォーカス制御を「親UIシーン」から切り離して、小さなコンポーネントとして合成できるようになります。

  • シーン構造がスッキリ
    親の Control スクリプトには「UIの表示・非表示」「アニメーション」「サウンド再生」などの責務だけを残し、
    フォーカス制御は FocusTrap に任せることで、巨大な _ready / _process から解放されます。
  • 使い回しがしやすい
    ポーズメニュー、オプションメニュー、ショップ画面など、
    「開いた瞬間に特定ボタンへフォーカスしたいUI」にはすべて同じコンポーネントを貼るだけでOKです。
  • 継承地獄を回避
    「BaseMenu.gd を継承した PauseMenu.gd / OptionsMenu.gd …」という構造にせず、
    それぞれのメニューはシンプルな Control シーンのまま、必要な機能だけコンポーネントで合成できます。
  • ゲームパッド対応の切り替えが簡単
    only_when_gamepad_usedfalse にすれば、キーボード操作メインのゲームでも即利用できます。

改造案:最後にフォーカスしていたボタンを記憶する

「毎回一番上のボタンではなく、前回選択していたボタンに戻したい」という場合は、
FocusTrap に「最後にフォーカスしていたボタン」を覚えさせる改造が考えられます。

例えば、以下のような関数を追加し、ボタンの focus_entered シグナルから呼ぶようにすると、
再表示時にそのボタンへ戻ることができます。


var _last_focused_button: BaseButton

func remember_focus(button: BaseButton) -> void:
    ## ボタン側から「今フォーカスされたよ」と教えてもらう
    if is_instance_valid(button):
        _last_focused_button = button

func _resolve_target_button() -> BaseButton:
    ## まずは「最後にフォーカスしていたボタン」を優先
    if is_instance_valid(_last_focused_button):
        return _last_focused_button

    ## それがなければ、従来通り target_button or 自動検出
    if is_instance_valid(target_button):
        return target_button

    # ... 以降は前述の実装と同じ

このように、小さなコンポーネントとして分離しておくと、
「記憶型フォーカス」「タブ切り替え対応」などの拡張も、UI全体を壊さずに局所的に改造していけます。
ぜひ自分のプロジェクト流の FocusTrap に育てていきましょう。