Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
251 lines
6.7 KiB
GDScript
251 lines
6.7 KiB
GDScript
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
|