From 91e997e20fee83ed3e4fa5aa8e8385a23f78ae0b Mon Sep 17 00:00:00 2001 From: Voyager Date: Mon, 17 Aug 2026 21:43:58 -0400 Subject: [PATCH] feat: replace the controller focus cursor --- ...ontroller_focus_presentation_validation.gd | 51 ++++++++ ui/controller_focus_presentation.gd | 122 +++++++++++++----- ui/icons/pictograms/arrow_cursor.png | Bin 658 -> 4353 bytes 3 files changed, 143 insertions(+), 30 deletions(-) diff --git a/tests/controller_focus_presentation_validation.gd b/tests/controller_focus_presentation_validation.gd index 025c3e0..ddb86a0 100644 --- a/tests/controller_focus_presentation_validation.gd +++ b/tests/controller_focus_presentation_validation.gd @@ -49,12 +49,26 @@ func _run() -> void: standard_button.grab_focus() await process_frame var focus_arrow := presentation.get("_focus_arrow") as TextureRect + var focus_shadow := presentation.get("_focus_arrow_shadow") as TextureRect _expect(focus_arrow != null, "controller focus arrow was not created") + _expect(focus_shadow != null, "controller focus shadow was not created") _expect(focus_arrow.visible, "controller focus arrow is not visible") + _expect(focus_shadow.visible, "controller focus shadow is not visible") _expect( focus_arrow.texture == FocusPresentationType.FOCUS_ARROW_TEXTURE, "controller focus arrow uses the wrong artwork", ) + _expect( + focus_shadow.texture == focus_arrow.texture, + "controller focus shadow does not follow the cursor silhouette", + ) + _expect( + focus_shadow.position.is_equal_approx( + focus_arrow.position + + FocusPresentationType.FOCUS_ARROW_SHADOW_OFFSET + ), + "controller focus shadow uses the wrong offset", + ) _expect( is_equal_approx( focus_arrow.rotation_degrees, @@ -115,6 +129,18 @@ func _run() -> void: _arrow_marks_top_left(focus_arrow, second_button.get_global_rect()), "controller focus arrow did not follow the focused control", ) + var bubble_button := BubbleButton.new() + bubble_button.text = "bubble" + bubble_button.position = Vector2(440.0, 60.0) + bubble_button.size = Vector2(120.0, 120.0) + stage.add_child(bubble_button) + await process_frame + bubble_button.grab_focus() + await process_frame + _expect( + _arrow_overlaps_bubble_edge(focus_arrow, bubble_button), + "controller focus arrow does not meet the visible bubble edge", + ) var pointer_motion := InputEventMouseMotion.new() pointer_motion.position = Vector2(4.0, 4.0) @@ -124,6 +150,10 @@ func _run() -> void: not focus_arrow.visible, "mouse motion does not clear controller focus presentation", ) + _expect( + not focus_shadow.visible, + "mouse motion leaves the controller focus shadow visible", + ) _expect( standard_button.get_theme_stylebox("focus") == old_focus_style, "native theme overrides were not restored after controller use", @@ -349,6 +379,27 @@ func _arrow_marks_top_left(arrow: Control, target: Rect2) -> bool: return actual_tip.distance_to(expected_tip) <= 0.5 +func _arrow_overlaps_bubble_edge( + arrow: Control, + bubble: BubbleButton, +) -> bool: + var arrow_pivot: Vector2 = arrow.position + arrow.size * 0.5 + var tip_from_pivot: Vector2 = ( + (FocusPresentationType.FOCUS_ARROW_TIP_UV - Vector2.ONE * 0.5) + * arrow.size + ).rotated(deg_to_rad( + FocusPresentationType.FOCUS_ARROW_ROTATION_DEGREES + )) + var actual_tip: Vector2 = arrow_pivot + tip_from_pivot + var local_tip: Vector2 = ( + bubble.get_global_transform_with_canvas().affine_inverse() + * actual_tip + ) + var radius: Vector2 = bubble.size * 0.5 + var normalized: Vector2 = (local_tip - radius) / radius + return normalized.length() >= 0.75 and normalized.length() <= 1.0 + + func _expect(condition: bool, message: String) -> void: if not condition: _failures.append(message) diff --git a/ui/controller_focus_presentation.gd b/ui/controller_focus_presentation.gd index f0ebd7b..63982cd 100644 --- a/ui/controller_focus_presentation.gd +++ b/ui/controller_focus_presentation.gd @@ -5,10 +5,13 @@ const CONTROLLER_MOTION_THRESHOLD: float = 0.35 const FOCUS_ARROW_TEXTURE: Texture2D = preload( "res://ui/icons/pictograms/arrow_cursor.png" ) -const FOCUS_ARROW_SIZE: Vector2 = Vector2(32.0, 32.0) -const FOCUS_ARROW_ROTATION_DEGREES: float = 150.0 -const FOCUS_ARROW_TIP_UV: Vector2 = Vector2(0.5, 0.0) -const FOCUS_ARROW_TARGET_OVERLAP: Vector2 = Vector2(10.0, 10.0) +const FOCUS_ARROW_SIZE: Vector2 = Vector2(64.0, 64.0) +const FOCUS_ARROW_ROTATION_DEGREES: float = 60.0 +const FOCUS_ARROW_TIP_UV: Vector2 = Vector2(0.96875, 0.4453125) +const FOCUS_ARROW_TARGET_OVERLAP: Vector2 = Vector2(16.0, 16.0) +const FOCUS_ARROW_BUBBLE_OVERLAP: float = 10.0 +const FOCUS_ARROW_SHADOW_OFFSET: Vector2 = Vector2(-6.0, -3.0) +const FOCUS_ARROW_SHADOW_COLOR: Color = Color(0.0, 0.035, 0.055, 0.56) const FOCUS_ARROW_CANVAS_LAYER: int = 120 const FOCUS_STYLE_REPLACEMENTS: Dictionary[StringName, StringName] = { @@ -52,6 +55,7 @@ var _original_hover_color_overrides: Dictionary[StringName, Dictionary] = {} var _original_popup_hover_style: Dictionary = {} var _original_popup_hover_color: Dictionary = {} var _focus_layer: CanvasLayer +var _focus_arrow_shadow: TextureRect var _focus_arrow: TextureRect @@ -111,21 +115,38 @@ func _build_focus_arrow() -> void: _focus_layer.name = "ControllerFocusArrowLayer" _focus_layer.layer = FOCUS_ARROW_CANVAS_LAYER add_child(_focus_layer) - _focus_arrow = TextureRect.new() - _focus_arrow.name = "ControllerFocusArrow" - _focus_arrow.texture = FOCUS_ARROW_TEXTURE - _focus_arrow.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - _focus_arrow.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED - _focus_arrow.size = FOCUS_ARROW_SIZE - _focus_arrow.pivot_offset = FOCUS_ARROW_SIZE * 0.5 - _focus_arrow.rotation_degrees = FOCUS_ARROW_ROTATION_DEGREES - _focus_arrow.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST - _focus_arrow.mouse_filter = Control.MOUSE_FILTER_IGNORE - _focus_arrow.focus_mode = Control.FOCUS_NONE - _focus_arrow.visible = false + _focus_arrow_shadow = _make_focus_cursor_texture( + "ControllerFocusArrowShadow" + ) + _focus_arrow_shadow.self_modulate = FOCUS_ARROW_SHADOW_COLOR + _focus_layer.add_child(_focus_arrow_shadow) + _focus_arrow = _make_focus_cursor_texture("ControllerFocusArrow") _focus_layer.add_child(_focus_arrow) +func _make_focus_cursor_texture(node_name: String) -> TextureRect: + var texture_rect := TextureRect.new() + texture_rect.name = node_name + texture_rect.texture = FOCUS_ARROW_TEXTURE + texture_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + texture_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + texture_rect.size = FOCUS_ARROW_SIZE + texture_rect.pivot_offset = FOCUS_ARROW_SIZE * 0.5 + texture_rect.rotation_degrees = FOCUS_ARROW_ROTATION_DEGREES + texture_rect.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + texture_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE + texture_rect.focus_mode = Control.FOCUS_NONE + texture_rect.visible = false + return texture_rect + + +func _set_focus_cursor_visible(is_visible: bool) -> void: + if _focus_arrow_shadow != null: + _focus_arrow_shadow.visible = is_visible + if _focus_arrow != null: + _focus_arrow.visible = is_visible + + func _set_controller_active(active: bool) -> void: if _controller_active == active: return @@ -177,7 +198,7 @@ func _apply_to_focus(control: Control) -> void: _focused_control = control _focus_layer.custom_viewport = control.get_viewport() _suppress_native_focus_highlight(control) - _focus_arrow.visible = true + _set_focus_cursor_visible(true) _update_arrow_geometry() @@ -195,7 +216,7 @@ func _apply_to_popup(popup: PopupMenu) -> void: _popup_scroll_offset = 0.0 _focus_layer.custom_viewport = popup _suppress_popup_focus_highlight(popup) - _focus_arrow.visible = true + _set_focus_cursor_visible(true) _update_arrow_geometry() @@ -225,29 +246,71 @@ func _update_arrow_geometry() -> void: ) ) ): - if _focus_arrow != null: - _focus_arrow.visible = false + _set_focus_cursor_visible(false) return - var focus_rect: Rect2 = ( - _popup_focus_target_rect(_focused_popup) - if _focused_popup != null - else _focus_target_rect(_focused_control) - ) var presentation_scale: Vector2 = _presentation_canvas_scale() var arrow_size: Vector2 = FOCUS_ARROW_SIZE * presentation_scale _focus_arrow.size = arrow_size _focus_arrow.pivot_offset = arrow_size * 0.5 + _focus_arrow_shadow.size = arrow_size + _focus_arrow_shadow.pivot_offset = arrow_size * 0.5 var desired_tip: Vector2 = ( - focus_rect.position + _popup_focus_target_rect(_focused_popup).position + FOCUS_ARROW_TARGET_OVERLAP * presentation_scale + if _focused_popup != null + else _focus_target_position( + _focused_control, + presentation_scale, + ) ) var tip_from_pivot: Vector2 = ( (FOCUS_ARROW_TIP_UV - Vector2.ONE * 0.5) * arrow_size ).rotated(deg_to_rad(FOCUS_ARROW_ROTATION_DEGREES)) - _focus_arrow.position = ( + var arrow_position: Vector2 = ( desired_tip - arrow_size * 0.5 - tip_from_pivot ) - _focus_arrow.visible = true + _focus_arrow.position = arrow_position + _focus_arrow_shadow.position = ( + arrow_position + + FOCUS_ARROW_SHADOW_OFFSET * presentation_scale + ) + _set_focus_cursor_visible(true) + + +func _focus_target_position( + control: Control, + presentation_scale: Vector2, +) -> Vector2: + if control is BubbleButton or control is CoolerFishSprite: + return _bubble_focus_target_position(control) + return ( + _focus_target_rect(control).position + + FOCUS_ARROW_TARGET_OVERLAP * presentation_scale + ) + + +func _bubble_focus_target_position(control: Control) -> Vector2: + var direction := Vector2.RIGHT.rotated( + deg_to_rad(FOCUS_ARROW_ROTATION_DEGREES) + ) + var radius: Vector2 = control.size * 0.5 + var safe_radius := Vector2( + maxf(radius.x, 0.001), + maxf(radius.y, 0.001), + ) + var boundary_distance: float = 1.0 / sqrt( + pow(direction.x / safe_radius.x, 2.0) + + pow(direction.y / safe_radius.y, 2.0) + ) + var local_tip: Vector2 = ( + radius + - direction * boundary_distance + + direction * FOCUS_ARROW_BUBBLE_OVERLAP + ) + return _rect_in_presentation_viewport( + control, + Rect2(local_tip, Vector2.ZERO), + ).position func _presentation_canvas_scale() -> Vector2: @@ -643,5 +706,4 @@ func _clear_focus_presentation() -> void: _popup_scroll_offset = 0.0 if _focus_layer != null: _focus_layer.custom_viewport = get_viewport() - if _focus_arrow != null: - _focus_arrow.visible = false + _set_focus_cursor_visible(false) diff --git a/ui/icons/pictograms/arrow_cursor.png b/ui/icons/pictograms/arrow_cursor.png index 0ea6e395c805bddbe7a94e4cde17e1e56e469527..e33dbd71f9024010e60ab67fe74660ea47dadb7a 100644 GIT binary patch literal 4353 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 Q>AxLNSJqZ4xMdmeAO6qCPyhe` delta 601 zcmZovn#4LmQk9v3fno0U#y3EUu{g-xiSg!1oq8aLCEd~2k%3`jKlh(R)`^Px^^E~O zA+A6PAUJ&g85li3bpI)kaRkH#F(Bv|knsQ{c>DJMBM+V*x$)@8vu8&hJllH@M1s^m zcy{L5vqSfu9s;X8eDB%ehc6D_e}4EOP!MbgSnBBA=Rn0kD}d?`-Kz((o&oI+-m+RA zNHLZK`2{mLJiCzwvL>4nJ=qZCRW5rVYGN2%PiKnkC`zsCs0e*SjD>eZ@J${}p zjv*0;-%dZscUXa=ZS_gH2IcL0{|DzqST`6;Z>c|M%Ie1gGW@G^^9 z%f`mfX}`WSSI&|)P}}jk*Rp=aX9|3ChC`Xke+2@