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_selectedPlayerMain に接続して、例えばこんな処理を書きます。


# 例: 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 をカスタマイズしてみてください。