diff --git a/main/main.gd b/main/main.gd index 685420f..6344dd6 100644 --- a/main/main.gd +++ b/main/main.gd @@ -380,6 +380,9 @@ func _initialize_after_data_root() -> void: _shop_interaction.local_player_range_changed.connect( _on_shop_range_changed ) + _game_ui.set_shop_npc_player_in_range( + _shop_interaction.is_local_player_in_range() + ) _save_manager.setup( _player.inventory, _player.collection_log, @@ -1119,6 +1122,9 @@ func _apply_runtime_settings(settings: PlayerSettingsType) -> void: return _apply_world_pixelation(settings.world_pixel_size) _ui_pixelation.set_pixel_size(settings.ui_pixel_size) + _ui_pixelation.set_on_screen_keyboard_enabled( + settings.on_screen_keyboard_enabled + ) _game_ui.get_title_screen().set_world_pixelation( settings.world_pixel_size ) @@ -1141,7 +1147,11 @@ func _apply_runtime_settings(settings: PlayerSettingsType) -> void: func _apply_world_pixelation(pixel_size: int) -> void: var root_viewport: Viewport = get_viewport() root_viewport.scaling_3d_mode = Viewport.SCALING_3D_MODE_NEAREST - root_viewport.scaling_3d_scale = 1.0 + root_viewport.scaling_3d_scale = ( + 0.75 + if OS.get_environment("NETFISHING_LOW_END") == "1" + else 1.0 + ) root_viewport.msaa_3d = Viewport.MSAA_DISABLED root_viewport.screen_space_aa = Viewport.SCREEN_SPACE_AA_DISABLED root_viewport.use_taa = false @@ -1670,6 +1680,7 @@ func _exit_tree() -> void: func _on_shop_range_changed(in_range: bool) -> void: + _game_ui.set_shop_npc_player_in_range(in_range) if not in_range: _game_ui.get_fishing_shop().close_for_range_exit() _game_ui.set_shop_prompt_visible(_can_show_shop_prompt()) diff --git a/settings/player_settings.gd b/settings/player_settings.gd index 2a8697b..8979e57 100644 --- a/settings/player_settings.gd +++ b/settings/player_settings.gd @@ -27,6 +27,7 @@ const UI_COMPACT_RENDER_HEIGHTS: Array[int] = [0, 408, 336, 264, 192] @export_range(0.001, 0.012, 0.0005) var mouse_camera_sensitivity: float = 0.005 @export_range(0.5, 5.0, 0.1) var controller_camera_sensitivity: float = 2.5 @export var invert_camera_y: bool = false +@export var on_screen_keyboard_enabled: bool = false @export var chat_draft: String = "" @export var chat_collapsed: bool = false @export var chat_dock_right: bool = false @@ -63,6 +64,7 @@ func copy() -> PlayerSettings: result.mouse_camera_sensitivity = mouse_camera_sensitivity result.controller_camera_sensitivity = controller_camera_sensitivity result.invert_camera_y = invert_camera_y + result.on_screen_keyboard_enabled = on_screen_keyboard_enabled result.chat_draft = chat_draft result.chat_collapsed = chat_collapsed result.chat_dock_right = chat_dock_right diff --git a/settings/player_settings_manager.gd b/settings/player_settings_manager.gd index 7597c82..a983ca3 100644 --- a/settings/player_settings_manager.gd +++ b/settings/player_settings_manager.gd @@ -45,6 +45,10 @@ func load_settings() -> bool: if ( typeof(accessibility.get("auto_click_enabled")) != TYPE_BOOL or typeof(camera.get("invert_vertical")) != TYPE_BOOL + or ( + accessibility.has("on_screen_keyboard_enabled") + and typeof(accessibility["on_screen_keyboard_enabled"]) != TYPE_BOOL + ) or ( accessibility.has("use_readable_interface_font") and typeof(accessibility["use_readable_interface_font"]) != TYPE_BOOL @@ -88,6 +92,9 @@ func load_settings() -> bool: -1.0 ) loaded.invert_camera_y = camera["invert_vertical"] + loaded.on_screen_keyboard_enabled = bool( + accessibility.get("on_screen_keyboard_enabled", false) + ) loaded.world_pixel_size = _read_clamped_integer( presentation.get( "world_pixel_size", @@ -176,6 +183,9 @@ func save_now() -> bool: "auto_click_enabled": current_settings.auto_click_enabled, "auto_click_interval": current_settings.auto_click_interval, "use_readable_interface_font": true, + "on_screen_keyboard_enabled": ( + current_settings.on_screen_keyboard_enabled + ), }, "camera": { "mouse_sensitivity": current_settings.mouse_camera_sensitivity, diff --git a/tests/controller_focus_presentation_validation.gd b/tests/controller_focus_presentation_validation.gd new file mode 100644 index 0000000..638e0cc --- /dev/null +++ b/tests/controller_focus_presentation_validation.gd @@ -0,0 +1,75 @@ +extends SceneTree + +const FocusPresentationType = preload( + "res://ui/controller_focus_presentation.gd" +) + +var _failures: Array[String] = [] + + +func _initialize() -> void: + _run.call_deferred() + + +func _run() -> void: + var stage := Control.new() + stage.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + root.add_child(stage) + var presentation := FocusPresentationType.new() + stage.add_child(presentation) + var standard_button := Button.new() + standard_button.text = "standard" + standard_button.custom_minimum_size = Vector2(120.0, 60.0) + stage.add_child(standard_button) + var authored_selector := Button.new() + authored_selector.text = "authored selector" + authored_selector.position = Vector2(140.0, 0.0) + authored_selector.custom_minimum_size = Vector2(160.0, 60.0) + authored_selector.set_meta(&"controller_focus_inversion_disabled", true) + stage.add_child(authored_selector) + await process_frame + + var controller_event := InputEventJoypadButton.new() + controller_event.button_index = JOY_BUTTON_A + controller_event.pressed = true + presentation._input(controller_event) + standard_button.grab_focus() + await process_frame + _expect( + standard_button.material != null, + "ordinary controller focus receives inversion", + ) + + authored_selector.grab_focus() + await process_frame + _expect( + standard_button.material == null, + "inversion clears when focus moves", + ) + _expect( + authored_selector.material == null, + "authored selector backgrounds opt out of inversion", + ) + + standard_button.grab_focus() + await process_frame + standard_button.focus_mode = Control.FOCUS_NONE + await process_frame + _expect( + standard_button.material == null, + "inversion clears when a focused control is defocused", + ) + + stage.queue_free() + if _failures.is_empty(): + print("Controller focus presentation 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_focus_presentation_validation.gd.uid b/tests/controller_focus_presentation_validation.gd.uid new file mode 100644 index 0000000..0cacf4a --- /dev/null +++ b/tests/controller_focus_presentation_validation.gd.uid @@ -0,0 +1 @@ +uid://bjay8ki3albk6 diff --git a/tests/controller_focus_recovery_validation.gd b/tests/controller_focus_recovery_validation.gd new file mode 100644 index 0000000..e34afaa --- /dev/null +++ b/tests/controller_focus_recovery_validation.gd @@ -0,0 +1,88 @@ +extends SceneTree + +const ControllerFocusRecoveryType = preload( + "res://ui/controller_focus_recovery.gd" +) + +var _failures: Array[String] = [] + + +func _initialize() -> void: + _run.call_deferred() + + +func _run() -> void: + var stage := Control.new() + stage.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + root.add_child(stage) + var recovery := ControllerFocusRecoveryType.new() + stage.add_child(recovery) + var option_list := VBoxContainer.new() + option_list.position = Vector2(100.0, 100.0) + stage.add_child(option_list) + var original := _make_button("cute", "cute (show variants)") + option_list.add_child(original) + await process_frame + + var controller_event := InputEventJoypadButton.new() + controller_event.button_index = JOY_BUTTON_A + controller_event.pressed = true + recovery._input(controller_event) + original.grab_focus() + await process_frame + _expect(root.gui_get_focus_owner() == original, "original option receives focus") + + option_list.remove_child(original) + original.queue_free() + var replacement := _make_button("cute", "cute (hide variants)") + option_list.add_child(replacement) + await process_frame + await process_frame + _expect( + root.gui_get_focus_owner() == replacement, + "focus follows a rebuilt semantic option", + ) + + var explicit_target := _make_button("explicit", "explicit") + option_list.add_child(explicit_target) + option_list.remove_child(replacement) + replacement.queue_free() + explicit_target.grab_focus() + await process_frame + _expect( + root.gui_get_focus_owner() == explicit_target, + "explicit focus changes take precedence over recovery", + ) + var leave_world_ui_event := InputEventJoypadButton.new() + leave_world_ui_event.button_index = JOY_BUTTON_LEFT_SHOULDER + leave_world_ui_event.pressed = true + recovery._input(leave_world_ui_event) + root.gui_release_focus() + await process_frame + await process_frame + _expect( + root.gui_get_focus_owner() == null, + "LB intentionally leaving world UI is never recovered", + ) + + stage.queue_free() + if _failures.is_empty(): + print("Controller focus recovery validation: PASS") + quit(0) + return + for failure: String in _failures: + push_error(failure) + quit(1) + + +func _make_button(text: String, tooltip: String) -> Button: + var button := Button.new() + button.text = text + button.tooltip_text = tooltip + button.custom_minimum_size = Vector2(120.0, 48.0) + return button + + +func _expect(condition: bool, message: String) -> void: + if not condition: + _failures.append(message) diff --git a/tests/controller_focus_recovery_validation.gd.uid b/tests/controller_focus_recovery_validation.gd.uid new file mode 100644 index 0000000..7bcb1a2 --- /dev/null +++ b/tests/controller_focus_recovery_validation.gd.uid @@ -0,0 +1 @@ +uid://cqk75fxykqrhs diff --git a/tests/controller_ui_navigation_validation.gd b/tests/controller_ui_navigation_validation.gd new file mode 100644 index 0000000..7cecbab --- /dev/null +++ b/tests/controller_ui_navigation_validation.gd @@ -0,0 +1,82 @@ +extends SceneTree + +const ControllerFocusNavigationType = preload( + "res://ui/controller_focus_navigation.gd" +) +const UIReferencePresentationType = preload( + "res://ui/ui_reference_presentation.gd" +) + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _validate_spatial_navigation() + _validate_controller_hierarchy_contract() + _validate_four_by_three_centering() + _validate_low_end_profile_contract() + print("Controller UI navigation validation: PASS") + quit() + + +func _validate_spatial_navigation() -> void: + var host := Control.new() + root.add_child(host) + var center := _make_button(host, "center", Vector2(200.0, 200.0)) + var left := _make_button(host, "left", Vector2(50.0, 200.0)) + var right := _make_button(host, "right", Vector2(350.0, 200.0)) + var top := _make_button(host, "top", Vector2(200.0, 50.0)) + var bottom := _make_button(host, "bottom", Vector2(200.0, 350.0)) + var controls: Array[Control] = [center, left, right, top, bottom] + ControllerFocusNavigationType.configure_spatial_neighbors(controls) + assert(center.get_node(center.focus_neighbor_left) == left) + assert(center.get_node(center.focus_neighbor_right) == right) + assert(center.get_node(center.focus_neighbor_top) == top) + assert(center.get_node(center.focus_neighbor_bottom) == bottom) + host.queue_free() + + +func _validate_controller_hierarchy_contract() -> void: + var source: String = FileAccess.get_file_as_string( + "res://ui/player_menu.gd" + ) + assert(source.contains("ROLE_POINTER_MODIFIER")) + assert(source.contains("ROLE_CAMERA_ZOOM")) + assert(source.contains("_handle_controller_secondary_switch")) + assert(source.contains("ROLE_LB")) + assert(source.contains("ROLE_RB")) + assert(source.contains("_reserve_main_navigation_for_page_switching")) + + +func _validate_four_by_three_centering() -> void: + var stage_position: Vector2 = ( + UIReferencePresentationType.get_stage_position(Vector2(640.0, 480.0)) + ) + assert(stage_position.is_equal_approx(Vector2(0.0, 120.0))) + + +func _validate_low_end_profile_contract() -> void: + var launcher: String = FileAccess.get_file_as_string( + "res://scripts/portmaster/NETfishing.sh" + ) + assert(launcher.contains("allwinner,h616")) + assert(launcher.contains("sun50iw9p1")) + assert(launcher.contains("NETFISHING_LOW_END=1")) + assert(launcher.contains("--max-fps 30")) + assert(launcher.contains("--audio-output-latency 40")) + + +func _make_button( + host: Control, + button_name: String, + button_position: Vector2, +) -> Button: + var button := Button.new() + button.name = button_name + button.position = button_position + button.size = Vector2(80.0, 80.0) + button.focus_mode = Control.FOCUS_ALL + host.add_child(button) + return button diff --git a/tests/controller_ui_navigation_validation.gd.uid b/tests/controller_ui_navigation_validation.gd.uid new file mode 100644 index 0000000..73ef752 --- /dev/null +++ b/tests/controller_ui_navigation_validation.gd.uid @@ -0,0 +1 @@ +uid://cbv0ia42rqu41 diff --git a/tests/on_screen_keyboard_validation.gd b/tests/on_screen_keyboard_validation.gd new file mode 100644 index 0000000..e794cda --- /dev/null +++ b/tests/on_screen_keyboard_validation.gd @@ -0,0 +1,82 @@ +extends SceneTree + +const KeyboardType = preload("res://ui/on_screen_keyboard.gd") +const SettingsManagerType = preload( + "res://settings/player_settings_manager.gd" +) + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _validate_default_and_persistence() + await _validate_keyboard_entry() + print("On-screen keyboard validation: PASS") + quit() + + +func _validate_default_and_persistence() -> void: + var defaults := PlayerSettings.new() + assert(not defaults.on_screen_keyboard_enabled) + var manager := SettingsManagerType.new() + root.add_child(manager) + assert(manager.load_settings()) + var edited: PlayerSettings = manager.current_settings.copy() + edited.on_screen_keyboard_enabled = true + assert(manager.apply_settings(edited)) + var reloaded := SettingsManagerType.new() + root.add_child(reloaded) + assert(reloaded.load_settings()) + assert(reloaded.current_settings.on_screen_keyboard_enabled) + manager.queue_free() + reloaded.queue_free() + + +func _validate_keyboard_entry() -> void: + 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) + await process_frame + keyboard.set_enabled(true) + edit.grab_focus() + var activate_event := InputEventJoypadButton.new() + activate_event.button_index = JOY_BUTTON_A + activate_event.pressed = true + keyboard.call("_input", activate_event) + assert(keyboard.is_open()) + 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") + keyboard.call("_move_caret", -1) + keyboard.call("_type_character", "C") + assert(edit.text == "a CB") + 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) + assert(not keyboard.is_open()) + assert(root.gui_get_focus_owner() == null) + edit.grab_focus() + keyboard.call("_input", activate_event) + assert(keyboard.is_open()) + var submitted: Array[String] = [] + edit.text_submitted.connect(func(value: String) -> void: + submitted.append(value) + ) + keyboard.call("_submit") + assert(not keyboard.is_open()) + assert(submitted == ["a B"]) + host.queue_free() diff --git a/tests/on_screen_keyboard_validation.gd.uid b/tests/on_screen_keyboard_validation.gd.uid new file mode 100644 index 0000000..973bdca --- /dev/null +++ b/tests/on_screen_keyboard_validation.gd.uid @@ -0,0 +1 @@ +uid://bgmt6joqdk37q diff --git a/tests/ui_scaling_runtime_validation.gd b/tests/ui_scaling_runtime_validation.gd index f3f4974..d329755 100644 --- a/tests/ui_scaling_runtime_validation.gd +++ b/tests/ui_scaling_runtime_validation.gd @@ -94,7 +94,10 @@ func _run() -> void: assert(player_menu.size.is_equal_approx( UIReferencePresentationType.REFERENCE_SIZE )) - assert(hotbar.position.is_equal_approx(Vector2.ZERO)) + assert(hotbar.position.is_equal_approx(Vector2( + 0.0, + canonical_stage.position.y, + ))) assert(hotbar.size.is_equal_approx( UIReferencePresentationType.REFERENCE_SIZE )) diff --git a/ui/chat_ui.gd b/ui/chat_ui.gd index b8690cb..2d4e5fb 100644 --- a/ui/chat_ui.gd +++ b/ui/chat_ui.gd @@ -568,6 +568,21 @@ func _refresh_input_ownership() -> void: ) _panel.mouse_filter = filter _history.mouse_filter = filter + _entry.focus_mode = ( + Control.FOCUS_ALL if _opened else Control.FOCUS_NONE + ) + _collapse_button.focus_mode = ( + Control.FOCUS_ALL if _opened else Control.FOCUS_NONE + ) + _height_button.focus_mode = ( + Control.FOCUS_ALL + if _opened and not _mobile_mode + else Control.FOCUS_NONE + ) + if not _opened: + var focus_owner: Control = get_viewport().gui_get_focus_owner() + if focus_owner in [_entry, _collapse_button, _height_button]: + get_viewport().gui_release_focus() func _clock_panel_style() -> StyleBoxFlat: @@ -886,9 +901,14 @@ func _refresh_visibility() -> void: _collapse_button.show() var collapsed := _presentation_state == PresentationState.COLLAPSED _panel.show() + _collapse_button.focus_mode = ( + Control.FOCUS_ALL if _opened else Control.FOCUS_NONE + ) _height_button.visible = not _mobile_mode _height_button.focus_mode = ( - Control.FOCUS_NONE if _mobile_mode else Control.FOCUS_ALL + Control.FOCUS_ALL + if _opened and not _mobile_mode + else Control.FOCUS_NONE ) _unread_indicator.visible = collapsed and _collapsed_has_unread _hint.visible = not _opened diff --git a/ui/components/bubble_hotbar/bubble_hotbar_slot.gd b/ui/components/bubble_hotbar/bubble_hotbar_slot.gd index 11a7db3..4bebdfe 100644 --- a/ui/components/bubble_hotbar/bubble_hotbar_slot.gd +++ b/ui/components/bubble_hotbar/bubble_hotbar_slot.gd @@ -56,6 +56,8 @@ var _base_position: Vector2 = Vector2.ZERO var _presented_size: Vector2 = Vector2.ZERO var _compact: bool = false var _presentation_initialized: bool = false +var _controller_preview_active: bool = false +var _controller_preview_texture: Texture2D func _ready() -> void: @@ -146,6 +148,15 @@ func set_drag_enabled(enabled: bool) -> void: _hovered = false +func set_controller_placement_preview( + active: bool, + texture: Texture2D, +) -> void: + _controller_preview_active = active + _controller_preview_texture = texture + refresh() + + func refresh() -> void: if _hotbar == null: return @@ -161,11 +172,16 @@ func refresh() -> void: if _fish_inventory != null and not catch_id.is_empty() else null ) - _item_icon.texture = ( + var assigned_texture: Texture2D = ( fish_catch.fish.display_texture if fish_catch != null else item.icon if item != null else null ) + _item_icon.texture = ( + _controller_preview_texture + if _controller_preview_active + else assigned_texture + ) var quantity: int = ( _bag.get_quantity(item_id) if _bag != null and not item_id.is_empty() @@ -177,7 +193,9 @@ func refresh() -> void: else "" ) _quantity_label.text = quantity_text - _quantity_label.visible = not quantity_text.is_empty() + _quantity_label.visible = ( + not _controller_preview_active and not quantity_text.is_empty() + ) tooltip_text = ( "%s · %.1f lb" % [ FishQualityType.qualified_name( @@ -192,7 +210,11 @@ func refresh() -> void: var was_selected: bool = _selected var was_empty: bool = _empty _selected = slot_index == _hotbar.get_selected_slot() - _empty = item == null and fish_catch == null + _empty = ( + not _controller_preview_active + and item == null + and fish_catch == null + ) if was_selected != _selected or was_empty != _empty: _apply_style() @@ -220,7 +242,9 @@ func _apply_style() -> void: var normal_fill: Color = profile.normal_fill normal_fill.a = 1.0 var selected_fill: Color = normal_fill - if _selected: + if _controller_preview_active: + selected_fill = normal_fill.lightened(0.22) + elif _selected: selected_fill = normal_fill.lightened(0.12) add_theme_stylebox_override( "normal", diff --git a/ui/components/bubble_menu/bubble_button.gd b/ui/components/bubble_menu/bubble_button.gd index 32cdef3..f01d50b 100644 --- a/ui/components/bubble_menu/bubble_button.gd +++ b/ui/components/bubble_menu/bubble_button.gd @@ -42,6 +42,53 @@ func _ready() -> void: _apply_icon_presentation(neutral_size) +func _gui_input(event: InputEvent) -> void: + if _adjustment_direction(self) == 0: + return + var direction: int = 0 + if event.is_action_pressed(&"ui_left"): + direction = -1 + elif event.is_action_pressed(&"ui_right"): + direction = 1 + if direction == 0: + return + var adjustment_button: BaseButton = _find_adjustment_button(direction) + if adjustment_button == null: + return + adjustment_button.pressed.emit() + accept_event() + + +func _find_adjustment_button(direction: int) -> BaseButton: + var parent_node: Node = get_parent() + if parent_node == null: + return null + for child: Node in parent_node.get_children(): + var button := child as BaseButton + if ( + button != null + and button.visible + and not button.disabled + and _adjustment_direction(button) == direction + ): + return button + return null + + +func _adjustment_direction(button: BaseButton) -> int: + var descriptions: Array[String] = [ + button.text.strip_edges().to_lower(), + button.tooltip_text.strip_edges().to_lower(), + button.accessibility_name.strip_edges().to_lower(), + ] + for description: String in descriptions: + if description in ["-", "−", "minus", "decrease"]: + return -1 + if description in ["+", "plus", "increase"]: + return 1 + return 0 + + func apply_layout( center: Vector2, bubble_size: Vector2, diff --git a/ui/components/bubble_menu/bubble_cluster.gd b/ui/components/bubble_menu/bubble_cluster.gd index 9a8d4d0..0ad2624 100644 --- a/ui/components/bubble_menu/bubble_cluster.gd +++ b/ui/components/bubble_menu/bubble_cluster.gd @@ -1,6 +1,10 @@ class_name BubbleCluster extends Control +const ControllerFocusNavigationType = preload( + "res://ui/controller_focus_navigation.gd" +) + @export var profile: BubbleMenuProfile @export var desktop_reference_size: Vector2 = Vector2(396.0, 318.0) @export var compact_reference_size: Vector2 = Vector2(294.0, 200.0) @@ -18,14 +22,6 @@ func configure(bubbles: Array[BubbleButton]) -> void: if bubble.profile == null: bubble.profile = profile bubble.apply_profile() - if index > 0: - bubble.focus_neighbor_top = bubble.get_path_to( - _bubbles[index - 1] - ) - if index + 1 < _bubbles.size(): - bubble.focus_neighbor_bottom = bubble.get_path_to( - _bubbles[index + 1] - ) func apply_layout(field_size: Vector2, compact: bool) -> void: @@ -52,6 +48,7 @@ func apply_layout(field_size: Vector2, compact: bool) -> void: bubble_size, profile.font_size_ratio ) + ControllerFocusNavigationType.configure_spatial_neighbors(_bubbles) func advance_motion(delta: float) -> void: diff --git a/ui/components/bubble_menu/cooler_fish_sprite.gd b/ui/components/bubble_menu/cooler_fish_sprite.gd index d491969..be47c17 100644 --- a/ui/components/bubble_menu/cooler_fish_sprite.gd +++ b/ui/components/bubble_menu/cooler_fish_sprite.gd @@ -19,6 +19,7 @@ var _quality_color := Color.WHITE func _ready() -> void: + set_meta(&"controller_focus_inversion_disabled", true) resized.connect(_update_visual_pivot) focus_entered.connect(_refresh_style) focus_exited.connect(_refresh_style) diff --git a/ui/controller_focus_navigation.gd b/ui/controller_focus_navigation.gd new file mode 100644 index 0000000..cd93796 --- /dev/null +++ b/ui/controller_focus_navigation.gd @@ -0,0 +1,57 @@ +class_name ControllerFocusNavigation +extends RefCounted + +const PERPENDICULAR_WEIGHT: float = 2.5 +const MINIMUM_FORWARD_DISTANCE: float = 0.5 + + +static func configure_spatial_neighbors(controls: Array) -> void: + var candidates: Array[Control] = [] + for item: Variant in controls: + var control := item as Control + if control == null or not control.is_visible_in_tree(): + continue + candidates.append(control) + for control: Control in candidates: + control.focus_neighbor_left = _neighbor_path( + control, candidates, Vector2.LEFT + ) + control.focus_neighbor_right = _neighbor_path( + control, candidates, Vector2.RIGHT + ) + control.focus_neighbor_top = _neighbor_path( + control, candidates, Vector2.UP + ) + control.focus_neighbor_bottom = _neighbor_path( + control, candidates, Vector2.DOWN + ) + + +static func _neighbor_path( + origin: Control, + candidates: Array[Control], + direction: Vector2, +) -> NodePath: + var origin_center: Vector2 = origin.get_global_rect().get_center() + var best: Control = null + var best_score: float = INF + for candidate: Control in candidates: + if candidate == origin or candidate.focus_mode == Control.FOCUS_NONE: + continue + var delta: Vector2 = ( + candidate.get_global_rect().get_center() - origin_center + ) + var forward_distance: float = delta.dot(direction) + if forward_distance <= MINIMUM_FORWARD_DISTANCE: + continue + var perpendicular_distance: float = absf(delta.cross(direction)) + var score: float = ( + forward_distance + + perpendicular_distance * PERPENDICULAR_WEIGHT + ) + if score < best_score: + best = candidate + best_score = score + if best == null: + return NodePath() + return origin.get_path_to(best) diff --git a/ui/controller_focus_navigation.gd.uid b/ui/controller_focus_navigation.gd.uid new file mode 100644 index 0000000..d13ed12 --- /dev/null +++ b/ui/controller_focus_navigation.gd.uid @@ -0,0 +1 @@ +uid://dfb6bobetmujn diff --git a/ui/controller_focus_presentation.gd b/ui/controller_focus_presentation.gd new file mode 100644 index 0000000..88954ac --- /dev/null +++ b/ui/controller_focus_presentation.gd @@ -0,0 +1,97 @@ +class_name ControllerFocusPresentation +extends Node + +const CONTROLLER_MOTION_THRESHOLD: float = 0.35 +const INVERSION_DISABLED_META: StringName = &"controller_focus_inversion_disabled" + +var _controller_active: bool = false +var _focused_item: CanvasItem +var _original_material: Material +var _inversion_material: ShaderMaterial + + +func _ready() -> void: + var inversion_shader := Shader.new() + inversion_shader.code = """ +shader_type canvas_item; +render_mode unshaded; + +void fragment() { + vec4 source = texture(TEXTURE, UV) * COLOR; + COLOR = vec4(vec3(1.0) - source.rgb, source.a); +} +""" + _inversion_material = ShaderMaterial.new() + _inversion_material.shader = inversion_shader + get_viewport().gui_focus_changed.connect(_on_focus_changed) + set_process_input(true) + set_process(true) + + +func _exit_tree() -> void: + _restore_focused_item() + + +func _input(event: InputEvent) -> void: + if event is InputEventJoypadButton: + if (event as InputEventJoypadButton).pressed: + _set_controller_active(true) + elif event is InputEventJoypadMotion: + if absf((event as InputEventJoypadMotion).axis_value) >= ( + CONTROLLER_MOTION_THRESHOLD + ): + _set_controller_active(true) + elif event is InputEventMouseButton: + if (event as InputEventMouseButton).pressed: + _set_controller_active(false) + elif event is InputEventKey: + if (event as InputEventKey).pressed: + _set_controller_active(false) + + +func _process(_delta: float) -> void: + if not is_instance_valid(_focused_item): + _focused_item = null + _original_material = null + return + var focus_owner: Control = get_viewport().gui_get_focus_owner() + if ( + focus_owner != _focused_item + or not focus_owner.is_visible_in_tree() + or focus_owner.focus_mode == Control.FOCUS_NONE + or bool(focus_owner.get_meta(INVERSION_DISABLED_META, false)) + ): + _apply_to_focus(focus_owner) + + +func _set_controller_active(active: bool) -> void: + if _controller_active == active: + return + _controller_active = active + _apply_to_focus(get_viewport().gui_get_focus_owner()) + + +func _on_focus_changed(control: Control) -> void: + _apply_to_focus(control) + + +func _apply_to_focus(control: Control) -> void: + _restore_focused_item() + if ( + not _controller_active + or control == null + or not control.is_visible_in_tree() + or control.focus_mode == Control.FOCUS_NONE + or bool(control.get_meta(INVERSION_DISABLED_META, false)) + ): + return + _focused_item = control + _original_material = _focused_item.material + _focused_item.material = _inversion_material + + +func _restore_focused_item() -> void: + if is_instance_valid(_focused_item): + _focused_item.material = _original_material + _focused_item = null + _original_material = null diff --git a/ui/controller_focus_presentation.gd.uid b/ui/controller_focus_presentation.gd.uid new file mode 100644 index 0000000..29b0a72 --- /dev/null +++ b/ui/controller_focus_presentation.gd.uid @@ -0,0 +1 @@ +uid://b2ccvcx1rfd1x diff --git a/ui/controller_focus_recovery.gd b/ui/controller_focus_recovery.gd new file mode 100644 index 0000000..55bc6ad --- /dev/null +++ b/ui/controller_focus_recovery.gd @@ -0,0 +1,142 @@ +class_name ControllerFocusRecovery +extends Node + +const CONTROLLER_AXIS_THRESHOLD: float = 0.35 +const SEMANTIC_MATCH_BONUS: float = 1000000.0 + +var _controller_active: bool = false +var _last_focus_center: Vector2 = Vector2.ZERO +var _last_focus_key: String = "" +var _scope_chain: Array[WeakRef] = [] +var _recovery_generation: int = 0 + + +func _ready() -> void: + set_process_input(true) + set_process(true) + get_viewport().gui_focus_changed.connect(_on_gui_focus_changed) + + +func _exit_tree() -> void: + var viewport := get_viewport() + if viewport.gui_focus_changed.is_connected(_on_gui_focus_changed): + viewport.gui_focus_changed.disconnect(_on_gui_focus_changed) + + +func _input(event: InputEvent) -> void: + if event is InputEventJoypadButton: + var button_event := event as InputEventJoypadButton + if button_event.pressed: + _controller_active = true + if button_event.button_index == JOY_BUTTON_LEFT_SHOULDER: + _recovery_generation += 1 + _scope_chain.clear() + return + if event is InputEventJoypadMotion: + if absf((event as InputEventJoypadMotion).axis_value) >= ( + CONTROLLER_AXIS_THRESHOLD + ): + _controller_active = true + return + if event is InputEventMouseButton: + if (event as InputEventMouseButton).pressed: + _controller_active = false + return + if event is InputEventKey and (event as InputEventKey).pressed: + _controller_active = false + + +func _process(_delta: float) -> void: + if ( + _controller_active + and not _scope_chain.is_empty() + and get_viewport().gui_get_focus_owner() == null + ): + _recover_focus(_recovery_generation) + + +func _on_gui_focus_changed(control: Control) -> void: + _recovery_generation += 1 + if control != null: + if _controller_active and control is BaseButton: + _remember_focus(control) + return + if not _controller_active or _scope_chain.is_empty(): + return + var generation: int = _recovery_generation + _recover_focus.call_deferred(generation) + + +func _remember_focus(control: Control) -> void: + _last_focus_center = control.get_global_rect().get_center() + _last_focus_key = _semantic_key(control) + _scope_chain.clear() + var recovery_root: Node = get_parent() + var ancestor: Node = control.get_parent() + while ancestor != null: + if ancestor is Control: + _scope_chain.append(weakref(ancestor)) + if ancestor == recovery_root: + break + ancestor = ancestor.get_parent() + + +func _recover_focus(generation: int) -> void: + if generation != _recovery_generation: + return + if not _controller_active or get_viewport().gui_get_focus_owner() != null: + return + for scope_reference: WeakRef in _scope_chain: + var scope := scope_reference.get_ref() as Control + if ( + scope == null + or not is_instance_valid(scope) + or not scope.is_inside_tree() + or not scope.is_visible_in_tree() + ): + continue + var replacement := _best_replacement_in(scope) + if replacement != null: + replacement.grab_focus() + return + + +func _best_replacement_in(scope: Control) -> Control: + var best: Control = null + var best_score: float = INF + for node: Node in scope.find_children("*", "Control", true, false): + var candidate := node as Control + if not _is_focusable(candidate): + continue + var distance: float = candidate.get_global_rect().get_center().distance_squared_to( + _last_focus_center + ) + if not _last_focus_key.is_empty() and _semantic_key(candidate) == _last_focus_key: + distance -= SEMANTIC_MATCH_BONUS + if distance < best_score: + best = candidate + best_score = distance + return best + + +func _is_focusable(control: Control) -> bool: + if ( + control == null + or not control.is_inside_tree() + or not control.is_visible_in_tree() + or control.focus_mode == Control.FOCUS_NONE + ): + return false + var button := control as BaseButton + return button == null or not button.disabled + + +func _semantic_key(control: Control) -> String: + if control.has_meta(&"controller_focus_key"): + return str(control.get_meta(&"controller_focus_key")) + var button := control as Button + var label: String = button.text if button != null else "" + var tooltip: String = control.tooltip_text + tooltip = tooltip.trim_suffix(" (show variants)") + tooltip = tooltip.trim_suffix(" (hide variants)") + return "%s|%s|%s" % [control.get_class(), label, tooltip] diff --git a/ui/controller_focus_recovery.gd.uid b/ui/controller_focus_recovery.gd.uid new file mode 100644 index 0000000..b7f8ce9 --- /dev/null +++ b/ui/controller_focus_recovery.gd.uid @@ -0,0 +1 @@ +uid://c7cw61ethr3pt diff --git a/ui/game_ui.gd b/ui/game_ui.gd index a684e8a..e96137c 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -133,6 +133,8 @@ const SHOP_SPEECH_CHARACTERS_PER_SECOND: float = 28.0 @onready var _shop_prompt_key: Label = %ShopPromptKey @onready var _shop_prompt_pointer: Polygon2D = %ShopPromptPointer var _shop_animalese_voice: AnimaleseVoiceType +var _shop_npc_player_in_range: bool = false +var _shop_npc_spoken_for_current_visit: bool = false @onready var _effect_status: Label = %EffectStatus @onready var _chat_ui: ChatUIType = %ChatUI @onready var _emote_radial_menu: EmoteRadialMenuType = %EmoteRadialMenu @@ -207,6 +209,12 @@ func _ready() -> void: _hotbar_ui.presentation_transition_finished.connect( _on_hotbar_presentation_transition_finished ) + _player_menu.controller_hotbar_placement_requested.connect( + _hotbar_ui.begin_controller_placement + ) + _player_menu.controller_hotbar_placement_ended.connect( + _hotbar_ui.end_controller_placement + ) _title_settings_panel.panel_visibility_changed.connect( _on_settings_visibility_changed ) @@ -503,7 +511,7 @@ func _handle_controller_chat_controls(event: InputEvent) -> bool: _chat_ui.toggle_chat() return true if focus_pressed: - _chat_ui.toggle_focus() + _chat_ui.refocus_gameplay() return true if accept_pressed: return _chat_ui.request_virtual_keyboard() @@ -1302,6 +1310,8 @@ func _on_hud_bait_inventory_changed() -> void: func set_gameplay_ui_enabled(enabled: bool) -> void: _gameplay_ui_enabled = enabled + if enabled: + _try_start_shop_npc_speech() _gameplay_transient_hud.visible = enabled and not _player_menu_open _experience_presentation.visible = enabled _refresh_chat_availability() @@ -1362,7 +1372,6 @@ func set_shop_prompt_visible( world_anchor: Vector3 = Vector3(0.0, INF, 0.0), ) -> void: _apply_shop_prompt_style() - var was_visible := _shop_prompt.visible _shop_prompt.visible = ( is_visible and _gameplay_ui_enabled @@ -1371,23 +1380,42 @@ func set_shop_prompt_visible( and not _shop_open ) if _shop_prompt.visible: - if not was_visible: - _ensure_shop_animalese_voice() - TypewriterRevealType.start( - _shop_prompt_message, - SHOP_SPEECH_CHARACTERS_PER_SECOND, - ) - _shop_animalese_voice.speak_text( - _shop_prompt_message, - _shop_prompt_message.text, - "shopkeeper", - SHOP_ANIMALESE_VOICE_ID, - SHOP_SPEECH_CHARACTERS_PER_SECOND, - ) if world_anchor.is_finite(): _position_shop_prompt(world_anchor) +func set_shop_npc_player_in_range(in_range: bool) -> void: + if _shop_npc_player_in_range == in_range: + return + _shop_npc_player_in_range = in_range + if not in_range: + _shop_npc_spoken_for_current_visit = false + return + _try_start_shop_npc_speech() + + +func _try_start_shop_npc_speech() -> void: + if ( + not _shop_npc_player_in_range + or _shop_npc_spoken_for_current_visit + or not _gameplay_ui_enabled + ): + return + _shop_npc_spoken_for_current_visit = true + _ensure_shop_animalese_voice() + TypewriterRevealType.start( + _shop_prompt_message, + SHOP_SPEECH_CHARACTERS_PER_SECOND, + ) + _shop_animalese_voice.speak_text( + _shop_prompt_message, + _shop_prompt_message.text, + "shopkeeper", + SHOP_ANIMALESE_VOICE_ID, + SHOP_SPEECH_CHARACTERS_PER_SECOND, + ) + + func _ensure_shop_animalese_voice() -> void: if _shop_animalese_voice != null: return diff --git a/ui/hotbar.gd b/ui/hotbar.gd index fa10df0..ffd0487 100644 --- a/ui/hotbar.gd +++ b/ui/hotbar.gd @@ -41,6 +41,12 @@ var _item_name_suppressed: bool = false var _motion_elapsed: float = 0.0 var _compact_layout: bool = false var _player_menu_context: bool = false +var _controller_placement_active: bool = false +var _controller_placement_kind: PlayerHotbarType.AssignmentKind = ( + PlayerHotbarType.AssignmentKind.EMPTY +) +var _controller_placement_identity: StringName +var _controller_placement_texture: Texture2D var _visibility_tween: Tween var _visibility_generation: int = 0 @@ -99,6 +105,79 @@ func set_drag_enabled(enabled: bool) -> void: _hide_item_name() +func begin_controller_placement( + assignment_kind: PlayerHotbarType.AssignmentKind, + identity: StringName, + initial_slot: int, +) -> void: + if _hotbar == null or identity.is_empty(): + return + _controller_placement_active = true + _controller_placement_kind = assignment_kind + _controller_placement_identity = identity + _controller_placement_texture = _resolve_controller_placement_texture() + var slot_count: int = _slots.size() + for index: int in slot_count: + var slot: BubbleHotbarSlotType = _slots[index] + slot.focus_mode = Control.FOCUS_ALL + slot.focus_neighbor_left = slot.get_path_to( + _slots[wrapi(index - 1, 0, slot_count)] + ) + slot.focus_neighbor_right = slot.get_path_to( + _slots[wrapi(index + 1, 0, slot_count)] + ) + slot.focus_neighbor_top = slot.get_path_to(slot) + slot.focus_neighbor_bottom = slot.get_path_to(slot) + var target_index: int = clampi(initial_slot, 0, slot_count - 1) + _hotbar.select_slot(target_index) + _refresh_controller_placement_preview() + _slots[target_index].call_deferred("grab_focus") + + +func end_controller_placement() -> void: + if not _controller_placement_active: + return + _controller_placement_active = false + _controller_placement_kind = PlayerHotbarType.AssignmentKind.EMPTY + _controller_placement_identity = StringName() + _controller_placement_texture = null + for slot: BubbleHotbarSlotType in _slots: + slot.focus_mode = Control.FOCUS_NONE + slot.set_controller_placement_preview(false, null) + _show_selected_item_briefly() + + +func _resolve_controller_placement_texture() -> Texture2D: + if ( + _controller_placement_kind == PlayerHotbarType.AssignmentKind.FISH + and _fish_inventory != null + ): + var fish_catch: FishCatchType = _fish_inventory.get_catch_by_id( + _controller_placement_identity + ) + return fish_catch.fish.display_texture if fish_catch != null else null + if ( + _controller_placement_kind == PlayerHotbarType.AssignmentKind.ITEM + and _catalog != null + ): + var item: ItemDataType = _catalog.get_item_by_id( + _controller_placement_identity + ) + return item.icon if item != null else null + return null + + +func _refresh_controller_placement_preview() -> void: + if not _controller_placement_active or _hotbar == null: + return + var target_index: int = _hotbar.get_selected_slot() + for slot: BubbleHotbarSlotType in _slots: + slot.set_controller_placement_preview( + slot.slot_index == target_index, + _controller_placement_texture, + ) + + func set_player_menu_context(enabled: bool) -> void: if _player_menu_context == enabled: return @@ -230,6 +309,9 @@ func _collect_slots() -> void: slot.item_hover_ended.connect(_on_slot_item_hover_ended) slot.item_drag_started.connect(_on_slot_drag_started) slot.item_drag_finished.connect(_on_slot_drag_finished) + slot.focus_entered.connect( + _on_controller_slot_focused.bind(slot.slot_index) + ) _slots.append(slot) _slots.sort_custom( func( @@ -278,10 +360,20 @@ func _on_selected_slot_changed( _item_id: StringName, ) -> void: _refresh() + if _controller_placement_active: + _refresh_controller_placement_preview() + return if _hovered_slot_index < 0: _show_selected_item_briefly() +func _on_controller_slot_focused(slot_index: int) -> void: + if not _controller_placement_active or _hotbar == null: + return + _hotbar.select_slot(slot_index) + _refresh_controller_placement_preview() + + func _on_slot_item_hovered( slot_index: int, item_id: StringName, diff --git a/ui/logbook_page.gd b/ui/logbook_page.gd index 504ec1c..a7d4767 100644 --- a/ui/logbook_page.gd +++ b/ui/logbook_page.gd @@ -453,6 +453,7 @@ func _refresh_catalog() -> void: func _make_entry(fish: FishDataType, discovered: bool) -> Button: var entry := Button.new() + entry.set_meta(&"controller_focus_inversion_disabled", true) entry.custom_minimum_size = CATALOG_ENTRY_SIZE entry.size_flags_horizontal = Control.SIZE_SHRINK_CENTER entry.size_flags_vertical = Control.SIZE_SHRINK_CENTER diff --git a/ui/on_screen_keyboard.gd b/ui/on_screen_keyboard.gd new file mode 100644 index 0000000..0d7315d --- /dev/null +++ b/ui/on_screen_keyboard.gd @@ -0,0 +1,456 @@ +class_name OnScreenKeyboard +extends Control + +const ControllerFocusNavigationType = preload( + "res://ui/controller_focus_navigation.gd" +) +const CHECK_ICON: Texture2D = preload( + "res://ui/icons/pictograms/check_mark_dark.png" +) +const CHARACTER_KEY_SIZE: Vector2 = Vector2(96.0, 72.0) +const CHARACTER_FONT_SIZE: int = 34 +const TRIGGER_PRESS_THRESHOLD: float = 0.55 +const TRIGGER_RELEASE_THRESHOLD: float = 0.25 + +signal text_submitted(value: String) + +enum Page { + LOWER, + UPPER, + SYMBOLS, +} + +var _enabled: bool = false +var _page: Page = Page.LOWER +var _target: Control +var _target_virtual_keyboard_enabled: bool = true +var _buffer: String = "" +var _preview: Label +var _page_buttons: Array[Button] = [] +var _keys_host: VBoxContainer +var _key_buttons: Array[Button] = [] +var _caret_left_button: Button +var _caret_right_button: Button +var _backspace_button: Button +var _space_button: Button +var _check_button: Button +var _left_trigger_pressed: bool = false +var _right_trigger_pressed: bool = false + + +func _ready() -> void: + process_mode = Node.PROCESS_MODE_ALWAYS + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + z_index = 1000 + mouse_filter = Control.MOUSE_FILTER_STOP + _build_interface() + hide() + set_process_input(true) + + +func set_enabled(enabled: bool) -> void: + _enabled = enabled + if not _enabled and visible: + _close_keyboard(false) + + +func is_enabled() -> bool: + return _enabled + + +func is_open() -> bool: + return visible + + +func _input(event: InputEvent) -> void: + if not _enabled: + return + if visible: + var joy_motion := event as InputEventJoypadMotion + if joy_motion != null and _handle_trigger_shortcut(joy_motion): + 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 event.is_action_pressed(&"ui_cancel"): + _close_keyboard(true) + get_viewport().set_input_as_handled() + return + var joy_event := event as InputEventJoypadButton + if ( + joy_event == null + or not joy_event.pressed + or ( + joy_event.button_index != JOY_BUTTON_A + and not event.is_action_pressed(&"ui_accept") + ) + ): + return + var focus_owner: Control = get_viewport().gui_get_focus_owner() + if _can_edit(focus_owner): + _open_for(focus_owner) + get_viewport().set_input_as_handled() + + +func _can_edit(control: Control) -> bool: + if control is LineEdit: + return (control as LineEdit).editable + if control is TextEdit: + return (control as TextEdit).editable + return false + + +func _open_for(control: Control) -> void: + _target = control + _target_virtual_keyboard_enabled = bool( + _target.get("virtual_keyboard_enabled") + ) + _target.set("virtual_keyboard_enabled", false) + _buffer = str(_target.get("text")) + _page = Page.LOWER + show() + _refresh_preview() + _rebuild_keys() + + +func _close_keyboard(restore_focus: bool) -> void: + var prior_target: Control = _target + if is_instance_valid(prior_target): + prior_target.set( + "virtual_keyboard_enabled", + _target_virtual_keyboard_enabled + ) + hide() + get_viewport().gui_release_focus() + _target = null + if restore_focus and is_instance_valid(prior_target): + prior_target.grab_focus() + + +func _submit() -> void: + var submitted_target: Control = _target + var submitted_text: String = _buffer + _close_keyboard(false) + if submitted_target is LineEdit: + (submitted_target as LineEdit).text_submitted.emit(submitted_text) + elif submitted_target is TextEdit: + (submitted_target as TextEdit).text_changed.emit() + text_submitted.emit(submitted_text) + + +func _type_character(character: String) -> void: + if not is_instance_valid(_target): + _close_keyboard(false) + return + var caret: int = _get_caret_column() + var next_text: String = ( + _buffer.substr(0, caret) + + character + + _buffer.substr(caret) + ) + if _target is LineEdit: + var line_edit := _target as LineEdit + if line_edit.max_length > 0 and next_text.length() > line_edit.max_length: + return + _buffer = next_text + _set_target_text(caret + character.length()) + + +func _type_space() -> void: + _type_character(" ") + + +func _backspace() -> void: + if not is_instance_valid(_target) or _buffer.is_empty(): + return + var caret: int = _get_caret_column() + if caret <= 0: + return + _buffer = _buffer.erase(caret - 1, 1) + _set_target_text(caret - 1) + + +func _move_caret(direction: int) -> void: + if not is_instance_valid(_target): + _close_keyboard(false) + return + var caret: int = 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) + _refresh_preview() + + +func _handle_trigger_shortcut(event: InputEventJoypadMotion) -> bool: + if event.axis == JOY_AXIS_TRIGGER_LEFT: + if event.axis_value <= TRIGGER_RELEASE_THRESHOLD: + _left_trigger_pressed = false + elif ( + event.axis_value >= TRIGGER_PRESS_THRESHOLD + and not _left_trigger_pressed + ): + _left_trigger_pressed = true + _move_caret(-1) + return true + elif event.axis == JOY_AXIS_TRIGGER_RIGHT: + if event.axis_value <= TRIGGER_RELEASE_THRESHOLD: + _right_trigger_pressed = false + elif ( + event.axis_value >= TRIGGER_PRESS_THRESHOLD + and not _right_trigger_pressed + ): + _right_trigger_pressed = true + _move_caret(1) + return true + return false + + +func _get_caret_column() -> int: + if _target is LineEdit: + return clampi( + (_target as LineEdit).caret_column, + 0, + _buffer.length() + ) + return _buffer.length() + + +func _set_target_text(caret: int) -> void: + 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() + _refresh_preview() + + +func _refresh_preview() -> void: + if _preview == null: + return + var displayed_text: String = _buffer + 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, "|") + + +func _set_page(page_index: int) -> void: + _page = page_index as Page + _rebuild_keys() + + +func _rebuild_keys() -> void: + for child: Node in _keys_host.get_children(): + _keys_host.remove_child(child) + child.queue_free() + _key_buttons.clear() + var rows: Array = _rows_for_page() + for row_value: Variant in rows: + var row := HBoxContainer.new() + row.alignment = BoxContainer.ALIGNMENT_CENTER + row.size_flags_vertical = Control.SIZE_SHRINK_CENTER + row.add_theme_constant_override("separation", 10) + _keys_host.add_child(row) + for character_value: Variant in row_value as Array: + var character: String = str(character_value) + var key_button: Button = _make_button(character, CHARACTER_KEY_SIZE) + key_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + key_button.size_flags_vertical = Control.SIZE_SHRINK_CENTER + key_button.add_theme_font_size_override( + "font_size", CHARACTER_FONT_SIZE + ) + key_button.pressed.connect(_type_character.bind(character)) + row.add_child(key_button) + _key_buttons.append(key_button) + for index: int in _page_buttons.size(): + _page_buttons[index].button_pressed = index == int(_page) + call_deferred("_configure_key_focus") + + +func _configure_key_focus() -> void: + var focus_controls: Array[Control] = [] + for button: Button in _key_buttons: + focus_controls.append(button) + for button: Button in [ + _caret_left_button, + _caret_right_button, + _backspace_button, + _space_button, + _check_button, + ]: + focus_controls.append(button) + ControllerFocusNavigationType.configure_spatial_neighbors(focus_controls) + if not _key_buttons.is_empty(): + _key_buttons[0].grab_focus() + + +func _rows_for_page() -> Array: + match _page: + Page.UPPER: + return [ + ["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"], + ["A", "S", "D", "F", "G", "H", "J", "K", "L"], + ["Z", "X", "C", "V", "B", "N", "M"], + ] + Page.SYMBOLS: + return [ + ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"], + ["!", "@", "#", "$", "%", "^", "&", "*", "(", ")"], + ["-", "_", "=", "+", "[", "]", "{", "}"], + [".", ",", "?", "/", ":", ";", "'", "\""] + ] + _: + return [ + ["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"], + ["a", "s", "d", "f", "g", "h", "j", "k", "l"], + ["z", "x", "c", "v", "b", "n", "m"], + ] + + +func _build_interface() -> void: + var backdrop := ColorRect.new() + backdrop.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + backdrop.color = Color(0.012, 0.075, 0.105, 0.82) + backdrop.mouse_filter = Control.MOUSE_FILTER_STOP + add_child(backdrop) + var screen_margin := MarginContainer.new() + screen_margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + for side: StringName in [ + &"margin_left", &"margin_top", &"margin_right", &"margin_bottom" + ]: + screen_margin.add_theme_constant_override(side, 14) + add_child(screen_margin) + var panel := PanelContainer.new() + panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL + panel.size_flags_vertical = Control.SIZE_EXPAND_FILL + panel.add_theme_stylebox_override( + "panel", + _make_style(Color(0.075, 0.27, 0.34, 0.98), 18, 4) + ) + screen_margin.add_child(panel) + var margin := MarginContainer.new() + for side: StringName in [&"margin_left", &"margin_top", &"margin_right", &"margin_bottom"]: + margin.add_theme_constant_override(side, 18) + panel.add_child(margin) + var layout := VBoxContainer.new() + layout.add_theme_constant_override("separation", 10) + margin.add_child(layout) + var preview_panel := PanelContainer.new() + preview_panel.custom_minimum_size = Vector2(0.0, 86.0) + preview_panel.add_theme_stylebox_override( + "panel", + _make_style(Color(0.82, 0.94, 0.95, 1.0), 10, 2) + ) + layout.add_child(preview_panel) + _preview = Label.new() + _preview.add_theme_color_override("font_color", Color(0.025, 0.12, 0.17, 1.0)) + _preview.add_theme_font_size_override("font_size", 30) + _preview.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT + _preview.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _preview.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + _preview.add_theme_constant_override("outline_size", 0) + preview_panel.add_child(_preview) + var page_row := HBoxContainer.new() + page_row.alignment = BoxContainer.ALIGNMENT_CENTER + page_row.add_theme_constant_override("separation", 12) + layout.add_child(page_row) + for page_name: String in ["lower", "upper", "symbols"]: + var page_button: Button = _make_button(page_name, Vector2(150.0, 44.0)) + page_button.toggle_mode = true + page_button.focus_mode = Control.FOCUS_NONE + page_button.pressed.connect(_set_page.bind(_page_buttons.size())) + page_row.add_child(page_button) + _page_buttons.append(page_button) + _keys_host = VBoxContainer.new() + _keys_host.size_flags_vertical = Control.SIZE_EXPAND_FILL + _keys_host.alignment = BoxContainer.ALIGNMENT_CENTER + _keys_host.add_theme_constant_override("separation", 12) + layout.add_child(_keys_host) + var utility_row := HBoxContainer.new() + utility_row.alignment = BoxContainer.ALIGNMENT_CENTER + utility_row.add_theme_constant_override("separation", 12) + layout.add_child(utility_row) + _caret_left_button = _make_button("LT <", Vector2(118.0, 64.0)) + _caret_left_button.tooltip_text = "move text cursor left" + _caret_left_button.pressed.connect(_move_caret.bind(-1)) + utility_row.add_child(_caret_left_button) + _caret_right_button = _make_button("> RT", Vector2(118.0, 64.0)) + _caret_right_button.tooltip_text = "move text cursor right" + _caret_right_button.pressed.connect(_move_caret.bind(1)) + utility_row.add_child(_caret_right_button) + _backspace_button = _make_button("backspace X", Vector2(180.0, 64.0)) + _backspace_button.pressed.connect(_backspace) + utility_row.add_child(_backspace_button) + _space_button = _make_button("space", Vector2(350.0, 64.0)) + _space_button.size_flags_stretch_ratio = 4.0 + _space_button.pressed.connect(_type_space) + utility_row.add_child(_space_button) + _check_button = _make_button("", Vector2(92.0, 64.0)) + _check_button.icon = CHECK_ICON + _check_button.expand_icon = true + _check_button.alignment = HORIZONTAL_ALIGNMENT_CENTER + _check_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER + _check_button.vertical_icon_alignment = VERTICAL_ALIGNMENT_CENTER + _check_button.tooltip_text = "submit" + _check_button.accessibility_name = "submit text" + _check_button.add_theme_constant_override("icon_max_width", 42) + _check_button.pressed.connect(_submit) + utility_row.add_child(_check_button) + _rebuild_keys() + + +func _make_button(label: String, minimum_size: Vector2) -> Button: + var button := Button.new() + button.text = label + button.custom_minimum_size = minimum_size + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + button.size_flags_vertical = Control.SIZE_EXPAND_FILL + button.focus_mode = Control.FOCUS_ALL + button.add_theme_font_size_override("font_size", 22) + button.add_theme_color_override("font_color", Color(0.025, 0.12, 0.17, 1.0)) + button.add_theme_color_override("font_hover_color", Color(0.025, 0.12, 0.17, 1.0)) + button.add_theme_color_override("font_focus_color", Color(0.025, 0.12, 0.17, 1.0)) + button.add_theme_stylebox_override( + "normal", + _make_style(Color(0.72, 0.88, 0.91, 1.0), 10, 2) + ) + button.add_theme_stylebox_override( + "hover", + _make_style(Color(0.87, 0.96, 0.96, 1.0), 10, 3) + ) + button.add_theme_stylebox_override("focus", button.get_theme_stylebox("hover")) + button.add_theme_stylebox_override( + "pressed", + _make_style(Color(0.44, 0.72, 0.77, 1.0), 10, 3) + ) + return button + + +func _make_style(color: Color, radius: int, border_width: int) -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = color + style.border_color = Color(0.025, 0.12, 0.17, 1.0) + style.set_border_width_all(border_width) + style.set_corner_radius_all(radius) + return style diff --git a/ui/on_screen_keyboard.gd.uid b/ui/on_screen_keyboard.gd.uid new file mode 100644 index 0000000..da300a2 --- /dev/null +++ b/ui/on_screen_keyboard.gd.uid @@ -0,0 +1 @@ +uid://dcanibf8hordh diff --git a/ui/player_menu.gd b/ui/player_menu.gd index 88ead8d..a2ac7ef 100644 --- a/ui/player_menu.gd +++ b/ui/player_menu.gd @@ -85,6 +85,12 @@ const MAIN_SHOP_BUYER_ID: StringName = &"main_fishing_shop" signal menu_visibility_changed(is_open: bool) signal inventory_hotbar_context_changed(show_hotbar: bool) +signal controller_hotbar_placement_requested( + assignment_kind: PlayerHotbarType.AssignmentKind, + identity: StringName, + initial_slot: int, +) +signal controller_hotbar_placement_ended signal menu_exit_started signal shop_cooler_modal_changed(is_open: bool) @@ -124,6 +130,12 @@ enum CloseReason { TEARDOWN, } +enum ControllerOwnership { + ITEM_LIST, + NOTEPAD_ACTIONS, + HOTBAR_PLACEMENT, +} + const INVENTORY_MAIN_POSITION := Vector2(54.0, 166.0) const INVENTORY_MAIN_SIZE := Vector2(882.0, 484.0) const INVENTORY_PANEL_GAP := 16.0 @@ -302,6 +314,15 @@ var _last_inventory_section: Section = Section.COOLER var _bag_view: BagView = BagView.EQUIPMENT var _tackle_view: TackleView = TackleView.BAIT var _selected_tackle_item_id: StringName +var _controller_ownership: ControllerOwnership = ControllerOwnership.ITEM_LIST +var _controller_source_section: Section = Section.COOLER +var _controller_source_identity: StringName +var _controller_notepad_actions: Array[BaseButton] = [] +var _controller_hotbar_assignment_kind: PlayerHotbarType.AssignmentKind = ( + PlayerHotbarType.AssignmentKind.EMPTY +) +var _controller_hotbar_identity: StringName +var _controller_previous_hotbar_slot: int = 0 var _sort_mode: SortMode = SortMode.CATCH_ORDER var _sort_descending: bool = true var _fish_selection := FishBatchSelectionType.new() @@ -589,12 +610,25 @@ func set_profile_preview_world_pixel_size(pixel_size: int) -> void: _profile_page.set_world_pixel_size(pixel_size) +var _left_page_trigger_held: bool = false +var _right_page_trigger_held: bool = false + + func _input(event: InputEvent) -> void: if event is InputEventKey and event.echo: return + if _handle_controller_ownership_input(event): + get_viewport().set_input_as_handled() + return + if visible: + _reserve_main_navigation_for_page_switching() + _reserve_visible_secondary_navigation() if _handle_controller_page_switch(event): get_viewport().set_input_as_handled() return + if _handle_controller_secondary_switch(event): + get_viewport().set_input_as_handled() + return if _handle_direct_page_shortcut(event): get_viewport().set_input_as_handled() return @@ -613,47 +647,360 @@ func _input(event: InputEvent) -> void: get_viewport().set_input_as_handled() +func _handle_controller_ownership_input(event: InputEvent) -> bool: + if not visible: + return false + var button_event := event as InputEventJoypadButton + var accept_pressed: bool = ( + button_event != null + and button_event.pressed + and _event_matches_controller_role( + event, + ControllerMappingManagerType.ROLE_A, + JOY_BUTTON_A, + ) + ) + var cancel_pressed: bool = ( + button_event != null + and button_event.pressed + and _event_matches_controller_role( + event, + ControllerMappingManagerType.ROLE_B, + JOY_BUTTON_B, + ) + ) + var alternate_pressed: bool = ( + button_event != null + and button_event.pressed + and _event_matches_controller_role( + event, + ControllerMappingManagerType.ROLE_Y, + JOY_BUTTON_Y, + ) + ) + if _controller_ownership == ControllerOwnership.HOTBAR_PLACEMENT: + if accept_pressed: + _confirm_controller_hotbar_placement() + return true + if cancel_pressed: + _release_controller_ownership(true, true) + return true + if alternate_pressed: + return true + return false + if _controller_ownership == ControllerOwnership.NOTEPAD_ACTIONS: + if cancel_pressed: + _release_controller_ownership(true, false) + return true + if alternate_pressed: + return true + var direction: int = 0 + if event.is_action_pressed("ui_left") or event.is_action_pressed("ui_up"): + direction = -1 + elif ( + event.is_action_pressed("ui_right") + or event.is_action_pressed("ui_down") + ): + direction = 1 + if direction != 0: + _focus_next_notepad_action(direction) + return true + return false + if alternate_pressed: + return _try_begin_controller_hotbar_placement() + if accept_pressed: + return _try_enter_notepad_controller_ownership() + return false + + +func _event_matches_controller_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 := event as InputEventJoypadButton + return button_event != null and button_event.button_index == fallback_button + + +func _try_enter_notepad_controller_ownership() -> bool: + var focus_owner: Control = get_viewport().gui_get_focus_owner() + if focus_owner == null: + return false + var source_identity: StringName + var actions: Array[BaseButton] = [] + if _current_section == Section.COOLER: + var fish_node := focus_owner as CoolerFishSpriteType + if fish_node == null or fish_node.catch_id.is_empty(): + return false + source_identity = fish_node.catch_id + _on_catch_card_pressed(source_identity) + if not _favorite_bubble.disabled: + actions.append(_favorite_bubble) + if not _sell_bubble.disabled: + actions.append(_sell_bubble) + elif _current_section == Section.TACKLE_BOX: + if not focus_owner.has_meta(&"controller_tackle_item_id"): + return false + source_identity = StringName( + str(focus_owner.get_meta(&"controller_tackle_item_id")) + ) + _select_tackle_item(source_identity) + if _tackle_equip_button.visible and not _tackle_equip_button.disabled: + actions.append(_tackle_equip_button) + else: + return false + if actions.is_empty(): + _restore_controller_item_focus(_current_section, source_identity) + return true + _controller_source_section = _current_section + _controller_source_identity = source_identity + _controller_notepad_actions = actions + _controller_ownership = ControllerOwnership.NOTEPAD_ACTIONS + actions.front().call_deferred("grab_focus") + return true + + +func _focus_next_notepad_action(direction: int) -> void: + var available: Array[BaseButton] = [] + for action: BaseButton in _controller_notepad_actions: + if ( + is_instance_valid(action) + and action.visible + and not action.disabled + and action.focus_mode != Control.FOCUS_NONE + ): + available.append(action) + if available.is_empty(): + return + var focused: Control = get_viewport().gui_get_focus_owner() + var current_index: int = available.find(focused) + if current_index < 0: + current_index = 0 if direction > 0 else available.size() - 1 + else: + current_index = wrapi( + current_index + direction, + 0, + available.size(), + ) + available[current_index].grab_focus() + + +func _try_begin_controller_hotbar_placement() -> bool: + if _hotbar == null: + return false + var focus_owner: Control = get_viewport().gui_get_focus_owner() + if focus_owner == null: + return false + var assignment_kind: PlayerHotbarType.AssignmentKind = ( + PlayerHotbarType.AssignmentKind.EMPTY + ) + var identity: StringName + if _current_section == Section.COOLER: + var fish_node := focus_owner as CoolerFishSpriteType + if fish_node == null or fish_node.catch_id.is_empty(): + return false + assignment_kind = PlayerHotbarType.AssignmentKind.FISH + identity = fish_node.catch_id + elif _current_section == Section.BAG: + var item_node := focus_owner as BagItemSpriteType + if item_node == null or item_node.item_id.is_empty(): + return false + var item: ItemDataType = ( + _item_catalog.get_item_by_id(item_node.item_id) + if _item_catalog != null + else null + ) + if item == null or not item.hotbar_allowed: + return true + assignment_kind = PlayerHotbarType.AssignmentKind.ITEM + identity = item_node.item_id + else: + return false + _controller_source_section = _current_section + _controller_source_identity = identity + _controller_hotbar_assignment_kind = assignment_kind + _controller_hotbar_identity = identity + _controller_previous_hotbar_slot = _hotbar.get_selected_slot() + var initial_slot: int = _find_controller_hotbar_assignment( + assignment_kind, + identity, + ) + if initial_slot < 0: + initial_slot = _controller_previous_hotbar_slot + _controller_ownership = ControllerOwnership.HOTBAR_PLACEMENT + controller_hotbar_placement_requested.emit( + assignment_kind, + identity, + initial_slot, + ) + return true + + +func _find_controller_hotbar_assignment( + assignment_kind: PlayerHotbarType.AssignmentKind, + identity: StringName, +) -> int: + for slot_index: int in range(PlayerHotbarType.SLOT_COUNT): + if ( + assignment_kind == PlayerHotbarType.AssignmentKind.FISH + and _hotbar.get_fish_catch_id(slot_index) == identity + ): + return slot_index + if ( + assignment_kind == PlayerHotbarType.AssignmentKind.ITEM + and _hotbar.get_item_id(slot_index) == identity + ): + return slot_index + return -1 + + +func _confirm_controller_hotbar_placement() -> void: + if ( + _controller_ownership != ControllerOwnership.HOTBAR_PLACEMENT + or _hotbar == null + ): + return + var slot_index: int = _hotbar.get_selected_slot() + var assigned: bool = false + if ( + _controller_hotbar_assignment_kind + == PlayerHotbarType.AssignmentKind.FISH + ): + assigned = _hotbar.assign_fish( + slot_index, + _controller_hotbar_identity, + ) + elif ( + _controller_hotbar_assignment_kind + == PlayerHotbarType.AssignmentKind.ITEM + ): + assigned = _hotbar.assign_item( + slot_index, + _controller_hotbar_identity, + ) + if assigned: + _release_controller_ownership(true, false) + + +func _release_controller_ownership( + restore_source_focus: bool, + restore_previous_hotbar_slot: bool, +) -> void: + if _controller_ownership == ControllerOwnership.ITEM_LIST: + return + var prior_ownership: ControllerOwnership = _controller_ownership + var source_section: Section = _controller_source_section + var source_identity: StringName = _controller_source_identity + _controller_ownership = ControllerOwnership.ITEM_LIST + _controller_notepad_actions.clear() + _controller_source_identity = StringName() + _controller_hotbar_assignment_kind = PlayerHotbarType.AssignmentKind.EMPTY + _controller_hotbar_identity = StringName() + if prior_ownership == ControllerOwnership.HOTBAR_PLACEMENT: + if restore_previous_hotbar_slot and _hotbar != null: + _hotbar.select_slot(_controller_previous_hotbar_slot) + controller_hotbar_placement_ended.emit() + if restore_source_focus: + call_deferred( + "_restore_controller_item_focus", + source_section, + source_identity, + ) + + +func _restore_controller_item_focus( + section: Section, + identity: StringName, +) -> void: + if not visible or section != _current_section or identity.is_empty(): + return + var target: Control + if section == Section.COOLER: + target = _fish_nodes.get(identity) as CoolerFishSpriteType + elif section == Section.BAG: + target = _bag_item_nodes.get(identity) as BagItemSpriteType + elif section == Section.TACKLE_BOX: + for child: Node in _tackle_item_list.get_children(): + var button := child as BaseButton + if ( + button != null + and button.has_meta(&"controller_tackle_item_id") + and StringName(str(button.get_meta( + &"controller_tackle_item_id" + ))) == identity + ): + target = button + break + if ( + target != null + and is_instance_valid(target) + and target.is_visible_in_tree() + and target.focus_mode != Control.FOCUS_NONE + ): + target.grab_focus() + + func _handle_controller_page_switch(event: InputEvent) -> bool: var button_event: InputEventJoypadButton = event as InputEventJoypadButton + var motion_event: InputEventJoypadMotion = event as InputEventJoypadMotion var use_mapping: bool = ( _controller_mapping_manager != null ) - var uses_left_bumper: bool = ( + var uses_left_trigger: bool = ( _controller_mapping_manager.event_uses_role( - event, ControllerMappingManagerType.ROLE_LB + event, ControllerMappingManagerType.ROLE_POINTER_MODIFIER ) if use_mapping else ( - button_event != null - and button_event.button_index == JOY_BUTTON_LEFT_SHOULDER + motion_event != null + and motion_event.axis == JOY_AXIS_TRIGGER_LEFT ) ) - var uses_right_bumper: bool = ( + var uses_right_trigger: bool = ( _controller_mapping_manager.event_uses_role( - event, ControllerMappingManagerType.ROLE_RB + event, ControllerMappingManagerType.ROLE_CAMERA_ZOOM ) if use_mapping else ( - button_event != null - and button_event.button_index == JOY_BUTTON_RIGHT_SHOULDER + motion_event != null + and motion_event.axis == JOY_AXIS_TRIGGER_RIGHT ) ) if ( - button_event == null - or not (uses_left_bumper or uses_right_bumper) + not (uses_left_trigger or uses_right_trigger) or not visible ): return false - if not button_event.pressed: + var is_pressed: bool = ( + button_event.pressed + if button_event != null + else motion_event.axis_value > 0.5 + ) + if not is_pressed: + if uses_left_trigger: + _left_page_trigger_held = false + if uses_right_trigger: + _right_page_trigger_held = false return true - # Shoulder input belongs to the Player Menu while it is visible, even when - # a transition or modal temporarily prevents changing pages. This keeps LB - # from opening Chat and RB from leaking into gameplay behind the menu. + if ( + (uses_left_trigger and _left_page_trigger_held) + or (uses_right_trigger and _right_page_trigger_held) + ): + return true + if uses_left_trigger: + _left_page_trigger_held = true + if uses_right_trigger: + _right_page_trigger_held = true + # Trigger input belongs to the Player Menu while it is visible, even when a + # transition or modal temporarily prevents changing pages. if ( _transitioning or _page_transitioning or _sale_confirmation.visible or get_viewport().gui_is_dragging() + or _controller_ownership != ControllerOwnership.ITEM_LIST ): return true var sections: Array[Section] = [ @@ -670,13 +1017,131 @@ func _handle_controller_page_switch(event: InputEvent) -> bool: if current_index < 0: current_index = 0 var direction: int = ( - -1 if uses_left_bumper else 1 + -1 if uses_left_trigger else 1 ) var next_index: int = wrapi(current_index + direction, 0, sections.size()) _show_section(sections[next_index]) return true +func _handle_controller_secondary_switch(event: InputEvent) -> bool: + var button_event := event as InputEventJoypadButton + if button_event == null or not visible: + return false + var use_mapping: bool = _controller_mapping_manager != null + var uses_left_bumper: bool = ( + _controller_mapping_manager.event_uses_role( + event, ControllerMappingManagerType.ROLE_LB + ) + if use_mapping + else button_event.button_index == JOY_BUTTON_LEFT_SHOULDER + ) + var uses_right_bumper: bool = ( + _controller_mapping_manager.event_uses_role( + event, ControllerMappingManagerType.ROLE_RB + ) + if use_mapping + else button_event.button_index == JOY_BUTTON_RIGHT_SHOULDER + ) + if not (uses_left_bumper or uses_right_bumper): + return false + if not button_event.pressed: + return true + if ( + _transitioning + or _page_transitioning + or _sale_confirmation.visible + or get_viewport().gui_is_dragging() + or _controller_ownership != ControllerOwnership.ITEM_LIST + ): + return true + var direction: int = -1 if uses_left_bumper else 1 + if _is_inventory_section(_current_section): + var inventory_sections: Array[Section] = [ + Section.COOLER, + Section.BAG, + Section.TACKLE_BOX, + ] + var inventory_index: int = inventory_sections.find(_current_section) + _show_section(inventory_sections[wrapi( + inventory_index + direction, + 0, + inventory_sections.size(), + )]) + return true + _cycle_visible_secondary_tabs(direction) + return true + + +func _cycle_visible_secondary_tabs(direction: int) -> bool: + var navigation_cluster := get_node_or_null("%NavigationCluster") as Control + var grouped_buttons: Dictionary = {} + _collect_visible_toggle_buttons(self, navigation_cluster, grouped_buttons) + var selected_group: Variant = null + var selected_group_y: float = INF + for group_value: Variant in grouped_buttons.keys(): + var buttons := grouped_buttons[group_value] as Array + if buttons.size() < 2: + continue + var group_y: float = INF + for item: Variant in buttons: + var button := item as BaseButton + group_y = minf(group_y, button.global_position.y) + if group_y < selected_group_y: + selected_group = group_value + selected_group_y = group_y + if selected_group == null: + return false + var tabs := grouped_buttons[selected_group] as Array + tabs.sort_custom(func(first: BaseButton, second: BaseButton) -> bool: + return first.global_position.x < second.global_position.x + ) + var current_index: int = 0 + for index: int in tabs.size(): + var tab := tabs[index] as BaseButton + if tab.button_pressed: + current_index = index + break + var target := tabs[wrapi( + current_index + direction, 0, tabs.size() + )] as BaseButton + target.set_pressed_no_signal(true) + target.pressed.emit() + return true + + +func _collect_visible_toggle_buttons( + root: Node, + navigation_cluster: Control, + grouped_buttons: Dictionary, +) -> void: + for child: Node in root.get_children(): + var child_control := child as Control + if child_control != null and not child_control.is_visible_in_tree(): + continue + var button := child as BaseButton + if ( + button != null + and button.toggle_mode + and ( + navigation_cluster == null + or not navigation_cluster.is_ancestor_of(button) + ) + ): + var group_key: Variant = ( + button.button_group + if button.button_group != null + else button.get_parent() + ) + if not grouped_buttons.has(group_key): + grouped_buttons[group_key] = [] + var buttons := grouped_buttons[group_key] as Array + buttons.append(button) + _collect_visible_toggle_buttons( + child, navigation_cluster, grouped_buttons + ) + + func _handle_direct_page_shortcut(event: InputEvent) -> bool: var key_event := event as InputEventKey if ( @@ -772,6 +1237,7 @@ func open_menu() -> void: or not _fishing_spot.can_open_player_menu() ): return + _release_controller_ownership(false, true) _menu_generation += 1 _transition_generation += 1 _cancel_presentation_tween() @@ -921,6 +1387,7 @@ func close_menu( if reason != CloseReason.USER: _finish_close(reason, restore_controls, _menu_generation) return + _release_controller_ownership(false, true) get_viewport().gui_cancel_drag() _close_sale_confirmation() if reason in [ @@ -949,6 +1416,7 @@ func close_for_session_end() -> void: func _exit_tree() -> void: + _release_controller_ownership(false, true) _cancel_presentation_tween() _cancel_page_tween() if visible: @@ -1007,6 +1475,7 @@ func _show_section(section: Section) -> void: and _profile_page.request_close_confirmation() ): return + _release_controller_ownership(false, true) _begin_page_transition(section) @@ -1122,22 +1591,39 @@ func _focus_current_section() -> void: if _shop_cooler_context_active: _focus_shop_cooler() return - if _current_section == Section.COOLER: - _cooler_sub_tab.grab_focus() - elif _current_section == Section.BAG: - _bag_sub_tab.grab_focus() - elif _current_section == Section.TACKLE_BOX: - _tackle_sub_tab.grab_focus() - elif _current_section == Section.LOGBOOK: - _catalog_logbook.focus_initial() - elif _current_section == Section.NET: - _the_net_page.focus_initial() - elif _current_section == Section.MAIL: - _mail_tab.grab_focus() - elif _current_section == Section.PLAYERS: - _players_tab.grab_focus() - else: - _profile_tab.grab_focus() + _reserve_main_navigation_for_page_switching() + _reserve_visible_secondary_navigation() + var navigation_cluster := get_node_or_null("%NavigationCluster") as Control + var candidates: Array[Control] = [] + _collect_focusable_content(self, navigation_cluster, candidates) + if candidates.is_empty(): + return + candidates.sort_custom(func(first: Control, second: Control) -> bool: + if not is_equal_approx(first.global_position.y, second.global_position.y): + return first.global_position.y < second.global_position.y + return first.global_position.x < second.global_position.x + ) + candidates[0].grab_focus() + + +func _collect_focusable_content( + root: Node, + navigation_cluster: Control, + output: Array[Control], +) -> void: + for child: Node in root.get_children(): + if child == navigation_cluster: + continue + var control := child as Control + if control != null and not control.is_visible_in_tree(): + continue + if ( + control != null + and control.focus_mode != Control.FOCUS_NONE + and not control is ScrollBar + ): + output.append(control) + _collect_focusable_content(child, navigation_cluster, output) func _process(delta: float) -> void: @@ -1161,6 +1647,7 @@ func _process(delta: float) -> void: func _configure_navigation_focus() -> void: + _reserve_main_navigation_for_page_switching() var navigation: Array[BubbleButtonType] = [ _inventory_tab, _logbook_tab, @@ -1182,6 +1669,48 @@ func _configure_navigation_focus() -> void: bubble.focus_neighbor_right = bubble.get_path_to(next) bubble.focus_neighbor_top = bubble.focus_neighbor_left bubble.focus_neighbor_bottom = bubble.focus_neighbor_right + + +func _reserve_main_navigation_for_page_switching() -> void: + var navigation_cluster := get_node_or_null("%NavigationCluster") as Control + if navigation_cluster == null: + return + _set_descendant_focus_disabled(navigation_cluster) + + +func _set_descendant_focus_disabled(root: Node) -> void: + for child: Node in root.get_children(): + var control := child as Control + if control != null: + control.focus_mode = Control.FOCUS_NONE + _set_descendant_focus_disabled(child) + + +func _reserve_visible_secondary_navigation() -> void: + var navigation_cluster := get_node_or_null("%NavigationCluster") as Control + var grouped_buttons: Dictionary = {} + _collect_visible_toggle_buttons(self, navigation_cluster, grouped_buttons) + var selected_tabs: Array = [] + var selected_group_y: float = INF + for group_value: Variant in grouped_buttons.keys(): + var buttons := grouped_buttons[group_value] as Array + if buttons.size() < 2: + continue + var group_y: float = INF + for item: Variant in buttons: + var button := item as BaseButton + group_y = minf(group_y, button.global_position.y) + if group_y < selected_group_y: + selected_tabs = buttons + selected_group_y = group_y + var focus_owner: Control = get_viewport().gui_get_focus_owner() + var displaced_focus: bool = selected_tabs.has(focus_owner) + for item: Variant in selected_tabs: + var tab := item as Control + tab.focus_mode = Control.FOCUS_NONE + if displaced_focus: + focus_owner.release_focus() + call_deferred("_focus_current_section") var inventory_tabs: Array[Button] = [ _cooler_sub_tab, _tackle_sub_tab, @@ -1359,6 +1888,7 @@ func _refresh_tackle_box() -> void: row.text = "%s ×%d" % [item.display_name, owned.quantity] row.alignment = HORIZONTAL_ALIGNMENT_LEFT row.toggle_mode = true + row.set_meta(&"controller_tackle_item_id", owned.item_id) row.button_pressed = owned.item_id == _selected_tackle_item_id if item.is_bait() and item.icon != null: _apply_tackle_bait_button_style(row) diff --git a/ui/players_page.gd b/ui/players_page.gd index b5a62ee..2f0d6a7 100644 --- a/ui/players_page.gd +++ b/ui/players_page.gd @@ -336,7 +336,32 @@ func _confirm(text: String, action: Callable) -> void: func _focus_first() -> void: - for child: Node in _tabs.get_children(): - if child is Button and child.visible and not child.disabled: - child.grab_focus() - return + var candidates: Array[Control] = [] + _collect_focusable_player_controls(self, candidates) + if candidates.is_empty(): + return + candidates.sort_custom(func(first: Control, second: Control) -> bool: + if not is_equal_approx(first.global_position.y, second.global_position.y): + return first.global_position.y < second.global_position.y + return first.global_position.x < second.global_position.x + ) + candidates[0].grab_focus() + + +func _collect_focusable_player_controls( + root: Node, + output: Array[Control], +) -> void: + for child: Node in root.get_children(): + if child == _tabs: + continue + var control := child as Control + if control != null and not control.is_visible_in_tree(): + continue + if ( + control != null + and control.focus_mode != Control.FOCUS_NONE + and not control is ScrollBar + ): + output.append(control) + _collect_focusable_player_controls(child, output) diff --git a/ui/settings/settings_bubble_page.gd b/ui/settings/settings_bubble_page.gd index 1ccff30..4710be9 100644 --- a/ui/settings/settings_bubble_page.gd +++ b/ui/settings/settings_bubble_page.gd @@ -1,6 +1,10 @@ class_name SettingsBubblePage extends Control +const ControllerFocusNavigationType = preload( + "res://ui/controller_focus_navigation.gd" +) + @export var page_id: StringName @export var cluster_path: NodePath = ^"BubbleCluster" @export var bubble_paths: Array[NodePath] = [] @@ -169,23 +173,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_bubbles) func _configure_focus_order() -> void: for bubble: BubbleButton in _bubbles: bubble.focus_mode = Control.FOCUS_NONE - for index: int in _focus_bubbles.size(): - var bubble: BubbleButton = _focus_bubbles[index] - if index > 0: - bubble.focus_neighbor_top = bubble.get_path_to( - _focus_bubbles[index - 1] - ) - bubble.focus_neighbor_left = bubble.focus_neighbor_top - if index + 1 < _focus_bubbles.size(): - bubble.focus_neighbor_bottom = bubble.get_path_to( - _focus_bubbles[index + 1] - ) - bubble.focus_neighbor_right = bubble.focus_neighbor_bottom + ControllerFocusNavigationType.configure_spatial_neighbors(_focus_bubbles) func _set_interactive(interactive: bool) -> void: diff --git a/ui/settings_panel.gd b/ui/settings_panel.gd index 768980a..640cc1d 100644 --- a/ui/settings_panel.gd +++ b/ui/settings_panel.gd @@ -53,6 +53,7 @@ enum PresentationMode { @onready var _mouse_value: BubbleButton = %MouseValue @onready var _controller_value: BubbleButton = %ControllerValue @onready var _invert_y_toggle: BubbleButton = %InvertYToggle +@onready var _on_screen_keyboard_toggle: BubbleButton = %OnScreenKeyboardToggle @onready var _auto_click_toggle: BubbleButton = %AutoClickToggle @onready var _auto_click_interval: BubbleButton = %AutoClickIntervalValue @@ -84,6 +85,7 @@ var _auto_click_interval_value: float = 0.20 var _mouse_sensitivity: float = 0.005 var _controller_sensitivity: float = 2.5 var _invert_camera_y: bool = false +var _on_screen_keyboard_enabled: bool = false var _network_profile: NetworkProfilePreferences var _network_session: NetworkSession var _data_root: PlayerDataRoot @@ -161,6 +163,7 @@ func _ready() -> void: _adjust_controller_sensitivity.bind(1) ) _invert_y_toggle.pressed.connect(_toggle_invert_y) + _on_screen_keyboard_toggle.pressed.connect(_toggle_on_screen_keyboard) _auto_click_toggle.pressed.connect(_toggle_auto_click) for control: Control in [_auto_click_toggle, _auto_click_interval]: control.mouse_entered.connect( @@ -704,6 +707,7 @@ func _apply_settings() -> void: edited.mouse_camera_sensitivity = _mouse_sensitivity edited.controller_camera_sensitivity = _controller_sensitivity edited.invert_camera_y = _invert_camera_y + edited.on_screen_keyboard_enabled = _on_screen_keyboard_enabled if _settings_manager.apply_settings(edited): if ( _presentation_mode == PresentationMode.TITLE_EMBEDDED @@ -725,6 +729,7 @@ func _load_controls() -> void: _mouse_sensitivity = settings.mouse_camera_sensitivity _controller_sensitivity = settings.controller_camera_sensitivity _invert_camera_y = settings.invert_camera_y + _on_screen_keyboard_enabled = settings.on_screen_keyboard_enabled _refresh_value_labels() @@ -755,6 +760,10 @@ func _refresh_value_labels() -> void: "invert\nvertical\ncamera\n" + ("on" if _invert_camera_y else "off") ) + _on_screen_keyboard_toggle.text = ( + "on-screen\nkeyboard\n" + + ("on" if _on_screen_keyboard_enabled else "off") + ) _auto_click_toggle.text = ( "accessibility\nauto-click\n" + ("on" if _auto_click_enabled else "off") @@ -883,6 +892,11 @@ func _toggle_invert_y() -> void: _refresh_value_labels() +func _toggle_on_screen_keyboard() -> void: + _on_screen_keyboard_enabled = not _on_screen_keyboard_enabled + _refresh_value_labels() + + func _toggle_auto_click() -> void: _auto_click_enabled = not _auto_click_enabled _refresh_value_labels() diff --git a/ui/settings_panel.tscn b/ui/settings_panel.tscn index 6d05831..e831674 100644 --- a/ui/settings_panel.tscn +++ b/ui/settings_panel.tscn @@ -397,8 +397,8 @@ grow_horizontal = 2 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/ControllerMapping"), 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/ControllerMapping"), NodePath("BubbleCluster/ControlsBackButton")]) +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/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/ControlsBackButton")]) initial_focus_path = NodePath("BubbleCluster/MouseValue") back_focus_path = NodePath("BubbleCluster/ControlsBackButton") @@ -491,17 +491,29 @@ neutral_size = Vector2(176, 170) compact_minimum_size = Vector2(170, 164) minimum_font_size = 14 maximum_font_size = 24 -desktop_anchor = Vector2(525, 145) -compact_anchor = Vector2(485, 135) +desktop_anchor = Vector2(525, 105) +compact_anchor = Vector2(485, 95) motion_phase = 4.5 +[node name="OnScreenKeyboardToggle" parent="ControlsPage/BubbleCluster" instance=ExtResource("4_bubble")] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 0) +text = "on-screen\nkeyboard\noff" +neutral_size = Vector2(170, 150) +compact_minimum_size = Vector2(154, 136) +minimum_font_size = 14 +maximum_font_size = 22 +desktop_anchor = Vector2(525, 255) +compact_anchor = Vector2(485, 235) +motion_phase = 4.7 + [node name="ControllerMapping" parent="ControlsPage/BubbleCluster" instance=ExtResource("4_bubble")] unique_name_in_owner = true custom_minimum_size = Vector2(0, 0) text = "map\ncontroller" neutral_size = Vector2(160, 150) -desktop_anchor = Vector2(525, 330) -compact_anchor = Vector2(485, 300) +desktop_anchor = Vector2(525, 390) +compact_anchor = Vector2(485, 355) compact_minimum_size = Vector2(148, 138) minimum_font_size = 14 maximum_font_size = 23 @@ -512,8 +524,8 @@ unique_name_in_owner = true custom_minimum_size = Vector2(0, 0) text = "back" neutral_size = Vector2(100, 94) -desktop_anchor = Vector2(635, 445) -compact_anchor = Vector2(555, 405) +desktop_anchor = Vector2(635, 470) +compact_anchor = Vector2(555, 420) compact_minimum_size = Vector2(84, 80) minimum_font_size = 14 maximum_font_size = 17 diff --git a/ui/title_screen.gd b/ui/title_screen.gd index 4531b3f..5031efc 100644 --- a/ui/title_screen.gd +++ b/ui/title_screen.gd @@ -608,6 +608,10 @@ func _prepare_awaiting_start_input() -> void: _navigation_focus_active = false _modal_restore_navigation_focus = false _button_center.show() + var bubble_field := get_node_or_null("%BubbleField") as Control + if bubble_field != null: + bubble_field.hide() + call_deferred("set_process", visible) _feedback_label.show() _feedback_label.modulate.a = 0.0 _continue_stats_hovered = false @@ -650,6 +654,10 @@ func _reveal_primary_menu() -> void: ): return _awaiting_start_input = false + _button_center.show() + var bubble_field := get_node_or_null("%BubbleField") as Control + if bubble_field != null: + bubble_field.show() _stop_entry_prompt_animation() _title_entry_generation += 1 _title_entry_transition_active = true diff --git a/ui/ui_pixelation_presenter.gd b/ui/ui_pixelation_presenter.gd index 18481c3..2cc2063 100644 --- a/ui/ui_pixelation_presenter.gd +++ b/ui/ui_pixelation_presenter.gd @@ -5,6 +5,13 @@ const MIN_UI_VIEWPORT_SIZE: Vector2i = Vector2i(256, 180) const UIReferencePresentationType = preload( "res://ui/ui_reference_presentation.gd" ) +const ControllerFocusPresentationType = preload( + "res://ui/controller_focus_presentation.gd" +) +const ControllerFocusRecoveryType = preload( + "res://ui/controller_focus_recovery.gd" +) +const OnScreenKeyboardType = preload("res://ui/on_screen_keyboard.gd") signal effective_pixel_size_changed( requested_pixel_size: int, @@ -16,6 +23,9 @@ signal effective_pixel_size_changed( @onready var _canonical_stage: Control = ( $UIViewport/GameUI/UIRoot/CanonicalStage ) +@onready var _hotbar: HotbarUI = ( + $UIViewport/GameUI/UIRoot/CanonicalStage/Hotbar +) @onready var _chat_ui: ChatUI = $UIViewport/GameUI/UIRoot/ChatUI @onready var _title_content_stage: Control = ( $UIViewport/GameUI/UIRoot/TitleScreen/ResponsiveTitleStage @@ -26,14 +36,25 @@ var _effective_pixel_size: int = PlayerSettings.DEFAULT_UI_PIXEL_SIZE var _gameplay_active: bool = false var _interactive_ui_open: bool = false var _passive_pointer_ui_enabled: bool = false +var _on_screen_keyboard: OnScreenKeyboardType func _ready() -> void: + _on_screen_keyboard = OnScreenKeyboardType.new() + _ui_root.add_child(_on_screen_keyboard) + var controller_focus_recovery := ControllerFocusRecoveryType.new() + _ui_root.add_child(controller_focus_recovery) + var controller_focus_presentation := ControllerFocusPresentationType.new() + _ui_root.add_child(controller_focus_presentation) var root_viewport: Viewport = get_viewport() root_viewport.size_changed.connect(_resize_presentation) _resize_presentation() +func set_on_screen_keyboard_enabled(enabled: bool) -> void: + _on_screen_keyboard.set_enabled(enabled) + + func set_pixel_size(pixel_size: int) -> void: _requested_pixel_size = clampi( pixel_size, @@ -120,6 +141,7 @@ func _resize_presentation() -> void: UIReferencePresentationType.get_stage_position(display_size) ) _canonical_stage.size = UIReferencePresentationType.REFERENCE_SIZE + _hotbar.position = Vector2(0.0, _canonical_stage.position.y) _title_content_stage.set_anchors_preset(Control.PRESET_TOP_LEFT) _title_content_stage.position = _canonical_stage.position _title_content_stage.size = UIReferencePresentationType.REFERENCE_SIZE