From e4ddf4e5a6bee3aaf9f83ca5005684726f20df19 Mon Sep 17 00:00:00 2001 From: Voyager Date: Fri, 21 Aug 2026 12:29:51 -0400 Subject: [PATCH] Fix fishing and chat interaction regressions --- fishing/fishing_spot.gd | 52 +++++++++++------- network/network_fishing_attempt.gd | 1 + network/network_fishing_service.gd | 60 ++++++++++++++------- player/player.gd | 61 ++++++++++++++++++++++ tests/art_tools_validation.gd | 51 +++++++++++++++++- tests/fishing_authority_validation.gd | 34 ++++++++++++ tests/fishing_multiplayer_validation.gd | 4 +- tests/gathering_showcase_validation.gd | 7 +++ tests/keyboard_mouse_mapping_validation.gd | 14 +++++ ui/chat_ui.gd | 10 +++- world/water_recovery_controller.gd | 5 +- 11 files changed, 256 insertions(+), 43 deletions(-) diff --git a/fishing/fishing_spot.gd b/fishing/fishing_spot.gd index 389d40b..a1c8ddf 100644 --- a/fishing/fishing_spot.gd +++ b/fishing/fishing_spot.gd @@ -417,20 +417,21 @@ func configure_accessibility_auto_click( func begin_water_recovery() -> void: _external_input_blocked = true - if state in [ - FishingState.AIMING_CAST, - FishingState.CASTING, - FishingState.WAITING_FOR_BITE, - FishingState.FIGHTING, - ]: - _cancel_attempt() - # Recovery replaces the normal rod-return presentation. Finish the local - # cleanup now so its temporary movement lock is not captured and restored - # after the player respawns. - if state != FishingState.READY: - _finalize_attempt_cleanup("") - elif state == FishingState.SHOWING_CATCH: + if state == FishingState.SHOWING_CATCH: _secure_showcase_catch_for_recovery() + elif is_fishing_sequence_active(): + if state in [ + FishingState.AIMING_CAST, + FishingState.CASTING, + FishingState.WAITING_FOR_BITE, + FishingState.FIGHTING, + ]: + _cancel_attempt() + # Recovery replaces every in-flight rod return. This includes RETURNING, + # which can be entered by Escape a frame before the water trigger fires. + # Finish cleanup now so recovery never snapshots the cast movement lock. + if state != FishingState.READY or _active_player != null: + _finalize_attempt_cleanup("") func end_water_recovery() -> void: @@ -538,6 +539,12 @@ func _unhandled_input(event: InputEvent) -> void: alternate_reel and state == FishingState.FIGHTING ): return + # A held keyboard key emits echo events. Treat the alternate reel exactly + # like a mouse button: one barrier hit per physical press, while the normal + # held-input path continues driving free reeling between barriers. + if event is InputEventKey and event.echo: + get_viewport().set_input_as_handled() + return var selected_item: ItemDataType = _get_active_item() if ( state == FishingState.READY @@ -948,7 +955,6 @@ func _on_cast_completed() -> void: if _selected_fish == null: _cleanup_attempt("nothing is biting here.", &"invalid") return - _consume_active_bait() state = FishingState.WAITING_FOR_BITE _active_player.set_fishing_visual(true) @@ -975,11 +981,13 @@ func _on_cast_completed() -> void: status_changed.emit("") -func _consume_active_bait() -> void: +func _consume_active_bait() -> bool: if _active_player == null or _active_player.active_bait_id.is_empty(): - return + return true if _local_bag == null or not _local_bag.remove_item(_active_player.active_bait_id, 1): _active_player.unequip_bait() + return false + return true func roll_bite_wait_time() -> float: @@ -1131,14 +1139,18 @@ func _activate_bite(confirmation_override: bool = false) -> void: return _set_bite_confirmation_pending(false) - state = FishingState.FIGHTING - _active_player.set_fighting_visual(true) - _state_time_remaining = 0.0 - _withdrawal_input_held = false _pending_catch = _fish_selector.create_catch(_selected_fish) if _pending_catch == null or not _pending_catch.is_valid(): _cancel_attempt() return + if not _consume_active_bait(): + _pending_catch = null + _cleanup_attempt("No bait available.", &"cancel") + return + state = FishingState.FIGHTING + _active_player.set_fighting_visual(true) + _state_time_remaining = 0.0 + _withdrawal_input_held = false _start_fight_audio() bite_activated.emit() status_changed.emit("fish on!") diff --git a/network/network_fishing_attempt.gd b/network/network_fishing_attempt.gd index 84511db..638b4da 100644 --- a/network/network_fishing_attempt.gd +++ b/network/network_fishing_attempt.gd @@ -22,6 +22,7 @@ var target: Vector3 var bobber_position: Vector3 var fish_id: StringName var rod_id: StringName +var bait_id: StringName var bait_tags: Array[StringName] = [] var lure_effects: Array[StringName] = [] var encounter_seed: int = 0 diff --git a/network/network_fishing_service.gd b/network/network_fishing_service.gd index e1dfe56..95de131 100644 --- a/network/network_fishing_service.gd +++ b/network/network_fishing_service.gd @@ -168,6 +168,7 @@ func cancel_local_attempt(reason: String = "Fishing attempt ended.") -> void: if _session.is_host(): _cancel_attempt(peer_id, reason) else: + _pending_local_bait_by_request.erase(attempt.request_id) submit_cancel_request.rpc_id(1, attempt.attempt_id) @@ -361,7 +362,7 @@ func _handle_cast_request(peer_id: int, data: Dictionary) -> void: owns_authoritative_bag and ( avatar_bag == null - or not avatar_bag.remove_item(bait_id, 1) + or not avatar_bag.owns_item(bait_id) ) ) ): @@ -378,6 +379,7 @@ func _handle_cast_request(peer_id: int, data: Dictionary) -> void: attempt.bobber_position = authoritative_target attempt.fish_id = selected_fish.id attempt.rod_id = rod.item_id + attempt.bait_id = bait_id attempt.bait_tags = _bait_tags_for_request(data) attempt.lure_effects.clear() if lure != null: @@ -564,7 +566,6 @@ func _start_bite(attempt: NetworkFishingAttempt) -> void: _cancel_attempt(attempt.owner_peer_id, "Fishing attempt ended.") return attempt.bite_confirmation_pending = false - attempt.phase = NetworkFishingAttempt.Phase.FIGHTING attempt.encounter_seed = _new_seed() var selector := FishSelectorType.new() selector.active_rod = rod @@ -575,6 +576,17 @@ func _start_bite(attempt: NetworkFishingAttempt) -> void: if fish_catch == null or not fish_catch.is_valid(): _cancel_attempt(attempt.owner_peer_id, "Fishing attempt ended.") return + if ( + attempt.owner_peer_id == _session.get_local_peer_id() + and not attempt.bait_id.is_empty() + and ( + _local_bag == null + or not _local_bag.remove_item(attempt.bait_id, 1) + ) + ): + _cancel_attempt(attempt.owner_peer_id, "No bait available.") + return + attempt.phase = NetworkFishingAttempt.Phase.FIGHTING attempt.catch_payload = fish_catch.to_network_dict() attempt.controller.start_authoritative_encounter( fish.catch_profile, @@ -1031,6 +1043,13 @@ func _apply_public_outcome(data: Dictionary) -> void: if peer_id == _session.get_local_peer_id(): if str(data["outcome"]) != "catch": if not _session.is_host(): + var local_attempt: NetworkFishingAttempt = _attempts.get( + peer_id + ) + if local_attempt != null: + _pending_local_bait_by_request.erase( + local_attempt.request_id + ) _attempts.erase(peer_id) local_attempt_ended.emit( StringName(str(data["outcome"])), @@ -1091,20 +1110,6 @@ func _apply_cast_accepted(data: Dictionary) -> void: # On the host this replaces the same authoritative value with itself. if not _session.is_host(): _attempts[peer_id] = attempt - var pending_bait_id: StringName = ( - _pending_local_bait_by_request.get( - attempt.request_id, - StringName(), - ) - ) - _pending_local_bait_by_request.erase(attempt.request_id) - if not pending_bait_id.is_empty() and ( - _local_bag == null - or not _local_bag.remove_item(pending_bait_id, 1) - ): - cancel_local_attempt("No bait available.") - local_cast_rejected.emit("No bait available.") - return local_cast_accepted.emit(attempt.attempt_id, target) else: var presentation := _get_remote_presentation(peer_id) @@ -1140,6 +1145,22 @@ func _apply_bite_started(data: Dictionary) -> void: return var peer_id: int = data["owner_peer_id"] if peer_id == _session.get_local_peer_id(): + if not _session.is_host(): + var attempt: NetworkFishingAttempt = _attempts.get(peer_id) + var pending_bait_id := StringName() + if attempt != null: + pending_bait_id = _pending_local_bait_by_request.get( + attempt.request_id, + StringName(), + ) + _pending_local_bait_by_request.erase(attempt.request_id) + if not pending_bait_id.is_empty() and ( + _local_bag == null + or not _local_bag.remove_item(pending_bait_id, 1) + ): + cancel_local_attempt("No bait available.") + local_cast_rejected.emit("No bait available.") + return local_bite_started.emit(str(data["attempt_id"])) else: var presentation := _get_remote_presentation(peer_id) @@ -1260,12 +1281,12 @@ func _dispose_attempt(peer_id: int) -> void: attempt.controller.reset() attempt.controller.queue_free() var avatar: Player = _spawn_service.get_avatar(peer_id) - var local_return_is_active: bool = ( + var local_fishing_sequence_is_active: bool = ( peer_id == _session.get_local_peer_id() and _fishing_spot != null - and _fishing_spot.is_returning() + and _fishing_spot.is_fishing_sequence_active() ) - if avatar != null and not local_return_is_active: + if avatar != null and not local_fishing_sequence_is_active: avatar.set_movement_enabled(true) @@ -1307,6 +1328,7 @@ func _clear_all() -> void: _result_acknowledgements.clear() _last_cast_time.clear() _last_input_time.clear() + _pending_local_bait_by_request.clear() _snapshot_accumulator = 0.0 _local_input_sequence = 0 diff --git a/player/player.gd b/player/player.gd index 7f3e7d2..d6281b3 100644 --- a/player/player.gd +++ b/player/player.gd @@ -85,6 +85,7 @@ const CHARACTER_NET_STRIKE_ANIMATION: StringName = &"strike" const CATCH_PRESENTATION_REFERENCE_LONG_SIDE_PX: float = 1280.0 const CATCH_PRESENTATION_MIN_TEXTURE_SCALE: float = 0.1 const CATCH_PRESENTATION_MAX_TEXTURE_SCALE: float = 20.0 +const CATCH_SHOWCASE_INPUT_OWNER: StringName = &"catch_showcase" # Add future networked emote animation IDs here. The protocol accepts unknown # safe IDs so newer clients can extend it, but Player only presents actions # explicitly approved by this catalog. @@ -122,6 +123,13 @@ const BLINK_INTERVAL_SECONDS := Vector2(2.8, 7.5) const BLINK_DURATION_SECONDS: float = 0.11 const CHARACTER_CALL_MOUTH_ID: String = "open_ah" const CHARACTER_CALL_MOUTH_DURATION_SECONDS: float = 0.16 +const SPEECH_MOUTH_IDS: Array[String] = [ + "open_ah", + "open_oh", + "open_small", + "open_smile", +] +const SPEECH_MOUTH_FRAME_SECONDS: float = 0.09 const BASE_REEL_SPEED: float = 0.16 const LANDING_DUST_MIN_FALL_SPEED: float = 2.5 const NETWORK_EXTRAPOLATION_LIMIT_SECONDS: float = 0.25 @@ -227,6 +235,42 @@ func play_character_call_visual() -> void: _apply_presented_appearance() +func play_speech_visual(duration_seconds: float) -> void: + stop_speech_visual() + if duration_seconds <= 0.0 or SPEECH_MOUTH_IDS.is_empty(): + return + var generation: int = _speech_visual_generation + var elapsed: float = 0.0 + var frame_index: int = 0 + _speech_mouth_active = true + while elapsed < duration_seconds: + _speech_mouth_id = SPEECH_MOUTH_IDS[ + frame_index % SPEECH_MOUTH_IDS.size() + ] + _apply_presented_appearance() + var frame_duration := minf( + SPEECH_MOUTH_FRAME_SECONDS, + duration_seconds - elapsed, + ) + await get_tree().create_timer(frame_duration).timeout + if generation != _speech_visual_generation: + return + elapsed += frame_duration + frame_index += 1 + _speech_mouth_active = false + _speech_mouth_id = "" + _apply_presented_appearance() + + +func stop_speech_visual() -> void: + _speech_visual_generation += 1 + if not _speech_mouth_active and _speech_mouth_id.is_empty(): + return + _speech_mouth_active = false + _speech_mouth_id = "" + _apply_presented_appearance() + + func set_fishing_visual(active: bool) -> void: if ( active @@ -295,6 +339,10 @@ func _apply_presented_appearance() -> void: if _fighting_visual_active: presented_appearance = appearance_snapshot.duplicate(true) presented_appearance["eyes"] = FIGHTING_EYES_ID + if _speech_mouth_active and not _speech_mouth_id.is_empty(): + if presented_appearance == appearance_snapshot: + presented_appearance = appearance_snapshot.duplicate(true) + presented_appearance["mouth"] = _speech_mouth_id if _character_call_mouth_active: if presented_appearance == appearance_snapshot: presented_appearance = appearance_snapshot.duplicate(true) @@ -505,6 +553,9 @@ var _blink_seconds_remaining: float = 0.0 var _blink_rng := RandomNumberGenerator.new() var _character_call_mouth_active: bool = false var _character_call_visual_generation: int = 0 +var _speech_mouth_active: bool = false +var _speech_mouth_id: String = "" +var _speech_visual_generation: int = 0 var _fishing_visual_active: bool = false var _fishing_visual_phase: FishingVisualPhase = FishingVisualPhase.NONE var _fishing_after_release_pending: bool = false @@ -1755,6 +1806,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(): + _free_camera_body.velocity = Vector3.ZERO + _free_camera_body.move_and_slide() + return var input_vector: Vector2 = Input.get_vector( "move_left", "move_right", @@ -2981,6 +3036,8 @@ func _begin_catch_showcase_now( ) -> void: if _pocket_visual_target == PocketVisualTarget.CATCH_SHOWCASE: _cancel_pocket_visual() + if local_control_enabled: + set_local_input_suppressed(CATCH_SHOWCASE_INPUT_OWNER, true) _showcase_animation_active = true set_fishing_visual(false) _kill_showcase_camera_tween() @@ -3149,6 +3206,8 @@ func end_catch_showcase( not _showcase_visual_rotation_stored and _showcase_camera_snapshot == null ): + if local_control_enabled: + set_local_input_suppressed(CATCH_SHOWCASE_INPUT_OWNER, false) if restored_callback.is_valid(): restored_callback.call() return @@ -3310,6 +3369,8 @@ func _complete_showcase_restore( and Input.is_action_pressed("camera_drag") ) _showcase_camera_snapshot = null + if local_control_enabled: + set_local_input_suppressed(CATCH_SHOWCASE_INPUT_OWNER, false) if restored_callback.is_valid(): restored_callback.call() diff --git a/tests/art_tools_validation.gd b/tests/art_tools_validation.gd index aeebe05..a4528c4 100644 --- a/tests/art_tools_validation.gd +++ b/tests/art_tools_validation.gd @@ -27,6 +27,7 @@ func _run() -> void: await physics_frame var player := main.get("_player") as Player + var session := main.get_node("%NetworkSession") as NetworkSession var sprint_dust := player.get_node("%SprintDust") as SprintDustTrail assert(sprint_dust != null) assert(sprint_dust.get_active_puff_count() == 0) @@ -310,6 +311,55 @@ func _run() -> void: right_stick_motion.axis_value = 0.0 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) + chat_ui.open_chat() + assert(chat_ui.is_open()) + var free_camera_position_before: 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(free_camera_body.global_position.is_equal_approx( + free_camera_position_before + )) + chat_ui.refocus_gameplay() + + # Typewriter speech cycles the shared open-mouth faceplates, then restores + # the player's authored mouth. World speech also follows the active freecam, + # not the inactive normal gameplay camera. + var normal_camera := player.get_gameplay_camera() + var normal_camera_transform: Transform3D = normal_camera.global_transform + var chat_anchor: Vector3 = player.get_chat_anchor_position() + normal_camera.look_at( + normal_camera.global_position + - (chat_anchor - normal_camera.global_position), + Vector3.UP, + ) + assert(normal_camera.is_position_behind(chat_anchor)) + assert(not free_camera.is_position_behind(chat_anchor)) + chat_ui.show_local_speech("hello there friend") + await process_frame + assert(bool(player.get("_speech_mouth_active"))) + var first_speech_mouth: String = str(player.get("_speech_mouth_id")) + await create_timer(0.12).timeout + var second_speech_mouth: String = str(player.get("_speech_mouth_id")) + assert(not first_speech_mouth.is_empty()) + assert(not second_speech_mouth.is_empty()) + assert(first_speech_mouth != second_speech_mouth) + chat_ui.call("_update_speech") + var speech: Dictionary = chat_ui.get("_speech") + var local_speech: Dictionary = speech.get(session.get_local_peer_id(), {}) + var speech_bubble := local_speech.get("bubble") as PanelContainer + assert(speech_bubble != null and speech_bubble.visible) + normal_camera.global_transform = normal_camera_transform + await create_timer(0.6).timeout + assert(not bool(player.get("_speech_mouth_active"))) + chat_ui.call("_on_peer_removed", session.get_local_peer_id()) + game_ui.call("_on_quick_action_selected", &"freecam") assert(not player.is_free_camera_active()) game_ui.call("_on_quick_action_selected", &"hud") @@ -754,7 +804,6 @@ func _run() -> void: assert(not service.is_active() and not toolbar.visible) print("Art tools validation: PASS") - var session := main.get_node("%NetworkSession") as NetworkSession session.disconnect_session("") main.queue_free() for _frame: int in 4: diff --git a/tests/fishing_authority_validation.gd b/tests/fishing_authority_validation.gd index 8046668..1f40086 100644 --- a/tests/fishing_authority_validation.gd +++ b/tests/fishing_authority_validation.gd @@ -35,10 +35,17 @@ func _run() -> void: var fishing_status := game_ui.get_node("%StatusLabel") as Label var fishing_panel := game_ui.get_node("%FishingPanel") as PanelContainer var player := main.get("_player") as Player + var item_catalog := main.get("item_catalog") as ItemCatalog assert(session.is_host()) 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 worms: ItemData = item_catalog.get_item_by_id(&"worms") + assert(worms != null) + if player.bag.get_quantity(&"worms") == 0: + assert(player.bag.add_item(&"worms", 2)) + assert(player.equip_bait(worms)) + var bait_quantity_before: int = player.bag.get_quantity(&"worms") var fresh_root := main.get_node( "TestWorld/Regions/GeneratedWorldRegion/WaterBodies/FreshWaterBodies" ) as Node3D @@ -90,6 +97,7 @@ func _run() -> void: await process_frame assert(fishing_spot.state == FishingSpotType.FishingState.WAITING_FOR_BITE) assert(service.has_local_attempt()) + assert(player.bag.get_quantity(&"worms") == bait_quantity_before) assert(fishing_status.text.is_empty()) assert(not fishing_status.visible) assert(not fishing_panel.visible) @@ -124,6 +132,7 @@ func _run() -> void: assert(fishing_spot.state == FishingSpotType.FishingState.READY) assert(player.is_movement_enabled()) assert(not bobber.visible) + assert(player.bag.get_quantity(&"worms") == bait_quantity_before) # A private host rolls and retains the authoritative catch before the fight # so its quality selects the barrier band clients receive in snapshots. @@ -141,9 +150,11 @@ func _run() -> void: attempts = service.get("_attempts") attempt = attempts.get(session.get_local_peer_id()) assert(attempt != null) + assert(player.bag.get_quantity(&"worms") == bait_quantity_before) service.call("_start_bite", attempt) await process_frame assert(attempt.phase == NetworkFishingAttempt.Phase.FIGHTING) + assert(player.bag.get_quantity(&"worms") == bait_quantity_before - 1) var catalog: FishPool = main.get("fish_catalog") as FishPool assert(catalog != null) var fish: FishData = catalog.get_fish_by_id(attempt.fish_id) @@ -218,6 +229,29 @@ func _run() -> void: assert(fishing_spot.can_change_hotbar_selection()) assert(fishing_spot.can_open_fishing_shop()) + # Escape can put the rod into RETURNING immediately before deep-water + # recovery begins. Recovery must finish that return before snapshotting its + # movement state, otherwise the respawn restores a permanent cast lock. + var water_recovery := main.get_node("%WaterRecovery") as WaterRecoveryController + assert(water_recovery != null) + var pause_menu: PauseMenu = game_ui.get_pause_menu() + pause_menu.open_menu() + assert(pause_menu.visible) + player.set_movement_enabled(false) + fishing_spot.set("_active_player", player) + fishing_spot.state = FishingSpotType.FishingState.RETURNING + water_recovery.call( + "_on_recovery_requested", + player, + player.global_position.y, + ) + assert(fishing_spot.state == FishingSpotType.FishingState.READY) + assert(bool(water_recovery.get("_prior_movement_enabled"))) + assert(bool(water_recovery.get("_prior_camera_input_enabled"))) + assert(not pause_menu.visible) + water_recovery.call("_finish_recovery") + assert(player.is_movement_enabled()) + print("Fishing authority validation: PASS") session.disconnect_session("") main.queue_free() diff --git a/tests/fishing_multiplayer_validation.gd b/tests/fishing_multiplayer_validation.gd index 7965af6..73de1bd 100644 --- a/tests/fishing_multiplayer_validation.gd +++ b/tests/fishing_multiplayer_validation.gd @@ -302,7 +302,9 @@ func _run_client() -> void: ] ) assert(service.has_local_attempt()) - assert(player.bag.get_quantity(&"worms") == worms_before_cast - 1) + # Bait remains available while the bobber is waiting. It is consumed only + # once the authoritative bite begins. + assert(player.bag.get_quantity(&"worms") == worms_before_cast) var fishing_deadline: int = Time.get_ticks_msec() + 5000 while ( Time.get_ticks_msec() < fishing_deadline diff --git a/tests/gathering_showcase_validation.gd b/tests/gathering_showcase_validation.gd index e565beb..26a9e34 100644 --- a/tests/gathering_showcase_validation.gd +++ b/tests/gathering_showcase_validation.gd @@ -59,6 +59,11 @@ func _run() -> void: assert(fishing_spot.present_external_catch(crab_catch)) assert(fishing_spot.state == FishingSpot.FishingState.SHOWING_CATCH) assert(not player.is_movement_enabled()) + # Network attempt cleanup may restore the base movement flag before the + # presentation finishes. The showcase owns a separate local-input lock. + player.set_movement_enabled(true) + assert(not bool(player.call("_is_movement_input_enabled"))) + assert(not bool(player.call("_is_camera_input_enabled"))) Input.action_release("fish_primary") await process_frame assert(not bool(fishing_spot.get("_put_away_press_armed"))) @@ -85,6 +90,8 @@ func _run() -> void: await process_frame assert(fishing_spot.state == FishingSpot.FishingState.READY) assert(player.is_movement_enabled()) + assert(bool(player.call("_is_movement_input_enabled"))) + 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"))) print("Gathering showcase validation: PASS") diff --git a/tests/keyboard_mouse_mapping_validation.gd b/tests/keyboard_mouse_mapping_validation.gd index 7d02736..d8e9a88 100644 --- a/tests/keyboard_mouse_mapping_validation.gd +++ b/tests/keyboard_mouse_mapping_validation.gd @@ -181,6 +181,20 @@ func _run() -> void: catch_profile.barrier_count_min = 0 catch_profile.barrier_count_max = 0 catch_controller.start_encounter(catch_profile, 1.0, 1) + var alternate_echo := InputEventKey.new() + alternate_echo.physical_keycode = KEY_QUOTELEFT + alternate_echo.pressed = true + alternate_echo.echo = true + assert(alternate_echo.is_action(&"reel_alternate")) + fishing_spot._unhandled_input(alternate_echo) + assert(not bool(catch_controller.get("_reel_input_held"))) + var alternate_key_press := InputEventKey.new() + alternate_key_press.physical_keycode = KEY_QUOTELEFT + alternate_key_press.pressed = true + assert(alternate_key_press.is_action(&"reel_alternate")) + fishing_spot._unhandled_input(alternate_key_press) + assert(bool(catch_controller.get("_reel_input_held"))) + catch_controller.set_reel_input(false) var alternate_press := InputEventAction.new() alternate_press.action = &"reel_alternate" alternate_press.pressed = true diff --git a/ui/chat_ui.gd b/ui/chat_ui.gd index 0b26ffb..c039046 100644 --- a/ui/chat_ui.gd +++ b/ui/chat_ui.gd @@ -1068,6 +1068,8 @@ func _show_speech_bubble( var voice_profile_id: String = VoiceProfilesType.DEFAULT_ID var sample_set_id: String = VoiceProfilesType.DEFAULT_SAMPLE_SET_ID var speaker_avatar := _spawn.get_avatar(peer_id) + if speaker_avatar != null: + speaker_avatar.play_speech_visual(reveal_seconds) if not requested_voice_profile_id.is_empty(): voice_profile_id = VoiceProfilesType.sanitized_id( requested_voice_profile_id @@ -1360,10 +1362,13 @@ func _update_speech() -> void: if bubble == null or now >= float(state.get("expires", 0.0)): if bubble != null: bubble.queue_free() + var expired_avatar := _spawn.get_avatar(peer_id) + if expired_avatar != null: + expired_avatar.stop_speech_visual() _speech.erase(peer_id) continue var avatar := _spawn.get_avatar(peer_id) - var camera := _player.get_gameplay_camera() + var camera := _player.get_active_gameplay_camera() if avatar == null or camera == null: bubble.hide() continue @@ -1824,4 +1829,7 @@ func _on_peer_removed(peer_id: int) -> void: var bubble := state.get("bubble") as PanelContainer if bubble != null: bubble.queue_free() + var avatar := _spawn.get_avatar(peer_id) + if avatar != null: + avatar.stop_speech_visual() _speech.erase(peer_id) diff --git a/world/water_recovery_controller.gd b/world/water_recovery_controller.gd index b764f80..9626881 100644 --- a/world/water_recovery_controller.gd +++ b/world/water_recovery_controller.gd @@ -143,14 +143,17 @@ func _on_recovery_requested( or not is_instance_valid(triggered_player) ): return - recovery_starting.emit() _generation += 1 _entry_position = _player.global_position _recovery_root_basis = _player.global_basis _fishing_spot.begin_water_recovery() _player.prepare_for_water_recovery() + # Snapshot the normalized gameplay state before recovery_starting closes + # menus for the handoff. Those closures deliberately disable movement and + # camera input, which are recovery-owned locks rather than states to restore. _prior_movement_enabled = _player.is_movement_enabled() _prior_camera_input_enabled = _player.is_camera_input_enabled() + recovery_starting.emit() _player.set_movement_enabled(false) _player.set_camera_input_enabled(false) _player.set_water_recovery_active(true)