extends CanvasLayer ## Doughnut action menu (ADR-0009). Open on LMB/Space, confirm click/Space, ## cancel Esc/RMB. Emergency is not a slice. signal confirmed(slice_id: String) signal cancelled() const Rules := preload("res://src/core/rules.gd") const Strings := preload("res://src/core/strings.gd") const SLICE_DEFS := [ {"id": "raise_hand", "label_key": "radial.raise_hand", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "raise hand"}, {"id": "eye_contact", "label_key": "radial.eye_contact", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "eye contact"}, {"id": "point", "label_key": "radial.point", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "point"}, {"id": "head_shake", "label_key": "radial.head_shake", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "head shake"}, {"id": "polite", "label_key": "radial.polite", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "polite direction"}, {"id": "inspect", "label_key": "radial.inspect", "level": -1, "action": "inspect"}, {"id": "wand", "label_key": "radial.wand", "level": -1, "action": "wand"}, {"id": "warning", "label_key": "radial.warning", "level": Rules.Level.FORMAL_WARNING, "action": "formal warning"}, {"id": "block", "label_key": "radial.block", "level": Rules.Level.BLOCKING, "action": "blocking"}, {"id": "reason", "label_key": "radial.reason", "level": Rules.Level.BLOCKING, "action": "request reason"}, {"id": "recognize", "label_key": "radial.recognize", "level": -1, "action": "recognize"}, {"id": "radio", "label_key": "radial.radio", "level": -1, "action": "radio assist"}, ] var selected := 0 var wand_available := false var person_name := "" var is_open := false var _opened_ms := 0 var _wheel: Control = null var _center_label: Label = null func _init() -> void: name = "ActionRadial" layer = 25 var wheel := Control.new() wheel.name = "Wheel" wheel.set_anchors_preset(Control.PRESET_FULL_RECT) wheel.mouse_filter = Control.MOUSE_FILTER_STOP wheel.draw.connect(_on_wheel_draw) wheel.gui_input.connect(_on_wheel_input) add_child(wheel) _wheel = wheel _center_label = Label.new() _center_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _center_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _center_label.set_anchors_preset(Control.PRESET_CENTER) _center_label.offset_left = -120 _center_label.offset_right = 120 _center_label.offset_top = -24 _center_label.offset_bottom = 24 _center_label.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(_center_label) hide() func slice_count() -> int: return SLICE_DEFS.size() func slice_id(index: int) -> String: if index < 0 or index >= SLICE_DEFS.size(): return "" return str(SLICE_DEFS[index]["id"]) func slice_def(index: int) -> Dictionary: if index < 0 or index >= SLICE_DEFS.size(): return {} return SLICE_DEFS[index] func is_slice_enabled(index: int) -> bool: if slice_id(index) == "wand": return wand_available return true func open_menu(p_name: String, p_wand: bool) -> void: person_name = p_name wand_available = p_wand selected = 0 is_open = true _opened_ms = Time.get_ticks_msec() _center_label.text = person_name show() _wheel.queue_redraw() func close_menu() -> void: is_open = false hide() func step(direction: int) -> void: if not is_open or SLICE_DEFS.is_empty(): return var n := SLICE_DEFS.size() var next := selected for i in n: next = (next + direction) % n if next < 0: next += n if is_slice_enabled(next): selected = next _wheel.queue_redraw() return func confirm_selected() -> String: if not is_open: return "" if not is_slice_enabled(selected): return "" return slice_id(selected) func angle_to_index(angle: float) -> int: # angle from atan2, 0 = +x, we want 0 = top (-PI/2). var n := float(SLICE_DEFS.size()) var shifted := angle + PI / 2.0 while shifted < 0.0: shifted += TAU while shifted >= TAU: shifted -= TAU var idx := int(floor(shifted / TAU * n)) % SLICE_DEFS.size() return idx func _on_wheel_input(event: InputEvent) -> void: if not is_open: return if event is InputEventMouseMotion: var idx := _index_at_mouse(event.position) if idx >= 0 and is_slice_enabled(idx): selected = idx _wheel.queue_redraw() elif event is InputEventMouseButton and event.pressed: if event.button_index == MOUSE_BUTTON_LEFT: if Time.get_ticks_msec() - _opened_ms < 120: return var idx := _index_at_mouse(event.position) if idx >= 0 and is_slice_enabled(idx): selected = idx confirmed.emit(slice_id(selected)) elif event.button_index == MOUSE_BUTTON_RIGHT: cancelled.emit() func _index_at_mouse(local_pos: Vector2) -> int: var center := _wheel.size * 0.5 var delta := local_pos - center if delta.length() < 36.0: return -1 return angle_to_index(atan2(delta.y, delta.x)) func _on_wheel_draw() -> void: if not is_open: return var center := _wheel.size * 0.5 var n := SLICE_DEFS.size() var outer := 174.0 var inner := 64.0 for i in n: var a0 := -PI / 2.0 + TAU * float(i) / float(n) var a1 := -PI / 2.0 + TAU * float(i + 1) / float(n) var col := Color(0.12, 0.14, 0.18, 0.82) if i == selected: col = Color(0.92, 0.78, 0.28, 0.9) if not is_slice_enabled(i): col = Color(0.2, 0.2, 0.22, 0.45) _draw_slice(center, inner, outer, a0, a1, col) var mid := (a0 + a1) * 0.5 var label_pos := center + Vector2(cos(mid), sin(mid)) * ((inner + outer) * 0.5) var text := Strings.lookup(str(SLICE_DEFS[i]["label_key"])) # With 12 slices the label box is tight: center it on the slice midpoint. var font := ThemeDB.fallback_font var text_size := font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, 12) _wheel.draw_string(font, label_pos - Vector2(text_size.x * 0.5, -4), text, HORIZONTAL_ALIGNMENT_LEFT, -1, 12, Color.WHITE) func _draw_slice(center: Vector2, inner: float, outer: float, a0: float, a1: float, color: Color) -> void: var pts := PackedVector2Array() var steps := 8 for s in steps + 1: var t := float(s) / float(steps) var ang := lerpf(a0, a1, t) pts.append(center + Vector2(cos(ang), sin(ang)) * outer) for s in steps + 1: var t := float(steps - s) / float(steps) var ang := lerpf(a0, a1, t) pts.append(center + Vector2(cos(ang), sin(ang)) * inner) _wheel.draw_colored_polygon(pts, color)