UI周りをゲームパッド対応しようとすると、地味に面倒なのが「フォーカス管理」ですね。
Godot 4標準でも focus_neighbor_* や focus_next / focus_previous などの仕組みはありますが、
- ポーズメニューを開いた瞬間に、特定のボタンへ自動でフォーカスを当てたい
- メニューを閉じてまた開いたら、毎回「一番上のボタン」からフォーカスを始めたい
- シーンごとにちょっとずつ違うUI構成で、毎回スクリプトを書き直したくない
…といった要望を、毎回「親コントロールの _ready に書く」「メニューごとにスクリプトを継承して書く」と、だんだんスクリプトが肥大化していきます。
さらに、UIシーンをプレイヤーや敵と同じように「継承ベース」で作ると、
- 少しUI構成を変えたいだけで、ベースシーンをいじる羽目になる
- メニューごとに
_unhandled_inputなどの処理がコピペされていく
といった「継承のつらみ」が出てきがちです。
そこでこの記事では、「UIが開いたら自動で親のボタンにフォーカスを当てる」だけに責務を絞った小さなコンポーネントFocusTrap コンポーネントを用意して、どのメニューにもポン付けできる形にしてみましょう。
【Godot 4】開いた瞬間にフォーカスバッチリ!「FocusTrap」コンポーネント
FocusTrap は、ざっくり言うと「親ノードのフォーカス制御を肩代わりする小さなノード」です。
- 親が
Control系(例:Panel、VBoxContainer)であることを前提に - ゲームパッド操作時に、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
使い方の手順
ここでは、典型的な「ポーズメニュー」や「オプションメニュー」での使い方を例にしてみます。
手順①:コンポーネントスクリプトを用意する
- 上記の
FocusTrap.gdを新規スクリプトとして保存します。
例:res://components/ui/FocusTrap.gd - Godotエディタで開き、エラーがないことを確認しておきましょう。
手順②:ポーズメニューのシーンに追加する
例として、以下のようなシーン構成のポーズメニューを考えます。
PauseMenu (Control) ├── Panel │ └── VBoxContainer │ ├── ResumeButton (Button) │ ├── OptionsButton (Button) │ └── QuitButton (Button) └── FocusTrap (Node)
PauseMenuシーンを開く- ルートの
PauseMenu (Control)の子としてNodeを追加 - その
NodeにFocusTrap.gdをアタッチ - インスペクタで以下のように設定
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)
このシーンでも、FocusTrap の target_button を BackButton にしておけば、
ゲームパッド操作時にオプションを開いた瞬間、BackButton にフォーカスが当たるようになります。
メリットと応用
FocusTrap を使うことで、UIのフォーカス制御を「親UIシーン」から切り離して、小さなコンポーネントとして合成できるようになります。
- シーン構造がスッキリ
親のControlスクリプトには「UIの表示・非表示」「アニメーション」「サウンド再生」などの責務だけを残し、
フォーカス制御はFocusTrapに任せることで、巨大な_ready/_processから解放されます。 - 使い回しがしやすい
ポーズメニュー、オプションメニュー、ショップ画面など、
「開いた瞬間に特定ボタンへフォーカスしたいUI」にはすべて同じコンポーネントを貼るだけでOKです。 - 継承地獄を回避
「BaseMenu.gd を継承した PauseMenu.gd / OptionsMenu.gd …」という構造にせず、
それぞれのメニューはシンプルなControlシーンのまま、必要な機能だけコンポーネントで合成できます。 - ゲームパッド対応の切り替えが簡単
only_when_gamepad_usedをfalseにすれば、キーボード操作メインのゲームでも即利用できます。
改造案:最後にフォーカスしていたボタンを記憶する
「毎回一番上のボタンではなく、前回選択していたボタンに戻したい」という場合は、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 に育てていきましょう。
