feat(client): Sprint 6 Touch — z-layer pipeline, cursor, fog, interactions, inventory, stance, radial
Three-scope z-layer rendering pipeline (D-049): world z:0-900 inside CanvasGroup, insert overlay CanvasLayer 10, UI CanvasLayer 20, modal CanvasLayer 30. Y-sort contract enforced (entities z_index=0). Reserved ranges for VFX, airborne, lower floors documented in constants.gd. Sprint 6 client tickets: - #429: Cursor state machine — 4 states, 150ms transitions (D-056) - #430: Fog shader rebuild — 5-layer fragment shader, animated noise (D-059) - #432: Entity interaction list — vertical multi-verb, insert-styled (D-057) - #433: World radial menu — 2 spokes, drag-release + click-click (D-058) - #438: Inventory UI — 3x3 grid, 40x40px, 1-9 hotkeys (D-065) - #439: Stance indicator — color-coded HUD, C/X keybinds (D-053) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/rendering/cursor_renderer.gd" id="1_cursor"]
|
||||
|
||||
; D-056: Cursor state machine — standalone scene for testing.
|
||||
; In the main scene, this is instantiated as a child of UILayer (z-layer 7).
|
||||
[node name="CursorStateMachine" type="Node2D"]
|
||||
script = ExtResource("1_cursor")
|
||||
@@ -0,0 +1,169 @@
|
||||
extends Control
|
||||
|
||||
## D-057: Entity interaction vertical list — insert-styled, z-layer 6.
|
||||
## Compact list of 2-4 verb options, anchored to entity position.
|
||||
## Sprint suppression (D-055): hidden while sprinting.
|
||||
## Diegetic test: labels disappear when insert is off.
|
||||
##
|
||||
## Replaces the single-line interaction_prompt for multi-verb scenarios.
|
||||
## Public API matches QA test contract (test_interaction_list.gd).
|
||||
|
||||
signal verb_selected(kind: String, entity_id: int)
|
||||
|
||||
const MAX_VERBS := 4
|
||||
const FADE_IN := 0.12
|
||||
const FADE_OUT := 0.10
|
||||
const LABEL_HEIGHT := 22
|
||||
const LABEL_GAP := 2
|
||||
const INSERT_FG := Color("#c8d0e0")
|
||||
const INSERT_DIM := Color("#8b8ba0")
|
||||
const INSERT_BG := Color(0.05, 0.05, 0.08, 0.7)
|
||||
|
||||
var _showing: bool = false
|
||||
var _insert_active: bool = true
|
||||
var _current_target_id: int = -1
|
||||
var _verb_items: Array = [] # sorted [{kind, label, priority, available}]
|
||||
var _selected_index: int = 0
|
||||
var _active_tween: Tween = null
|
||||
var _verb_labels: Array[Label] = []
|
||||
|
||||
@onready var _vbox: VBoxContainer = $VBox
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
modulate.a = 0.0
|
||||
visible = false
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
|
||||
|
||||
func update_from_state() -> void:
|
||||
# D-055: Sprint stance suppresses interaction list
|
||||
if GameState.player_stance == "Sprint":
|
||||
_hide()
|
||||
return
|
||||
|
||||
# Diegetic toggle — insert off means no overlay data
|
||||
if not _insert_active:
|
||||
_hide()
|
||||
return
|
||||
|
||||
var interactions: Array = GameState.nearby_interactions
|
||||
if interactions.is_empty():
|
||||
_hide()
|
||||
return
|
||||
|
||||
var interaction: Dictionary = interactions[0]
|
||||
var entity_id: int = interaction.get("entity_id", -1)
|
||||
var verbs: Array = interaction.get("verbs", [])
|
||||
|
||||
if verbs.is_empty():
|
||||
_hide()
|
||||
return
|
||||
|
||||
# Sort by priority ascending, cap at MAX_VERBS
|
||||
var sorted: Array = verbs.duplicate()
|
||||
sorted.sort_custom(func(a, b): return a.get("priority", 99) < b.get("priority", 99))
|
||||
if sorted.size() > MAX_VERBS:
|
||||
sorted = sorted.slice(0, MAX_VERBS)
|
||||
|
||||
_current_target_id = entity_id
|
||||
_verb_items = sorted
|
||||
_selected_index = 0
|
||||
_rebuild_labels()
|
||||
_show()
|
||||
|
||||
|
||||
func _rebuild_labels() -> void:
|
||||
# Clear existing labels
|
||||
for lbl in _verb_labels:
|
||||
if is_instance_valid(lbl):
|
||||
lbl.queue_free()
|
||||
_verb_labels.clear()
|
||||
|
||||
for i in range(_verb_items.size()):
|
||||
var verb: Dictionary = _verb_items[i]
|
||||
var lbl := Label.new()
|
||||
lbl.text = verb.get("label", "")
|
||||
lbl.add_theme_font_size_override("font_size", 14)
|
||||
lbl.add_theme_color_override("font_color", INSERT_FG if i == _selected_index else INSERT_DIM)
|
||||
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_vbox.add_child(lbl)
|
||||
_verb_labels.append(lbl)
|
||||
|
||||
|
||||
func _show() -> void:
|
||||
if _showing:
|
||||
return
|
||||
_showing = true
|
||||
visible = true
|
||||
if _active_tween and _active_tween.is_valid():
|
||||
_active_tween.kill()
|
||||
_active_tween = create_tween()
|
||||
_active_tween.tween_property(self, "modulate:a", 1.0, FADE_IN)
|
||||
|
||||
|
||||
func _hide() -> void:
|
||||
if not _showing:
|
||||
return
|
||||
_showing = false
|
||||
_current_target_id = -1
|
||||
_verb_items.clear()
|
||||
if _active_tween and _active_tween.is_valid():
|
||||
_active_tween.kill()
|
||||
_active_tween = create_tween()
|
||||
_active_tween.tween_property(self, "modulate:a", 0.0, FADE_OUT)
|
||||
_active_tween.tween_callback(func():
|
||||
visible = false
|
||||
for lbl in _verb_labels:
|
||||
if is_instance_valid(lbl):
|
||||
lbl.queue_free()
|
||||
_verb_labels.clear()
|
||||
)
|
||||
|
||||
|
||||
# -- Lazy sync for getters (tests may set GameState without calling update) --
|
||||
|
||||
func _ensure_synced() -> void:
|
||||
if _current_target_id == -1 and not GameState.nearby_interactions.is_empty():
|
||||
update_from_state()
|
||||
|
||||
|
||||
# -- Public API (QA test contract) ------------------------------------------
|
||||
|
||||
func get_visible_verb_count() -> int:
|
||||
_ensure_synced()
|
||||
return _verb_items.size()
|
||||
|
||||
|
||||
func is_showing() -> bool:
|
||||
return _showing
|
||||
|
||||
|
||||
func get_selected_verb() -> String:
|
||||
_ensure_synced()
|
||||
if _verb_items.is_empty():
|
||||
return ""
|
||||
return _verb_items[_selected_index].get("kind", "")
|
||||
|
||||
|
||||
func get_interaction_target() -> int:
|
||||
_ensure_synced()
|
||||
return _current_target_id
|
||||
|
||||
|
||||
func get_verb_labels() -> Array:
|
||||
var labels: Array = []
|
||||
for verb in _verb_items:
|
||||
labels.append(verb.get("label", ""))
|
||||
return labels
|
||||
|
||||
|
||||
func set_insert_active(active: bool) -> void:
|
||||
_insert_active = active
|
||||
if not active and _showing:
|
||||
_hide()
|
||||
|
||||
|
||||
func get_z_layer() -> int:
|
||||
return Constants.CANVAS_INSERT
|
||||
@@ -0,0 +1,16 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/interaction_list.gd" id="1_list"]
|
||||
|
||||
[node name="InteractionList" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 0
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1_list")
|
||||
|
||||
[node name="VBox" type="VBoxContainer" parent="."]
|
||||
layout_mode = 0
|
||||
offset_right = 160.0
|
||||
offset_bottom = 100.0
|
||||
mouse_filter = 2
|
||||
theme_override_constants/separation = 2
|
||||
@@ -0,0 +1,139 @@
|
||||
extends Control
|
||||
|
||||
## D-065: Inventory grid — 3x3 slots, bottom-right, 40x40px icons.
|
||||
## No empty slots displayed — icons appear only when items are carried.
|
||||
## 1-9 hotkeys for direct slot access. Reads from GameState.player_inventory.
|
||||
## Lives on UILayer (z-layer 7).
|
||||
|
||||
signal item_selected(slot: int, item_id: int)
|
||||
|
||||
const SLOT_SIZE := 40
|
||||
const SLOT_GAP := 4
|
||||
const GRID_COLS := 3
|
||||
const GRID_ROWS := 3
|
||||
const MAX_SLOTS := 9
|
||||
|
||||
const SLOT_BG := Color(0.1, 0.1, 0.14, 0.6)
|
||||
const SLOT_BORDER := Color(0.3, 0.3, 0.38, 0.8)
|
||||
const SLOT_ACTIVE := Color("#c8d0e0")
|
||||
const SLOT_TEXT := Color("#c8d0e0")
|
||||
const SLOT_TEXT_DIM := Color("#8b8ba0")
|
||||
const HOTKEY_SIZE := 10
|
||||
|
||||
var _slots: Array[Dictionary] = [] # [{item_id, name, slot}] from GameState
|
||||
var _slot_nodes: Array[Control] = []
|
||||
var _selected_slot: int = -1
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_build_grid()
|
||||
visible = false
|
||||
|
||||
|
||||
func _build_grid() -> void:
|
||||
for node in _slot_nodes:
|
||||
if is_instance_valid(node):
|
||||
node.queue_free()
|
||||
_slot_nodes.clear()
|
||||
|
||||
var grid_w: float = GRID_COLS * SLOT_SIZE + (GRID_COLS - 1) * SLOT_GAP
|
||||
var grid_h: float = GRID_ROWS * SLOT_SIZE + (GRID_ROWS - 1) * SLOT_GAP
|
||||
|
||||
# Position grid at bottom-right with margin
|
||||
custom_minimum_size = Vector2(grid_w, grid_h)
|
||||
size = Vector2(grid_w, grid_h)
|
||||
|
||||
|
||||
func update_from_state() -> void:
|
||||
_slots = GameState.player_inventory.duplicate()
|
||||
|
||||
if _slots.is_empty():
|
||||
visible = false
|
||||
return
|
||||
|
||||
visible = true
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if _slots.is_empty():
|
||||
return
|
||||
|
||||
for item in _slots:
|
||||
var slot_idx: int = item.get("slot", 0)
|
||||
if slot_idx < 0 or slot_idx >= MAX_SLOTS:
|
||||
continue
|
||||
|
||||
var col: int = slot_idx % GRID_COLS
|
||||
var row: int = slot_idx / GRID_COLS
|
||||
var pos := Vector2(
|
||||
col * (SLOT_SIZE + SLOT_GAP),
|
||||
row * (SLOT_SIZE + SLOT_GAP)
|
||||
)
|
||||
var rect := Rect2(pos, Vector2(SLOT_SIZE, SLOT_SIZE))
|
||||
|
||||
# Slot background
|
||||
draw_rect(rect, SLOT_BG)
|
||||
# Border
|
||||
draw_rect(rect, SLOT_BORDER, false, 1.0)
|
||||
|
||||
# Selected highlight
|
||||
if slot_idx == _selected_slot:
|
||||
draw_rect(rect, SLOT_ACTIVE, false, 2.0)
|
||||
|
||||
# Item name (truncated, centered)
|
||||
var item_name: String = item.get("name", "?")
|
||||
if item_name.length() > 5:
|
||||
item_name = item_name.substr(0, 4) + "."
|
||||
var font := ThemeDB.fallback_font
|
||||
var font_size := 11
|
||||
var text_size := font.get_string_size(item_name, HORIZONTAL_ALIGNMENT_CENTER, -1, font_size)
|
||||
var text_pos := pos + Vector2((SLOT_SIZE - text_size.x) / 2.0, SLOT_SIZE / 2.0 + text_size.y / 4.0)
|
||||
draw_string(font, text_pos, item_name, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, SLOT_TEXT)
|
||||
|
||||
# Hotkey number (top-left corner)
|
||||
var hotkey := str(slot_idx + 1)
|
||||
draw_string(font, pos + Vector2(3, HOTKEY_SIZE + 2), hotkey, HORIZONTAL_ALIGNMENT_LEFT, -1, HOTKEY_SIZE, SLOT_TEXT_DIM)
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if not visible:
|
||||
return
|
||||
if event is InputEventKey and event.pressed and not event.echo:
|
||||
var key: int = event.physical_keycode
|
||||
# 1-9 hotkeys (Key_1 = 49, Key_9 = 57)
|
||||
if key >= KEY_1 and key <= KEY_9:
|
||||
var slot_idx: int = key - KEY_1
|
||||
_select_slot(slot_idx)
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
func _select_slot(slot_idx: int) -> void:
|
||||
# Find item in this slot
|
||||
for item in _slots:
|
||||
if item.get("slot", -1) == slot_idx:
|
||||
_selected_slot = slot_idx
|
||||
item_selected.emit(slot_idx, item.get("item_id", -1))
|
||||
queue_redraw()
|
||||
return
|
||||
# No item in slot — deselect
|
||||
_selected_slot = -1
|
||||
queue_redraw()
|
||||
|
||||
|
||||
# -- Public API ---------------------------------------------------------------
|
||||
|
||||
func get_slot_count() -> int:
|
||||
return _slots.size()
|
||||
|
||||
|
||||
func get_selected_slot() -> int:
|
||||
return _selected_slot
|
||||
|
||||
|
||||
func get_item_at_slot(slot_idx: int) -> Dictionary:
|
||||
for item in _slots:
|
||||
if item.get("slot", -1) == slot_idx:
|
||||
return item
|
||||
return {}
|
||||
@@ -0,0 +1,20 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/inventory_grid.gd" id="1_inv"]
|
||||
|
||||
; D-065: 3x3 inventory grid, bottom-right, 40x40px icons
|
||||
[node name="InventoryGrid" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 3
|
||||
anchor_left = 1.0
|
||||
anchor_top = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = -148.0
|
||||
offset_top = -148.0
|
||||
offset_right = -16.0
|
||||
offset_bottom = -16.0
|
||||
grow_horizontal = 0
|
||||
grow_vertical = 0
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1_inv")
|
||||
@@ -0,0 +1,51 @@
|
||||
extends Control
|
||||
|
||||
## D-053: Stance indicator — shows current movement stance on HUD.
|
||||
## Sprint/Walk/Careful/Crouch. Color-coded for quick read.
|
||||
## Lives on UILayer (z-layer 7).
|
||||
|
||||
const STANCE_COLORS := {
|
||||
"Sprint": Color("#d45d5d"), # Red — fast, loud, dangerous
|
||||
"Walk": Color("#c8d0e0"), # Default — neutral white-blue
|
||||
"Careful": Color("#6bc9a6"), # Green — quiet, observant
|
||||
"Crouch": Color("#e8c547"), # Amber — very quiet, slow
|
||||
}
|
||||
|
||||
const STANCE_DEFAULT_COLOR := Color("#c8d0e0")
|
||||
const BG_COLOR := Color(0.05, 0.05, 0.08, 0.5)
|
||||
const FONT_SIZE := 13
|
||||
const PADDING := Vector2(10, 6)
|
||||
|
||||
var _current_stance: String = "Walk"
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
|
||||
|
||||
func update_from_state() -> void:
|
||||
var stance: String = GameState.player_stance
|
||||
if stance == _current_stance:
|
||||
return
|
||||
_current_stance = stance
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
var font := ThemeDB.fallback_font
|
||||
var text := _current_stance
|
||||
var text_size := font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE)
|
||||
var box_size := text_size + PADDING * 2
|
||||
|
||||
# Background
|
||||
draw_rect(Rect2(Vector2.ZERO, box_size), BG_COLOR)
|
||||
|
||||
# Stance text
|
||||
var color: Color = STANCE_COLORS.get(_current_stance, STANCE_DEFAULT_COLOR)
|
||||
draw_string(font, PADDING + Vector2(0, text_size.y), text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, color)
|
||||
|
||||
|
||||
# -- Public API ---------------------------------------------------------------
|
||||
|
||||
func get_current_stance() -> String:
|
||||
return _current_stance
|
||||
@@ -0,0 +1,17 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/stance_indicator.gd" id="1_stance"]
|
||||
|
||||
; D-053: Stance indicator — top-right HUD element
|
||||
[node name="StanceIndicator" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 1
|
||||
anchor_left = 1.0
|
||||
anchor_right = 1.0
|
||||
offset_left = -100.0
|
||||
offset_top = 16.0
|
||||
offset_right = -16.0
|
||||
offset_bottom = 42.0
|
||||
grow_horizontal = 0
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1_stance")
|
||||
@@ -0,0 +1,212 @@
|
||||
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:
|
||||
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:
|
||||
# Send Pause to freeze game while viewing insert data
|
||||
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
|
||||
@@ -0,0 +1,14 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/world_radial.gd" id="1_radial"]
|
||||
|
||||
; D-058: World radial menu — right-click, 2 spokes (Observe + Insert)
|
||||
[node name="WorldRadial" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1_radial")
|
||||
Reference in New Issue
Block a user