From 611eeb9d8db654fcc71babe9af517c52dff489ad Mon Sep 17 00:00:00 2001 From: Voyager Date: Sat, 15 Aug 2026 10:16:09 -0400 Subject: [PATCH] Fix pause menu input transition races --- tests/art_tools_validation.gd | 66 +++++++++++++++++++++++++++++++++++ ui/pause_menu.gd | 42 +++++++++++----------- 2 files changed, 86 insertions(+), 22 deletions(-) diff --git a/tests/art_tools_validation.gd b/tests/art_tools_validation.gd index 16d2768..9dce65d 100644 --- a/tests/art_tools_validation.gd +++ b/tests/art_tools_validation.gd @@ -284,6 +284,7 @@ func _run() -> void: start_button.button_index = JOY_BUTTON_START start_button.pressed = true assert(bool(main.call("_is_pause_open_request", start_button))) + await _validate_pause_browser_transition(game_ui, player) var player_menu := game_ui.get_node("%PlayerMenu") as PlayerMenu player_menu.open_section(PlayerMenu.Section.PROFILE) for _frame: int in 12: @@ -565,3 +566,68 @@ func _run() -> void: await process_frame await create_timer(0.1).timeout quit() + + +func _validate_pause_browser_transition( + game_ui: GameUI, + player: Player, +) -> void: + var pause_menu := game_ui.get_pause_menu() + var root_page := pause_menu.get_node("%RootPage") as Control + var join_page := pause_menu.get_node("%JoinGamePage") as JoinGamePage + pause_menu.open_menu() + while bool(pause_menu.get("_root_transition_active")): + await process_frame + assert(pause_menu.visible and root_page.visible) + assert(not bool(player.call("_is_movement_input_enabled"))) + + pause_menu.call("_open_join_game") + while not join_page.visible: + await process_frame + assert(bool(pause_menu.get("_root_transition_active"))) + pause_menu.call("_close_join_game") + assert(not join_page.visible) + # This is the issue #73 input: Escape arrives while the root is returning. + assert(pause_menu.handle_escape()) + while bool(pause_menu.get("_root_transition_active")): + await process_frame + assert(pause_menu.visible and root_page.visible) + assert(not bool(player.call("_is_movement_input_enabled"))) + + assert(pause_menu.handle_escape()) + while pause_menu.visible: + await process_frame + assert(player.is_movement_enabled()) + + # Fishing can complete while pause owns local input. Closing pause must only + # release its own lock, not restore the stale movement value from open time. + player.set_movement_enabled(false) + pause_menu.open_menu() + while bool(pause_menu.get("_root_transition_active")): + await process_frame + assert(not bool(player.call("_is_movement_input_enabled"))) + player.set_movement_enabled(true) + assert(player.is_movement_enabled()) + assert(not bool(player.call("_is_movement_input_enabled"))) + pause_menu.close_menu(PauseMenu.CloseReason.USER_RETURN) + assert(player.is_movement_enabled()) + assert(bool(player.call("_is_movement_input_enabled"))) + + # Opening pause during a right-click camera drag must not snapshot and later + # restore the drag's transient captured mouse mode after the drag is cancelled. + assert( + pause_menu.call( + "_get_restorable_mouse_mode", + Input.MOUSE_MODE_CAPTURED, + ) == Input.MOUSE_MODE_VISIBLE + ) + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + player.call("_set_camera_dragging", true) + assert(bool(player.get("_camera_dragging"))) + pause_menu.open_menu() + while bool(pause_menu.get("_root_transition_active")): + await process_frame + assert(not bool(player.get("_camera_dragging"))) + assert(Input.mouse_mode == Input.MOUSE_MODE_VISIBLE) + pause_menu.close_menu(PauseMenu.CloseReason.USER_RETURN) + assert(Input.mouse_mode == Input.MOUSE_MODE_VISIBLE) diff --git a/ui/pause_menu.gd b/ui/pause_menu.gd index f98b971..8e33a30 100644 --- a/ui/pause_menu.gd +++ b/ui/pause_menu.gd @@ -63,10 +63,7 @@ var _settings_manager: SettingsManagerType var _fishing_spot: FishingSpotType var _network_session: NetworkSessionType var _saved_servers: SavedServerStoreType -var _prior_movement_enabled: bool = true -var _prior_camera_enabled: bool = true var _prior_mouse_mode: Input.MouseMode = Input.MOUSE_MODE_VISIBLE -var _control_snapshot_stored: bool = false var _mouse_snapshot_stored: bool = false var _confirmation_action: ConfirmationAction = ConfirmationAction.NONE var _action_in_progress: bool = false @@ -128,13 +125,11 @@ func setup( func open_menu() -> void: if visible or _player == null: return - _prior_movement_enabled = _player.is_movement_enabled() - _prior_camera_enabled = _player.is_camera_input_enabled() - _prior_mouse_mode = Input.mouse_mode - _control_snapshot_stored = true + # Suppression ends an active right-click camera drag and restores its prior + # mouse mode. Snapshot only after that transient capture has been released. + _player.set_local_input_suppressed(INPUT_OWNER, true) + _prior_mouse_mode = _get_restorable_mouse_mode(Input.mouse_mode) _mouse_snapshot_stored = true - _player.set_movement_enabled(false) - _player.set_camera_input_enabled(false) _fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, true) Input.mouse_mode = Input.MOUSE_MODE_VISIBLE _feedback.text = "" @@ -452,8 +447,9 @@ func report_reset_failure() -> void: func _begin_root_entry(flurry_already_emitted: bool) -> void: - if _root_transition_active: - return + # Child pages become interactive before the root page has completely left. + # If one closes during that overlap, reverse the active root transition + # instead of dropping the navigation request and leaving every page hidden. _root_transition_generation += 1 _root_transition_active = true if not flurry_already_emitted: @@ -530,15 +526,20 @@ func _finish_close(reason: CloseReason, restore_controls: bool) -> void: _action_in_progress = false _transition_flurry.clear_flurries() hide() + if _player != null and is_instance_valid(_player): + # Pause owns only its input suppression. Gameplay systems such as fishing + # remain free to change their underlying movement state while it is open. + _player.set_local_input_suppressed(INPUT_OWNER, false) + if not restore_controls: + # The caller is transferring control to title/session or recovery logic. + # Preserve the previous no-input handoff until that system takes over. + _player.set_movement_enabled(false) + _player.set_camera_input_enabled(false) if _fishing_spot != null and is_instance_valid(_fishing_spot): _fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, false) var current_viewport: Viewport = get_viewport() if current_viewport != null: current_viewport.gui_release_focus() - if restore_controls: - _restore_controls() - else: - _control_snapshot_stored = false _apply_mouse_close_policy(reason) menu_visibility_changed.emit(false) @@ -555,13 +556,10 @@ func _update_responsive_pause_stage() -> void: _presentation_scale_root.position = Vector2.ZERO -func _restore_controls() -> void: - if not _control_snapshot_stored: - return - if _player != null and is_instance_valid(_player): - _player.set_movement_enabled(_prior_movement_enabled) - _player.set_camera_input_enabled(_prior_camera_enabled) - _control_snapshot_stored = false +func _get_restorable_mouse_mode(mouse_mode: Input.MouseMode) -> Input.MouseMode: + if mouse_mode == Input.MOUSE_MODE_CAPTURED: + return Input.MOUSE_MODE_VISIBLE + return mouse_mode func _apply_mouse_close_policy(reason: CloseReason) -> void: