From 8f8398cbcd3a5027cfed04e41130ab3f4dbf1dc9 Mon Sep 17 00:00:00 2001 From: Voyager Date: Sat, 22 Aug 2026 22:56:30 -0400 Subject: [PATCH] Stabilize camera input across fishing and radial menus --- player/player.gd | 31 ++++++-- tests/art_tools_validation.gd | 17 +++- tests/gathering_showcase_validation.gd | 37 ++++++++- ui/game_ui.gd | 105 +++++++++++++++++-------- 4 files changed, 147 insertions(+), 43 deletions(-) diff --git a/player/player.gd b/player/player.gd index 680350c..6d237bf 100644 --- a/player/player.gd +++ b/player/player.gd @@ -375,7 +375,6 @@ class ShowcaseCameraSnapshot: var pitch_rotation: Vector3 var spring_length: float var target_zoom: float - var camera_input_enabled: bool var camera_dragging: bool @@ -477,6 +476,7 @@ var _free_camera_yaw: float = 0.0 var _free_camera_pitch: float = 0.0 var _movement_enabled: bool = true var _local_input_suppressors: Dictionary[StringName, bool] = {} +var _camera_input_suppressors: Dictionary[StringName, bool] = {} var _water_recovery_active: bool = false var _remote_recovery_presentation_active: bool = false var _remote_recovery_visual_origin: Vector3 @@ -1789,7 +1789,10 @@ func _update_free_camera_physics() -> void: if _free_camera == null or _free_camera_body == null: _set_free_camera_active(false) return - if not _is_movement_input_enabled(): + # Freecam translation is camera input, not avatar movement. Fishing locks + # the avatar after a cast, but the detached camera must remain movable. + # Camera/menu input ownership still stops freecam while an overlay is active. + if not _is_camera_input_enabled(): _free_camera_body.velocity = Vector3.ZERO _free_camera_body.move_and_slide() return @@ -2483,7 +2486,11 @@ func _is_movement_input_enabled() -> bool: func _is_camera_input_enabled() -> bool: - return _camera_input_enabled and _local_input_suppressors.is_empty() + return ( + _camera_input_enabled + and _local_input_suppressors.is_empty() + and _camera_input_suppressors.is_empty() + ) func set_camera_input_enabled(enabled: bool) -> void: @@ -2492,6 +2499,19 @@ func set_camera_input_enabled(enabled: bool) -> void: _set_camera_dragging(false) +func set_camera_input_suppressed( + owner: StringName, + suppressed: bool, +) -> void: + if owner.is_empty(): + return + if suppressed: + _camera_input_suppressors[owner] = true + _set_camera_dragging(false) + else: + _camera_input_suppressors.erase(owner) + + func set_camera_active(active: bool) -> void: if _free_camera_active and _free_camera != null: _free_camera.current = active @@ -3344,9 +3364,6 @@ func _complete_showcase_restore( _camera_pitch.rotation = _showcase_camera_snapshot.pitch_rotation _spring_arm.spring_length = _showcase_camera_snapshot.spring_length _target_zoom = _showcase_camera_snapshot.target_zoom - _camera_input_enabled = ( - _showcase_camera_snapshot.camera_input_enabled - ) _set_camera_dragging( _showcase_camera_snapshot.camera_dragging and Input.is_action_pressed("camera_drag") @@ -3367,9 +3384,7 @@ func _capture_showcase_camera_snapshot() -> void: _showcase_camera_snapshot.pitch_rotation = _camera_pitch.rotation _showcase_camera_snapshot.spring_length = _spring_arm.spring_length _showcase_camera_snapshot.target_zoom = _target_zoom - _showcase_camera_snapshot.camera_input_enabled = _camera_input_enabled _showcase_camera_snapshot.camera_dragging = _camera_dragging - _camera_input_enabled = false _set_camera_dragging(false) diff --git a/tests/art_tools_validation.gd b/tests/art_tools_validation.gd index 44ec053..f370605 100644 --- a/tests/art_tools_validation.gd +++ b/tests/art_tools_validation.gd @@ -312,10 +312,23 @@ func _run() -> void: Input.parse_input_event(right_stick_motion) await process_frame - # Chat owns all movement while text entry is active, including developer - # freecam movement which reads the same WASD actions as the player. var free_camera_body := player.get("_free_camera_body") as CharacterBody3D assert(free_camera_body != null) + # A released fishing cast locks only the avatar. The detached camera must + # continue to accept translation while that movement lock is active. + player.set_movement_enabled(false) + var cast_locked_camera_position: Vector3 = free_camera_body.global_position + Input.action_press("move_forward") + for _frame: int in 3: + await physics_frame + Input.action_release("move_forward") + assert(not free_camera_body.global_position.is_equal_approx( + cast_locked_camera_position + )) + player.set_movement_enabled(true) + + # Chat owns all movement while text entry is active, including developer + # freecam movement which reads the same WASD actions as the player. chat_ui.open_chat() assert(chat_ui.is_open()) var free_camera_position_before: Vector3 = free_camera_body.global_position diff --git a/tests/gathering_showcase_validation.gd b/tests/gathering_showcase_validation.gd index 26a9e34..7099f7c 100644 --- a/tests/gathering_showcase_validation.gd +++ b/tests/gathering_showcase_validation.gd @@ -30,8 +30,14 @@ func _run() -> void: var player := main.get("_player") as Player var fishing_spot := main.get_node("%FishingSpot") as FishingSpot + var game_ui := main.get_node("%GameUI") as GameUI var catalog := main.get("fish_catalog") as FishPool - assert(player != null and fishing_spot != null and catalog != null) + assert( + player != null + and fishing_spot != null + and game_ui != null + and catalog != null + ) fishing_spot.minimum_showcase_duration = 0.15 assert(player.bag.add_item(&"crab_net")) assert(player.hotbar.assign_item(0, &"crab_net")) @@ -82,6 +88,18 @@ func _run() -> void: await process_frame assert(fishing_spot.get("_pending_catch") == null) + # A radial menu can open after the showcase panel disappears but before the + # pocket animation restores gameplay. Its camera lock must not overwrite the + # showcase's underlying camera state when the two finish in either order. + Input.action_press("open_emotes") + var emote_press := InputEventAction.new() + emote_press.action = &"open_emotes" + emote_press.pressed = true + game_ui.call("_input", emote_press) + var emote_menu := game_ui.get_node("%EmoteRadialMenu") as EmoteRadialMenu + assert(emote_menu.is_open()) + assert(not bool(player.call("_is_camera_input_enabled"))) + var pocket_deadline: int = Time.get_ticks_msec() + 6000 while ( Time.get_ticks_msec() < pocket_deadline @@ -91,6 +109,23 @@ func _run() -> void: assert(fishing_spot.state == FishingSpot.FishingState.READY) assert(player.is_movement_enabled()) assert(bool(player.call("_is_movement_input_enabled"))) + assert(not bool(player.call("_is_camera_input_enabled"))) + Input.action_release("open_emotes") + var emote_release := InputEventAction.new() + emote_release.action = &"open_emotes" + emote_release.pressed = false + game_ui.call("_input", emote_release) + assert(not emote_menu.is_open()) + assert(bool(player.call("_is_camera_input_enabled"))) + + # A focused chat field or popup can consume the wheel's release event. The + # polled action state must still close the radial and release its camera lock. + Input.action_press("open_emotes") + game_ui.call("_input", emote_press) + assert(emote_menu.is_open()) + Input.action_release("open_emotes") + await process_frame + assert(not emote_menu.is_open()) assert(bool(player.call("_is_camera_input_enabled"))) assert(player.inventory.contains_catch_id(crab_catch.catch_id)) assert(bool(player.get("_active_item_is_net"))) diff --git a/ui/game_ui.gd b/ui/game_ui.gd index c8d6f34..e0b87c6 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -81,6 +81,8 @@ signal shop_backdrop_visibility_changed(is_visible: bool) signal virtual_pointer_mode_changed(is_active: bool) const VIRTUAL_MOUSE_INPUT_OWNER: StringName = &"controller_virtual_mouse" +const EMOTE_RADIAL_CAMERA_OWNER: StringName = &"emote_radial_menu" +const QUICK_RADIAL_CAMERA_OWNER: StringName = &"quick_radial_menu" const VIRTUAL_MOUSE_TRIGGER_THRESHOLD: float = 0.55 const VIRTUAL_MOUSE_TRIGGER_RELEASE_THRESHOLD: float = 0.35 const VIRTUAL_MOUSE_STICK_DEADZONE: float = 0.18 @@ -194,8 +196,6 @@ var _experience_award_queue: Array[Dictionary] = [] var _experience_animation_active: bool = false var _experience_animation_generation: int = 0 var _experience_panel_rest_y: float = 18.0 -var _emote_prior_camera_input_enabled: bool = true -var _quick_prior_camera_input_enabled: bool = true var _virtual_mouse_active: bool = false var _virtual_mouse_prior_camera_input_enabled: bool = true var _virtual_mouse_prior_mouse_mode: Input.MouseMode = Input.MOUSE_MODE_VISIBLE @@ -581,15 +581,10 @@ func _input(event: InputEvent) -> void: ): var emote_is_open: bool = _emote_radial_menu.is_open() if emote_is_open != emote_was_open and _player != null: - if emote_is_open: - _emote_prior_camera_input_enabled = ( - _player.is_camera_input_enabled() - ) - _player.set_camera_input_enabled(false) - else: - _player.set_camera_input_enabled( - _emote_prior_camera_input_enabled - ) + _player.set_camera_input_suppressed( + EMOTE_RADIAL_CAMERA_OWNER, + emote_is_open, + ) get_viewport().set_input_as_handled() return var quick_was_open: bool = _quick_radial_menu.is_open() @@ -599,18 +594,63 @@ func _input(event: InputEvent) -> void: ): var quick_is_open: bool = _quick_radial_menu.is_open() if quick_is_open != quick_was_open and _player != null: - if quick_is_open: - _quick_prior_camera_input_enabled = ( - _player.is_camera_input_enabled() - ) - _player.set_camera_input_enabled(false) - else: - _player.set_camera_input_enabled( - _quick_prior_camera_input_enabled - ) + _player.set_camera_input_suppressed( + QUICK_RADIAL_CAMERA_OWNER, + quick_is_open, + ) get_viewport().set_input_as_handled() +func _reconcile_radial_menu_input() -> void: + var blocked: bool = ( + not _gameplay_ui_enabled + or _system_menu_open + or _player_menu_open + or _shop_open + or _storage_open + or _chat_input_open + or _showcase_active + or _virtual_mouse_active + ) + if ( + _emote_radial_menu != null + and _emote_radial_menu.is_open() + and (blocked or not Input.is_action_pressed("open_emotes")) + ): + _close_emote_radial_menu() + if ( + _quick_radial_menu != null + and _quick_radial_menu.is_open() + and (blocked or not Input.is_action_pressed("open_quick_actions")) + ): + _close_quick_radial_menu() + + +func _close_radial_menus() -> void: + _close_emote_radial_menu() + _close_quick_radial_menu() + + +func _close_emote_radial_menu() -> void: + if _emote_radial_menu != null and _emote_radial_menu.is_open(): + _emote_radial_menu.close_menu() + if _player != null and is_instance_valid(_player): + _player.set_camera_input_suppressed( + EMOTE_RADIAL_CAMERA_OWNER, + false, + ) + + +func _close_quick_radial_menu() -> void: + if _quick_radial_menu != null and _quick_radial_menu.is_open(): + _quick_radial_menu.close_menu() + if _player != null and is_instance_valid(_player): + _player.set_camera_input_suppressed( + QUICK_RADIAL_CAMERA_OWNER, + false, + ) + + func _can_use_character_call() -> bool: return ( _gameplay_ui_enabled @@ -1356,6 +1396,7 @@ func is_input_mapping_capturing() -> bool: func _process(delta: float) -> void: + _reconcile_radial_menu_input() _poll_virtual_mouse_controller_state() _update_virtual_mouse(delta) _update_controller_menu_scroll(delta) @@ -1578,18 +1619,7 @@ func set_gameplay_ui_enabled(enabled: bool) -> void: _refresh_chat_availability() if not enabled: _end_virtual_mouse() - if _emote_radial_menu != null and _emote_radial_menu.is_open(): - _emote_radial_menu.close_menu() - if _player != null: - _player.set_camera_input_enabled( - _emote_prior_camera_input_enabled - ) - if _quick_radial_menu != null and _quick_radial_menu.is_open(): - _quick_radial_menu.close_menu() - if _player != null: - _player.set_camera_input_enabled( - _quick_prior_camera_input_enabled - ) + _close_radial_menus() if _surface_drawing != null: _surface_drawing.deactivate() close_player_menu_for_session_end() @@ -1658,6 +1688,8 @@ func _refresh_gameplay_hud_visibility() -> void: func set_system_menu_open(is_open: bool) -> void: _system_menu_open = is_open + if is_open: + _close_radial_menus() _refresh_surface_drawing_activation() _refresh_gameplay_hud_visibility() _refresh_chat_availability() @@ -2071,6 +2103,8 @@ func _on_showcase_changed( showcase_visible: bool, ) -> void: _showcase_active = showcase_visible + if showcase_visible: + _close_radial_menus() _refresh_surface_drawing_activation() if not showcase_visible: _set_fishing_panel_showcase_position(false) @@ -2314,6 +2348,7 @@ func _on_player_menu_visibility_changed(is_open: bool) -> void: _chat_ui.set_world_speech_visible(not is_open) if is_open: _end_virtual_mouse() + _close_radial_menus() _refresh_surface_drawing_activation() _refresh_gameplay_hud_visibility() if is_open: @@ -2366,6 +2401,8 @@ func _on_shop_exit_started() -> void: func _on_shop_visibility_changed(is_open: bool) -> void: _shop_open = is_open + if is_open: + _close_radial_menus() _refresh_surface_drawing_activation() if is_open: shop_backdrop_visibility_changed.emit(true) @@ -2389,6 +2426,8 @@ func _on_shop_visibility_changed(is_open: bool) -> void: func _on_storage_visibility_changed(is_open: bool) -> void: _storage_open = is_open + if is_open: + _close_radial_menus() _refresh_surface_drawing_activation() _refresh_gameplay_hud_visibility() _refresh_chat_availability() @@ -2544,6 +2583,8 @@ func _emit_interactive_pointer_ui_changed() -> void: func _on_chat_text_entry_ownership_changed(active: bool) -> void: _chat_input_open = active + if active: + _close_radial_menus() _refresh_surface_drawing_activation() _refresh_chat_availability() _emit_interactive_pointer_ui_changed()