Godot で UI を作っていると、Control をドラッグで動かしたい場面ってけっこうありますよね。
でも素直にやろうとすると、
Controlを継承したカスタムクラスを毎回作る- ドラッグ処理を各 UI ごとにコピペして散らばる
- 「このウィンドウはヘッダーだけ掴める」「これは全体を掴める」みたいな差分が地味に面倒
みたいな「小さいけどダルい問題」にハマりがちです。
そこで今回は、どんな UI にも後付けできる「ドラッグ移動コンポーネント」を用意して、
「継承ベース」ではなく「コンポーネントをアタッチするだけ」でドラッグ移動を実現してみましょう。
【Godot 4】どのUIもつかんで動かせ!「Draggable」コンポーネント
この Draggable コンポーネントは、
- 自分の親の Control をマウスドラッグで動かす
- 画面内に収まるように自動で制限する(オプション)
- クリックしてもボタンなどの UI の通常動作を邪魔しない(オプション)
といった機能を持っています。
つまり「ドラッグしたい UI の子ノードとして置くだけ」で、どんな UI でもドラッグ可能にできるわけですね。
フルコード(GDScript / Godot 4)
extends Node
class_name Draggable
## 親の Control ノードをマウスドラッグで動かすコンポーネント
##
## 使い方:
## - 親が Control のシーンに、この Draggable を子として追加するだけ
## - 親 Control の位置がドラッグで動くようになります
@export var enabled: bool = true:
set(value):
enabled = value
# ドラッグ中に無効化されたら強制終了
if not enabled and _is_dragging:
_is_dragging = false
## どこを掴めるか:
## - null: 親 Control 全体がドラッグ可能
## - NodePath: 指定した子 Control 上だけドラッグ可能(ウィンドウのヘッダーなど)
@export var drag_handle_path: NodePath
## 画面(ビューポート)外への移動を制限するかどうか
@export var clamp_to_viewport: bool = true
## ドラッグ開始/終了をログに出したいとき用
@export var debug_log: bool = false
## ドラッグ対象(親の Control)
var _target: Control
## 掴んだ時点でのマウス位置(グローバル)
var _drag_start_mouse_pos: Vector2
## 掴んだ時点でのターゲット位置(グローバル)
var _drag_start_target_pos: Vector2
## ドラッグ中フラグ
var _is_dragging: bool = false
## 実際にマウスイベントを受け取る Control(ハンドル)
var _handle_control: Control
func _ready() -> void:
# 親が Control であることを確認
_target = get_parent() as Control
if _target == null:
push_warning("Draggable: 親が Control ではありません。このコンポーネントは Control の子として使ってください。")
set_process(false)
set_process_input(false)
return
# ドラッグ用のハンドルを解決
if drag_handle_path == NodePath(""):
# ハンドル未指定: 親 Control 全体を掴めるようにする
_handle_control = _target
else:
var node := get_node_or_null(drag_handle_path)
_handle_control = node as Control
if _handle_control == null:
push_warning("Draggable: drag_handle_path に指定されたノードが Control ではありません。親 Control 全体をハンドルとして使います。")
_handle_control = _target
# 入力を受け取るためにマウスフィルターを調整
# ハンドルが親そのものの場合は、既存の設定を尊重する
if _handle_control != _target:
# ハンドル専用のときは確実にイベントを取りたいので STOP にしておく
_handle_control.mouse_filter = Control.MOUSE_FILTER_STOP
set_process_input(true)
func _input(event: InputEvent) -> void:
if not enabled:
return
if _handle_control == null or _target == null:
return
# マウスイベントのみ処理
if not (event is InputEventMouseButton or event is InputEventMouseMotion):
return
# グローバル座標系で処理する
var viewport := get_viewport()
if viewport == null:
return
# ボタンイベント(左クリックで掴む/離す)
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
var mb := event as InputEventMouseButton
if mb.pressed:
# クリック位置がハンドル内かどうか判定
var mouse_pos: Vector2 = mb.position
# Control のグローバル矩形を取得
var global_rect: Rect2 = _handle_control.get_global_rect()
if global_rect.has_point(mouse_pos):
_start_drag(mouse_pos)
# 他の UI にイベントを渡したくない場合はここで return してもよい
# 今回は「ドラッグしつつボタンも押せる」ようにしたいのでそのまま続行
else:
# 左ボタンを離したらドラッグ終了
if _is_dragging:
_end_drag()
# マウス移動イベント
elif event is InputEventMouseMotion:
if _is_dragging:
var mm := event as InputEventMouseMotion
_update_drag(mm.position)
func _start_drag(mouse_global_pos: Vector2) -> void:
_is_dragging = true
_drag_start_mouse_pos = mouse_global_pos
_drag_start_target_pos = _target.global_position
if debug_log:
print("Draggable: drag start at ", mouse_global_pos, " target_pos=", _drag_start_target_pos)
func _update_drag(mouse_global_pos: Vector2) -> void:
# マウスの移動量に応じてターゲットを動かす
var delta: Vector2 = mouse_global_pos - _drag_start_mouse_pos
var new_pos: Vector2 = _drag_start_target_pos + delta
if clamp_to_viewport:
new_pos = _clamp_position_to_viewport(new_pos)
_target.global_position = new_pos
func _end_drag() -> void:
_is_dragging = false
if debug_log:
print("Draggable: drag end. final_pos=", _target.global_position)
func _clamp_position_to_viewport(pos: Vector2) -> Vector2:
# ターゲットのサイズとビューポートサイズを元に、画面内に収める
var viewport := get_viewport()
if viewport == null:
return pos
var vp_size: Vector2 = viewport.get_visible_rect().size
var size: Vector2 = _target.size
var clamped := pos
clamped.x = clampf(clamped.x, 0.0, max(0.0, vp_size.x - size.x))
clamped.y = clampf(clamped.y, 0.0, max(0.0, vp_size.y - size.y))
return clamped
使い方の手順
ここからは、具体的なシーン例を交えつつ、実際の使い方を見ていきましょう。
例1: シンプルなドラッグ可能ウィンドウ
「ウィンドウ全体を掴んで動かせる」パターンです。
WindowPanel (Panel) ※親が Control であることが重要 ├── Label └── Draggable (Node)
- Draggable.gd を用意
上記のコードをres://components/Draggable.gdなどに保存します。 - ドラッグさせたい Control のシーンを開く
例:PanelやWindow、TextureRectなど。 - 子ノードとして Draggable を追加
- + ボタン →
Nodeを追加 - スクリプトに
Draggable.gdをアタッチ - ノード名を
Draggableにしておくとわかりやすいです
シーン構成は次のようになります:
WindowPanel (Panel)
├── Label
└── Draggable (Node)
- + ボタン →
- インスペクタでパラメータを確認
enabled: true のままで OKdrag_handle_path: 空のまま → 親 Control 全体を掴めるclamp_to_viewport: true にしておけば画面外に行きません
これでゲームを実行すると、Panel のどこを掴んでもドラッグで移動できるようになります。
例2: タイトルバーだけ掴めるウィンドウ
「ウィンドウのヘッダー部分だけドラッグ可能」にしたい場合です。
ウィンドウの中にボタンやスクロールビューがあるときは、このパターンがおすすめです。
Window (Panel) ├── TitleBar (Panel) ※ここだけドラッグ可能にしたい │ └── Label ├── Content (VBoxContainer) │ ├── Button │ └── Button └── Draggable (Node)
- TitleBar 用の Control を用意
上記のように、掴む部分をTitleBar (Panel)などのControlで作っておきます。 - 親ウィンドウの子に Draggable を追加
構成図のようにDraggableノードを Window の直下に置きます。 - drag_handle_path を設定
Draggableノードを選択- インスペクタの
drag_handle_pathに../TitleBarを指定
この構成にすると、
- TitleBar 上だけドラッグで移動
- Content 内のボタンは通常通りクリックできる
という挙動になります。
細かい UI の操作感を崩さずに、ドラッグ移動だけを「後付け」できるのがコンポーネントの良いところですね。
例3: エディタ風のドッカブルパネル
エディタっぽい UI で「コンソール」「インスペクタ」などのパネルをドラッグで動かしたい場合も、同じ要領です。
InspectorPanel (PanelContainer) ├── Header (HBoxContainer) │ ├── Icon (TextureRect) │ └── Title (Label) ├── Body (VBoxContainer) │ └── ...(中身いろいろ) └── Draggable (Node)
InspectorPanelシーンの直下にDraggableを追加drag_handle_pathに../Headerを設定clamp_to_viewportは好みで ON/OFF
これで、エディタ風 UI でも簡単に「つかんで動かせる」パネルを量産できます。
メリットと応用
この Draggable コンポーネントを使うことで、次のようなメリットがあります。
- 継承地獄からの解放
「ドラッグできる Panel」「ドラッグできる TextureRect」などをクラスごとに作る必要がなく、
どんな Control にも 同じコンポーネントをポン付けできます。 - シーン構造がシンプル
既存の UI 構造を壊さず、Draggableを 1 ノード追加するだけ。
しかも「ドラッグ可能」という責務がDraggableノードにきれいに分離されます。 - レベルデザイン / UI デザインが楽
「このウィンドウは動かしたい」「このダイアログは固定にしたい」といった違いも、Draggableノードの有無やenabledフラグだけで制御可能です。 - ドラッグ挙動の共通管理
ドラッグのスナップ、慣性、グリッド吸着などを追加したくなったときも、
1 箇所(Draggable.gd)を書き換えるだけで、全 UI に反映されます。
改造案:グリッドにスナップさせる
例えば「ドラッグ後の位置を 32px グリッドにスナップさせたい」ときは、_end_drag() を少し拡張してみましょう。
@export var snap_to_grid: bool = false
@export var grid_size: Vector2 = Vector2(32, 32)
func _end_drag() -> void:
_is_dragging = false
if snap_to_grid:
var pos := _target.global_position
pos.x = round(pos.x / grid_size.x) * grid_size.x
pos.y = round(pos.y / grid_size.y) * grid_size.y
_target.global_position = pos
if debug_log:
print("Draggable: drag end. final_pos=", _target.global_position)
こうしておけば、レベルエディタ風 UI やレイアウトツールなどにも、そのまま流用できますね。
コンポーネント指向で作っておくと、こういう「欲しくなったときの一手間」が本当に楽になります。
ぜひ、自分のプロジェクト用にカスタマイズしながら、
「継承より合成」の快適さを体感してみてください。
