Files
settled-reach/client/ui/world_radial.gd
T
2026-04-05 11:09:10 +02:00

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