Files
agame/addons/csg_blockout/scripts/csg_pie_menu.gd
T
2026-10-01 05:36:12 +08:00

209 lines
6.7 KiB
GDScript

@tool
class_name CsgPieMenu extends Control
signal action_triggered(item: Dictionary)
signal close_requested()
const BASE_INNER_RADIUS: float = 30.0
const BASE_OUTER_RADIUS: float = 120.0
const BASE_LINE_WIDTH: float = 2.0
const BASE_FONT_SIZE: int = 14
var editor_scale: float = 1.0
var items: Array = [] # Array[Dictionary]
var menu_stack: Array = [] # Array[Array]
var inner_radius: float = BASE_INNER_RADIUS
var outer_radius: float = BASE_OUTER_RADIUS
var active_item_index: int = -1
var is_in_deadzone: bool = true
# Style configuration
var bg_color: Color = Color(0.1, 0.1, 0.15, 0.85)
var hover_color: Color = Color(0.3, 0.5, 0.8, 0.95)
var text_color: Color = Color.WHITE
var line_color: Color = Color(0.2, 0.2, 0.2, 0.8)
var line_width: float = BASE_LINE_WIDTH
var font_size: int = BASE_FONT_SIZE
# Animation state
var anim_progress: float = 0.0
var popup_tween: Tween
var item_hover_progress: Array[float] = []
func _ready() -> void:
set_process(true)
mouse_filter = Control.MOUSE_FILTER_IGNORE
func apply_editor_scale(scale: float) -> void:
editor_scale = maxf(scale, 0.1)
inner_radius = BASE_INNER_RADIUS * editor_scale
outer_radius = BASE_OUTER_RADIUS * editor_scale
line_width = BASE_LINE_WIDTH * editor_scale
font_size = int(round(BASE_FONT_SIZE * editor_scale))
queue_redraw()
func setup(menu_items: Array, p_scale: float = -1.0) -> void:
if p_scale > 0.0:
apply_editor_scale(p_scale)
items = menu_items
menu_stack.clear()
active_item_index = -1
_play_popup_anim()
func _play_popup_anim() -> void:
item_hover_progress.resize(items.size())
item_hover_progress.fill(0.0)
anim_progress = 0.0
if popup_tween and popup_tween.is_valid():
popup_tween.kill()
popup_tween = create_tween().set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
popup_tween.tween_property(self, "anim_progress", 1.0, 0.25)
queue_redraw()
func _process(delta: float) -> void:
if items.is_empty():
return
var mouse_pos: Vector2 = get_local_mouse_position()
var distance: float = mouse_pos.length()
var new_active_index: int = -1
is_in_deadzone = distance < inner_radius
if not is_in_deadzone and distance < outer_radius * 2.0:
var angle: float = mouse_pos.angle()
var sector_angle: float = TAU / items.size()
var shifted_angle: float = wrapf(angle + sector_angle / 2.0, 0.0, TAU)
new_active_index = int(shifted_angle / sector_angle) % items.size()
if new_active_index != active_item_index:
active_item_index = new_active_index
var needs_redraw: bool = false
if popup_tween and popup_tween.is_running():
needs_redraw = true
for i in range(items.size()):
var target: float = 1.0 if i == active_item_index else 0.0
if abs(item_hover_progress[i] - target) > 0.01:
item_hover_progress[i] = lerpf(item_hover_progress[i], target, 20.0 * delta)
needs_redraw = true
if needs_redraw:
queue_redraw()
func _draw() -> void:
if items.is_empty():
return
var current_outer: float = outer_radius * anim_progress
var current_inner: float = inner_radius * anim_progress
if current_outer <= 0.01:
return
var num_items: int = items.size()
var sector_angle: float = TAU / num_items
var font: Font = get_theme_font("font", "Label")
# Draw shadow
draw_circle(Vector2.ZERO, current_outer + 2.0 * editor_scale, Color(0, 0, 0, 0.3 * anim_progress))
for i in range(num_items):
var start_angle: float = i * sector_angle - sector_angle / 2.0
var end_angle: float = start_angle + sector_angle
var hover_t: float = item_hover_progress[i]
# Base color lerp + global alpha
var color: Color = bg_color.lerp(hover_color, hover_t)
color.a *= anim_progress
# Draw sector polygon
var points: PackedVector2Array = _get_sector_polygon(start_angle, end_angle, current_inner, current_outer, 16)
draw_polygon(points, PackedColorArray([color]))
# Draw separator lines
var line_start: Vector2 = Vector2(cos(start_angle), sin(start_angle)) * current_inner
var line_end: Vector2 = Vector2(cos(start_angle), sin(start_angle)) * current_outer
var l_color: Color = line_color
l_color.a *= anim_progress
draw_line(line_start, line_end, l_color, line_width, true)
# Draw item text
var center_angle: float = i * sector_angle
var text_radius: float = (current_inner + current_outer) / 2.0
var text_pos: Vector2 = Vector2(cos(center_angle), sin(center_angle)) * text_radius
var item: Dictionary = items[i]
var text: String = item.get("label", "")
var text_size: Vector2 = font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)
text_pos -= text_size / 2.0
text_pos.y += font.get_ascent(font_size) / 2.0
var t_color: Color = text_color
t_color.a *= anim_progress
draw_string(font, text_pos, text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, t_color)
# Draw center deadzone indicator
if not menu_stack.is_empty():
var back_color: Color = hover_color if is_in_deadzone else Color(0.2, 0.2, 0.2, 0.8)
back_color.a *= anim_progress
draw_circle(Vector2.ZERO, current_inner - 2.0 * editor_scale, back_color)
var back_text: String = "<"
var bt_size: Vector2 = font.get_string_size(back_text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)
var bt_pos: Vector2 = - bt_size / 2.0
bt_pos.y += font.get_ascent(font_size) / 2.0
var bt_color: Color = text_color
bt_color.a *= anim_progress
draw_string(font, bt_pos, back_text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, bt_color)
func _get_sector_polygon(start_angle: float, end_angle: float, inner_r: float, outer_r: float, point_count: int) -> PackedVector2Array:
var points: PackedVector2Array = PackedVector2Array()
for i in range(point_count + 1):
var t: float = float(i) / float(point_count)
var angle: float = lerpf(start_angle, end_angle, t)
points.append(Vector2(cos(angle), sin(angle)) * outer_r)
for i in range(point_count + 1):
var t: float = float(point_count - i) / float(point_count)
var angle: float = lerpf(start_angle, end_angle, t)
points.append(Vector2(cos(angle), sin(angle)) * inner_r)
return points
func execute_active_item(trigger_close_on_empty: bool = false) -> void:
if is_in_deadzone:
if not menu_stack.is_empty():
go_back()
elif trigger_close_on_empty:
close_requested.emit()
return
if active_item_index >= 0 and active_item_index < items.size():
var item: Dictionary = items[active_item_index]
if item.get("type") == "submenu":
action_triggered.emit(item)
menu_stack.push_back(items)
items = item.get("children", [])
active_item_index = -1
_play_popup_anim()
else:
action_triggered.emit(item)
close_requested.emit()
else:
if trigger_close_on_empty:
close_requested.emit()
func execute_back() -> void:
if not menu_stack.is_empty():
go_back()
else:
close_requested.emit()
func go_back() -> void:
if not menu_stack.is_empty():
items = menu_stack.pop_back()
active_item_index = -1
_play_popup_anim()