From 8ea877ecf4cc99214266e07b24d02692bb26c3ff Mon Sep 17 00:00:00 2001 From: Voyager Date: Sun, 16 Aug 2026 16:40:10 -0400 Subject: [PATCH] fix: harden controller navigation and text entry --- main/main.gd | 5 + project.godot | 1 + scripts/run_validations.sh | 6 + settings/controller_mapping_manager.gd | 3 +- tests/android_readiness_validation.gd | 2 +- tests/art_tools_validation.gd | 15 ++ ...ontroller_focus_presentation_validation.gd | 41 ++++ tests/controller_mapping_validation.gd | 14 +- ...ontroller_menu_accessibility_validation.gd | 113 +++++++++- ...controller_world_interaction_validation.gd | 134 ++++++++++++ ...roller_world_interaction_validation.gd.uid | 1 + tests/economy_regression_validation.gd | 4 +- tests/fishing_shop_controller_validation.gd | 99 +++++++++ .../fishing_shop_controller_validation.gd.uid | 1 + tests/logbook_validation.gd | 20 ++ tests/on_screen_keyboard_validation.gd | 140 +++++++++++- .../bubble_menu/cooler_fish_sprite.gd | 12 +- ui/controller_focus_presentation.gd | 127 +++++++++++ ui/fish_batch_selection.gd | 6 + ui/fishing_shop.gd | 150 ++++++++++++- ui/game_ui.gd | 19 ++ ui/logbook_page.gd | 1 + ui/on_screen_keyboard.gd | 206 +++++++++++++++--- ui/profile_page.gd | 88 ++++++-- ui/settings/settings_bubble_page.gd | 44 +++- ui/settings_panel.gd | 150 +++++++++++++ ui/settings_panel.tscn | 5 +- ui/ui_pixelation_presenter.gd | 9 + 28 files changed, 1320 insertions(+), 96 deletions(-) create mode 100644 tests/controller_world_interaction_validation.gd create mode 100644 tests/controller_world_interaction_validation.gd.uid create mode 100644 tests/fishing_shop_controller_validation.gd create mode 100644 tests/fishing_shop_controller_validation.gd.uid diff --git a/main/main.gd b/main/main.gd index 93f98c0..6177e17 100644 --- a/main/main.gd +++ b/main/main.gd @@ -770,6 +770,7 @@ func _initialize_application(dedicated: bool) -> void: _interface_fonts, ) _game_ui.setup_controller_mapping(_controller_mapping_manager) + _ui_pixelation.setup_controller_mapping(_controller_mapping_manager) _game_ui.setup_keyboard_mouse_mapping(_keyboard_mouse_mapping_manager) _data_root.conflict_detected.connect(_on_portable_conflict) _data_root.status_changed.connect(_on_data_root_status) @@ -1224,6 +1225,10 @@ func _configure_popup_dialog( func _input(event: InputEvent) -> void: + # Do not let controller aliases close or manipulate the world/menu behind + # the modal on-screen keyboard. The keyboard consumes the event itself. + if _game_ui.is_controller_text_entry_open(): + return if _handle_data_root_controller_input(event): get_viewport().set_input_as_handled() return diff --git a/project.godot b/project.godot index c10c8c2..5e429d9 100644 --- a/project.godot +++ b/project.godot @@ -98,6 +98,7 @@ slow_walk={ interact={ "deadzone": 0.2, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":16,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":3,"pressure":0.0,"pressed":false,"script":null) ] } fish_primary={ diff --git a/scripts/run_validations.sh b/scripts/run_validations.sh index b0f153c..72aeffe 100755 --- a/scripts/run_validations.sh +++ b/scripts/run_validations.sh @@ -11,19 +11,23 @@ readonly RUN_ROOT="$(mktemp -d -t netfishing-validations.XXXXXX)" readonly -a QUICK_TESTS=( "scripts/validate_animalese_samples.gd" "tests/android_readiness_validation.gd" + "tests/camera_drag_validation.gd" "tests/controller_focus_presentation_validation.gd" "tests/controller_focus_recovery_validation.gd" "tests/controller_menu_accessibility_validation.gd" "tests/controller_mapping_validation.gd" + "tests/controller_world_interaction_validation.gd" "tests/controller_ui_navigation_validation.gd" "tests/dedicated_server_config_validation.gd" "tests/exported_decal_hotfix_validation.gd" "tests/file_dialog_controller_navigation_validation.gd" "tests/fish_catalog_content_validation.gd" "tests/fish_quality_validation.gd" + "tests/fishing_shop_controller_validation.gd" "tests/fishing_audio_validation.gd" "tests/fishing_surface_validation.gd" "tests/fur_pattern_validation.gd" + "tests/inventory_storage_validation.gd" "tests/keyboard_mouse_mapping_validation.gd" "tests/logbook_validation.gd" "tests/network_player_animation_protocol_validation.gd" @@ -31,6 +35,7 @@ readonly -a QUICK_TESTS=( "tests/player_experience_validation.gd" "tests/shoreline_ambience_validation.gd" "tests/surface_drawing_validation.gd" + "tests/tackle_order_validation.gd" "tests/terrain_blender_material_validation.gd" "tests/texture_sampling_validation.gd" "tests/world_time_validation.gd" @@ -54,6 +59,7 @@ readonly -a HOST_TESTS=( "tests/fish_hotbar_showcase_validation.gd" "tests/fishing_authority_validation.gd" "tests/gathering_showcase_validation.gd" + "tests/inventory_storage_persistence_validation.gd" "tests/job_system_validation.gd" "tests/surface_drawing_runtime_validation.gd" ) diff --git a/settings/controller_mapping_manager.gd b/settings/controller_mapping_manager.gd index 31df408..17e8643 100644 --- a/settings/controller_mapping_manager.gd +++ b/settings/controller_mapping_manager.gd @@ -80,7 +80,7 @@ const ROLE_LABELS: Dictionary = { ROLE_A: "jump / menu accept", ROLE_B: "menu back", ROLE_X: "player menu", - ROLE_Y: "character call", + ROLE_Y: "interact / character call", ROLE_LB: "focus chat or world", ROLE_RB: "primary action", ROLE_POINTER_MODIFIER: "virtual mouse modifier", @@ -138,6 +138,7 @@ const BUTTON_ACTION_ROLES: Dictionary = { &"ui_accept": ROLE_A, &"ui_cancel": ROLE_B, &"open_backpack": ROLE_X, + &"interact": ROLE_Y, &"character_call": ROLE_Y, &"focus_gameplay": ROLE_LB, &"fish_primary": ROLE_RB, diff --git a/tests/android_readiness_validation.gd b/tests/android_readiness_validation.gd index 41bde86..7472063 100644 --- a/tests/android_readiness_validation.gd +++ b/tests/android_readiness_validation.gd @@ -20,7 +20,7 @@ func _init() -> void: assert(_has_joypad_button(&"jump", JOY_BUTTON_A)) assert(_has_joypad_button(&"ui_accept", JOY_BUTTON_A)) assert(_has_joypad_button(&"ui_cancel", JOY_BUTTON_B)) - assert(not _has_joypad_button(&"interact", JOY_BUTTON_Y)) + assert(_has_joypad_button(&"interact", JOY_BUTTON_Y)) assert(_has_joypad_button(&"character_call", JOY_BUTTON_Y)) assert(_has_joypad_button(&"fish_primary", JOY_BUTTON_RIGHT_SHOULDER)) assert(not _has_joypad_motion( diff --git a/tests/art_tools_validation.gd b/tests/art_tools_validation.gd index b4f4339..45e76c0 100644 --- a/tests/art_tools_validation.gd +++ b/tests/art_tools_validation.gd @@ -221,6 +221,16 @@ func _run() -> void: assert(bool(game_ui.call("_handle_controller_chat_controls", select_button))) assert(chat_ui.is_open()) chat_ui.refocus_gameplay() + chat_ui.open_chat() + assert(chat_ui.is_open()) + typed_chat_entry.text = "/not-a-real-command" + chat_ui.call("_send") + assert(not chat_ui.is_open()) + assert(not typed_chat_entry.has_focus()) + assert(not bool(chat_ui.get("_input_lock_applied"))) + assert(chat_status.visible) + assert(chat_status.text == "Unknown command: /not-a-real-command") + chat_ui.call("_set_status", "") var quick_menu := game_ui.get_node( "%QuickRadialMenu" ) as QuickRadialMenu @@ -695,8 +705,13 @@ func _validate_pause_browser_transition( var drag_motion := InputEventMouseMotion.new() drag_motion.button_mask = MOUSE_BUTTON_MASK_RIGHT drag_motion.relative = Vector2(24.0, -8.0) + drag_motion.screen_relative = drag_motion.relative player.call("_input", drag_motion) assert(not is_equal_approx(camera_yaw.rotation.y, yaw_before_drag)) + await process_frame + assert(bool(player.get("_camera_dragging"))) + if DisplayServer.get_name() != "headless": + assert(Input.mouse_mode == Input.MOUSE_MODE_CAPTURED) var drag_release := InputEventMouseButton.new() drag_release.button_index = MOUSE_BUTTON_RIGHT drag_release.button_mask = 0 diff --git a/tests/controller_focus_presentation_validation.gd b/tests/controller_focus_presentation_validation.gd index d0791cc..025c3e0 100644 --- a/tests/controller_focus_presentation_validation.gd +++ b/tests/controller_focus_presentation_validation.gd @@ -3,6 +3,9 @@ extends SceneTree const FocusPresentationType = preload( "res://ui/controller_focus_presentation.gd" ) +const FishBatchSelectionType = preload( + "res://ui/fish_batch_selection.gd" +) var _failures: Array[String] = [] @@ -12,6 +15,7 @@ func _initialize() -> void: func _run() -> void: + _validate_controller_cooler_focus() root.size = Vector2i(1280, 720) var stage := Control.new() stage.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) @@ -23,8 +27,10 @@ func _run() -> void: standard_button.position = Vector2(80.0, 60.0) standard_button.custom_minimum_size = Vector2(120.0, 60.0) var normal_style := _button_style(Color("123f4e")) + var old_hover_style := _button_style(Color("58c6d4")) var old_focus_style := _button_style(Color("238697")) standard_button.add_theme_stylebox_override("normal", normal_style) + standard_button.add_theme_stylebox_override("hover", old_hover_style) standard_button.add_theme_stylebox_override("focus", old_focus_style) stage.add_child(standard_button) var second_button := Button.new() @@ -82,6 +88,22 @@ func _run() -> void: ) stage.scale = Vector2.ONE await process_frame + var hover_motion := InputEventMouseMotion.new() + hover_motion.position = standard_button.get_global_rect().get_center() + hover_motion.global_position = hover_motion.position + root.push_input(hover_motion, true) + await process_frame + _expect( + root.gui_get_hovered_control() == standard_button, + "the pointer did not reach the hover-suppression test button", + ) + presentation._input(controller_event) + standard_button.grab_focus() + await process_frame + _expect( + standard_button.get_theme_stylebox("hover") == normal_style, + "controller use leaves a stale mouse-hover highlight visible", + ) second_button.grab_focus() await process_frame @@ -106,6 +128,10 @@ func _run() -> void: standard_button.get_theme_stylebox("focus") == old_focus_style, "native theme overrides were not restored after controller use", ) + _expect( + standard_button.get_theme_stylebox("hover") == old_hover_style, + "mouse hover styling was not restored after controller use", + ) presentation._input(controller_event) var item_list := ItemList.new() @@ -284,6 +310,21 @@ func _run() -> void: quit(1) +func _validate_controller_cooler_focus() -> void: + var selection := FishBatchSelectionType.new() + selection.set_visible_order([&"first", &"second"]) + selection.select_only(&"first") + selection.focus_only(&"second") + _expect( + selection.get_focused_id() == &"second", + "cooler focus does not follow controller navigation", + ) + _expect( + selection.get_selected_ids() == [&"first"], + "moving cooler focus unexpectedly changes the selected catch set", + ) + + func _button_style(color: Color) -> StyleBoxFlat: var style := StyleBoxFlat.new() style.bg_color = color diff --git a/tests/controller_mapping_validation.gd b/tests/controller_mapping_validation.gd index e67224c..2a6383a 100644 --- a/tests/controller_mapping_validation.gd +++ b/tests/controller_mapping_validation.gd @@ -104,18 +104,20 @@ func _validate_manager(manager: ControllerMappingManagerType) -> String: return "right-stick click is not assigned to the sneak action" if ControllerMappingManagerType.ROLE_LABELS.get( ControllerMappingManagerType.ROLE_Y, "" - ) != "character call": - return "Y is not presented as the character-call button" + ) != "interact / character call": + return "Y does not present its contextual gameplay actions" + if ControllerMappingManagerType.BUTTON_ACTION_ROLES.get( + &"interact", &"" + ) != ControllerMappingManagerType.ROLE_Y: + return "Y is not assigned to the interact action" if ControllerMappingManagerType.BUTTON_ACTION_ROLES.get( &"character_call", &"" ) != ControllerMappingManagerType.ROLE_Y: return "Y is not assigned to the character-call action" - if ControllerMappingManagerType.BUTTON_ACTION_ROLES.has(&"interact"): - return "Y still exposes the obsolete controller interact binding" if not _has_joy_button(&"character_call", JOY_BUTTON_Y): return "character call does not default to Y" - if _has_joy_button(&"interact", JOY_BUTTON_Y): - return "Y still defaults to interact" + if not _has_joy_button(&"interact", JOY_BUTTON_Y): + return "interact does not default to Y" if not _has_joy_button(&"sneak", JOY_BUTTON_RIGHT_STICK): return "sneak does not default to right-stick click" var trigger_button := InputEventJoypadButton.new() diff --git a/tests/controller_menu_accessibility_validation.gd b/tests/controller_menu_accessibility_validation.gd index 71c9cd4..da18d79 100644 --- a/tests/controller_menu_accessibility_validation.gd +++ b/tests/controller_menu_accessibility_validation.gd @@ -43,6 +43,7 @@ func _run() -> void: root.size = Vector2i(1280, 720) await _validate_join_game_navigation() await _validate_data_settings_navigation() + await _validate_settings_adjustment_navigation() await _validate_mail_navigation() await _validate_profile_confirmation_focus() await _validate_profile_voice_navigation() @@ -196,6 +197,65 @@ func _validate_data_settings_navigation() -> void: await process_frame +func _validate_settings_adjustment_navigation() -> void: + var panel := SettingsPanelScene.instantiate() as SettingsPanel + root.add_child(panel) + await process_frame + panel.show() + var sound_page := panel.get_node("%SoundPage") as SettingsBubblePage + sound_page.show_page(false) + for _frame: int in 2: + await process_frame + var environment := panel.get_node("%EnvironmentVolumeSlider") as HSlider + var sound_back := panel.get_node("%SoundBackButton") as Button + _assert_neighbor( + environment, + &"focus_neighbor_bottom", + sound_back, + ) + _assert_neighbor(sound_back, &"focus_neighbor_top", environment) + sound_page.hide_page() + + var controls_page := panel.get_node("%ControlsPage") as SettingsBubblePage + controls_page.show_page(false) + for _frame: int in 2: + await process_frame + var parent := panel.get_node("%MouseValue") as Button + var decrease := panel.get_node("%MouseDecrease") as Button + var increase := panel.get_node("%MouseIncrease") as Button + _expect( + decrease.focus_mode == Control.FOCUS_NONE + and increase.focus_mode == Control.FOCUS_NONE, + "Sensitivity adjustment bubbles are reachable before their parent.", + ) + parent.grab_focus() + var accept := InputEventJoypadButton.new() + accept.button_index = JOY_BUTTON_A + accept.pressed = true + panel.call("_input", accept) + _expect( + decrease.focus_mode == Control.FOCUS_ALL + and increase.focus_mode == Control.FOCUS_ALL, + "Selecting a sensitivity parent did not enter its adjustment zone.", + ) + _assert_neighbor(parent, &"focus_neighbor_left", decrease) + _assert_neighbor(parent, &"focus_neighbor_right", increase) + panel.handle_back() + for _frame: int in 2: + await process_frame + _expect( + decrease.focus_mode == Control.FOCUS_NONE + and increase.focus_mode == Control.FOCUS_NONE, + "Leaving an adjustment zone did not hide its child controls.", + ) + _expect( + root.gui_get_focus_owner() == parent, + "Leaving an adjustment zone did not restore its parent focus.", + ) + panel.queue_free() + await process_frame + + func _validate_mail_navigation() -> void: var page := MailPageType.new() as Control root.add_child(page) @@ -364,6 +424,31 @@ func _validate_profile_confirmation_focus() -> void: root.gui_get_focus_owner() in option_controls, "Selecting an appearance feature did not focus its options.", ) + var rebuilt_option := root.gui_get_focus_owner() as BaseButton + if rebuilt_option != null: + rebuilt_option.pressed.emit() + for _frame: int in 3: + await process_frame + option_groups = page.call("_controller_option_groups") + option_controls.clear() + if not option_groups.is_empty(): + for item: Variant in option_groups.front(): + var option_control := item as Control + if option_control != null: + option_controls.append(option_control) + _expect( + root.gui_get_focus_owner() in option_controls, + "Rebuilding appearance options dropped controller focus.", + ) + root.gui_release_focus() + var controller_down := InputEventJoypadButton.new() + controller_down.button_index = JOY_BUTTON_DPAD_DOWN + controller_down.pressed = true + page.call("handle_controller_input", controller_down) + _expect( + root.gui_get_focus_owner() in option_controls, + "Appearance options did not recover a lost controller focus owner.", + ) var cancel_options := InputEventAction.new() cancel_options.action = &"ui_cancel" cancel_options.pressed = true @@ -774,7 +859,7 @@ func _validate_inventory_tab_zone_transitions() -> void: ) # Add three representative equipment entries so entering the content zone - # exercises the real three-column directional layout. + # exercises the real five-column directional layout. var item_field := menu.get_node("%BagItemField") as Control var bag_nodes: Dictionary = menu.get("_bag_item_nodes") var owned_items: Array[OwnedItemType] = [] @@ -944,6 +1029,32 @@ func _validate_inventory_tab_zone_transitions() -> void: management_requests[0] == 1, "Equipment-to-hotbar navigation did not open hotbar management.", ) + var up := InputEventAction.new() + up.action = &"ui_up" + up.pressed = true + _expect( + bool(menu.call("_handle_controller_ownership_input", up)), + "Up from hotbar management was not consumed.", + ) + _expect( + menu.get("_controller_ownership") + == PlayerMenuType.ControllerOwnership.ITEM_LIST, + "Up from hotbar management did not return to Inventory contents.", + ) + for _frame: int in 2: + await process_frame + _expect( + root.gui_get_focus_owner() == hotbar_source, + "Up from hotbar management did not restore the source item focus.", + ) + _expect( + bool(menu.call("_handle_controller_ownership_input", down)), + "Inventory contents could not re-enter hotbar management.", + ) + _expect( + management_requests[0] == 2, + "Re-entering the hotbar did not reopen hotbar management.", + ) _expect( bool(menu.call("_handle_controller_ownership_input", accept)), "Hotbar management did not consume controller A.", diff --git a/tests/controller_world_interaction_validation.gd b/tests/controller_world_interaction_validation.gd new file mode 100644 index 0000000..d9e77a5 --- /dev/null +++ b/tests/controller_world_interaction_validation.gd @@ -0,0 +1,134 @@ +extends SceneTree + +const MainScene = preload("res://main/main.tscn") + +var _failures: Array[String] = [] + + +func _initialize() -> void: + _run.call_deferred() + + +func _run() -> void: + root.size = Vector2i(1280, 720) + var main := MainScene.instantiate() + root.add_child(main) + for _frame: int in 4: + await process_frame + if not bool(main.get("_application_initialized")): + main.call("_activate_selected_data_path", "", true) + for _frame: int in 8: + await process_frame + _expect( + bool(main.get("_application_initialized")), + "application initialization failed", + ) + _expect( + bool(main.call("_prepare_private_host")), + "private host preparation failed", + ) + var save_manager := main.get("_save_manager") as PlayerSaveManager + _expect( + save_manager != null and save_manager.initialize_new_game(), + "new-game initialization failed", + ) + main.call("_enter_gameplay") + for _frame: int in 8: + await process_frame + + var player := main.get("_player") as Player + var interaction := main.get("_shop_interaction") as FishingShopInteraction + var game_ui := main.get("_game_ui") as GameUI + var chat_service := main.get_node( + "%NetworkChatService" + ) as NetworkChatService + _expect(player != null, "local player is unavailable") + _expect(interaction != null, "shop interaction is unavailable") + _expect(game_ui != null, "game UI is unavailable") + _expect(chat_service != null, "network chat service is unavailable") + if ( + player != null + and interaction != null + and game_ui != null + and chat_service != null + ): + player.global_position = interaction.global_position + for _frame: int in 6: + await physics_frame + _expect( + interaction.is_local_player_in_range(), + "local player did not enter the shop interaction range", + ) + var character_calls: Array[String] = [] + var capture_call := func( + _peer_id: int, + call_id: String, + _pitch_scale: float, + ) -> void: + character_calls.append(call_id) + chat_service.character_call_received.connect(capture_call) + var press := InputEventJoypadButton.new() + press.device = 0 + press.button_index = JOY_BUTTON_Y + press.pressed = true + _expect( + press.is_action_pressed("interact"), + "Y does not resolve to interact", + ) + _expect( + press.is_action_pressed("character_call"), + "Y does not resolve to character call", + ) + game_ui.call("_input", press) + main.call("_unhandled_input", press) + await process_frame + _expect( + game_ui.get_fishing_shop().visible, + "Y did not open the shop while the player was in range", + ) + _expect( + character_calls.is_empty(), + "Y played a character call instead of opening the shop", + ) + player.global_position = interaction.global_position + Vector3(20.0, 0.0, 0.0) + for _frame: int in 6: + await physics_frame + for _frame: int in 60: + if not game_ui.get_fishing_shop().visible: + break + await process_frame + _expect( + not interaction.is_local_player_in_range(), + "local player did not leave the shop interaction range", + ) + _expect( + not game_ui.get_fishing_shop().visible, + "shop did not close after leaving its interaction range", + ) + game_ui.call("_input", press) + await process_frame + _expect( + character_calls.size() == 1, + "Y did not play a character call away from an interaction", + ) + chat_service.character_call_received.disconnect(capture_call) + + var session := main.get_node("%NetworkSession") as NetworkSession + if session != null: + session.disconnect_session("Controller interaction validation complete.") + main.queue_free() + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout + if _failures.is_empty(): + print("Controller world interaction validation: PASS") + quit(0) + return + for failure: String in _failures: + push_error(failure) + quit(1) + + +func _expect(condition: bool, message: String) -> void: + if not condition: + _failures.append(message) diff --git a/tests/controller_world_interaction_validation.gd.uid b/tests/controller_world_interaction_validation.gd.uid new file mode 100644 index 0000000..8315ee5 --- /dev/null +++ b/tests/controller_world_interaction_validation.gd.uid @@ -0,0 +1 @@ +uid://b6fg46sad1ma6 diff --git a/tests/economy_regression_validation.gd b/tests/economy_regression_validation.gd index debe48c..cc9059d 100644 --- a/tests/economy_regression_validation.gd +++ b/tests/economy_regression_validation.gd @@ -422,7 +422,6 @@ func _test_player_menu_sale( ) as SubViewport assert(sell_action.visible and not sell_action.disabled) assert(sell_action.mouse_filter == Control.MOUSE_FILTER_STOP) - assert(sell_action.focus_mode == Control.FOCUS_ALL) assert(ui_viewport != null) await _activate_pointer_control(sell_action, ui_viewport) await process_frame @@ -632,7 +631,8 @@ func _test_fishing_shop_sale_ui( })) var balance_before: int = player.wallet.get_balance() assert(shop.open_shop()) - await process_frame + for _frame: int in 2: + await process_frame assert(shop_backdrop.visible) assert(shop_backdrop.material is ShaderMaterial) assert(shop.has_node("InputBlocker") and not shop.has_node("Dimmer")) diff --git a/tests/fishing_shop_controller_validation.gd b/tests/fishing_shop_controller_validation.gd new file mode 100644 index 0000000..97263c3 --- /dev/null +++ b/tests/fishing_shop_controller_validation.gd @@ -0,0 +1,99 @@ +extends SceneTree + +const FishingShopScene: PackedScene = preload( + "res://ui/fishing_shop.tscn" +) + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + root.size = Vector2i(1280, 720) + var shop := FishingShopScene.instantiate() as FishingShop + root.add_child(shop) + shop.show() + await process_frame + + var tabs: Array = shop.get("_shop_tabs") as Array + assert(tabs.size() == FishingShop.ShopSection.size()) + var supplies_list := shop.get_node("%SuppliesList") as VBoxContainer + var dummy_stock := Button.new() + dummy_stock.name = "ControllerTestStock" + dummy_stock.text = "test stock" + supplies_list.add_child(dummy_stock) + var activations: Array[int] = [0] + dummy_stock.pressed.connect( + func() -> void: + activations[0] += 1 + ) + + var accept := InputEventJoypadButton.new() + accept.button_index = JOY_BUTTON_A + accept.pressed = true + var cancel := InputEventJoypadButton.new() + cancel.button_index = JOY_BUTTON_B + cancel.pressed = true + + for section_index: int in range(FishingShop.ShopSection.ART_SUPPLIES + 1): + shop.set("_shop_section", section_index) + shop.call("_select_shop_section", section_index, false) + shop.call("_enter_shop_tabs_zone") + for _frame: int in 2: + await process_frame + var tab := tabs[section_index] as Button + assert( + root.gui_get_focus_owner() == tab, + "section %d tab focus mismatch: focused=%s tab=%s visible=%s mode=%d" + % [ + section_index, + root.gui_get_focus_owner(), + tab, + tab.is_visible_in_tree(), + tab.focus_mode, + ], + ) + assert( + shop.get("_controller_zone") + == FishingShop.ControllerZone.TABS + ) + shop.call("_input", accept) + for _frame: int in 2: + await process_frame + assert(int(shop.get("_shop_section")) == section_index) + assert( + shop.get("_controller_zone") + == FishingShop.ControllerZone.CONTENT + ) + assert(not _array_contains_control( + tabs, + root.gui_get_focus_owner(), + )) + if section_index == FishingShop.ShopSection.BAIT: + assert(root.gui_get_focus_owner() == dummy_stock) + shop.call("_input", accept) + assert(activations[0] == 1) + shop.call("_input", cancel) + for _frame: int in 2: + await process_frame + assert(shop.visible) + assert( + shop.get("_controller_zone") + == FishingShop.ControllerZone.TABS + ) + assert(root.gui_get_focus_owner() == tab) + + shop.hide() + shop.queue_free() + await process_frame + print("Fishing shop controller validation: PASS") + quit() + + +func _array_contains_control(values: Array, target: Control) -> bool: + for value: Variant in values: + var control := value as Control + if control == target: + return true + return false diff --git a/tests/fishing_shop_controller_validation.gd.uid b/tests/fishing_shop_controller_validation.gd.uid new file mode 100644 index 0000000..228bf9b --- /dev/null +++ b/tests/fishing_shop_controller_validation.gd.uid @@ -0,0 +1 @@ +uid://duwikmb08papi diff --git a/tests/logbook_validation.gd b/tests/logbook_validation.gd index 8e09e6d..0d98609 100644 --- a/tests/logbook_validation.gd +++ b/tests/logbook_validation.gd @@ -82,6 +82,26 @@ func _validate_page() -> void: page.get("_category") == LogbookCatalog.Category.FRESH_WATER ) assert((page.get("_catalog_grid") as GridContainer).columns == 4) + var catalog_scroll := page.get("_catalog_scroll") as ScrollContainer + assert(catalog_scroll != null and catalog_scroll.follow_focus) + page.call( + "_set_controller_zone", + LogbookPage.ControllerZone.ENTRIES, + ) + for _frame: int in 2: + await process_frame + var initial_entries: Array = ( + page.get("_entry_buttons") as Dictionary + ).values() + assert(initial_entries.size() > 4) + var final_entry := initial_entries.back() as Button + final_entry.grab_focus() + for _frame: int in 3: + await process_frame + assert(catalog_scroll.scroll_vertical > 0) + page.reset_controller_zone() + for _frame: int in 2: + await process_frame var category_tabs: Array = page.get("_category_tabs") as Array var category_tab_categories: Array = ( page.get("_category_tab_categories") as Array diff --git a/tests/on_screen_keyboard_validation.gd b/tests/on_screen_keyboard_validation.gd index 27c2dbd..822fb37 100644 --- a/tests/on_screen_keyboard_validation.gd +++ b/tests/on_screen_keyboard_validation.gd @@ -1,11 +1,26 @@ extends SceneTree const KeyboardType = preload("res://ui/on_screen_keyboard.gd") +const ControllerMappingManagerType = preload( + "res://settings/controller_mapping_manager.gd" +) const SettingsManagerType = preload( "res://settings/player_settings_manager.gd" ) +class ControllerInputProbe: + extends Node + + var pressed_buttons: Array[int] = [] + + + func _input(event: InputEvent) -> void: + var button := event as InputEventJoypadButton + if button != null and button.pressed: + pressed_buttons.append(button.button_index) + + func _initialize() -> void: call_deferred("_run") @@ -13,6 +28,7 @@ func _initialize() -> void: func _run() -> void: _validate_default_and_persistence() await _validate_keyboard_entry() + await _validate_automapped_face_buttons() print("On-screen keyboard validation: PASS") quit() @@ -50,6 +66,8 @@ func _validate_keyboard_entry() -> void: var host := Control.new() root.add_child(host) var edit := LineEdit.new() + edit.text = "seed" + edit.caret_column = 0 edit.focus_mode = Control.FOCUS_ALL host.add_child(edit) var keyboard := KeyboardType.new() @@ -70,25 +88,75 @@ func _validate_keyboard_entry() -> void: edit.grab_focus() keyboard.call("_input", activate_event) assert(keyboard.is_open()) + assert(edit.caret_column == edit.text.length()) + assert(keyboard.get("_buffer_caret") == edit.text.length()) + assert( + str((keyboard.get("_preview") as Label).text).begins_with("seed") + ) + var left_page_event := InputEventJoypadButton.new() + left_page_event.button_index = JOY_BUTTON_LEFT_SHOULDER + left_page_event.pressed = true + keyboard.call("_input", left_page_event) + assert(keyboard.is_open()) + assert(keyboard.get("_page") == KeyboardType.Page.SYMBOLS) + var right_page_event := InputEventJoypadButton.new() + right_page_event.button_index = JOY_BUTTON_RIGHT_SHOULDER + right_page_event.pressed = true + keyboard.call("_input", right_page_event) + assert(keyboard.is_open()) + assert(keyboard.get("_page") == KeyboardType.Page.LOWER) + for _frame: int in 2: + await process_frame + var focused_key := root.gui_get_focus_owner() as Button + assert(focused_key != null and focused_key.text == "q") + var conflicting_cancel_binding := InputEventJoypadButton.new() + conflicting_cancel_binding.button_index = JOY_BUTTON_A + InputMap.action_add_event(&"ui_cancel", conflicting_cancel_binding) + keyboard.call("_input", activate_event) + InputMap.action_erase_event(&"ui_cancel", conflicting_cancel_binding) + assert(keyboard.is_open()) + assert(edit.text == "seedq") + var left_trigger := InputEventJoypadMotion.new() + left_trigger.axis = JOY_AXIS_TRIGGER_LEFT + left_trigger.axis_value = 1.0 + keyboard.call("_input", left_trigger) + assert(keyboard.get("_buffer_caret") == edit.text.length() - 1) + var right_trigger := InputEventJoypadMotion.new() + right_trigger.axis = JOY_AXIS_TRIGGER_RIGHT + right_trigger.axis_value = 1.0 + keyboard.call("_input", right_trigger) + assert(keyboard.get("_buffer_caret") == edit.text.length()) + keyboard.call("_process", KeyboardType.CARET_BLINK_INTERVAL) + assert( + not str((keyboard.get("_preview") as Label).text).contains( + KeyboardType.CARET_GLYPH + ) + ) keyboard.call("_type_character", "a") keyboard.call("_type_space") keyboard.call("_set_page", KeyboardType.Page.UPPER) keyboard.call("_type_character", "B") - assert(edit.text == "a B") + assert(edit.text == "seedqa B") keyboard.call("_move_caret", -1) keyboard.call("_type_character", "C") - assert(edit.text == "a CB") + assert(edit.text == "seedqa CB") + var input_probe := ControllerInputProbe.new() + root.add_child(input_probe) + root.move_child(input_probe, 0) var backspace_event := InputEventJoypadButton.new() backspace_event.button_index = JOY_BUTTON_X backspace_event.pressed = true - keyboard.call("_input", backspace_event) - assert(edit.text == "a B") - var defocus_event := InputEventJoypadButton.new() - defocus_event.button_index = JOY_BUTTON_LEFT_SHOULDER - defocus_event.pressed = true - keyboard.call("_input", defocus_event) + Input.parse_input_event(backspace_event) + await process_frame + assert(edit.text == "seedqa B") + assert(input_probe.pressed_buttons.is_empty()) + var close_event := InputEventJoypadButton.new() + close_event.button_index = JOY_BUTTON_B + close_event.pressed = true + keyboard.call("_input", close_event) assert(not keyboard.is_open()) - assert(root.gui_get_focus_owner() == null) + assert(root.gui_get_focus_owner() == edit) + input_probe.queue_free() edit.grab_focus() keyboard.call("_input", activate_event) assert(keyboard.is_open()) @@ -98,5 +166,57 @@ func _validate_keyboard_entry() -> void: ) keyboard.call("_submit") assert(not keyboard.is_open()) - assert(submitted == ["a B"]) + assert(submitted == ["seedqa B"]) host.queue_free() + + +func _validate_automapped_face_buttons() -> void: + var manager := ControllerMappingManagerType.new() + root.add_child(manager) + await process_frame + var swapped_bindings: Dictionary = ( + ControllerMappingManagerType.default_bindings() + ) + swapped_bindings[str(ControllerMappingManagerType.ROLE_A)] = { + "kind": "button", + "button": int(JOY_BUTTON_B), + } + swapped_bindings[str(ControllerMappingManagerType.ROLE_B)] = { + "kind": "button", + "button": int(JOY_BUTTON_A), + } + manager._active_profile_key = "keyboard-mapping-test" + manager._profiles[manager._active_profile_key] = { + "controller_name": "keyboard mapping test", + "bindings": swapped_bindings, + } + var host := Control.new() + root.add_child(host) + var edit := LineEdit.new() + edit.focus_mode = Control.FOCUS_ALL + host.add_child(edit) + var keyboard := KeyboardType.new() + host.add_child(keyboard) + keyboard.setup_controller_mapping(manager) + keyboard.set_enabled(true) + await process_frame + var mapped_accept := InputEventJoypadButton.new() + mapped_accept.device = manager.get_active_device_id() + mapped_accept.button_index = JOY_BUTTON_B + mapped_accept.pressed = true + edit.grab_focus() + keyboard.call("_input", mapped_accept) + assert(keyboard.is_open()) + for _frame: int in 2: + await process_frame + keyboard.call("_input", mapped_accept) + assert(keyboard.is_open()) + assert(edit.text == "q") + var mapped_cancel := InputEventJoypadButton.new() + mapped_cancel.device = manager.get_active_device_id() + mapped_cancel.button_index = JOY_BUTTON_A + mapped_cancel.pressed = true + keyboard.call("_input", mapped_cancel) + assert(not keyboard.is_open()) + host.queue_free() + manager.queue_free() diff --git a/ui/components/bubble_menu/cooler_fish_sprite.gd b/ui/components/bubble_menu/cooler_fish_sprite.gd index 32b5532..ce83f46 100644 --- a/ui/components/bubble_menu/cooler_fish_sprite.gd +++ b/ui/components/bubble_menu/cooler_fish_sprite.gd @@ -15,6 +15,7 @@ var _depth_scale: float = 1.0 var _batch_selected: bool = false var _focused_catch: bool = false var _hovered: bool = false +var _controller_focus_presentation_active: bool = false var _quality_color := Color.WHITE @@ -96,6 +97,11 @@ func set_item_state( _refresh_style() +func set_controller_focus_presentation_active(active: bool) -> void: + _controller_focus_presentation_active = active + _refresh_style() + + func advance_presentation(delta: float, elapsed: float) -> void: var response: float = 1.0 - exp(-9.0 * delta) _neutral_position = _neutral_position.lerp(_target_position, response) @@ -107,7 +113,7 @@ func advance_presentation(delta: float, elapsed: float) -> void: interaction_lift += 3.0 if _focused_catch: interaction_lift += 1.5 - elif _hovered: + elif _hovered and not _controller_focus_presentation_active: interaction_lift += 0.8 _visual_root.position = Vector2(0.0, bob - interaction_lift) _visual_root.rotation = tilt @@ -116,7 +122,7 @@ func advance_presentation(delta: float, elapsed: float) -> void: emphasis += 0.085 if _focused_catch: emphasis += 0.035 - elif _hovered: + elif _hovered and not _controller_focus_presentation_active: emphasis += 0.045 _visual_root.scale = Vector2.ONE * _depth_scale * emphasis @@ -150,7 +156,7 @@ func _refresh_style() -> void: ) var circle_visible: bool = ( _batch_selected - or _hovered + or (_hovered and not _controller_focus_presentation_active) or _focused_catch ) var base_style: StyleBox = idle diff --git a/ui/controller_focus_presentation.gd b/ui/controller_focus_presentation.gd index 04b259f..f0ebd7b 100644 --- a/ui/controller_focus_presentation.gd +++ b/ui/controller_focus_presentation.gd @@ -24,6 +24,20 @@ const FOCUS_COLOR_REPLACEMENTS: Dictionary[StringName, StringName] = { &"font_selected_color": &"font_color", &"font_hovered_selected_color": &"font_hovered_color", } +const HOVER_STYLE_REPLACEMENTS: Dictionary[StringName, StringName] = { + &"hover": &"normal", + &"hover_pressed": &"pressed", + &"hovered": &"", + &"hovered_selected": &"", +} +const HOVER_COLOR_REPLACEMENTS: Dictionary[StringName, StringName] = { + &"font_hover_color": &"font_color", + &"icon_hover_color": &"icon_normal_color", + &"font_hover_pressed_color": &"font_pressed_color", + &"icon_hover_pressed_color": &"icon_pressed_color", + &"font_hovered_color": &"font_color", + &"font_hovered_selected_color": &"font_color", +} var _controller_active: bool = false var _focused_control: Control @@ -32,6 +46,9 @@ var _popup_scroll_offset: float = 0.0 var _suppressed_control: Control var _original_style_overrides: Dictionary[StringName, Dictionary] = {} var _original_color_overrides: Dictionary[StringName, Dictionary] = {} +var _suppressed_hover_control: Control +var _original_hover_style_overrides: Dictionary[StringName, Dictionary] = {} +var _original_hover_color_overrides: Dictionary[StringName, Dictionary] = {} var _original_popup_hover_style: Dictionary = {} var _original_popup_hover_color: Dictionary = {} var _focus_layer: CanvasLayer @@ -47,6 +64,7 @@ func _ready() -> void: func _exit_tree() -> void: _clear_focus_presentation() + _restore_native_hover_highlight() func _input(event: InputEvent) -> void: @@ -69,6 +87,7 @@ func _input(event: InputEvent) -> void: func _process(_delta: float) -> void: + _update_hover_suppression() if _controller_active: var focused_popup: PopupMenu = _active_focused_popup(get_viewport()) if focused_popup != null: @@ -111,6 +130,7 @@ func _set_controller_active(active: bool) -> void: if _controller_active == active: return _controller_active = active + _update_hover_suppression() if _controller_active: var focused_popup: PopupMenu = _active_focused_popup(get_viewport()) if focused_popup != null: @@ -440,6 +460,113 @@ func _suppress_native_focus_highlight(control: Control) -> void: ) +func _update_hover_suppression() -> void: + if not _controller_active: + _restore_native_hover_highlight() + return + var hovered: Control = _hover_presentation_control( + _hovered_control_in_viewport(get_viewport()) + ) + if hovered == _suppressed_hover_control: + return + _restore_native_hover_highlight() + if hovered != null: + _suppress_native_hover_highlight(hovered) + + +func _hovered_control_in_viewport(viewport: Viewport) -> Control: + var embedded_windows: Array[Window] = viewport.get_embedded_subwindows() + for index: int in range(embedded_windows.size() - 1, -1, -1): + var window: Window = embedded_windows[index] + if not window.visible: + continue + var nested_hovered: Control = _hovered_control_in_viewport(window) + if nested_hovered != null: + return nested_hovered + return viewport.gui_get_hovered_control() + + +func _hover_presentation_control(control: Control) -> Control: + var candidate: Control = control + while candidate != null: + if ( + candidate is BaseButton + or candidate is ItemList + or candidate is Tree + or candidate.focus_mode != Control.FOCUS_NONE + ): + return candidate + candidate = candidate.get_parent() as Control + return null + + +func _suppress_native_hover_highlight(control: Control) -> void: + _suppressed_hover_control = control + _original_hover_style_overrides.clear() + _original_hover_color_overrides.clear() + if control.has_method(&"set_controller_focus_presentation_active"): + control.call(&"set_controller_focus_presentation_active", true) + for hover_name: StringName in HOVER_STYLE_REPLACEMENTS: + var replacement_name: StringName = HOVER_STYLE_REPLACEMENTS[hover_name] + _original_hover_style_overrides[hover_name] = { + "had_override": control.has_theme_stylebox_override(hover_name), + "value": control.get_theme_stylebox(hover_name), + } + var replacement: StyleBox = ( + StyleBoxEmpty.new() + if replacement_name.is_empty() + else control.get_theme_stylebox(replacement_name) + ) + control.add_theme_stylebox_override(hover_name, replacement) + for hover_name: StringName in HOVER_COLOR_REPLACEMENTS: + var replacement_name: StringName = HOVER_COLOR_REPLACEMENTS[hover_name] + if not control.has_theme_color(replacement_name): + continue + _original_hover_color_overrides[hover_name] = { + "had_override": control.has_theme_color_override(hover_name), + "value": control.get_theme_color(hover_name), + } + control.add_theme_color_override( + hover_name, + control.get_theme_color(replacement_name), + ) + + +func _restore_native_hover_highlight() -> void: + if not is_instance_valid(_suppressed_hover_control): + _suppressed_hover_control = null + _original_hover_style_overrides.clear() + _original_hover_color_overrides.clear() + return + for hover_name: StringName in _original_hover_style_overrides: + var state: Dictionary = _original_hover_style_overrides[hover_name] + if bool(state.get("had_override", false)): + _suppressed_hover_control.add_theme_stylebox_override( + hover_name, + state.get("value") as StyleBox, + ) + else: + _suppressed_hover_control.remove_theme_stylebox_override(hover_name) + for hover_name: StringName in _original_hover_color_overrides: + var state: Dictionary = _original_hover_color_overrides[hover_name] + if bool(state.get("had_override", false)): + _suppressed_hover_control.add_theme_color_override( + hover_name, + state.get("value") as Color, + ) + else: + _suppressed_hover_control.remove_theme_color_override(hover_name) + if _suppressed_hover_control.has_method( + &"set_controller_focus_presentation_active" + ): + _suppressed_hover_control.call( + &"set_controller_focus_presentation_active", false + ) + _suppressed_hover_control = null + _original_hover_style_overrides.clear() + _original_hover_color_overrides.clear() + + func _suppress_popup_focus_highlight(popup: PopupMenu) -> void: _original_popup_hover_style = { "had_override": popup.has_theme_stylebox_override(&"hover"), diff --git a/ui/fish_batch_selection.gd b/ui/fish_batch_selection.gd index ab147ea..bc54275 100644 --- a/ui/fish_batch_selection.gd +++ b/ui/fish_batch_selection.gd @@ -67,6 +67,12 @@ func select_only(catch_id: StringName) -> void: _anchor_id = catch_id +func focus_only(catch_id: StringName) -> void: + if catch_id.is_empty() or not _visible_ids.has(catch_id): + return + _focused_id = catch_id + + func clear() -> void: _selected_ids.clear() _focused_id = StringName() diff --git a/ui/fishing_shop.gd b/ui/fishing_shop.gd index d331d79..4cebb6d 100644 --- a/ui/fishing_shop.gd +++ b/ui/fishing_shop.gd @@ -93,6 +93,11 @@ enum ShopSection { SELL_FISH, } +enum ControllerZone { + TABS, + CONTENT, +} + const SHOP_SECTION_LABELS: Array[String] = [ "Upgrades", "Bait and Lures", @@ -175,6 +180,7 @@ var _cooler_modal_open: bool = false var _shop_section: ShopSection = ShopSection.UPGRADES var _shop_tabs: Array[OrganizerTab] = [] var _controller_mapping_manager: ControllerMappingManagerType +var _controller_zone: ControllerZone = ControllerZone.TABS func _ready() -> void: @@ -204,6 +210,40 @@ func _input(event: InputEvent) -> void: var button_event := event as InputEventJoypadButton if button_event == null: return + var uses_accept: bool = ( + _controller_mapping_manager.event_matches_role( + event, + ControllerMappingManagerType.ROLE_A, + ) + if _controller_mapping_manager != null + else button_event.button_index == JOY_BUTTON_A + ) + var uses_cancel: bool = ( + _controller_mapping_manager.event_matches_role( + event, + ControllerMappingManagerType.ROLE_B, + ) + if _controller_mapping_manager != null + else button_event.button_index == JOY_BUTTON_B + ) + if uses_cancel: + get_viewport().set_input_as_handled() + if not button_event.pressed: + return + if _controller_zone == ControllerZone.CONTENT: + _enter_shop_tabs_zone() + else: + close_shop() + return + if uses_accept: + get_viewport().set_input_as_handled() + if not button_event.pressed or _transaction_in_progress: + return + if _controller_zone == ControllerZone.TABS: + _enter_shop_content_zone() + else: + _activate_focused_shop_control() + return var uses_left_bumper: bool = ( _controller_mapping_manager.event_uses_role( event, @@ -226,12 +266,69 @@ func _input(event: InputEvent) -> void: if not button_event.pressed or _transaction_in_progress: return var direction: int = -1 if uses_left_bumper else 1 + var current_section: int = _focused_shop_tab_index() + if current_section < 0: + current_section = int(_shop_section) var next_section: int = wrapi( - int(_shop_section) + direction, + current_section + direction, 0, ShopSection.size(), ) - _select_shop_section(next_section, true) + if _controller_zone == ControllerZone.TABS: + _focus_shop_tab(next_section) + else: + _select_shop_section(next_section, true) + + +func _enter_shop_tabs_zone() -> void: + _controller_zone = ControllerZone.TABS + _apply_shop_controller_zone_focus_modes() + _configure_controller_focus() + _focus_shop_tab(int(_shop_section)) + + +func _enter_shop_content_zone() -> void: + var section_index: int = _focused_shop_tab_index() + if section_index < 0: + section_index = int(_shop_section) + _controller_zone = ControllerZone.CONTENT + _select_shop_section(section_index, true) + + +func _activate_focused_shop_control() -> bool: + var focused := get_viewport().gui_get_focus_owner() as BaseButton + if ( + focused == null + or focused.disabled + or _is_shop_tab(focused) + or not focused.is_visible_in_tree() + ): + return false + focused.pressed.emit() + return true + + +func _focused_shop_tab_index() -> int: + var focused: Control = get_viewport().gui_get_focus_owner() + for tab_index: int in _shop_tabs.size(): + if _shop_tabs[tab_index] == focused: + return tab_index + return -1 + + +func _is_shop_tab(control: Control) -> bool: + for tab: OrganizerTab in _shop_tabs: + if tab == control: + return true + return false + + +func _focus_shop_tab(section_index: int) -> void: + if section_index < 0 or section_index >= _shop_tabs.size(): + return + var tab: OrganizerTab = _shop_tabs[section_index] + if tab.focus_mode != Control.FOCUS_NONE and not tab.disabled: + tab.call_deferred("grab_focus") func _request_shop_cooler() -> bool: @@ -249,10 +346,15 @@ func _focus_shop_section() -> void: _set_feedback("") _configure_controller_focus() if _shop_section == ShopSection.UPGRADES: - _reel_purchase.grab_focus() - return + for upgrade_button: Button in [ + _reel_purchase, + _barrier_purchase, + _cooler_purchase, + ]: + if not upgrade_button.disabled: + upgrade_button.grab_focus() + return if _shop_section == ShopSection.SELL_FISH: - _shop_tabs[int(ShopSection.SELL_FISH)].grab_focus() return for child: Node in _supplies_list.find_children( "*", "Button", true, false @@ -261,9 +363,9 @@ func _focus_shop_section() -> void: if stock_button != null and not stock_button.disabled: stock_button.grab_focus() return - var section_index: int = int(_shop_section) - if section_index >= 0 and section_index < _shop_tabs.size(): - _shop_tabs[section_index].grab_focus() + var close_button := %CloseButton as Button + if close_button.focus_mode != Control.FOCUS_NONE: + close_button.grab_focus() func _build_shop_tabs() -> void: @@ -293,6 +395,8 @@ func _build_shop_tabs() -> void: func _select_shop_section(section_index: int, focus_content: bool) -> void: if _closing: return + if focus_content: + _controller_zone = ControllerZone.CONTENT if section_index == int(_shop_section): if section_index != ShopSection.SELL_FISH: var showing_upgrades := section_index == ShopSection.UPGRADES @@ -325,11 +429,36 @@ func _select_shop_section(section_index: int, focus_content: bool) -> void: func _configure_controller_focus() -> void: if not visible or _cooler_page_active: return + _apply_shop_controller_zone_focus_modes() var candidates: Array[Control] = [] _collect_controller_focusables(self, candidates) ControllerFocusNavigationType.configure_spatial_neighbors(candidates) +func _apply_shop_controller_zone_focus_modes() -> void: + var tabs_active: bool = _controller_zone == ControllerZone.TABS + for tab: OrganizerTab in _shop_tabs: + tab.focus_mode = ( + Control.FOCUS_ALL if tabs_active else Control.FOCUS_NONE + ) + var content_focus_mode := ( + Control.FOCUS_NONE if tabs_active else Control.FOCUS_ALL + ) + for content_root: Control in [_upgrades_content, _supplies_content]: + _set_descendant_button_focus_mode(content_root, content_focus_mode) + (%CloseButton as Button).focus_mode = content_focus_mode + + +func _set_descendant_button_focus_mode( + root_control: Control, + focus_mode: Control.FocusMode, +) -> void: + for child: Node in root_control.find_children("*", "BaseButton", true, false): + var button := child as BaseButton + if button != null: + button.focus_mode = focus_mode + + func _collect_controller_focusables( root: Node, output: Array[Control], @@ -488,10 +617,11 @@ func open_shop() -> bool: deactivate_shop_cooler_page() show() _shop_tab_bar.show() + _controller_zone = ControllerZone.TABS _select_shop_section(ShopSection.UPGRADES, false) _refresh_all() call_deferred("_configure_controller_focus") - _reel_purchase.grab_focus() + _focus_shop_tab(int(ShopSection.UPGRADES)) menu_visibility_changed.emit(true) return true @@ -751,6 +881,7 @@ func _refresh_supplies() -> void: _supplies_list.remove_child(child) child.queue_free() if _shop_section in [ShopSection.UPGRADES, ShopSection.SELL_FISH]: + call_deferred("_configure_controller_focus") return _stock_title.text = SHOP_SECTION_LABELS[int(_shop_section)] var stock_item_ids: Array[StringName] = ( @@ -934,6 +1065,7 @@ func _refresh_supplies() -> void: var canvas_grid := _add_stock_icon_grid() for product_id: StringName in ArtShopStockType.GRID_PRODUCTS: _add_art_upgrade_button(product_id, canvas_grid) + call_deferred("_configure_controller_focus") func _item_belongs_in_current_section(item: ItemDataType) -> bool: diff --git a/ui/game_ui.gd b/ui/game_ui.gd index c341d48..766dd11 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -456,6 +456,10 @@ func setup( func _input(event: InputEvent) -> void: + # The on-screen keyboard owns controller input while it is open. Its + # overlay is processed before the UI beneath it and consumes the event. + if is_controller_text_entry_open(): + return if _handle_virtual_mouse_input(event): get_viewport().set_input_as_handled() return @@ -476,6 +480,8 @@ func _input(event: InputEvent) -> void: ): return if event.is_action_pressed("character_call") and _can_use_character_call(): + if _character_call_yields_to_world_interaction(event): + return _network_chat_service.send_local_character_call( _network_profile.call_id ) @@ -554,6 +560,19 @@ func _can_use_character_call() -> bool: ) +func _character_call_yields_to_world_interaction( + event: InputEvent, +) -> bool: + return ( + event.is_action_pressed("interact") + and _shop_interaction != null + and _shop_interaction.is_local_player_in_range() + and _fishing_spot != null + and _fishing_spot.can_open_fishing_shop() + and not _fishing_shop.visible + ) + + func _on_character_call_received( peer_id: int, call_id: String, diff --git a/ui/logbook_page.gd b/ui/logbook_page.gd index 6f05a41..7817209 100644 --- a/ui/logbook_page.gd +++ b/ui/logbook_page.gd @@ -358,6 +358,7 @@ func _build_interface() -> void: _catalog_scroll.horizontal_scroll_mode = ( ScrollContainer.SCROLL_MODE_DISABLED ) + _catalog_scroll.follow_focus = true _catalog_scroll.scroll_vertical_custom_step = CATALOG_ROW_STEP catalog_scroll_margin.add_child(_catalog_scroll) _catalog_grid = GridContainer.new() diff --git a/ui/on_screen_keyboard.gd b/ui/on_screen_keyboard.gd index 6256f0d..e1b870c 100644 --- a/ui/on_screen_keyboard.gd +++ b/ui/on_screen_keyboard.gd @@ -4,6 +4,9 @@ extends Control const ControllerFocusNavigationType = preload( "res://ui/controller_focus_navigation.gd" ) +const ControllerMappingManagerType = preload( + "res://settings/controller_mapping_manager.gd" +) const CHECK_ICON: Texture2D = preload( "res://ui/icons/pictograms/check_mark_dark.png" ) @@ -12,6 +15,8 @@ const CHARACTER_FONT_SIZE: int = 34 const CANONICAL_WINDOW_SIZE: Vector2 = Vector2(1280.0, 720.0) const TRIGGER_PRESS_THRESHOLD: float = 0.55 const TRIGGER_RELEASE_THRESHOLD: float = 0.25 +const CARET_BLINK_INTERVAL: float = 0.5 +const CARET_GLYPH: String = "▌" signal text_submitted(value: String) @@ -22,10 +27,14 @@ enum Page { } var _enabled: bool = false +var _controller_mapping_manager: ControllerMappingManagerType var _page: Page = Page.LOWER var _target: Control var _target_virtual_keyboard_enabled: bool = true var _buffer: String = "" +var _buffer_caret: int = 0 +var _caret_blink_elapsed: float = 0.0 +var _caret_visible: bool = true var _preview: Label var _page_buttons: Array[Button] = [] var _keys_host: VBoxContainer @@ -52,6 +61,21 @@ func _ready() -> void: _build_interface() hide() set_process_input(true) + set_process(true) + + +func _process(delta: float) -> void: + if not visible: + return + _caret_blink_elapsed += delta + if _caret_blink_elapsed < CARET_BLINK_INTERVAL: + return + _caret_blink_elapsed = fmod( + _caret_blink_elapsed, + CARET_BLINK_INTERVAL, + ) + _caret_visible = not _caret_visible + _refresh_preview() func set_enabled(enabled: bool) -> void: @@ -68,6 +92,12 @@ func is_open() -> bool: return visible +func setup_controller_mapping( + mapping_manager: ControllerMappingManagerType, +) -> void: + _controller_mapping_manager = mapping_manager + + func request_for_focused_control() -> bool: return request_for_control(get_viewport().gui_get_focus_owner()) @@ -88,23 +118,51 @@ func _input(event: InputEvent) -> void: return if visible: var joy_motion := event as InputEventJoypadMotion - if joy_motion != null and _handle_trigger_shortcut(joy_motion): + if joy_motion != null: + if not _handle_trigger_shortcut(joy_motion): + _move_key_focus(_controller_direction(event)) get_viewport().set_input_as_handled() return var joy_button := event as InputEventJoypadButton - if joy_button != null and joy_button.pressed: - if joy_button.button_index == JOY_BUTTON_X: - _backspace() - get_viewport().set_input_as_handled() - return - if joy_button.button_index == JOY_BUTTON_LEFT_SHOULDER: - _close_keyboard(false) - get_viewport().set_input_as_handled() - return - if joy_button.button_index == JOY_BUTTON_RIGHT_SHOULDER: - _set_page(wrapi(int(_page) + 1, 0, Page.size())) - get_viewport().set_input_as_handled() - return + if joy_button != null: + if joy_button.pressed: + # Resolve the automapper's logical roles before raw button numbers. + # Handheld mappings do not necessarily report their labeled A/B/X + # buttons using Godot's matching physical button constants. + if _event_matches_role( + event, + ControllerMappingManagerType.ROLE_X, + JOY_BUTTON_X, + ): + _backspace() + elif _event_matches_role( + event, + ControllerMappingManagerType.ROLE_LB, + JOY_BUTTON_LEFT_SHOULDER, + ): + _set_page(wrapi(int(_page) - 1, 0, Page.size())) + elif _event_matches_role( + event, + ControllerMappingManagerType.ROLE_RB, + JOY_BUTTON_RIGHT_SHOULDER, + ): + _set_page(wrapi(int(_page) + 1, 0, Page.size())) + elif _event_matches_role( + event, + ControllerMappingManagerType.ROLE_A, + JOY_BUTTON_A, + ): + _activate_focused_key() + elif _event_matches_role( + event, + ControllerMappingManagerType.ROLE_B, + JOY_BUTTON_B, + ): + _close_keyboard(true) + else: + _move_key_focus(_controller_direction(event)) + get_viewport().set_input_as_handled() + return if event.is_action_pressed(&"ui_cancel"): _close_keyboard(true) get_viewport().set_input_as_handled() @@ -114,8 +172,15 @@ func _input(event: InputEvent) -> void: joy_event == null or not joy_event.pressed or ( - joy_event.button_index != JOY_BUTTON_A - and not event.is_action_pressed(&"ui_accept") + not _event_matches_role( + event, + ControllerMappingManagerType.ROLE_A, + JOY_BUTTON_A, + ) + and ( + _controller_mapping_manager != null + or not event.is_action_pressed(&"ui_accept") + ) ) ): return @@ -123,6 +188,17 @@ func _input(event: InputEvent) -> void: get_viewport().set_input_as_handled() +func _event_matches_role( + event: InputEvent, + role: StringName, + fallback_button: JoyButton, +) -> bool: + if _controller_mapping_manager != null: + return _controller_mapping_manager.event_matches_role(event, role) + var button := event as InputEventJoypadButton + return button != null and button.button_index == fallback_button + + func _is_available_for_controller() -> bool: return should_enable_for_controller( _enabled, @@ -164,9 +240,12 @@ func _open_for(control: Control) -> void: ) _target.set("virtual_keyboard_enabled", false) _buffer = str(_target.get("text")) + _buffer_caret = _buffer.length() + _set_target_caret(_buffer_caret) _page = Page.LOWER _attach_to_target_window(control) show() + _reset_caret_blink() _refresh_preview() _rebuild_keys() @@ -179,6 +258,8 @@ func _close_keyboard(restore_focus: bool) -> void: _target_virtual_keyboard_enabled ) hide() + _left_trigger_pressed = false + _right_trigger_pressed = false get_viewport().gui_release_focus() _restore_portable_host() _target = null @@ -283,15 +364,13 @@ func _move_caret(direction: int) -> void: if not is_instance_valid(_target): _close_keyboard(false) return - var caret: int = clampi( + _buffer_caret = clampi( _get_caret_column() + direction, 0, _buffer.length(), ) - if _target is LineEdit: - (_target as LineEdit).caret_column = caret - elif _target is TextEdit: - (_target as TextEdit).set_caret_column(caret) + _set_target_caret(_buffer_caret) + _reset_caret_blink() _refresh_preview() @@ -320,28 +399,44 @@ func _handle_trigger_shortcut(event: InputEventJoypadMotion) -> bool: func _get_caret_column() -> int: - if _target is LineEdit: - return clampi( - (_target as LineEdit).caret_column, - 0, - _buffer.length() - ) - return _buffer.length() + return clampi(_buffer_caret, 0, _buffer.length()) func _set_target_text(caret: int) -> void: + _buffer_caret = clampi(caret, 0, _buffer.length()) if _target is LineEdit: var line_edit := _target as LineEdit line_edit.text = _buffer - line_edit.caret_column = caret line_edit.text_changed.emit(_buffer) elif _target is TextEdit: var text_edit := _target as TextEdit text_edit.text = _buffer text_edit.text_changed.emit() + _set_target_caret(_buffer_caret) + _reset_caret_blink() _refresh_preview() +func _set_target_caret(caret: int) -> void: + if _target is LineEdit: + (_target as LineEdit).caret_column = caret + elif _target is TextEdit: + var text_edit := _target as TextEdit + var text_before_caret: String = _buffer.substr(0, caret) + var caret_line: int = text_before_caret.count("\n") + var last_newline: int = text_before_caret.rfind("\n") + var caret_column: int = ( + caret if last_newline < 0 else caret - last_newline - 1 + ) + text_edit.set_caret_line(caret_line) + text_edit.set_caret_column(caret_column) + + +func _reset_caret_blink() -> void: + _caret_blink_elapsed = 0.0 + _caret_visible = true + + func _refresh_preview() -> void: if _preview == null: return @@ -349,7 +444,58 @@ func _refresh_preview() -> void: if _target is LineEdit and (_target as LineEdit).secret: displayed_text = "*".repeat(_buffer.length()) var caret: int = clampi(_get_caret_column(), 0, displayed_text.length()) - _preview.text = displayed_text.insert(caret, "|") + var caret_glyph: String = CARET_GLYPH if _caret_visible else "" + _preview.text = displayed_text.insert(caret, caret_glyph) + + +func _controller_direction(event: InputEvent) -> Vector2: + if event.is_action_pressed(&"ui_up"): + return Vector2.UP + if event.is_action_pressed(&"ui_down"): + return Vector2.DOWN + if event.is_action_pressed(&"ui_left"): + return Vector2.LEFT + if event.is_action_pressed(&"ui_right"): + return Vector2.RIGHT + return Vector2.ZERO + + +func _move_key_focus(direction: Vector2) -> void: + if direction == Vector2.ZERO: + return + var focused: Control = get_viewport().gui_get_focus_owner() + if focused == null or not is_ancestor_of(focused): + _focus_first_key() + return + var neighbor_path := NodePath() + if direction == Vector2.UP: + neighbor_path = focused.focus_neighbor_top + elif direction == Vector2.DOWN: + neighbor_path = focused.focus_neighbor_bottom + elif direction == Vector2.LEFT: + neighbor_path = focused.focus_neighbor_left + elif direction == Vector2.RIGHT: + neighbor_path = focused.focus_neighbor_right + if neighbor_path.is_empty(): + return + var neighbor := focused.get_node_or_null(neighbor_path) as Control + if ControllerFocusNavigationType.is_focusable(neighbor): + neighbor.grab_focus() + + +func _activate_focused_key() -> void: + var focused: Control = get_viewport().gui_get_focus_owner() + if focused == null or not is_ancestor_of(focused): + _focus_first_key() + focused = get_viewport().gui_get_focus_owner() + var button := focused as BaseButton + if button != null and not button.disabled: + button.pressed.emit() + + +func _focus_first_key() -> void: + if not _key_buttons.is_empty(): + _key_buttons[0].grab_focus() func _set_page(page_index: int) -> void: diff --git a/ui/profile_page.gd b/ui/profile_page.gd index 2a19633..fcc8e3b 100644 --- a/ui/profile_page.gd +++ b/ui/profile_page.gd @@ -219,6 +219,8 @@ func consume_escape() -> bool: func handle_controller_input(event: InputEvent) -> bool: if not _profile_active or not _profile_interactive: return false + if event is InputEventJoypadButton or event is InputEventJoypadMotion: + _ensure_controller_zone_focus() var cancel_pressed := _event_matches_controller_press( event, &"ui_cancel", @@ -395,23 +397,7 @@ func _apply_controller_zone_focus() -> void: func _focus_controller_zone() -> void: if not _profile_interactive: return - var controls: Array[Control] = [] - match _controller_zone: - ControllerZone.ACCOUNT: - controls = _account_controller_controls() - ControllerZone.CATEGORIES: - controls = _controls_under(_category_list) - ControllerZone.OPTIONS: - var groups: Array = _controller_option_groups() - if not groups.is_empty(): - for item: Variant in groups[clampi( - _controller_option_depth, 0, groups.size() - 1 - )]: - var option_control := item as Control - if option_control != null: - controls.append(option_control) - ControllerZone.COLOR_PICKER: - controls = _color_picker_controller_controls() + var controls: Array[Control] = _active_controller_zone_controls() for control: Control in controls: var button := control as BaseButton if ( @@ -429,6 +415,55 @@ func _focus_controller_zone() -> void: return +func _ensure_controller_zone_focus() -> void: + var controls: Array[Control] = _active_controller_zone_controls() + if controls.is_empty(): + if _controller_zone == ControllerZone.OPTIONS: + _controller_zone = ControllerZone.CATEGORIES + _controller_option_depth = 0 + controls = _active_controller_zone_controls() + if controls.is_empty(): + return + var focused: Control = get_viewport().gui_get_focus_owner() + if ( + focused != null + and focused in controls + and focused.is_visible_in_tree() + and focused.focus_mode != Control.FOCUS_NONE + ): + return + _apply_controller_zone_focus() + _focus_controller_zone() + + +func _active_controller_zone_controls() -> Array[Control]: + if ( + _discard_confirmation != null + and _discard_confirmation.visible + ): + return [_confirmation_confirm, _keep_editing_button] + match _controller_zone: + ControllerZone.ACCOUNT: + return _account_controller_controls() + ControllerZone.CATEGORIES: + return _controls_under(_category_list) + ControllerZone.OPTIONS: + var groups: Array = _controller_option_groups() + if groups.is_empty(): + return [] + var controls: Array[Control] = [] + for item: Variant in groups[clampi( + _controller_option_depth, 0, groups.size() - 1 + )]: + var option_control := item as Control + if option_control != null: + controls.append(option_control) + return controls + ControllerZone.COLOR_PICKER: + return _color_picker_controller_controls() + return [] + + func _account_controller_controls() -> Array[Control]: var controls: Array[Control] = [] for control: Control in [ @@ -451,21 +486,23 @@ func _controller_option_groups() -> Array: if _option_list == null: return groups if _category_id != "fur_pattern": - groups.append(_controls_under(_option_list)) + var option_controls: Array[Control] = _controls_under(_option_list) + if not option_controls.is_empty(): + groups.append(option_controls) return groups var section_tabs := _option_list.find_child( "FurSectionTabs", true, false ) as Node - groups.append(_controls_under(section_tabs)) + _append_controller_group(groups, _controls_under(section_tabs)) if _active_fur_section == FUR_SECTION_PATTERNS: - groups.append(_controls_under(_option_list.find_child( + _append_controller_group(groups, _controls_under(_option_list.find_child( "FurPatternPartTabs", true, false ))) - groups.append(_controls_under(_option_list.find_child( + _append_controller_group(groups, _controls_under(_option_list.find_child( "FurPatternGrid", true, false ))) else: - groups.append(_controls_under(_option_list.find_child( + _append_controller_group(groups, _controls_under(_option_list.find_child( "FurColorChannelGrid", true, false ))) var palette_controls: Array[Control] = _controls_under( @@ -476,10 +513,15 @@ func _controller_option_groups() -> Array: ) as Control if custom_picker != null: palette_controls.append(custom_picker) - groups.append(palette_controls) + _append_controller_group(groups, palette_controls) return groups +func _append_controller_group(groups: Array, controls: Array[Control]) -> void: + if not controls.is_empty(): + groups.append(controls) + + func _controls_under(root: Node) -> Array[Control]: var controls: Array[Control] = [] if root == null: diff --git a/ui/settings/settings_bubble_page.gd b/ui/settings/settings_bubble_page.gd index 4489a5d..8fc6fca 100644 --- a/ui/settings/settings_bubble_page.gd +++ b/ui/settings/settings_bubble_page.gd @@ -11,6 +11,7 @@ const ControllerFocusNavigationType = preload( @export var focus_paths: Array[NodePath] = [] @export var initial_focus_path: NodePath @export var back_focus_path: NodePath +@export var back_entry_path: NodePath @export var maximum_layout_size: Vector2 = Vector2(720.0, 520.0) @export var compact_maximum_layout_size: Vector2 = Vector2.ZERO @export var compact_width_threshold: float = 680.0 @@ -146,6 +147,23 @@ func focus_back() -> void: back.grab_focus() +func set_controller_focus_scope(controls: Array[Control]) -> void: + for focus_control: Control in _focus_controls: + focus_control.focus_mode = Control.FOCUS_NONE + for control: Control in controls: + if control != null and control.is_visible_in_tree(): + control.focus_mode = Control.FOCUS_ALL + ControllerFocusNavigationType.configure_spatial_neighbors(controls) + + +func restore_controller_focus_scope() -> void: + if not visible or _is_transitioning: + return + for focus_control: Control in _focus_controls: + focus_control.focus_mode = Control.FOCUS_ALL + _configure_focus_navigation() + + func get_page_id() -> StringName: return page_id @@ -173,13 +191,13 @@ func _update_layout() -> void: _cluster.position = _resting_cluster_position _cluster.size = field_size _cluster.apply_layout(field_size, compact) - ControllerFocusNavigationType.configure_spatial_neighbors(_focus_controls) + _configure_focus_navigation() func _configure_focus_order() -> void: for bubble: BubbleButton in _bubbles: bubble.focus_mode = Control.FOCUS_NONE - ControllerFocusNavigationType.configure_spatial_neighbors(_focus_controls) + _configure_focus_navigation() func _set_interactive(interactive: bool) -> void: @@ -203,16 +221,26 @@ func _set_interactive(interactive: bool) -> void: if interactive: call_deferred("_refresh_focus_navigation") else: - ControllerFocusNavigationType.configure_spatial_neighbors( - _focus_controls - ) + _configure_focus_navigation() func _refresh_focus_navigation() -> void: if visible and not _is_transitioning: - ControllerFocusNavigationType.configure_spatial_neighbors( - _focus_controls - ) + _configure_focus_navigation() + + +func _configure_focus_navigation() -> void: + ControllerFocusNavigationType.configure_spatial_neighbors(_focus_controls) + if back_entry_path.is_empty() or back_focus_path.is_empty(): + return + var entry := get_node_or_null(back_entry_path) as Control + var back := get_node_or_null(back_focus_path) as Control + if ( + ControllerFocusNavigationType.is_focusable(entry) + and ControllerFocusNavigationType.is_focusable(back) + ): + entry.focus_neighbor_bottom = entry.get_path_to(back) + back.focus_neighbor_top = back.get_path_to(entry) func _finish_transition_out(generation: int, completed: Callable) -> void: diff --git a/ui/settings_panel.gd b/ui/settings_panel.gd index 818d35e..f0169af 100644 --- a/ui/settings_panel.gd +++ b/ui/settings_panel.gd @@ -131,6 +131,7 @@ var _pending_identity_operation := "" var _pending_identity_type := "" var _pending_identity_path := "" var _pending_import_data: Dictionary = {} +var _active_adjustment_group: Dictionary = {} func _ready() -> void: @@ -293,6 +294,56 @@ func setup_data_and_identity( _refresh_data_page() +func _input(event: InputEvent) -> void: + if not visible or is_input_mapping_capturing(): + return + var mouse_button := event as InputEventMouseButton + if ( + mouse_button != null + and mouse_button.pressed + and not _active_adjustment_group.is_empty() + ): + _exit_adjustment_zone(false) + return + var button := event as InputEventJoypadButton + if button == null or not button.pressed: + return + if ( + not _active_adjustment_group.is_empty() + and _event_matches_controller_role( + event, + ControllerMappingManagerType.ROLE_B, + &"ui_cancel", + JOY_BUTTON_B, + ) + ): + _exit_adjustment_zone(true) + get_viewport().set_input_as_handled() + return + if not _event_matches_controller_role( + event, + ControllerMappingManagerType.ROLE_A, + &"ui_accept", + JOY_BUTTON_A, + ): + return + if _active_adjustment_group.is_empty(): + var group: Dictionary = _adjustment_group_for_control( + get_viewport().gui_get_focus_owner() + ) + if group.is_empty(): + return + _enter_adjustment_zone(group) + get_viewport().set_input_as_handled() + return + var focused := get_viewport().gui_get_focus_owner() as BaseButton + var decrease := _active_adjustment_group.get("decrease") as BaseButton + var increase := _active_adjustment_group.get("increase") as BaseButton + if focused == decrease or focused == increase: + focused.pressed.emit() + get_viewport().set_input_as_handled() + + func open_panel( settings_manager: SettingsManagerType, presentation_mode: PresentationMode = PresentationMode.GAMEPLAY_MODAL, @@ -370,6 +421,9 @@ func _finish_panel_close(applied_result: bool) -> void: func handle_back() -> void: + if not _active_adjustment_group.is_empty(): + _exit_adjustment_zone(true) + return if ( _keyboard_mouse_mapping_panel != null and _keyboard_mouse_mapping_panel.is_open() @@ -440,9 +494,105 @@ func _push_page(page_id: StringName) -> void: or get_active_page_id() == page_id ): return + _exit_adjustment_zone(false) _start_page_transition(page_id, true) +func _adjustment_group_for_control(control: Control) -> Dictionary: + for group: Dictionary in _adjustment_groups(): + if group.get("parent") as Control == control: + return group + return {} + + +func _adjustment_groups() -> Array[Dictionary]: + return [ + { + "page": _controls_page, + "parent": _mouse_value, + "decrease": %MouseDecrease, + "increase": %MouseIncrease, + }, + { + "page": _controls_page, + "parent": _controller_value, + "decrease": %ControllerDecrease, + "increase": %ControllerIncrease, + }, + { + "page": _accessibility_page, + "parent": _auto_click_interval, + "decrease": %IntervalDecrease, + "increase": %IntervalIncrease, + }, + ] + + +func _enter_adjustment_zone(group: Dictionary) -> void: + var page := group.get("page") as SettingsBubblePage + var parent := group.get("parent") as Control + var decrease := group.get("decrease") as Control + var increase := group.get("increase") as Control + if page == null or parent == null or decrease == null or increase == null: + return + _active_adjustment_group = group + var controls: Array[Control] = [decrease, parent, increase] + page.set_controller_focus_scope(controls) + _set_adjustment_neighbors(decrease, parent, increase) + parent.grab_focus() + + +func _exit_adjustment_zone(restore_parent_focus: bool) -> void: + if _active_adjustment_group.is_empty(): + return + var page := _active_adjustment_group.get("page") as SettingsBubblePage + var parent := _active_adjustment_group.get("parent") as Control + var decrease := _active_adjustment_group.get("decrease") as Control + var increase := _active_adjustment_group.get("increase") as Control + _active_adjustment_group.clear() + for control: Control in [decrease, increase]: + if control != null: + control.focus_mode = Control.FOCUS_NONE + if page != null: + page.restore_controller_focus_scope() + if restore_parent_focus and parent != null: + parent.call_deferred("grab_focus") + + +func _set_adjustment_neighbors( + decrease: Control, + parent: Control, + increase: Control, +) -> void: + decrease.focus_neighbor_left = decrease.get_path_to(decrease) + decrease.focus_neighbor_right = decrease.get_path_to(parent) + parent.focus_neighbor_left = parent.get_path_to(decrease) + parent.focus_neighbor_right = parent.get_path_to(increase) + increase.focus_neighbor_left = increase.get_path_to(parent) + increase.focus_neighbor_right = increase.get_path_to(increase) + for control: Control in [decrease, parent, increase]: + control.focus_neighbor_top = control.get_path_to(control) + control.focus_neighbor_bottom = control.get_path_to(control) + + +func _event_matches_controller_role( + event: InputEvent, + role: StringName, + action: StringName, + fallback_button: JoyButton, +) -> bool: + if _controller_mapping_manager != null: + return _controller_mapping_manager.event_matches_role(event, role) + var button := event as InputEventJoypadButton + return ( + button != null + and ( + button.button_index == fallback_button + or event.is_action_pressed(action) + ) + ) + + func _start_page_transition(page_id: StringName, push_page: bool) -> void: var outgoing_page: SettingsBubblePage = _get_active_page() var incoming_page: SettingsBubblePage = _pages.get(page_id) diff --git a/ui/settings_panel.tscn b/ui/settings_panel.tscn index a93ed60..7c62481 100644 --- a/ui/settings_panel.tscn +++ b/ui/settings_panel.tscn @@ -425,6 +425,7 @@ bubble_paths = Array[NodePath]([NodePath("BubbleCluster/SoundBackButton")]) focus_paths = Array[NodePath]([NodePath("Paper/Content/VolumeGrid/MasterVolumeSlider"), NodePath("Paper/Content/VolumeGrid/MusicVolumeSlider"), NodePath("Paper/Content/VolumeGrid/EffectsVolumeSlider"), NodePath("Paper/Content/VolumeGrid/EnvironmentVolumeSlider"), NodePath("BubbleCluster/SoundBackButton")]) initial_focus_path = NodePath("Paper/Content/VolumeGrid/MasterVolumeSlider") back_focus_path = NodePath("BubbleCluster/SoundBackButton") +back_entry_path = NodePath("Paper/Content/VolumeGrid/EnvironmentVolumeSlider") compact_maximum_layout_size = Vector2(544, 400) [node name="BubbleCluster" type="Control" parent="SoundPage"] @@ -601,7 +602,7 @@ grow_vertical = 2 script = ExtResource("3_page") page_id = &"controls" bubble_paths = Array[NodePath]([NodePath("BubbleCluster/MouseDecrease"), NodePath("BubbleCluster/MouseValue"), NodePath("BubbleCluster/MouseIncrease"), NodePath("BubbleCluster/ControllerDecrease"), NodePath("BubbleCluster/ControllerValue"), NodePath("BubbleCluster/ControllerIncrease"), NodePath("BubbleCluster/InvertYToggle"), NodePath("BubbleCluster/OnScreenKeyboardToggle"), NodePath("BubbleCluster/ControllerMapping"), NodePath("BubbleCluster/KeyboardMapping"), NodePath("BubbleCluster/ControlsBackButton")]) -focus_paths = Array[NodePath]([NodePath("BubbleCluster/MouseDecrease"), NodePath("BubbleCluster/MouseValue"), NodePath("BubbleCluster/MouseIncrease"), NodePath("BubbleCluster/ControllerDecrease"), NodePath("BubbleCluster/ControllerValue"), NodePath("BubbleCluster/ControllerIncrease"), NodePath("BubbleCluster/InvertYToggle"), NodePath("BubbleCluster/OnScreenKeyboardToggle"), NodePath("BubbleCluster/ControllerMapping"), NodePath("BubbleCluster/KeyboardMapping"), NodePath("BubbleCluster/ControlsBackButton")]) +focus_paths = Array[NodePath]([NodePath("BubbleCluster/MouseValue"), NodePath("BubbleCluster/ControllerValue"), NodePath("BubbleCluster/InvertYToggle"), NodePath("BubbleCluster/OnScreenKeyboardToggle"), NodePath("BubbleCluster/ControllerMapping"), NodePath("BubbleCluster/KeyboardMapping"), NodePath("BubbleCluster/ControlsBackButton")]) initial_focus_path = NodePath("BubbleCluster/MouseValue") back_focus_path = NodePath("BubbleCluster/ControlsBackButton") @@ -758,7 +759,7 @@ grow_vertical = 2 script = ExtResource("3_page") page_id = &"accessibility" bubble_paths = Array[NodePath]([NodePath("BubbleCluster/AutoClickToggle"), NodePath("BubbleCluster/IntervalDecrease"), NodePath("BubbleCluster/AutoClickIntervalValue"), NodePath("BubbleCluster/IntervalIncrease"), NodePath("BubbleCluster/IntervalHelp"), NodePath("BubbleCluster/AccessibilityBackButton")]) -focus_paths = Array[NodePath]([NodePath("BubbleCluster/AutoClickToggle"), NodePath("BubbleCluster/IntervalDecrease"), NodePath("BubbleCluster/AutoClickIntervalValue"), NodePath("BubbleCluster/IntervalIncrease"), NodePath("BubbleCluster/AccessibilityBackButton")]) +focus_paths = Array[NodePath]([NodePath("BubbleCluster/AutoClickToggle"), NodePath("BubbleCluster/AutoClickIntervalValue"), NodePath("BubbleCluster/AccessibilityBackButton")]) initial_focus_path = NodePath("BubbleCluster/AutoClickToggle") back_focus_path = NodePath("BubbleCluster/AccessibilityBackButton") diff --git a/ui/ui_pixelation_presenter.gd b/ui/ui_pixelation_presenter.gd index 4307c57..4edacbb 100644 --- a/ui/ui_pixelation_presenter.gd +++ b/ui/ui_pixelation_presenter.gd @@ -11,6 +11,9 @@ const ControllerFocusPresentationType = preload( const ControllerFocusRecoveryType = preload( "res://ui/controller_focus_recovery.gd" ) +const ControllerMappingManagerType = preload( + "res://settings/controller_mapping_manager.gd" +) const OnScreenKeyboardType = preload("res://ui/on_screen_keyboard.gd") signal effective_pixel_size_changed( @@ -60,6 +63,12 @@ func set_on_screen_keyboard_enabled(enabled: bool) -> void: _on_screen_keyboard.set_enabled(enabled) +func setup_controller_mapping( + mapping_manager: ControllerMappingManagerType, +) -> void: + _on_screen_keyboard.setup_controller_mapping(mapping_manager) + + func set_pixel_size(pixel_size: int) -> void: _requested_pixel_size = clampi( pixel_size,