Fix pause menu input transition races

This commit is contained in:
Alexander Sellite 2026-08-15 10:16:09 -04:00
parent 13fc64b80c
commit 611eeb9d8d
2 changed files with 86 additions and 22 deletions

View file

@ -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)

View file

@ -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: