Godot 4 で UI を組んでいると、Window や Panel を「ドラッグで動かしたい」と思うこと、けっこうありますよね。
でも素直にやろうとすると、
Panelを継承したカスタムシーンを作って…- タイトルバーにマウスイベントを書いて…
- 毎回同じようなコードをコピペして…
みたいな「継承+コピペ地獄」にハマりがちです。
さらに、Panel だったり Window だったり TextureRect だったり、動かしたい UI の種類が増えてくると、継承ベースだとクラスがどんどん増えて管理がつらくなります。
そこで登場するのが、今回のコンポーネント指向なアプローチ。
「ドラッグで動かせるウィンドウ」という機能を 1 つのコンポーネントに閉じ込めて、必要な UI にポン付けできるようにしたのが、この記事で紹介する DraggableWindow コンポーネントです。
【Godot 4】ドラッグで動くフローティングUI!「DraggableWindow」コンポーネント
DraggableWindow は、「タイトルバー(ドラッグ領域)」をマウスで掴んで、親の UI コントロールを動かすためのコンポーネントです。
- どの UI ノードでも OK(
Panel/Window/TextureRectなど) - ドラッグに使う「タイトルバー」用ノードを指定するだけ
- 画面外に出ないようにするかどうかもフラグで制御
- コンポーネントなので、既存シーンに後付けしやすい
「継承より合成」のお手本みたいなやつですね。
では、フルコードから見ていきましょう。
フルコード: DraggableWindow.gd
extends Node
class_name DraggableWindow
## UI パネルを「タイトルバーをドラッグして」動かせるようにするコンポーネント。
## 親の Control(Panel, Window, TextureRect など)を移動対象とします。
## ドラッグに使う「タイトルバー」ノード。
## - 通常は Panel 内の Label や HBoxContainer などを指定します。
## - 未指定の場合、このコンポーネント自身の親 Control 全体がドラッグ領域になります。
@export var title_bar_path: NodePath
## 画面外に出ないように制限するかどうか。
## true の場合、親の Rect はビューポート内にクランプされます。
@export var clamp_to_viewport: bool = true
## ドラッグ中に ZIndex を一時的に最前面にするかどうか。
## UI が重なっている場合に、ドラッグ中のウィンドウを前面に出したいときに便利です。
@export var raise_on_drag: bool = true
## ドラッグ中にマウスカーソルの形状を変更するかどうか。
## 例: つかめることを示すために "MOVE" アイコンを表示。
@export var change_mouse_cursor: bool = true
@export var drag_cursor_shape: Input.CursorShape = Input.CURSOR_MOVE
## 内部状態
var _dragging: bool = false
var _drag_offset: Vector2 = Vector2.ZERO
var _target_control: Control
var _title_bar_control: Control
var _original_z_index: int = 0
var _original_mouse_cursor: Input.CursorShape = Input.CURSOR_ARROW
func _ready() -> void:
## 親が Control であることを確認
_target_control = get_parent() as Control
if _target_control == null:
push_warning("DraggableWindow: Parent must be a Control. This component will be disabled.")
set_process(false)
set_process_unhandled_input(false)
return
## タイトルバーの取得
if title_bar_path != NodePath():
_title_bar_control = get_node_or_null(title_bar_path) as Control
else:
## パス未指定なら、親 Control 全体をドラッグ領域とする
_title_bar_control = _target_control
if _title_bar_control == null:
push_warning("DraggableWindow: title_bar_path does not point to a Control. Using parent as title bar.")
_title_bar_control = _target_control
## マウス入力を受け取るためにマウスフィルターを調整
_title_bar_control.mouse_filter = Control.MOUSE_FILTER_STOP
## 必要なシグナルを接続
_title_bar_control.gui_input.connect(_on_title_bar_gui_input)
_title_bar_control.mouse_entered.connect(_on_title_bar_mouse_entered)
_title_bar_control.mouse_exited.connect(_on_title_bar_mouse_exited)
set_process(true)
func _on_title_bar_gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton:
var mb := event as InputEventMouseButton
if mb.button_index == MOUSE_BUTTON_LEFT:
if mb.pressed:
_start_drag(mb.position)
else:
_stop_drag()
elif event is InputEventMouseMotion and _dragging:
var mm := event as InputEventMouseMotion
_update_drag(mm.position, mm.relative)
func _start_drag(local_mouse_position: Vector2) -> void:
_dragging = true
## ドラッグ開始時点でのマウス位置とウィンドウ位置の差分を記録
_drag_offset = local_mouse_position
_original_z_index = _target_control.z_index
if raise_on_drag:
_target_control.z_index = 4096 ## 適当な大きい値で最前面に
if change_mouse_cursor:
_original_mouse_cursor = Input.get_default_cursor_shape()
Input.set_default_cursor_shape(drag_cursor_shape)
func _stop_drag() -> void:
if not _dragging:
return
_dragging = false
if raise_on_drag:
_target_control.z_index = _original_z_index
if change_mouse_cursor:
Input.set_default_cursor_shape(_original_mouse_cursor)
func _update_drag(local_mouse_position: Vector2, _relative: Vector2) -> void:
if not _dragging:
return
## タイトルバーのローカル座標から、親 Control のグローバル位置を更新
## タイトルバーのグローバルマウス位置を取得し、オフセット分だけ引く
var global_mouse_pos: Vector2 = _title_bar_control.get_global_mouse_position()
var new_global_pos: Vector2 = global_mouse_pos - _drag_offset
## Control のグローバル位置を更新
_target_control.global_position = new_global_pos
if clamp_to_viewport:
_clamp_to_viewport()
func _clamp_to_viewport() -> void:
var viewport_rect: Rect2 = _target_control.get_viewport_rect()
var rect: Rect2 = _target_control.get_global_rect()
## rect を viewport 内に収まるようにクランプ
var clamped_pos: Vector2 = rect.position
## 左・上
if rect.position.x < viewport_rect.position.x:
clamped_pos.x = viewport_rect.position.x
if rect.position.y < viewport_rect.position.y:
clamped_pos.y = viewport_rect.position.y
## 右・下
var max_x = viewport_rect.end.x - rect.size.x
var max_y = viewport_rect.end.y - rect.size.y
if clamped_pos.x > max_x:
clamped_pos.x = max_x
if clamped_pos.y > max_y:
clamped_pos.y = max_y
_target_control.global_position = clamped_pos
func _on_title_bar_mouse_entered() -> void:
## ホバー中だけカーソルを変えたい場合はここで処理してもよいが、
## 今回はドラッグ中のみ change_mouse_cursor で制御する方針。
pass
func _on_title_bar_mouse_exited() -> void:
## ここでカーソルを戻す実装も可能。
pass
## --- おまけ: スクリプトからタイトルバーを差し替えたいとき用 ---
func set_title_bar(node: Control) -> void:
## 実行時にタイトルバーを変更するヘルパー。
if _title_bar_control:
## 既存シグナルを切っておく
if _title_bar_control.gui_input.is_connected(_on_title_bar_gui_input):
_title_bar_control.gui_input.disconnect(_on_title_bar_gui_input)
if _title_bar_control.mouse_entered.is_connected(_on_title_bar_mouse_entered):
_title_bar_control.mouse_entered.disconnect(_on_title_bar_mouse_entered)
if _title_bar_control.mouse_exited.is_connected(_on_title_bar_mouse_exited):
_title_bar_control.mouse_exited.disconnect(_on_title_bar_mouse_exited)
_title_bar_control = node
_title_bar_control.mouse_filter = Control.MOUSE_FILTER_STOP
_title_bar_control.gui_input.connect(_on_title_bar_gui_input)
_title_bar_control.mouse_entered.connect(_on_title_bar_mouse_entered)
_title_bar_control.mouse_exited.connect(_on_title_bar_mouse_exited)
使い方の手順
ここからは、実際にシーンに組み込む手順を見ていきましょう。
例として「インベントリウィンドウ」をドラッグ可能にしてみます。
手順①: スクリプトをプロジェクトに追加する
res://components/ui/DraggableWindow.gdなど、好きな場所に保存します。- Godot エディタを再読み込みすると、スクリプトクラスとして
DraggableWindowがインスペクタから選べるようになります。
手順②: ウィンドウ用の UI シーンを作る
例として、インベントリ画面用のシーン構成はこんな感じにします:
InventoryWindow (Panel) ├── TitleBar (HBoxContainer) │ ├── Icon (TextureRect) │ └── TitleLabel (Label) ├── Body (VBoxContainer) │ └── ItemList (ItemList) └── DraggableWindow (Node) ← このノードにスクリプトを付ける
ポイントは、動かしたい UI(ここでは InventoryWindow)の子として DraggableWindow ノードを置くことです。
このコンポーネントは「自分の親の Control を動かす」実装になっているので、親子関係が重要ですね。
手順③: コンポーネントをアタッチして設定する
DraggableWindowノードを選択。- インスペクタの「スクリプト」欄で、
DraggableWindow.gdを指定。 - エクスポート変数を設定:
title_bar_path:../TitleBarを指定(ドラッグ領域にしたいノードへのパス)。
もし「ウィンドウ全体をドラッグ可能にしたい」なら、ここは空のままで OK です。clamp_to_viewport: true にすると画面外に出ないように制限。raise_on_drag: true にするとドラッグ中に最前面へ。change_mouse_cursor: true にするとドラッグ中カーソルをMOVEに変更。
手順④: 実行してドラッグを確認する
シーンを実行し、タイトルバー部分を左クリックしながらドラッグしてみてください。
InventoryWindow 全体が、マウスに追従して動くはずです。
別の使用例: 敵情報ポップアップ、ミニマップ、設定ウィンドウなど
このコンポーネントの良いところは、「ドラッグして動かせる」という機能を 1 箇所に閉じ込めていることです。
なので、次のような UI にもそのまま流用できます。
例1: 敵情報ポップアップ
EnemyInfoPopup (Panel) ├── TitleBar (HBoxContainer) │ └── TitleLabel (Label) ├── Body (VBoxContainer) │ ├── EnemyName (Label) │ └── EnemyStats (RichTextLabel) └── DraggableWindow (Node)
- タイトルバーに敵の名前を表示しつつ、そのバーをドラッグ可能に。
- 戦闘中に画面の邪魔にならない位置へユーザーが動かせる。
例2: ミニマップウィンドウ
MiniMapWindow (TextureRect)
├── TitleBar (Panel)
│ └── Label ("Mini Map")
└── DraggableWindow (Node)
- ミニマップ自体は
TextureRectでも OK。親がControlなら動かせます。 - プレイヤーが好きな位置にミニマップを配置できる UI になります。
例3: 設定ウィンドウ(Window ノード)
SettingsWindow (Window)
├── CustomTitleBar (MarginContainer)
│ └── Label ("Settings")
├── Body (VBoxContainer)
│ └── ...(設定項目)
└── DraggableWindow (Node)
- Godot の
Windowノードにもそのまま使えます。 - OS のネイティブタイトルバーを隠して、ゲーム内スキンのタイトルバーを自作する時にも便利ですね。
メリットと応用
DraggableWindow コンポーネントを使うことで、UI 周りの設計がかなりスッキリします。
- 継承ツリーが増えない
「ドラッグ可能な Panel」「ドラッグ可能な Window」…とクラスを分ける必要がなく、
どの UI でも「親にコンポーネントを 1 個ぶら下げるだけ」で機能追加できます。 - シーン構造が読みやすい
シーンツリーを見れば、「あ、この UI は DraggableWindow を持っているから動かせるんだな」と一目で分かります。 - 再利用性が高い
別プロジェクトに持っていくのも簡単で、「UI をドラッグ可能にしたい」という要件が出たら、とりあえずこれを突っ込めば OK。 - タイトルバーの見た目と機能を分離
ドラッグ判定はtitle_bar_pathで指定するだけなので、タイトルバーの見た目は自由にいじれます。
ラベルでも、HBoxContainer でも、TextureRect でも OK です。
コンポーネント指向の良さは、こういう「機能のカプセル化」と「後付けのしやすさ」にありますね。
Godot で UI を作り込んでいくほど、この差が効いてきます。
改造案: ダブルクリックで中央にリセットする機能を追加
例えば、「タイトルバーをダブルクリックしたら画面中央にウィンドウを戻す」機能を追加してみましょう。
以下のような関数を DraggableWindow に足し、_on_title_bar_gui_input から呼び出せば OK です。
func _on_title_bar_gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton:
var mb := event as InputEventMouseButton
if mb.button_index == MOUSE_BUTTON_LEFT:
if mb.double_click:
_center_in_viewport()
return
if mb.pressed:
_start_drag(mb.position)
else:
_stop_drag()
elif event is InputEventMouseMotion and _dragging:
var mm := event as InputEventMouseMotion
_update_drag(mm.position, mm.relative)
func _center_in_viewport() -> void:
if _target_control == null:
return
var viewport_rect := _target_control.get_viewport_rect()
var size := _target_control.size
var center_pos := viewport_rect.size * 0.5 - size * 0.5
_target_control.global_position = center_pos
こんな感じで、「ドラッグで動かせるウィンドウ」という基本コンポーネントをベースに、
プロジェクトごとの UI 仕様に合わせて、機能をどんどん積み増ししていくと楽しいですね。
ぜひ、自分のプロジェクトの UI たちに DraggableWindow をポン付けして、
「継承より合成」の気持ちよさを体験してみてください。
