UIを作っていると、ボタンやアイコンに「ちょっとした説明」を付けたくなることがありますよね。Godot 4 には Control.hint_tooltip という仕組みがありますが、
- デザインをカスタムしたい
- ゲーム中のツールチップを「専用レイヤー」にまとめて管理したい
- ワールド空間(3D/2D)とUI空間で共通の仕組みにしたい
といったタイミングで、標準のやり方だけでは物足りなくなってきます。
さらに、各ボタン用に派生シーンを作って、継承でツールチップ機能を足していくと、
- 「このボタンは Tooltip 付きのシーンだったっけ?」と混乱しやすい
- 似たような機能を持つ UI シーンが乱立して管理がつらい
- ツールチップ仕様を変えたい時に、全部の派生シーンを直すハメになる
という「継承地獄」にハマりがちです。
そこで今回は、「どんなノードにもポン付けできる」コンポーネント方式のツールチップ、Tooltip コンポーネントを用意して、マウスホバー時に専用レイヤーへテキストを表示する仕組みを作ってみましょう。
【Godot 4】どのノードにもポン付け!「Tooltip」コンポーネント
今回の構成は以下の 2 コンポーネントです。
- TooltipLayer … 画面上にツールチップを 1 個だけ描画・管理するレイヤー
- Tooltip … 任意のノードに付けて「マウスホバー時に TooltipLayer に表示を依頼」するコンポーネント
どちらも継承ではなく「合成(Composition)」で使います。
ボタンやアイコンのシーン構造を変えずに、あとから Tooltip 機能を追加できるのがポイントですね。
TooltipLayer.gd(ツールチップ表示レイヤー)
extends CanvasLayer
class_name TooltipLayer
## 画面に 1 つだけ置いて使う、ツールチップ専用レイヤー。
## 他のノードから「show_tooltip」「hide_tooltip」を呼び出して使います。
@export_group("UI Nodes")
@export var tooltip_panel: PanelContainer
## ツールチップの背景用。任意の PanelContainer をアサイン。
## 例: StyleBoxFlat で枠線や背景色を設定しておく。
@export var tooltip_label: Label
## 実際のテキストを表示する Label をアサイン。
@export_group("Settings")
@export var follow_mouse: bool = true
## true のとき、マウス位置に追従して表示します。
## false のときは、show_tooltip() に渡された position を使います。
@export var offset: Vector2 = Vector2(16, 16)
## マウス位置からどれだけずらして表示するか。
@export var clamp_to_viewport: bool = true
## true のとき、ツールチップが画面の外にはみ出さないように調整します。
var _is_showing: bool = false
func _ready() -> void:
if tooltip_panel:
tooltip_panel.visible = false
else:
push_warning("TooltipLayer: 'tooltip_panel' が未設定です。インスペクタで PanelContainer をアサインしてください。")
if not tooltip_label:
push_warning("TooltipLayer: 'tooltip_label' が未設定です。Label をアサインしてください。")
func _process(delta: float) -> void:
if not _is_showing:
return
if follow_mouse and tooltip_panel:
_update_position(get_viewport().get_mouse_position())
func show_tooltip(text: String, position: Vector2 = Vector2.INF) -> void:
## ツールチップを表示します。
## position = Vector2.INF のときはマウス位置を使います。
if not tooltip_panel or not tooltip_label:
return
tooltip_label.text = text
tooltip_panel.visible = true
_is_showing = true
var base_pos := position
if base_pos == Vector2.INF:
base_pos = get_viewport().get_mouse_position()
_update_position(base_pos)
func hide_tooltip() -> void:
## ツールチップを非表示にします。
if not tooltip_panel:
return
tooltip_panel.visible = false
_is_showing = false
func _update_position(base_pos: Vector2) -> void:
## ツールチップの位置を更新します。
if not tooltip_panel:
return
var pos := base_pos + offset
if clamp_to_viewport:
var viewport_rect := Rect2(Vector2.ZERO, get_viewport().get_visible_rect().size)
var panel_size := tooltip_panel.size
# 右・下にはみ出さないように調整
pos.x = min(pos.x, viewport_rect.size.x - panel_size.x)
pos.y = min(pos.y, viewport_rect.size.y - panel_size.y)
# 左・上方向はとりあえず 0 以上に制限
pos.x = max(pos.x, 0.0)
pos.y = max(pos.y, 0.0)
tooltip_panel.position = pos
Tooltip.gd(任意ノードに付けるコンポーネント)
extends Node
class_name Tooltip
## 任意のノードにアタッチして使う Tooltip コンポーネント。
## マウスホバー時に TooltipLayer へテキストを表示するだけの責務を持ちます。
@export_group("Tooltip Text")
@export_multiline var tooltip_text: String = ""
## 実際に表示するテキスト。
## 複数行もOK。インスペクタ上で改行して入力できます。
@export var enabled: bool = true
## false にすると一時的に Tooltip を無効化できます。
@export_group("Target & Layer")
@export var target_control: Control
## マウスホバーを検出する対象の Control。
## 未設定の場合は、親ノードが Control なら自動的にそれを使います。
@export var tooltip_layer_path: NodePath
## TooltipLayer へのパス。
## 未設定の場合は、シーンツリー上から最初に見つかった TooltipLayer を自動検索します。
@export_group("Behavior")
@export var show_on_hover: bool = true
## true のとき、マウスが target_control 上に入ったタイミングで表示します。
@export var hide_on_exit: bool = true
## true のとき、マウスが target_control から出たタイミングで非表示にします。
@export var show_delay_sec: float = 0.2
## 表示までのディレイ(秒)。0 なら即時表示。
var _tooltip_layer: TooltipLayer
var _hovered: bool = false
var _delay_timer: Timer
func _ready() -> void:
_setup_target_control()
_setup_layer()
_setup_timer()
func _setup_target_control() -> void:
if not target_control:
# 親が Control ならそれを自動的にターゲットにする
var parent_control := get_parent() as Control
if parent_control:
target_control = parent_control
if not target_control:
push_warning("Tooltip: 'target_control' が未設定で、親も Control ではありません。ホバー検出ができません。")
return
# マウスイベント用シグナルに接続
# すでに接続されている場合はエラーになるので、is_connected でチェック
if not target_control.mouse_entered.is_connected(_on_target_mouse_entered):
target_control.mouse_entered.connect(_on_target_mouse_entered)
if not target_control.mouse_exited.is_connected(_on_target_mouse_exited):
target_control.mouse_exited.connect(_on_target_mouse_exited)
func _setup_layer() -> void:
if tooltip_layer_path != NodePath():
var node := get_node_or_null(tooltip_layer_path)
_tooltip_layer = node as TooltipLayer
if not _tooltip_layer:
push_warning("Tooltip: tooltip_layer_path に指定されたノードは TooltipLayer ではありません。")
else:
# シーンツリーを遡って最初に見つかった TooltipLayer を使う
var current: Node = self
while current:
var layer := current.get_node_or_null("TooltipLayer") as TooltipLayer
if layer:
_tooltip_layer = layer
break
current = current.get_parent()
if not _tooltip_layer:
# それでも見つからなければ、ツリー全体から検索(コストは高め)
var tree := get_tree()
if tree:
for node in tree.get_nodes_in_group("TooltipLayerGroup"):
_tooltip_layer = node as TooltipLayer
if _tooltip_layer:
break
if not _tooltip_layer:
push_warning("Tooltip: TooltipLayer が見つかりません。シーン内に TooltipLayer を 1 つ配置してください。")
func _setup_timer() -> void:
_delay_timer = Timer.new()
_delay_timer.one_shot = true
_delay_timer.autostart = false
add_child(_delay_timer)
_delay_timer.timeout.connect(_on_delay_timeout)
func _on_target_mouse_entered() -> void:
if not enabled or not show_on_hover:
return
if tooltip_text.is_empty():
return
_hovered = true
if show_delay_sec <= 0.0:
_show()
else:
_delay_timer.start(show_delay_sec)
func _on_target_mouse_exited() -> void:
_hovered = false
if hide_on_exit:
_hide()
func _on_delay_timeout() -> void:
# ホバー中のままなら表示
if _hovered:
_show()
func _show() -> void:
if not _tooltip_layer:
return
_tooltip_layer.show_tooltip(tooltip_text)
func _hide() -> void:
if not _tooltip_layer:
return
_tooltip_layer.hide_tooltip()
※ TooltipLayer を自動検出しやすくするために、必要なら add_to_group("TooltipLayerGroup") を TooltipLayer._ready() に追記してもOKです。
使い方の手順
ここからは実際のシーン構成例を交えつつ、ステップごとに使い方を見ていきましょう。
手順①:TooltipLayer シーンを用意する
- 新規シーンを作成し、ルートに
CanvasLayerを置きます。 - ルートに
TooltipLayer.gdをアタッチします。 - 子として
PanelContainerと、その下にLabelを配置します。 TooltipLayerのインスペクタでtooltip_panelに PanelContainertooltip_labelに Label
をアサインします。
シーン構成図の例:
TooltipLayer (CanvasLayer)
└── TooltipPanel (PanelContainer)
└── TooltipLabel (Label)
この TooltipLayer シーンを メインシーンにインスタンスしておけば、ゲーム中どこからでもツールチップを共有できます。
手順②:UI ボタンに Tooltip コンポーネントを付ける
例えばメインメニューの「スタートボタン」にツールチップを付けたい場合:
MainMenu (Control)
├── StartButton (Button)
│ └── Tooltip (Node) <-- このノードに Tooltip.gd をアタッチ
└── TooltipLayer (CanvasLayer)
└── TooltipPanel (PanelContainer)
└── TooltipLabel (Label)
StartButtonの子としてNodeを追加し、名前をTooltipにします。- その
TooltipノードにTooltip.gdをアタッチします。 - インスペクタで
tooltip_textに「ゲームを開始します」などの説明を入力target_controlは空でもOK(親が Button なので自動検出されます)tooltip_layer_pathも空でOK(同じシーン内の TooltipLayer を自動検索)
これでゲーム実行時、StartButton にマウスを乗せると TooltipLayer にテキストが表示されるようになります。
手順③:インベントリのアイテムスロットにも使い回す
コンポーネントなので、同じ仕組みを別の UI にもそのまま使えます。
InventoryUI (Control)
├── ItemSlot (TextureRect)
│ ├── Icon (TextureRect)
│ └── Tooltip (Node) <-- Tooltip.gd
└── TooltipLayer (CanvasLayer)
└── TooltipPanel (PanelContainer)
└── TooltipLabel (Label)
ItemSlot(TextureRect)に Tooltip ノードを子として追加し、Tooltip.gdをアタッチ。target_controlは親の TextureRect を自動検出。tooltip_textに「HP を 50 回復するポーション」などを設定。
インベントリスロットのプレハブ(シーン)に Tooltip を仕込んでおけば、インスタンスするだけで全部のスロットが同じ仕組みでツールチップを出してくれるようになります。
手順④:ゲーム全体で 1 つの TooltipLayer を共有する
UI シーンごとに TooltipLayer を作るのではなく、ゲーム全体で 1 つの TooltipLayer をオートロードしておくのもおすすめです。
- TooltipLayer シーンを作ったら、
Project Settings > Autoloadに追加します。 - 名前を
TooltipLayerのまま登録すると、どのシーンからも/root/TooltipLayerでアクセスできます。 Tooltip.gdのtooltip_layer_pathを/root/TooltipLayerに設定しておけば、自動検出に頼らず確実に参照できます。
この場合のシーン構成図(例):
MainScene (Node2D or Control)
├── UI (CanvasLayer / Control)
│ ├── StartButton (Button)
│ │ └── Tooltip (Node)
│ └── ItemSlot (TextureRect)
│ └── Tooltip (Node)
└── ...(ゲーム本体のノード)
/root
├── MainScene
└── TooltipLayer (CanvasLayer, Autoload)
└── TooltipPanel (PanelContainer)
└── TooltipLabel (Label)
メリットと応用
この Tooltip コンポーネント構成のメリットを整理すると:
- 継承いらず:Button を継承した「TooltipButton」みたいな派生クラスを増やさなくて済む。
- 責務が分離:
- TooltipLayer … 描画と位置決めの責務だけ
- Tooltip … 「いつ表示するか」のトリガーだけ
- どんな UI にも後付け可能:既存のシーン構造を壊さず、子ノードに 1 個付けるだけで導入できる。
- デザイナーとエンジニアの分業がしやすい:TooltipLayer の見た目(Panel, Label のスタイル)は UI デザイナーがいじり、Tooltip コンポーネント側はコードだけで完結。
- テキストの一元管理:tooltip_text を後からローカライズ対応するなども簡単。
「深いノード階層」や「継承ベースの UI クラス」を増やさずに、必要な機能だけをコンポーネントとして足していくスタイルになっているので、プロジェクトが大きくなっても整理しやすい構造になりますね。
改造案:ゲームパッドフォーカスでもツールチップを表示する
マウスだけでなく、ゲームパッド操作でフォーカスが移動した時にもツールチップを表示したい場合、Tooltip.gd に以下のような関数を追加して、フォーカスシグナルに接続するのもアリです。
func enable_focus_tooltip() -> void:
## target_control のフォーカス時にも Tooltip を表示するようにする。
if not target_control:
return
if not target_control.focus_entered.is_connected(_on_focus_entered):
target_control.focus_entered.connect(_on_focus_entered)
if not target_control.focus_exited.is_connected(_on_focus_exited):
target_control.focus_exited.connect(_on_focus_exited)
func _on_focus_entered() -> void:
if not enabled:
return
if tooltip_text.is_empty():
return
_hovered = true
if show_delay_sec <= 0.0:
_show()
else:
_delay_timer.start(show_delay_sec)
func _on_focus_exited() -> void:
_hovered = false
_hide()
このように、Tooltip コンポーネントに少しずつ機能を足していけば、「マウスでもパッドでも同じ Tooltip 体験」を簡単に作れます。
継承を増やさず、コンポーネントを育てていくスタイルで、Godot プロジェクトをどんどん拡張していきましょう。
