Files
settled-reach/client/ui/world_radial.gd
T
jpmschweitzerandClaude Opus 4.6 9e8ccb13bf fix(client): address PR #22 review — 3 critical bugs, 2 warnings, 8 suggestions
Critical fixes:
- fog_state: guard _compute_bounds() against all-invalid tiles (negative Rect2i crash)
- fog_shader: read Camera2D zoom dynamically instead of hardcoded Vector2(2,2)
- world_radial: set custom_minimum_size in _ready() from spoke geometry

Warnings:
- fog.gdshader: tighten PERIPHERAL_LOW 0.15→0.55 to match D-059 peripheral band
- Extract color_for_entity_kind() to Constants.gd, decouple CursorRenderer from EntityRenderer

Documentation: shallow copy assumption, gradual decay TODO, tween guard rationale,
fade timing rationale, ToggleInsert TODO, monologue consume-once, hover offset safety,
v6 fixture gap TODO.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:23:11 +01:00

216 lines
5.8 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 Pause on activate, Pause again on close (toggle).
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
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
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
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()
_close_menu()
func _activate_insert() -> void:
# TODO(v7): replace PAUSE toggle with dedicated ToggleInsert action in protocol
if not _insert_active:
_insert_active = true
_send_pause()
func _send_pause() -> void:
SimBridge.send_input({
"action": InputMapper.Action.PAUSE,
"timestamp_msec": Time.get_ticks_msec(),
})
func deactivate_insert() -> void:
# Called when closing insert view — send Pause again (toggle)
if _insert_active:
_insert_active = false
_send_pause()
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 font := ThemeDB.fallback_font
var text_size := 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(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