Godot 4 で UI を組んでいると、WindowPanel を「ドラッグで動かしたい」と思うこと、けっこうありますよね。
でも素直にやろうとすると、

  • 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)

使い方の手順

ここからは、実際にシーンに組み込む手順を見ていきましょう。
例として「インベントリウィンドウ」をドラッグ可能にしてみます。

手順①: スクリプトをプロジェクトに追加する

  1. res://components/ui/DraggableWindow.gd など、好きな場所に保存します。
  2. Godot エディタを再読み込みすると、スクリプトクラスとして DraggableWindow がインスペクタから選べるようになります。

手順②: ウィンドウ用の UI シーンを作る

例として、インベントリ画面用のシーン構成はこんな感じにします:

InventoryWindow (Panel)
 ├── TitleBar (HBoxContainer)
 │    ├── Icon (TextureRect)
 │    └── TitleLabel (Label)
 ├── Body (VBoxContainer)
 │    └── ItemList (ItemList)
 └── DraggableWindow (Node)  ← このノードにスクリプトを付ける

ポイントは、動かしたい UI(ここでは InventoryWindow)の子として DraggableWindow ノードを置くことです。
このコンポーネントは「自分の親の Control を動かす」実装になっているので、親子関係が重要ですね。

手順③: コンポーネントをアタッチして設定する

  1. DraggableWindow ノードを選択。
  2. インスペクタの「スクリプト」欄で、DraggableWindow.gd を指定。
  3. エクスポート変数を設定:
    • 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 をポン付けして、
「継承より合成」の気持ちよさを体験してみてください。