extends Control ## D-058: World radial menu — right-click, 2 spokes v0.1 (Observe + Insert). ## Insert-styled: geometric lines, thin spokes, nearly transparent. ## Renders on InsertOverlay (CanvasLayer 10). ## Drag-release for power users, click-click for newcomers. ## Insert spoke sends PauseSimulation on activate (#518/D-058). ## Selecting any non-Insert spoke, cancelling, or pressing Escape calls ## deactivate_insert() which sends ResumeSimulation. Pause/resume are idempotent. signal spoke_selected(spoke_name: String) enum Spoke { NONE, OBSERVE, INSERT } const SPOKE_RADIUS := 60.0 const INNER_RADIUS := 16.0 const LINE_COLOR := Color("#c8d0e0") const LINE_DIM := Color(0.78, 0.82, 0.88, 0.3) const HOVER_COLOR := Color("#e0e8ff") const BG_COLOR := Color(0.05, 0.05, 0.08, 0.35) const LINE_WIDTH := 1.5 const ICON_SIZE := 12.0 # Spoke angles: Observe = up (270°), Insert = down (90°) const SPOKE_ANGLES := { Spoke.OBSERVE: -PI / 2.0, Spoke.INSERT: PI / 2.0, } const SPOKE_NAMES := { Spoke.OBSERVE: "Observe", Spoke.INSERT: "Insert", } var _open: bool = false var _origin: Vector2 = Vector2.ZERO var _hovered_spoke: int = Spoke.NONE var _drag_mode: bool = false var _insert_active: bool = false var _cached_font: Font = null func _ready() -> void: var menu_extent := SPOKE_RADIUS + ICON_SIZE + 20.0 # spoke + icon + label margin custom_minimum_size = Vector2(menu_extent * 2.0, menu_extent * 2.0) size = custom_minimum_size visible = false mouse_filter = Control.MOUSE_FILTER_STOP _cached_font = get_theme_default_font() func _input(event: InputEvent) -> void: if event is InputEventMouseButton: if event.button_index == MOUSE_BUTTON_RIGHT: if event.pressed and not _open: _open_menu(event.global_position) get_viewport().set_input_as_handled() elif not event.pressed and _open and _drag_mode: # Drag-release: select hovered spoke _confirm_selection() get_viewport().set_input_as_handled() elif event.pressed and _open and not _drag_mode: # Click-click: second click confirms _confirm_selection() get_viewport().set_input_as_handled() if event is InputEventMouseMotion and _open: _update_hover(event.global_position) queue_redraw() func _open_menu(pos: Vector2) -> void: _open = true _origin = pos _hovered_spoke = Spoke.NONE _drag_mode = true visible = true # Position the control so _origin is at center global_position = _origin - size / 2.0 queue_redraw() func _close_menu() -> void: _open = false _hovered_spoke = Spoke.NONE _drag_mode = false visible = false # Handle Escape key to dismiss menu and deactivate insert if active. func _unhandled_input(event: InputEvent) -> void: if _open and event.is_action_pressed("ui_cancel"): if _insert_active: deactivate_insert() _close_menu() get_viewport().set_input_as_handled() func _update_hover(mouse_pos: Vector2) -> void: var delta := mouse_pos - _origin var dist := delta.length() if dist < INNER_RADIUS: _hovered_spoke = Spoke.NONE _drag_mode = dist > 4.0 # Still dragging if moved at all return _drag_mode = true var angle := delta.angle() # Find closest spoke var best_spoke: int = Spoke.NONE var best_diff: float = PI # Max angular distance for spoke in SPOKE_ANGLES: var spoke_angle: float = SPOKE_ANGLES[spoke] var diff := absf(angle_difference(angle, spoke_angle)) if diff < best_diff and diff < PI / 3.0: # 60° acceptance zone best_diff = diff best_spoke = spoke _hovered_spoke = best_spoke func _confirm_selection() -> void: if _hovered_spoke != Spoke.NONE: var name: String = SPOKE_NAMES.get(_hovered_spoke, "") spoke_selected.emit(name) if _hovered_spoke == Spoke.INSERT: _activate_insert() elif _insert_active: # Selecting any non-Insert spoke closes the insert and resumes sim deactivate_insert() elif _insert_active: # No spoke selected (cancelled) while insert active — close insert deactivate_insert() _close_menu() func _activate_insert() -> void: # #518/D-058: Send PauseSimulation when insert opens. Idempotent — # if already paused (e.g. Gauntlet interlude), server ignores duplicate. if not _insert_active: _insert_active = true ( SimBridge . send_input( { "action": InputMapper.Action.PAUSE, "timestamp_msec": Time.get_ticks_msec(), } ) ) func deactivate_insert() -> void: # #518/D-058: Send ResumeSimulation when insert closes. if _insert_active: _insert_active = false ( SimBridge . send_input( { "action": InputMapper.Action.UNPAUSE, "timestamp_msec": Time.get_ticks_msec(), } ) ) func _draw() -> void: if not _open: return var center := size / 2.0 # Background circle draw_circle(center, SPOKE_RADIUS + 8.0, BG_COLOR) # Inner ring draw_arc(center, INNER_RADIUS, 0, TAU, 32, LINE_DIM, 1.0) # Outer ring draw_arc(center, SPOKE_RADIUS, 0, TAU, 48, LINE_DIM, 1.0) # Spokes for spoke in SPOKE_ANGLES: var angle: float = SPOKE_ANGLES[spoke] var dir := Vector2(cos(angle), sin(angle)) var inner_pt := center + dir * INNER_RADIUS var outer_pt := center + dir * SPOKE_RADIUS var color := HOVER_COLOR if spoke == _hovered_spoke else LINE_COLOR var width := LINE_WIDTH * 2.0 if spoke == _hovered_spoke else LINE_WIDTH # Spoke line draw_line(inner_pt, outer_pt, color, width) # Icon at spoke tip var icon_center := center + dir * (SPOKE_RADIUS + ICON_SIZE + 4.0) _draw_spoke_icon(spoke, icon_center, color) # Label var label: String = SPOKE_NAMES.get(spoke, "") var text_size := _cached_font.get_string_size(label, HORIZONTAL_ALIGNMENT_CENTER, -1, 11) var label_pos := icon_center + Vector2(-text_size.x / 2.0, ICON_SIZE + 14.0) draw_string(_cached_font, label_pos, label, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, color) func _draw_spoke_icon(spoke: int, center: Vector2, color: Color) -> void: match spoke: Spoke.OBSERVE: # Eye icon — simple geometric eye shape var hw := ICON_SIZE * 0.7 var hh := ICON_SIZE * 0.4 # Eye outline (two arcs) draw_arc(center - Vector2(0, hh * 0.3), hw, PI * 0.15, PI * 0.85, 12, color, 1.0) draw_arc(center + Vector2(0, hh * 0.3), hw, -PI * 0.85, -PI * 0.15, 12, color, 1.0) # Pupil draw_circle(center, 2.5, color) Spoke.INSERT: # Phone/device icon — simple rectangle var hw := ICON_SIZE * 0.35 var hh := ICON_SIZE * 0.55 draw_rect(Rect2(center - Vector2(hw, hh), Vector2(hw * 2, hh * 2)), color, false, 1.0) # Screen line draw_line( center - Vector2(hw * 0.6, hh * 0.3), center + Vector2(hw * 0.6, -hh * 0.3), color, 1.0 ) # -- Public API --------------------------------------------------------------- func is_open() -> bool: return _open func get_hovered_spoke() -> String: return SPOKE_NAMES.get(_hovered_spoke, "") func is_insert_active() -> bool: return _insert_active