Godotでリングメニュー(ラジアルメニュー)を作ろうとすると、ついこうなりがちですよね。
- プレイヤーシーンに直接 UI をベタ書きしてしまう
- 「メニュー専用シーン」をプレイヤーから
instance()して、プレイヤー側でゴリゴリ制御する - UI のレイアウトを
Controlの深いツリーで表現して、どのノードが何をしているのか分からなくなる
さらに、「聖剣伝説方式」のリングメニューだと……
- ボタンを押した位置(だいたいマウス位置やプレイヤー位置)を中心に表示したい
- 項目を円形に並べたい(角度計算がちょっと面倒)
- 選択中のスロットをハイライトしたい
これを毎回プレイヤーのスクリプトに書き足していくと、あっという間に Godot の「深い継承 + 深いノード階層」コンボが炸裂して、メンテがつらくなります。
そこで今回は、「RadialMenu」コンポーネントとして完全に独立させてしまいましょう。
プレイヤーでも敵でも、UI 専用のシーンでも、「好きなノードにポン付けするだけ」でリングメニューが使えるようにします。
【Godot 4】マウス周りにクルッと展開!「RadialMenu」コンポーネント
このコンポーネントは、以下の特徴を持つ Control ベースの UI コンポーネントです。
- ボタンを押すと、マウス位置を中心にリングメニューを表示
- メニュー項目は 配列で定義するだけ(テキスト or アイコン)
- マウスの方向で 選択スロットを自動判定
- 選択された項目は シグナルで外部に通知(プレイヤーやゲームロジックとは疎結合)
「RadialMenu」自体はプレイヤーを一切知らないので、どのシーンにもコンポーネントとして再利用可能です。
フルコード: RadialMenu.gd
extends Control
class_name RadialMenu
## 聖剣伝説風のリングメニューコンポーネント
## - 任意のノードにアタッチして使う
## - メニュー項目は items 配列で指定
## - show_at_mouse() / show_at_position() で表示開始
signal item_selected(index: int, label: String) # 項目が決定されたとき
signal menu_opened
signal menu_closed
@export_group("メニュー項目")
@export var items: Array[String] = [
"Item 1",
"Item 2",
"Item 3",
"Item 4"
]
## 将来的にアイコン対応したい場合は、別配列やリソースにするのがオススメ
@export_group("レイアウト")
@export_range(32.0, 512.0, 1.0)
var radius: float = 120.0 ## 中心から各スロットまでの距離(ピクセル)
@export_range(0.0, 360.0, 1.0)
var start_angle_deg: float = -90.0 ## 0 番目のスロットの開始角度(度)。-90 で真上から開始。
@export var clockwise: bool = true ## スロットを時計回りに配置するか
@export_group("見た目")
@export var slot_size: Vector2 = Vector2(64, 32) ## 各スロットの見た目サイズ(当たり判定にも利用)
@export var slot_color_normal: Color = Color(0.1, 0.1, 0.1, 0.8)
@export var slot_color_hover: Color = Color(0.9, 0.9, 0.3, 0.9)
@export var slot_color_border: Color = Color(1, 1, 1, 0.8)
@export var slot_border_width: float = 2.0
@export var background_color: Color = Color(0, 0, 0, 0.4) ## メニュー全体の背景(薄い暗転など)
@export_group("入力設定")
@export var close_on_release: bool = true ## マウスボタンを離したタイミングで決定&クローズ
@export var mouse_button: MouseButton = MOUSE_BUTTON_RIGHT ## どのボタンで操作するか
@export_group("デバッグ")
@export var debug_always_visible: bool = false ## デバッグ用。常に表示してマウスで選択確認したいとき用。
var _center: Vector2 = Vector2.ZERO ## メニューの中心座標(グローバル)
var _is_open: bool = false
var _hover_index: int = -1 ## 現在マウス方向で選択されているスロット
var _angle_per_item: float = 0.0
func _ready() -> void:
## フルスクリーン UI として使いやすいように、画面全体を覆う Control にしておく
mouse_filter = MOUSE_FILTER_PASS
anchor_left = 0.0
anchor_top = 0.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 0.0
offset_top = 0.0
offset_right = 0.0
offset_bottom = 0.0
visible = debug_always_visible
_is_open = debug_always_visible
if items.size() > 0:
_angle_per_item = 360.0 / float(items.size())
else:
_angle_per_item = 0.0
func show_at_mouse() -> void:
## 現在のマウス位置を中心にメニューを開く
var vp := get_viewport()
if not vp:
return
var mouse_pos: Vector2 = vp.get_mouse_position()
show_at_position(mouse_pos)
func show_at_position(world_pos: Vector2) -> void:
## 任意の画面座標を中心にメニューを開く
_center = world_pos
_is_open = true
visible = true
_hover_index = -1
emit_signal("menu_opened")
queue_redraw()
func close_menu(emit_selected: bool = true) -> void:
if not _is_open and not debug_always_visible:
return
if emit_selected and _hover_index >= 0 and _hover_index < items.size():
emit_signal("item_selected", _hover_index, items[_hover_index])
if not debug_always_visible:
_is_open = false
visible = false
emit_signal("menu_closed")
queue_redraw()
func _unhandled_input(event: InputEvent) -> void:
## コンポーネント自身が入力を受け取って、開閉や決定を管理する
if event is InputEventMouseButton and event.button_index == mouse_button:
var mb := event as InputEventMouseButton
if mb.pressed:
## ボタンを押したときにメニューを開く
show_at_mouse()
## メニューがマウス操作を優先するよう、イベントを消費
get_viewport().set_input_as_handled()
else:
## ボタンを離したときに選択を確定して閉じる
if close_on_release:
close_menu(true)
get_viewport().set_input_as_handled()
if event is InputEventMouseMotion:
if _is_open or debug_always_visible:
_update_hover_index((event as InputEventMouseMotion).position)
queue_redraw()
get_viewport().set_input_as_handled()
func _update_hover_index(mouse_pos: Vector2) -> void:
if items.is_empty():
_hover_index = -1
return
## マウスから中心へのベクトルを計算
var v: Vector2 = mouse_pos - _center
if v.length() < 8.0:
## ほぼ中心なら何も選択しない
_hover_index = -1
return
## atan2 はラジアンを返すので度に変換
var angle_rad: float = atan2(v.y, v.x) # -PI ~ PI
var angle_deg: float = rad_to_deg(angle_rad) # -180 ~ 180
## Godot の atan2 は右方向が 0 度、反時計回りが正
## ここでは 0 ~ 360 に正規化して扱いやすくする
if angle_deg < 0.0:
angle_deg += 360.0
## 開始角度と回転方向を考慮してスロット番号に変換
var local_angle: float = angle_deg - start_angle_deg
if clockwise:
local_angle = -local_angle
## 0 ~ 360 に再度正規化
local_angle = fposmod(local_angle, 360.0)
if _angle_per_item <= 0.0:
_hover_index = -1
return
var index: int = int(floor(local_angle / _angle_per_item))
if index >= 0 and index < items.size():
_hover_index = index
else:
_hover_index = -1
func _draw() -> void:
if not _is_open and not debug_always_visible:
return
## 背景(うっすら暗転)
_draw_background()
if items.is_empty():
return
## 各スロットを描画
for i in items.size():
_draw_slot(i)
func _draw_background() -> void:
## 画面全体を覆う半透明矩形
var rect := Rect2(Vector2.ZERO, get_size())
draw_rect(rect, background_color, true)
func _draw_slot(index: int) -> void:
var angle_deg_center: float = start_angle_deg + _angle_per_item * (clockwise ? -index : index)
var angle_rad_center: float = deg_to_rad(angle_deg_center)
## スロットの中心位置(ローカル座標系で描画するので、_center もそのまま使える)
var dir := Vector2(cos(angle_rad_center), sin(angle_rad_center))
var slot_center: Vector2 = _center + dir * radius
## スロット矩形(中央揃え)
var rect := Rect2(
slot_center - slot_size * 0.5,
slot_size
)
## 色を選択中かどうかで変える
var is_hover := (index == _hover_index)
var fill_color := is_hover ? slot_color_hover : slot_color_normal
## 本体
draw_rect(rect, fill_color, true)
## 枠線
if slot_border_width > 0.0:
draw_rect(rect, slot_color_border, false, slot_border_width)
## テキスト描画(簡易版)
var label := items[index]
var font: Font = get_theme_default_font()
if font:
var text_size := font.get_string_size(label)
var text_pos := slot_center - text_size * 0.5 + Vector2(0, font.get_height() * 0.15)
draw_string(font, text_pos, label, HORIZONTAL_ALIGNMENT_LEFT, -1.0, 16.0, Color.WHITE)
## --- 便利 API ---
func set_items(new_items: Array[String]) -> void:
items = new_items
if items.size() > 0:
_angle_per_item = 360.0 / float(items.size())
else:
_angle_per_item = 0.0
queue_redraw()
func is_open() -> bool:
return _is_open or debug_always_visible
使い方の手順
ここからは、実際にプレイヤーに「RadialMenu」をくっつけて、右クリックでリングメニューを開く例で見ていきましょう。
手順①: コンポーネントスクリプトを用意
上の RadialMenu.gd をプロジェクトのどこか(例: res://ui/RadialMenu.gd)に保存します。
class_name RadialMenu を付けているので、エディタの「アタッチスクリプト」や「ノード追加」から簡単に検索できます。
手順②: UI シーン or ルートに RadialMenu ノードを追加
リングメニューは画面全体を覆う UI なので、通常はルートの UI シーンに置くのが扱いやすいです。例えばこんな構成:
Main (Node2D)
├── Player (CharacterBody2D)
│ ├── Sprite2D
│ └── CollisionShape2D
└── CanvasLayer
└── RadialMenu (Control) ← このノードに RadialMenu.gd をアタッチ
ポイント:
- Player シーンには一切 UI を持たせない(Sprite と当たり判定だけ)
- RadialMenu は CanvasLayer 配下に置いて、ワールド座標に左右されない UI として扱う
- RadialMenu 自体は プレイヤーを知らないので、別ゲームでもそのまま再利用できます
手順③: 項目とシグナルを設定
RadialMenu ノードを選択して、インスペクタから以下を設定します。
items:["Potion", "Magic", "Weapon", "Settings"]など、好きな項目名に変更radius: 120~180 くらいが視認性良いですmouse_button: 右クリックで開きたいならMOUSE_BUTTON_RIGHTのままでOK
次に、シグナル接続でゲーム側とつなぎます。
RadialMenu ノードの「ノード」タブから、item_selected を Player か Main に接続して、例えばこんな処理を書きます。
# 例: Main.gd に接続した場合
func _on_RadialMenu_item_selected(index: int, label: String) -> void:
match label:
"Potion":
_use_potion()
"Magic":
_open_magic_menu()
"Weapon":
_switch_weapon()
"Settings":
_open_settings()
_:
print("選択:", label, " (index =", index, ")")
func _use_potion() -> void:
print("ポーションを使用!")
func _open_magic_menu() -> void:
print("魔法サブメニューを開く")
func _switch_weapon() -> void:
print("武器切り替え")
func _open_settings() -> void:
print("設定メニューを開く")
RadialMenu は 「どのラベルが選ばれたか」だけを通知し、
実際のロジック(回復・武器変更など)は 外側のシーンに任せるのがポイントです。
手順④: 動作確認(右クリックで展開)
- ゲームを実行
- 右クリックを押す → マウス位置を中心にリングメニューが開く
- ボタンを押したままマウスをぐるっと回して、項目を選ぶ
- ボタンを離す → 選択中の項目が
item_selectedシグナルで通知され、メニューが閉じる
もし「開いたままにしたい」「選択は左クリックで決めたい」といった UI にしたい場合は、
close_on_release = false にして、外部から close_menu() を呼び出す運用もできます。
メリットと応用
この RadialMenu をコンポーネントとして切り出すことで、次のようなメリットがあります。
- プレイヤーのスクリプトがスリムになる
- プレイヤーは「右クリックでメニューを開く」ことすら知らなくてOK
- 入力処理も描画も、UI 側(RadialMenu)が完全に担当
- シーン構造がフラットになる
- UI は CanvasLayer 配下に集約され、ワールドシーンとはきれいに分離
- 「プレイヤーの子に UI をぶら下げる」パターンから卒業できます
- 再利用性が高い
- 別プロジェクトでも
RadialMenu.gdとシーンをコピペすれば即利用可能 - 敵専用のリングメニュー(AI 用)や、ビルドメニュー、スキルホイールなどにも流用できます
- 別プロジェクトでも
- Godot の「継承地獄」を避けられる
- 「PlayerWithRingMenu.gd」みたいな派生スクリプトを作らなくて済む
- 代わりに「RadialMenu コンポーネントをアタッチするだけ」で機能追加できます
応用例としては、次のようなアイデアがあります。
- スキルのクールダウンをスロットの色やゲージで表現する
- アイコン(Texture2D)を描画して、テキストを非表示にする
- ゲームパッドのスティック方向でも選択できるようにする
改造案: ゲームパッド左スティックで選択する
簡単な改造として、「左スティックの向きでも選択スロットを変えたい」場合のコード例です。
RadialMenu.gd に次の関数を追加し、_process() から呼び出すと動きます。
func _process(delta: float) -> void:
if not is_open():
return
_update_hover_by_gamepad()
func _update_hover_by_gamepad() -> void:
## 左スティックの入力をベクトルとして取得
var x := Input.get_action_strength("ui_right") - Input.get_action_strength("ui_left")
var y := Input.get_action_strength("ui_down") - Input.get_action_strength("ui_up")
var v := Vector2(x, y)
if v.length() < 0.3:
## 入力が小さければ無視
return
## スティックの方向を、マウスと同じロジックに流用するため、
## 仮想的な「マウス位置」として _center + v * radius を使う
var virtual_mouse := _center + v.normalized() * radius
_update_hover_index(virtual_mouse)
queue_redraw()
これで、ゲームパッド勢にも優しいリングメニューになりますね。
入力まわりも RadialMenu 内で完結しているので、プレイヤー側のコードは一切いじらなくて済みます。
こんな感じで、UI ロジックをコンポーネント化していくと、「継承より合成」スタイルの開発がどんどん楽しくなってきます。
ぜひ自分のプロジェクト用に、RadialMenu をカスタマイズしてみてください。
