Fix fishing and chat interaction regressions

This commit is contained in:
Alexander Sellite 2026-08-21 12:29:51 -04:00
parent 66ae8c8be7
commit e4ddf4e5a6
11 changed files with 256 additions and 43 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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