diff --git a/export_presets.cfg b/export_presets.cfg index 2365877..1df6f73 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -9,7 +9,7 @@ custom_features="" export_filter="all_resources" include_filter="" exclude_filter="builds/*,playtest/*,scripts/*" -export_path="builds/v0.6.5-alpha/windows-x86_64/NETfishing.exe" +export_path="builds/v0.6.6-alpha/windows-x86_64/NETfishing.exe" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -35,11 +35,11 @@ application/modify_resources=true application/icon="res://art/exported/system_icons/netfishing.ico" application/console_wrapper_icon="" application/icon_interpolation=4 -application/file_version="0.6.5.0" -application/product_version="0.6.5.0" +application/file_version="0.6.6.0" +application/product_version="0.6.6.0" application/company_name="" application/product_name="NETfishing" -application/file_description="NETfishing v0.6.5-alpha" +application/file_description="NETfishing v0.6.6-alpha" application/copyright="" application/trademarks="" application/export_angle=0 @@ -62,7 +62,7 @@ custom_features="" export_filter="all_resources" include_filter="" exclude_filter="builds/*,playtest/*,scripts/*" -export_path="builds/v0.6.5-alpha/linux-arm64/NETfishing.arm64" +export_path="builds/v0.6.6-alpha/linux-arm64/NETfishing.arm64" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -92,7 +92,7 @@ custom_features="" export_filter="all_resources" include_filter="" exclude_filter="builds/*,playtest/*,scripts/*" -export_path="builds/v0.6.5-alpha/macos/NETfishing.zip" +export_path="builds/v0.6.6-alpha/macos/NETfishing.zip" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -108,8 +108,8 @@ custom_template/release="" application/bundle_identifier="io.woofmeow.netfishing" application/icon="res://art/exported/system_icons/netfishing_1024.png" application/icon_interpolation=0 -application/short_version="0.6.4" -application/version="0.6.4" +application/short_version="0.6.6" +application/version="0.6.6" application/architecture="universal" codesign/enable=false notarization/enable=false @@ -125,7 +125,7 @@ custom_features="" export_filter="all_resources" include_filter="" exclude_filter="builds/*,playtest/*,scripts/*,tests/*" -export_path="builds/v0.6.5-alpha/server-linux-x86_64/NETfishingServer.x86_64" +export_path="builds/v0.6.6-alpha/server-linux-x86_64/NETfishingServer.x86_64" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -155,7 +155,7 @@ custom_features="" export_filter="all_resources" include_filter="" exclude_filter="builds/*,playtest/*,scripts/*" -export_path="builds/v0.6.5-alpha/android/NETfishing.apk" +export_path="builds/v0.6.6-alpha/android/NETfishing.apk" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -176,8 +176,8 @@ architectures/armeabi-v7a=false architectures/arm64-v8a=true architectures/x86=false architectures/x86_64=false -version/code=60500 -version/name="v0.6.5-alpha" +version/code=60600 +version/name="v0.6.6-alpha" package/unique_name="io.woofmeow.netfishing" package/name="NETfishing" package/signed=true @@ -214,7 +214,7 @@ custom_features="" export_filter="all_resources" include_filter="" exclude_filter="builds/*,playtest/*,scripts/*" -export_path="builds/v0.6.5-alpha/linux-x86_64/NETfishing.x86_64" +export_path="builds/v0.6.6-alpha/linux-x86_64/NETfishing.x86_64" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" diff --git a/fishing/fishing_presentation.gd b/fishing/fishing_presentation.gd index c38ddd6..71fedac 100644 --- a/fishing/fishing_presentation.gd +++ b/fishing/fishing_presentation.gd @@ -431,7 +431,7 @@ func _draw_line_ribbon(points: PackedVector3Array) -> void: return var half_thickness: float = line_thickness * 0.5 - _line_mesh.surface_begin(Mesh.PRIMITIVE_TRIANGLES) + var ribbon_vertices := PackedVector3Array() for point_index: int in range(1, points.size()): var start: Vector3 = points[point_index - 1] var end: Vector3 = points[point_index] @@ -452,12 +452,17 @@ func _draw_line_ribbon(points: PackedVector3Array) -> void: var start_right: Vector3 = to_local(start + side) var end_right: Vector3 = to_local(end + side) var end_left: Vector3 = to_local(end - side) - _line_mesh.surface_add_vertex(start_left) - _line_mesh.surface_add_vertex(start_right) - _line_mesh.surface_add_vertex(end_right) - _line_mesh.surface_add_vertex(start_left) - _line_mesh.surface_add_vertex(end_right) - _line_mesh.surface_add_vertex(end_left) + ribbon_vertices.append(start_left) + ribbon_vertices.append(start_right) + ribbon_vertices.append(end_right) + ribbon_vertices.append(start_left) + ribbon_vertices.append(end_right) + ribbon_vertices.append(end_left) + if ribbon_vertices.is_empty(): + return + _line_mesh.surface_begin(Mesh.PRIMITIVE_TRIANGLES) + for vertex: Vector3 in ribbon_vertices: + _line_mesh.surface_add_vertex(vertex) _line_mesh.surface_end() diff --git a/playtest/README-PLAYTEST.txt b/playtest/README-PLAYTEST.txt index 47e0a86..042898d 100644 --- a/playtest/README-PLAYTEST.txt +++ b/playtest/README-PLAYTEST.txt @@ -1,12 +1,13 @@ NETfishing -v0.6.5-alpha -Alpha 0.6.5 +v0.6.6-alpha +Alpha 0.6.6 Thank you for trying this early private playtest. -NETfishing is currently a local gameplay prototype for a future -multiplayer-first game. Networking and multiplayer are NOT implemented in -this build. +NETfishing is an early multiplayer fishing game. This alpha includes local +progression, player-hosted and dedicated multiplayer sessions, public room +discovery, and direct UDP connections. Expect rough edges and please keep a +backup of any save or identity data you care about. INSTALLATION @@ -60,6 +61,9 @@ FISHING FEATURES TO TRY +- Hosting a private or public room and joining compatible online sessions +- Direct-IP, saved-server, recent-server, and public-room connection flows +- Text chat, player calls, player lists, relationships, and moderation tools - Movement, jumping, camera rotation, and zoom - Short, medium, and maximum-distance casts - Valid water and invalid land casts @@ -101,11 +105,11 @@ Please include: - How movement, casting, chase pressure, barrier clicking, menus, and camera controls felt -KNOWN PRE-ALPHA LIMITATIONS +KNOWN ALPHA LIMITATIONS -- Multiplayer/networking is not implemented. -- Fish and character art are temporary. -- UI and balance values are placeholders. -- There is no audio. +- Public sessions remain direct connections; restrictive NAT or carrier-grade + networks may prevent some players from joining. +- Multiplayer, dedicated-server deployment, UI, and balance are still alpha. +- Some fish and character artwork remains temporary. - There is no installer or automatic updater. - Windows builds are unsigned. diff --git a/project.godot b/project.godot index ac103f3..3a76545 100644 --- a/project.godot +++ b/project.godot @@ -11,7 +11,7 @@ config_version=5 [application] config/name="NETFISHING" -config/version="0.6.5-alpha" +config/version="0.6.6-alpha" run/main_scene="res://main/main.tscn" config/features=PackedStringArray("4.7", "GL Compatibility") config/icon="res://art/exported/system_icons/netfishing_256.png" diff --git a/scripts/build_playtest.sh b/scripts/build_playtest.sh index 7ddf4f9..c0cd14b 100755 --- a/scripts/build_playtest.sh +++ b/scripts/build_playtest.sh @@ -4,12 +4,12 @@ set -euo pipefail readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" readonly PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" -readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.6.5-alpha" +readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.6.6-alpha" readonly WINDOWS_DIR="${BUILD_ROOT}/windows-x86_64" readonly LINUX_DIR="${BUILD_ROOT}/linux-x86_64" readonly README_SOURCE="${PROJECT_ROOT}/playtest/README-PLAYTEST.txt" -readonly WINDOWS_ZIP="${BUILD_ROOT}/NETfishing-v0.6.5-alpha-windows-x86_64.zip" -readonly LINUX_ZIP="${BUILD_ROOT}/NETfishing-v0.6.5-alpha-linux-x86_64.zip" +readonly WINDOWS_ZIP="${BUILD_ROOT}/NETfishing-v0.6.6-alpha-windows-x86_64.zip" +readonly LINUX_ZIP="${BUILD_ROOT}/NETfishing-v0.6.6-alpha-linux-x86_64.zip" readonly GODOT_BIN="${GODOT_BIN:-godot}" if [[ ! -f "${PROJECT_ROOT}/project.godot" ]]; then diff --git a/tests/art_tools_validation.gd b/tests/art_tools_validation.gd index 874cc4e..1fa4dee 100644 --- a/tests/art_tools_validation.gd +++ b/tests/art_tools_validation.gd @@ -109,6 +109,7 @@ func _run() -> void: select_button.button_index = JOY_BUTTON_BACK select_button.pressed = true assert(bool(game_ui.call("_handle_controller_chat_controls", select_button))) + await process_frame assert(chat_ui.is_open()) assert(chat_panel.mouse_filter == Control.MOUSE_FILTER_STOP) var typed_chat_entry := chat_ui.find_child( @@ -121,6 +122,8 @@ func _run() -> void: accept_button.pressed = true assert(bool(game_ui.call("_handle_controller_chat_controls", accept_button))) assert(typed_chat_entry.virtual_keyboard_enabled) + await process_frame + assert(typed_chat_entry.has_focus()) var left_bumper := InputEventJoypadButton.new() left_bumper.button_index = JOY_BUTTON_LEFT_SHOULDER left_bumper.pressed = true @@ -129,10 +132,8 @@ func _run() -> void: assert(chat_panel.mouse_filter == Control.MOUSE_FILTER_IGNORE) assert(not typed_chat_entry.virtual_keyboard_enabled) assert(bool(game_ui.call("_handle_controller_chat_controls", left_bumper))) - assert(chat_ui.is_open()) - assert(not typed_chat_entry.virtual_keyboard_enabled) - assert(bool(game_ui.call("_handle_controller_chat_controls", left_bumper))) assert(not chat_ui.is_open()) + assert(not typed_chat_entry.virtual_keyboard_enabled) assert(bool(game_ui.call("_handle_controller_chat_controls", select_button))) assert(not chat_ui.is_open()) assert(chat_ui.is_collapsed()) @@ -315,5 +316,7 @@ func _run() -> void: var session := main.get_node("%NetworkSession") as NetworkSession session.disconnect_session("") main.queue_free() - await process_frame + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout quit() diff --git a/tests/controller_focus_presentation_validation.gd b/tests/controller_focus_presentation_validation.gd index 638e0cc..f0d0355 100644 --- a/tests/controller_focus_presentation_validation.gd +++ b/tests/controller_focus_presentation_validation.gd @@ -39,6 +39,15 @@ func _run() -> void: standard_button.material != null, "ordinary controller focus receives inversion", ) + var pointer_motion := InputEventMouseMotion.new() + pointer_motion.position = Vector2(4.0, 4.0) + presentation._input(pointer_motion) + await process_frame + _expect( + standard_button.material == null, + "mouse motion clears controller focus presentation", + ) + presentation._input(controller_event) authored_selector.grab_focus() await process_frame diff --git a/tests/controller_focus_recovery_validation.gd b/tests/controller_focus_recovery_validation.gd index e34afaa..f598511 100644 --- a/tests/controller_focus_recovery_validation.gd +++ b/tests/controller_focus_recovery_validation.gd @@ -23,14 +23,45 @@ func _run() -> void: var original := _make_button("cute", "cute (show variants)") option_list.add_child(original) await process_frame + original.grab_focus() + await process_frame + await process_frame + _expect( + root.gui_get_focus_owner() == null, + "programmatic focus stays pending outside navigation mode", + ) + var keyboard_navigation := InputEventKey.new() + keyboard_navigation.keycode = KEY_DOWN + keyboard_navigation.pressed = true + recovery._input(keyboard_navigation) + await process_frame + await process_frame + _expect( + root.gui_get_focus_owner() == original, + "keyboard navigation restores pending initial focus", + ) + var menu_shortcut := InputEventKey.new() + menu_shortcut.physical_keycode = KEY_TAB + menu_shortcut.pressed = true + recovery._input(menu_shortcut) + await process_frame + await process_frame + _expect( + root.gui_get_focus_owner() == null, + "the menu shortcut does not leave a pseudo-focused option", + ) 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") + await process_frame + await process_frame + _expect( + root.gui_get_focus_owner() == original, + "controller navigation restores pending initial focus", + ) option_list.remove_child(original) original.queue_free() @@ -53,6 +84,63 @@ func _run() -> void: root.gui_get_focus_owner() == explicit_target, "explicit focus changes take precedence over recovery", ) + var pointer_motion := InputEventMouseMotion.new() + pointer_motion.position = Vector2(4.0, 4.0) + recovery._input(pointer_motion) + await process_frame + await process_frame + _expect( + root.gui_get_focus_owner() == null, + "mouse motion releases controller focus", + ) + await process_frame + _expect( + root.gui_get_focus_owner() == null, + "mouse motion prevents automatic focus recovery", + ) + recovery._input(controller_event) + await process_frame + await process_frame + _expect( + root.gui_get_focus_owner() == explicit_target, + "controller navigation can resume from pointer mode", + ) + var pointer_press := InputEventMouseButton.new() + pointer_press.button_index = MOUSE_BUTTON_LEFT + pointer_press.pressed = true + recovery._input(pointer_press) + await process_frame + _expect( + root.gui_get_focus_owner() == explicit_target, + "mouse-down preserves focus through button activation", + ) + var pointer_release := InputEventMouseButton.new() + pointer_release.button_index = MOUSE_BUTTON_LEFT + pointer_release.pressed = false + recovery._input(pointer_release) + await process_frame + await process_frame + _expect( + root.gui_get_focus_owner() == null, + "mouse-up clears the completed button focus", + ) + recovery._input(controller_event) + await process_frame + await process_frame + + var outside_button := _make_button("outside", "outside") + stage.add_child(outside_button) + option_list.hide() + root.gui_release_focus() + await process_frame + await process_frame + _expect( + root.gui_get_focus_owner() == null, + "focus recovery never escapes a hidden menu scope", + ) + option_list.show() + explicit_target.grab_focus() + await process_frame var leave_world_ui_event := InputEventJoypadButton.new() leave_world_ui_event.button_index = JOY_BUTTON_LEFT_SHOULDER leave_world_ui_event.pressed = true @@ -64,6 +152,14 @@ func _run() -> void: root.gui_get_focus_owner() == null, "LB intentionally leaving world UI is never recovered", ) + var accessibility_only := Control.new() + accessibility_only.focus_mode = Control.FOCUS_ACCESSIBILITY + stage.add_child(accessibility_only) + await process_frame + _expect( + not bool(recovery.call("_is_focusable", accessibility_only)), + "accessibility-only controls are not recovery targets", + ) stage.queue_free() if _failures.is_empty(): diff --git a/tests/economy_regression_validation.gd b/tests/economy_regression_validation.gd index 2ae44c2..f0046fa 100644 --- a/tests/economy_regression_validation.gd +++ b/tests/economy_regression_validation.gd @@ -127,7 +127,9 @@ func _run_multiplayer_host() -> void: print("Economy multiplayer host validation: PASS") session.disconnect_session("Economy host validation complete.") main.queue_free() - await process_frame + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout quit() @@ -229,7 +231,9 @@ func _run_multiplayer_client() -> void: print("Economy multiplayer client validation: PASS") session.disconnect_session("Economy client validation complete.") main.queue_free() - await process_frame + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout quit() @@ -408,7 +412,7 @@ func _test_player_menu_sale( assert(sell_action.mouse_filter == Control.MOUSE_FILTER_STOP) assert(sell_action.focus_mode == Control.FOCUS_ALL) assert(ui_viewport != null) - await _activate_focused_button(sell_action, ui_viewport) + await _activate_pointer_control(sell_action, ui_viewport) await process_frame assert(confirmation.visible) assert(confirm_button.visible and not confirm_button.disabled) @@ -417,7 +421,7 @@ func _test_player_menu_sale( > (player_menu.get_node("%CoolerOuterWall") as Control).z_index ) _sale_result.clear() - await _activate_focused_button(confirm_button, ui_viewport) + await _activate_pointer_control(confirm_button, ui_viewport) await process_frame assert(not _sale_result.is_empty() and bool(_sale_result[1])) assert(not player.inventory.contains_catch_id(fish_catch.catch_id)) @@ -436,18 +440,29 @@ func _test_player_menu_sale( await create_timer(2.2).timeout -func _activate_focused_button( - button: Button, +func _activate_pointer_control( + control: Control, ui_viewport: SubViewport, ) -> void: - button.grab_focus() + var presenter := ui_viewport.get_parent() as SubViewportContainer + assert(presenter != null) + var local_center: Vector2 = control.get_global_transform_with_canvas() * ( + control.size * 0.5 + ) + var center: Vector2 = presenter.position + local_center * presenter.scale + var motion := InputEventMouseMotion.new() + motion.position = center + motion.global_position = center + root.push_input(motion, true) await process_frame - assert(button.has_focus()) + assert(ui_viewport.gui_get_hovered_control() == control) for is_pressed: bool in [true, false]: - var accept := InputEventAction.new() - accept.action = &"ui_accept" - accept.pressed = is_pressed - ui_viewport.push_input(accept, false) + var click := InputEventMouseButton.new() + click.button_index = MOUSE_BUTTON_LEFT + click.position = center + click.global_position = center + click.pressed = is_pressed + root.push_input(click, true) await process_frame @@ -560,14 +575,14 @@ func _test_fishing_shop_sale_ui( assert(art_supplies_tab != null and art_supplies_tab.text == "Art Supplies") var sell_mode := shop_tabs[5] as Button assert(sell_mode != null and sell_mode.text == "Sell Fish") - await _activate_focused_button(art_supplies_tab, ui_viewport) + await _activate_pointer_control(art_supplies_tab, ui_viewport) await process_frame var stock_sections: Array[String] = [] for child: Node in shop.get_node("%SuppliesList").get_children(): if child is Label: stock_sections.append((child as Label).text) assert(stock_sections == ["art kit", "markers", "brushes", "grids"]) - await _activate_focused_button(sell_mode, ui_viewport) + await _activate_pointer_control(sell_mode, ui_viewport) await process_frame assert(shop.visible and not player_menu.visible) assert(not shop.has_node("ShopPanel/Margin/Layout/Body/FishSales")) @@ -584,6 +599,11 @@ func _test_fishing_shop_sale_ui( var water_surface := player_menu.get("_cooler_water_surface") as ColorRect assert(cooler_outer_wall != null and cooler_outer_wall.visible) assert(water_surface.visible and water_surface.material is ShaderMaterial) + var cooler_sort_option := player_menu.get("_cooler_sort_option") as Control + await _activate_pointer_control(cooler_sort_option, ui_viewport) + var cooler_choice_panel := cooler_sort_option.get("_choice_panel") as Control + assert(cooler_choice_panel.visible) + cooler_sort_option.call("close_choices") assert( StringName( (player_menu.get("_sale_buyer_override") as FishBuyerProfile).id @@ -594,10 +614,10 @@ func _test_fishing_shop_sale_ui( var reserved_button := fish_nodes.get(reserved_catch.catch_id) as Button assert(fish_button != null and fish_button.visible) assert(reserved_button != null and reserved_button.visible) - await _activate_focused_button(fish_button, ui_viewport) + await _activate_pointer_control(fish_button, ui_viewport) var sell_button := player_menu.get("_sell_bubble") as Button assert(sell_button.visible and not sell_button.disabled) - await _activate_focused_button(sell_button, ui_viewport) + await _activate_pointer_control(sell_button, ui_viewport) await process_frame var confirmation := player_menu.get("_sale_confirmation") as Control var confirm_button := player_menu.get("_confirm_sale_button") as Button @@ -607,14 +627,14 @@ func _test_fishing_shop_sale_ui( > cooler_outer_wall.z_index ) _sale_result.clear() - await _activate_focused_button(confirm_button, ui_viewport) + await _activate_pointer_control(confirm_button, ui_viewport) await process_frame assert(not _sale_result.is_empty() and bool(_sale_result[1])) assert(not player.inventory.contains_catch_id(fish_catch.catch_id)) assert(player.wallet.get_balance() == balance_before + fish_catch.sale_value) assert(not sale_service.is_local_sale_pending()) assert(reservations.release(reservation_id)) - await _activate_focused_button(shop_tabs[0] as Button, ui_viewport) + await _activate_pointer_control(shop_tabs[0] as Button, ui_viewport) await process_frame assert(shop.visible and (shop.get_node("%ShopPanel") as Control).visible) assert(not (shop.get_node("%ShopCoolerPage") as Control).visible) diff --git a/tests/fish_hotbar_showcase_validation.gd b/tests/fish_hotbar_showcase_validation.gd index ec802b0..8919221 100644 --- a/tests/fish_hotbar_showcase_validation.gd +++ b/tests/fish_hotbar_showcase_validation.gd @@ -3,7 +3,6 @@ extends SceneTree const MainScene = preload("res://main/main.tscn") const FishCatchType = preload("res://fish/fish_catch.gd") const FishDataType = preload("res://fish/fish_data.gd") -const FishingSpotType = preload("res://fishing/fishing_spot.gd") func _initialize() -> void: @@ -40,7 +39,6 @@ func _run() -> void: var service := main.get_node( "%NetworkFishShowcaseService" ) as NetworkFishShowcaseService - var fishing_spot := main.get_node("%FishingSpot") as FishingSpotType assert(player != null and fish_catalog != null and service != null) var fish: FishDataType = fish_catalog.get_fish_by_id(&"bluegill") assert(fish != null) @@ -83,22 +81,18 @@ func _run() -> void: assert(player.hotbar.get_selected_fish_catch_id() == fish_catch.catch_id) assert(player.hotbar.get_selected_item_id().is_empty()) - var press := InputEventMouseButton.new() - press.button_index = MOUSE_BUTTON_LEFT - press.pressed = true - fishing_spot.call("_unhandled_input", press) - await process_frame assert(service.is_local_showcase_visible()) assert(service.get_local_showcase_catch_id() == fish_catch.catch_id) - assert((player.get_node("%HeldFishDisplay") as Node3D).visible) - fishing_spot.call("_unhandled_input", press) + await _wait_for_held_fish_visibility(player, true) + assert(service.toggle_selected_fish()) await process_frame assert(not service.is_local_showcase_visible()) - assert(not (player.get_node("%HeldFishDisplay") as Node3D).visible) + await _wait_for_held_fish_visibility(player, false) var saved_weather: WorldWeatherService.Weather = ( world_weather.get_weather() ) var saved_weather_seconds: float = world_weather.get_seconds_remaining() + var saved_time_hours: float = world_time.get_time_hours() assert(save_manager.save_now()) var save_path: String = str(save_manager.get("_save_path")) @@ -116,7 +110,7 @@ func _run() -> void: == 125 ) assert(absf( - float((parsed as Dictionary)["world"]["time_hours"]) - 19.75 + float((parsed as Dictionary)["world"]["time_hours"]) - saved_time_hours ) < 0.01) assert( int((parsed as Dictionary)["world"]["weather"]) @@ -149,7 +143,7 @@ func _run() -> void: ) assert(save_manager.load_player_data()) assert(player.experience.get_total_experience() == 125) - assert(absf(world_time.get_time_hours() - 19.75) < 0.01) + assert(absf(world_time.get_time_hours() - saved_time_hours) < 0.01) assert(world_weather.get_weather() == saved_weather) assert(absf( world_weather.get_seconds_remaining() - saved_weather_seconds @@ -163,14 +157,13 @@ func _run() -> void: FishQuality.Tier.EXCEPTIONAL, ) ) - assert(not service.is_local_showcase_visible()) - assert(service.toggle_selected_fish()) assert(service.is_local_showcase_visible()) + await _wait_for_held_fish_visibility(player, true) assert(player.inventory.remove_catch_by_id(fish_catch.catch_id) != null) await process_frame assert(player.hotbar.get_fish_catch_id(1).is_empty()) assert(not service.is_local_showcase_visible()) - assert(not (player.get_node("%HeldFishDisplay") as Node3D).visible) + await _wait_for_held_fish_visibility(player, false) var valid_state: Dictionary = { "session_id": "session", @@ -202,5 +195,26 @@ func _run() -> void: print("Fish hotbar showcase validation: PASS") session.disconnect_session("") main.queue_free() - await process_frame + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout quit() + + +func _wait_for_held_fish_visibility( + player: Player, + expected_visible: bool, +) -> void: + var held_fish_display := player.get("_held_fish_display") as Node3D + assert(held_fish_display != null) + var deadline_msec: int = Time.get_ticks_msec() + 2000 + while ( + held_fish_display.visible != expected_visible + and Time.get_ticks_msec() < deadline_msec + ): + await process_frame + assert( + held_fish_display.visible == expected_visible, + "Held fish display did not become %s within the timeout." + % expected_visible, + ) diff --git a/tests/fish_showcase_multiplayer_validation.gd b/tests/fish_showcase_multiplayer_validation.gd index 23f02ef..72f4a59 100644 --- a/tests/fish_showcase_multiplayer_validation.gd +++ b/tests/fish_showcase_multiplayer_validation.gd @@ -35,8 +35,8 @@ func _run_host() -> void: var fish_catch: FishCatch = _add_bluegill(main, player) assert(player.hotbar.assign_fish(1, fish_catch.catch_id)) assert(player.hotbar.select_slot(1)) - assert(service.toggle_selected_fish()) assert(service.is_local_showcase_visible()) + await _wait_for_held_fish_visibility(player, true) assert(session.set_host_open(true)) var remote_peer_id: int = 0 @@ -66,7 +66,9 @@ func _run_host() -> void: print("Fish showcase multiplayer host validation: PASS") session.disconnect_session("") main.queue_free() - await process_frame + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout quit() @@ -118,8 +120,8 @@ func _run_client() -> void: var fish_catch: FishCatch = _add_bluegill(main, player) assert(player.hotbar.assign_fish(1, fish_catch.catch_id)) assert(player.hotbar.select_slot(1)) - assert(service.toggle_selected_fish()) - assert((player.get("_held_fish_display") as Node3D).visible) + assert(service.is_local_showcase_visible()) + await _wait_for_held_fish_visibility(player, true) await create_timer(2.0).timeout assert(service.toggle_selected_fish()) var held_display := player.get("_held_fish_display") as Node3D @@ -132,7 +134,9 @@ func _run_client() -> void: print("Fish showcase multiplayer client validation: PASS") session.disconnect_session("") main.queue_free() - await process_frame + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout quit() @@ -166,3 +170,18 @@ func _create_initialized_main() -> Node: await process_frame assert(bool(main.get("_application_initialized"))) return main + + +func _wait_for_held_fish_visibility( + player: Player, + expected_visible: bool, +) -> void: + var held_fish_display := player.get("_held_fish_display") as Node3D + assert(held_fish_display != null) + var deadline_msec: int = Time.get_ticks_msec() + 2000 + while ( + held_fish_display.visible != expected_visible + and Time.get_ticks_msec() < deadline_msec + ): + await process_frame + assert(held_fish_display.visible == expected_visible) diff --git a/tests/fishing_authority_validation.gd b/tests/fishing_authority_validation.gd index dc79c44..52ab878 100644 --- a/tests/fishing_authority_validation.gd +++ b/tests/fishing_authority_validation.gd @@ -36,8 +36,14 @@ func _run() -> void: assert(not session.is_open_host()) assert(service != null and fishing_spot != null and player != null) assert(player.hotbar.get_selected_item_id() == &"basic_fishing_rod") + var pond := main.get_node( + "TestWorld/Regions/StarterIslandRegion/WaterBodies/Pond" + ) as Node3D + var pond_region := pond.get_node("FishingRegion") as FishableWaterRegion + assert(pond != null and pond_region != null) + var pond_surface_y: float = pond_region.get_surface_height() - player.global_position = Vector3(-0.5, 3.95, 2.1) + player.global_position = pond.global_position + Vector3(8.9, 1.44, 0.0) var visuals := player.get_node("Visuals") as Node3D visuals.rotation.y = PI * 0.5 for _frame: int in 4: @@ -50,8 +56,8 @@ func _run() -> void: fishing_spot.set("_cast_charge", 0.32) fishing_spot.call("_update_cast_charge", 0.0) var aimed_target: Vector3 = fishing_spot.get("_cast_target") - assert(aimed_target.x < -1.35) - assert(is_equal_approx(aimed_target.y, 2.51)) + assert(aimed_target.x < player.global_position.x - 0.85) + assert(is_equal_approx(aimed_target.y, pond_surface_y)) assert(fishing_spot.is_target_fishable(aimed_target)) fishing_spot.call("_confirm_cast") assert(fishing_spot.state == FishingSpotType.FishingState.CASTING) @@ -68,7 +74,7 @@ func _run() -> void: var attempts: Dictionary = service.get("_attempts") var attempt: NetworkFishingAttempt = attempts.get(session.get_local_peer_id()) assert(attempt != null) - assert(is_equal_approx(attempt.target.y, 2.51)) + assert(is_equal_approx(attempt.target.y, pond_surface_y)) assert(attempt.bobber_position.is_equal_approx(attempt.target)) fishing_spot.set("_withdrawal_input_held", true) @@ -124,29 +130,42 @@ func _run() -> void: fish, ) assert(fish_catch != null and fish_catch.is_valid()) - var baseline_controller := CatchController.new() - root.add_child(baseline_controller) - baseline_controller.start_authoritative_encounter( + var reference_controller := CatchController.new() + root.add_child(reference_controller) + reference_controller.start_authoritative_encounter( fish.catch_profile, attempt.reel_speed, attempt.barrier_damage, attempt.encounter_seed, - FishQuality.Tier.BORING, + fish_catch.quality, + int(fish.rarity), + fish.get_weight_percentile(fish_catch.weight_lb), ) var quality_barriers: Array = attempt.controller.get("_barriers") - var baseline_barriers: Array = baseline_controller.get("_barriers") - assert(quality_barriers.size() == baseline_barriers.size()) + var reference_barriers: Array = reference_controller.get("_barriers") + assert(quality_barriers.size() == reference_barriers.size()) for barrier_index: int in quality_barriers.size(): var quality_barrier: RefCounted = quality_barriers[barrier_index] - var baseline_barrier: RefCounted = baseline_barriers[barrier_index] + var reference_barrier: RefCounted = reference_barriers[barrier_index] assert( - int(quality_barrier.get("maximum_health")) - == FishQuality.apply_barrier_health( - int(baseline_barrier.get("maximum_health")), - fish_catch.quality, + is_equal_approx( + float(quality_barrier.get("position")), + float(reference_barrier.get("position")), ) ) - baseline_controller.queue_free() + assert( + int(quality_barrier.get("maximum_health")) + == int(reference_barrier.get("maximum_health")) + ) + assert( + int(quality_barrier.get("maximum_health")) + >= FishQuality.BARRIER_HEALTH_MINIMUMS[fish_catch.quality] + ) + assert( + int(quality_barrier.get("maximum_health")) + <= FishQuality.BARRIER_HEALTH_MAXIMUMS[fish_catch.quality] + ) + reference_controller.queue_free() service.call("_cancel_attempt", session.get_local_peer_id(), "") await process_frame assert(not service.has_local_attempt()) @@ -154,5 +173,7 @@ func _run() -> void: print("Fishing authority validation: PASS") session.disconnect_session("") main.queue_free() - await process_frame + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout quit() diff --git a/tests/fishing_multiplayer_validation.gd b/tests/fishing_multiplayer_validation.gd index 2dae5e3..f6a8b40 100644 --- a/tests/fishing_multiplayer_validation.gd +++ b/tests/fishing_multiplayer_validation.gd @@ -150,7 +150,9 @@ func _run_host() -> void: print("Fishing multiplayer host validation: PASS") session.disconnect_session("") main.queue_free() - await process_frame + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout quit() @@ -325,7 +327,9 @@ func _run_client() -> void: print("Fishing multiplayer client validation: PASS") session.disconnect_session("") main.queue_free() - await process_frame + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout quit() diff --git a/tests/fishing_surface_validation.gd b/tests/fishing_surface_validation.gd index 15cc428..6ed3486 100644 --- a/tests/fishing_surface_validation.gd +++ b/tests/fishing_surface_validation.gd @@ -266,11 +266,47 @@ func _validate_bite_wait_distribution() -> void: if wait_seconds >= FishingSpotType.BITE_LONG_MAX_SECONDS: very_long_count += 1 var average_wait_seconds: float = total_wait_seconds / 10000.0 - assert(quick_count >= 2300 and quick_count <= 2700) + var quick_ratio: float = float(quick_count) / 10000.0 + var very_long_ratio: float = float(very_long_count) / 10000.0 + var very_long_probability: float = 1.0 - ( + FishingSpotType.BITE_QUICK_PROBABILITY + + FishingSpotType.BITE_TYPICAL_PROBABILITY + + FishingSpotType.BITE_LONG_PROBABILITY + ) + var expected_average_wait_seconds: float = ( + FishingSpotType.BITE_QUICK_PROBABILITY + * ( + FishingSpotType.BITE_QUICK_MIN_SECONDS + + FishingSpotType.BITE_QUICK_MAX_SECONDS + ) + * 0.5 + + FishingSpotType.BITE_TYPICAL_PROBABILITY + * ( + FishingSpotType.BITE_QUICK_MAX_SECONDS + + FishingSpotType.BITE_TYPICAL_MAX_SECONDS + ) + * 0.5 + + FishingSpotType.BITE_LONG_PROBABILITY + * ( + FishingSpotType.BITE_TYPICAL_MAX_SECONDS + + FishingSpotType.BITE_LONG_MAX_SECONDS + ) + * 0.5 + + very_long_probability + * ( + FishingSpotType.BITE_LONG_MAX_SECONDS + + FishingSpotType.BITE_MAX_SECONDS + ) + * 0.5 + ) + assert(absf( + quick_ratio - FishingSpotType.BITE_QUICK_PROBABILITY + ) <= 0.02) assert(typical_or_long_count > quick_count) - assert(very_long_count >= 100 and very_long_count <= 300) - assert(average_wait_seconds >= 45.0) - assert(average_wait_seconds <= 49.0) + assert(absf(very_long_ratio - very_long_probability) <= 0.005) + assert(absf( + average_wait_seconds - expected_average_wait_seconds + ) <= 1.0) fishing_spot.queue_free() await process_frame @@ -305,11 +341,16 @@ func _validate_remote_presentation() -> void: fish_catch.sale_value = 1 assert(fish_catch.is_valid()) presentation.play_return(fish_catch) - await create_timer(0.6).timeout var catch_display := player.find_child( "CatchDisplay", true, false ) as Node3D assert(catch_display != null) + var showcase_deadline_msec: int = Time.get_ticks_msec() + 2000 + while ( + not catch_display.visible + and Time.get_ticks_msec() < showcase_deadline_msec + ): + await process_frame assert(catch_display.visible) await presentation.return_completed assert(not catch_display.visible) diff --git a/tests/inventory_notepad_art_validation.gd b/tests/inventory_notepad_art_validation.gd index cbe3414..cba0797 100644 --- a/tests/inventory_notepad_art_validation.gd +++ b/tests/inventory_notepad_art_validation.gd @@ -62,7 +62,9 @@ func _run() -> void: await _capture_inventory_pages(player_menu) print("Inventory notepad artwork validation: PASS") presentation_stage.queue_free() - await process_frame + for _frame: int in 10: + await process_frame + await create_timer(0.1).timeout quit() diff --git a/tests/job_multiplayer_validation.gd b/tests/job_multiplayer_validation.gd index 5994eae..b40fc2e 100644 --- a/tests/job_multiplayer_validation.gd +++ b/tests/job_multiplayer_validation.gd @@ -56,7 +56,9 @@ func _run_host() -> void: print("Job multiplayer host validation: PASS") session.disconnect_session("") main.queue_free() - await process_frame + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout quit() @@ -93,7 +95,9 @@ func _run_client() -> void: print("Job multiplayer client validation: PASS") session.disconnect_session("") main.queue_free() - await process_frame + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout quit() diff --git a/tests/logbook_runtime_validation.gd b/tests/logbook_runtime_validation.gd index 77280ea..3df30a6 100644 --- a/tests/logbook_runtime_validation.gd +++ b/tests/logbook_runtime_validation.gd @@ -79,10 +79,15 @@ func _run() -> void: "_show_section_immediate", PlayerMenu.Section.PROFILE ) await process_frame - assert(not bool( + assert(bool( player_menu.call("_handle_direct_page_shortcut", shortcut) )) + await create_timer(0.5).timeout assert(player_menu.visible) + assert(logbook.visible) + assert( + player_menu.get("_current_section") == PlayerMenu.Section.LOGBOOK + ) player_menu.close_menu(PlayerMenu.CloseReason.TEARDOWN, false) print( @@ -90,7 +95,8 @@ func _run() -> void: % [root.size.x, root.size.y] ) main.queue_free() - await process_frame + for _frame: int in 4: + await process_frame quit() diff --git a/tests/logbook_validation.gd b/tests/logbook_validation.gd index 38b7ade..866a34f 100644 --- a/tests/logbook_validation.gd +++ b/tests/logbook_validation.gd @@ -171,7 +171,7 @@ func _validate_page() -> void: page.activate() await process_frame assert(page.get("_category") == WaterType.Type.FRESH_WATER) - assert((page.get("_catalog_grid") as GridContainer).columns == 5) + assert((page.get("_catalog_grid") as GridContainer).columns == 4) var initial_detail_body := page.get("_detail_body") as VBoxContainer assert(not initial_detail_body.get_parent() is ScrollContainer) assert( @@ -209,7 +209,7 @@ func _validate_page() -> void: assert(portrait.source_texture == fish.display_texture) assert( portrait.custom_minimum_size - == LogbookPortraitType.ENTRY_FRAME_SIZE + == LogbookPage.CATALOG_PORTRAIT_SIZE ) assert( portrait.expand_mode diff --git a/tests/player_experience_ui_validation.gd b/tests/player_experience_ui_validation.gd index 93c23eb..31ed497 100644 --- a/tests/player_experience_ui_validation.gd +++ b/tests/player_experience_ui_validation.gd @@ -12,6 +12,14 @@ func _run() -> void: root.add_child(game_ui) await process_frame game_ui.set("_gameplay_ui_enabled", true) + var panel := game_ui.get_node("%ExperienceProgressPanel") as PanelContainer + var bubble := game_ui.get_node("%ExperienceBubble") as PanelContainer + var bubble_label := game_ui.get_node("%ExperienceBubbleLabel") as Label + var award_label := game_ui.get_node("%ExperienceAwardLabel") as Label + assert(panel != null and bubble != null) + assert(not panel.visible and not bubble.visible) + game_ui.call("_on_showcase_changed", "bluegill", "common", 1.0, 0, true) + await process_frame game_ui.call( "_on_experience_awarded", 50, @@ -20,13 +28,6 @@ func _run() -> void: 1, 1, ) - var panel := game_ui.get_node("%ExperienceProgressPanel") as PanelContainer - var bubble := game_ui.get_node("%ExperienceBubble") as PanelContainer - var bubble_label := game_ui.get_node("%ExperienceBubbleLabel") as Label - var award_label := game_ui.get_node("%ExperienceAwardLabel") as Label - assert(panel != null and bubble != null) - assert(not panel.visible and not bubble.visible) - game_ui.call("_on_showcase_changed", "bluegill", "common", 1.0, 0, true) await process_frame assert(not panel.visible and not bubble.visible) game_ui.call("_on_showcase_changed", "", "", 0.0, 0, false) @@ -43,5 +44,8 @@ func _run() -> void: await create_timer(1.2).timeout assert(not panel.visible and not bubble.visible) game_ui.queue_free() + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout print("Player experience UI validation: PASS") quit() diff --git a/tests/surface_drawing_multiplayer_validation.gd b/tests/surface_drawing_multiplayer_validation.gd index 12925ba..7b4d8d6 100644 --- a/tests/surface_drawing_multiplayer_validation.gd +++ b/tests/surface_drawing_multiplayer_validation.gd @@ -114,7 +114,9 @@ func _run_host() -> void: print("Surface drawing multiplayer host validation: PASS") session.disconnect_session("") main.queue_free() - await process_frame + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout quit() @@ -207,7 +209,9 @@ func _run_client() -> void: print("Surface drawing multiplayer client validation: PASS") session.disconnect_session("") main.queue_free() - await process_frame + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout quit() diff --git a/tests/surface_drawing_runtime_validation.gd b/tests/surface_drawing_runtime_validation.gd index 6e6e68d..b8c00d9 100644 --- a/tests/surface_drawing_runtime_validation.gd +++ b/tests/surface_drawing_runtime_validation.gd @@ -110,7 +110,9 @@ func _run() -> void: print("Surface drawing runtime validation: PASS") session.disconnect_session("") main.queue_free() - await process_frame + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout quit() diff --git a/tests/surface_drawing_validation.gd b/tests/surface_drawing_validation.gd index aa94f0f..596cec0 100644 --- a/tests/surface_drawing_validation.gd +++ b/tests/surface_drawing_validation.gd @@ -21,6 +21,8 @@ func _run() -> void: await _validate_blocked_author_visibility() _validate_peer_capability_tracking() print("Surface drawing validation: PASS") + for _frame: int in 4: + await process_frame quit() @@ -118,6 +120,7 @@ func _validate_art_unlocks() -> void: assert(unlocks.is_brush_size_unlocked(4)) assert(unlocks.is_grid_size_unlocked(128)) assert(not unlocks.restore_mask(PlayerArtUnlocks.ALL_UNLOCK_MASK + 1)) + unlocks.free() func _validate_grid_snapping() -> void: @@ -265,6 +268,7 @@ func _validate_blocked_author_visibility() -> void: }) var world := Node3D.new() root.add_child(world) + world.add_child(relationships) var canvas := SurfaceDrawingCanvas.new() world.add_child(canvas) var state: Dictionary = { diff --git a/tests/ui_scaling_runtime_validation.gd b/tests/ui_scaling_runtime_validation.gd index d329755..f7144eb 100644 --- a/tests/ui_scaling_runtime_validation.gd +++ b/tests/ui_scaling_runtime_validation.gd @@ -205,6 +205,8 @@ func _run() -> void: presenter.queue_free() root.size = original_size - await process_frame + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout print("UI scaling runtime validation: PASS") quit() diff --git a/tests/world_time_multiplayer_validation.gd b/tests/world_time_multiplayer_validation.gd index 2d82aea..6801de0 100644 --- a/tests/world_time_multiplayer_validation.gd +++ b/tests/world_time_multiplayer_validation.gd @@ -83,7 +83,9 @@ func _run_host() -> void: print("World time multiplayer host validation: PASS") session.disconnect_session("") main.queue_free() - await process_frame + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout quit() @@ -195,7 +197,9 @@ func _run_client() -> void: print("World time multiplayer client validation: PASS") session.disconnect_session("") main.queue_free() - await process_frame + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout quit() diff --git a/ui/controller_focus_presentation.gd b/ui/controller_focus_presentation.gd index 88954ac..7223ca7 100644 --- a/ui/controller_focus_presentation.gd +++ b/ui/controller_focus_presentation.gd @@ -41,6 +41,8 @@ func _input(event: InputEvent) -> void: CONTROLLER_MOTION_THRESHOLD ): _set_controller_active(true) + elif event is InputEventMouseMotion: + _set_controller_active(false) elif event is InputEventMouseButton: if (event as InputEventMouseButton).pressed: _set_controller_active(false) diff --git a/ui/controller_focus_recovery.gd b/ui/controller_focus_recovery.gd index 55bc6ad..bcaf6c8 100644 --- a/ui/controller_focus_recovery.gd +++ b/ui/controller_focus_recovery.gd @@ -3,11 +3,28 @@ extends Node const CONTROLLER_AXIS_THRESHOLD: float = 0.35 const SEMANTIC_MATCH_BONUS: float = 1000000.0 +const KEYBOARD_NAVIGATION_ACTIONS: Array[StringName] = [ + &"ui_accept", + &"ui_cancel", + &"ui_up", + &"ui_down", + &"ui_left", + &"ui_right", + &"ui_focus_next", + &"ui_focus_prev", + &"ui_page_up", + &"ui_page_down", + &"ui_home", + &"ui_end", +] var _controller_active: bool = false +var _focus_navigation_active: bool = false var _last_focus_center: Vector2 = Vector2.ZERO var _last_focus_key: String = "" var _scope_chain: Array[WeakRef] = [] +var _pending_focus: WeakRef +var _pointer_button_down: bool = false var _recovery_generation: int = 0 @@ -27,23 +44,51 @@ func _input(event: InputEvent) -> void: if event is InputEventJoypadButton: var button_event := event as InputEventJoypadButton if button_event.pressed: + _pointer_button_down = false _controller_active = true + _focus_navigation_active = true if button_event.button_index == JOY_BUTTON_LEFT_SHOULDER: _recovery_generation += 1 _scope_chain.clear() + _pending_focus = null + return + _request_pending_focus() return if event is InputEventJoypadMotion: if absf((event as InputEventJoypadMotion).axis_value) >= ( CONTROLLER_AXIS_THRESHOLD ): + _pointer_button_down = false _controller_active = true + _focus_navigation_active = true + _request_pending_focus() + return + if event is InputEventMouseMotion: + _pointer_button_down = ( + (event as InputEventMouseMotion).button_mask != 0 + ) + _leave_focus_navigation(not _pointer_button_down) return if event is InputEventMouseButton: - if (event as InputEventMouseButton).pressed: - _controller_active = false + var mouse_button := event as InputEventMouseButton + if mouse_button.button_index in [ + MOUSE_BUTTON_LEFT, + MOUSE_BUTTON_RIGHT, + MOUSE_BUTTON_MIDDLE, + ]: + _pointer_button_down = mouse_button.pressed + _leave_focus_navigation(false) + if not mouse_button.pressed: + _release_current_pointer_focus.call_deferred() return if event is InputEventKey and (event as InputEventKey).pressed: + _pointer_button_down = false _controller_active = false + if _is_keyboard_navigation_event(event): + _focus_navigation_active = true + _request_pending_focus() + else: + _leave_focus_navigation() func _process(_delta: float) -> void: @@ -58,8 +103,16 @@ func _process(_delta: float) -> void: func _on_gui_focus_changed(control: Control) -> void: _recovery_generation += 1 if control != null: - if _controller_active and control is BaseButton: - _remember_focus(control) + if _focus_navigation_active: + _pending_focus = null + if _controller_active and control is BaseButton: + _remember_focus(control) + return + if _keeps_pointer_focus(control): + return + if _is_focusable(control): + _pending_focus = weakref(control) + _release_focus_if_inactive.call_deferred(control) return if not _controller_active or _scope_chain.is_empty(): return @@ -92,13 +145,18 @@ func _recover_focus(generation: int) -> void: scope == null or not is_instance_valid(scope) or not scope.is_inside_tree() - or not scope.is_visible_in_tree() ): continue + if not scope.is_visible_in_tree(): + _scope_chain.clear() + return var replacement := _best_replacement_in(scope) if replacement != null: replacement.grab_focus() - return + else: + _scope_chain.clear() + return + _scope_chain.clear() func _best_replacement_in(scope: Control) -> Control: @@ -124,7 +182,7 @@ func _is_focusable(control: Control) -> bool: control == null or not control.is_inside_tree() or not control.is_visible_in_tree() - or control.focus_mode == Control.FOCUS_NONE + or control.focus_mode not in [Control.FOCUS_CLICK, Control.FOCUS_ALL] ): return false var button := control as BaseButton @@ -140,3 +198,80 @@ func _semantic_key(control: Control) -> String: tooltip = tooltip.trim_suffix(" (show variants)") tooltip = tooltip.trim_suffix(" (hide variants)") return "%s|%s|%s" % [control.get_class(), label, tooltip] + + +func _leave_focus_navigation(release_focus: bool = true) -> void: + _controller_active = false + _focus_navigation_active = false + _recovery_generation += 1 + _scope_chain.clear() + var focus_owner: Control = get_viewport().gui_get_focus_owner() + if focus_owner == null or _keeps_pointer_focus(focus_owner): + return + if _is_focusable(focus_owner): + _pending_focus = weakref(focus_owner) + if release_focus: + _release_focus_if_inactive.call_deferred(focus_owner) + + +func _release_focus_if_inactive(control: Control) -> void: + if ( + _focus_navigation_active + or _pointer_button_down + or control == null + or not is_instance_valid(control) + or get_viewport().gui_get_focus_owner() != control + or _keeps_pointer_focus(control) + ): + return + get_viewport().gui_release_focus() + + +func _release_current_pointer_focus() -> void: + var focus_owner: Control = get_viewport().gui_get_focus_owner() + if focus_owner != null: + _release_focus_if_inactive(focus_owner) + + +func _request_pending_focus() -> void: + if ( + not _focus_navigation_active + or _pending_focus == null + or get_viewport().gui_get_focus_owner() != null + ): + return + _restore_pending_focus.call_deferred(_recovery_generation) + + +func _restore_pending_focus(generation: int) -> void: + if ( + generation != _recovery_generation + or not _focus_navigation_active + or _pending_focus == null + or get_viewport().gui_get_focus_owner() != null + ): + return + var target := _pending_focus.get_ref() as Control + if not _is_focusable(target): + _pending_focus = null + return + _pending_focus = null + target.grab_focus() + + +func _is_keyboard_navigation_event(event: InputEvent) -> bool: + if event.is_action_pressed(&"open_backpack"): + return false + if ( + event.is_action_pressed(&"ui_cancel") + and get_viewport().gui_get_focus_owner() == null + ): + return false + for action: StringName in KEYBOARD_NAVIGATION_ACTIONS: + if event.is_action_pressed(action): + return true + return false + + +func _keeps_pointer_focus(control: Control) -> bool: + return control is LineEdit or control is TextEdit diff --git a/ui/fishing_shop.gd b/ui/fishing_shop.gd index 9f93cf8..3c7d7f4 100644 --- a/ui/fishing_shop.gd +++ b/ui/fishing_shop.gd @@ -74,6 +74,8 @@ const SUPPLY_PRICE_HORIZONTAL_PADDING: float = 12.0 @onready var _wallet_label: Label = %WalletLabel @onready var _shop_panel: PanelContainer = %ShopPanel +@onready var _shop_panel_margin: MarginContainer = $ShopPanel/Margin +@onready var _shop_panel_layout: VBoxContainer = $ShopPanel/Margin/Layout @onready var _shop_cooler_page: Control = %ShopCoolerPage @onready var _shop_cooler_mount: Control = %ShopCoolerMount @onready var _shop_body: HBoxContainer = $ShopPanel/Margin/Layout/Body @@ -367,6 +369,7 @@ func get_shop_cooler_mount() -> Control: func activate_shop_cooler_page() -> void: _cooler_page_active = true _shop_panel.show() + _set_shop_panel_background_pointer_blocking(false) _shop_body.hide() _feedback.hide() _shop_cooler_page.show() @@ -377,6 +380,7 @@ func deactivate_shop_cooler_page() -> void: _shop_tab_bar.show() _cooler_page_active = false _cooler_modal_open = false + _set_shop_panel_background_pointer_blocking(true) for tab: OrganizerTab in _shop_tabs: tab.disabled = false _shop_cooler_page.hide() @@ -385,6 +389,17 @@ func deactivate_shop_cooler_page() -> void: _feedback.show() +func _set_shop_panel_background_pointer_blocking(blocking: bool) -> void: + var mouse_filter := ( + Control.MOUSE_FILTER_STOP + if blocking + else Control.MOUSE_FILTER_IGNORE + ) + _shop_panel.mouse_filter = mouse_filter + _shop_panel_margin.mouse_filter = mouse_filter + _shop_panel_layout.mouse_filter = mouse_filter + + func set_shop_cooler_modal_open(is_open: bool) -> void: _cooler_modal_open = is_open for tab: OrganizerTab in _shop_tabs: diff --git a/ui/pause_menu.gd b/ui/pause_menu.gd index 3d2baa8..dccac73 100644 --- a/ui/pause_menu.gd +++ b/ui/pause_menu.gd @@ -351,7 +351,10 @@ func _open_confirmation( else BubbleConfirmationPageType.InitialFocus.CONFIRM ) ) - _begin_root_exit(_show_confirmation) + # Confirmation actions reject input while the root page is still leaving. + # Finish that exit before exposing an interactive confirmation page so a + # pointer click can never land in the overlap window and be discarded. + _begin_root_exit(_show_confirmation, false) func _show_confirmation() -> void: @@ -450,7 +453,10 @@ func _finish_root_entry(generation: int) -> void: _root_transition_active = false -func _begin_root_exit(completed: Callable) -> void: +func _begin_root_exit( + completed: Callable, + overlap_next_page: bool = true, +) -> void: if _root_transition_active or not _root_page.visible: return _root_transition_generation += 1 @@ -458,9 +464,14 @@ func _begin_root_exit(completed: Callable) -> void: _emit_transition_flurry() var generation: int = _root_transition_generation _root_page.transition_out( - _finish_root_exit.bind(generation), + _finish_root_exit.bind( + generation, + Callable() if overlap_next_page else completed, + ), 0.0 ) + if not overlap_next_page: + return await get_tree().create_timer( UIMotion.BUBBLE_TRANSITION_OVERLAP_DELAY ).timeout @@ -471,10 +482,13 @@ func _begin_root_exit(completed: Callable) -> void: func _finish_root_exit( generation: int, + completed: Callable, ) -> void: if generation != _root_transition_generation or not visible: return _root_transition_active = false + if completed.is_valid(): + completed.call() func _finish_user_close() -> void: diff --git a/ui/title_screen.tscn b/ui/title_screen.tscn index 040aebe..d285ea9 100644 --- a/ui/title_screen.tscn +++ b/ui/title_screen.tscn @@ -209,7 +209,7 @@ unique_name_in_owner = true layout_mode = 2 theme_override_colors/font_color = Color(0.682, 0.733, 0.761, 1) theme_override_font_sizes/font_size = 22 -text = "v0.6.5-alpha" +text = "v0.6.6-alpha" horizontal_alignment = 1 [node name="Spacer" type="Control" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent"]