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 シーンを用意する

  1. 新規シーンを作成し、ルートに CanvasLayer を置きます。
  2. ルートに TooltipLayer.gd をアタッチします。
  3. 子として PanelContainer と、その下に Label を配置します。
  4. TooltipLayer のインスペクタで

    • tooltip_panel に PanelContainer

    • tooltip_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)
  1. StartButton の子として Node を追加し、名前を Tooltip にします。
  2. その Tooltip ノードに Tooltip.gd をアタッチします。
  3. インスペクタで
    • 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 をオートロードしておくのもおすすめです。

  1. TooltipLayer シーンを作ったら、Project Settings > Autoload に追加します。
  2. 名前を TooltipLayer のまま登録すると、どのシーンからも /root/TooltipLayer でアクセスできます。
  3. Tooltip.gdtooltip_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 プロジェクトをどんどん拡張していきましょう。