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: func begin_water_recovery() -> void:
_external_input_blocked = true _external_input_blocked = true
if state in [ if state == FishingState.SHOWING_CATCH:
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:
_secure_showcase_catch_for_recovery() _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: func end_water_recovery() -> void:
@ -538,6 +539,12 @@ func _unhandled_input(event: InputEvent) -> void:
alternate_reel and state == FishingState.FIGHTING alternate_reel and state == FishingState.FIGHTING
): ):
return 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() var selected_item: ItemDataType = _get_active_item()
if ( if (
state == FishingState.READY state == FishingState.READY
@ -948,7 +955,6 @@ func _on_cast_completed() -> void:
if _selected_fish == null: if _selected_fish == null:
_cleanup_attempt("nothing is biting here.", &"invalid") _cleanup_attempt("nothing is biting here.", &"invalid")
return return
_consume_active_bait()
state = FishingState.WAITING_FOR_BITE state = FishingState.WAITING_FOR_BITE
_active_player.set_fishing_visual(true) _active_player.set_fishing_visual(true)
@ -975,11 +981,13 @@ func _on_cast_completed() -> void:
status_changed.emit("") 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(): 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): if _local_bag == null or not _local_bag.remove_item(_active_player.active_bait_id, 1):
_active_player.unequip_bait() _active_player.unequip_bait()
return false
return true
func roll_bite_wait_time() -> float: func roll_bite_wait_time() -> float:
@ -1131,14 +1139,18 @@ func _activate_bite(confirmation_override: bool = false) -> void:
return return
_set_bite_confirmation_pending(false) _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) _pending_catch = _fish_selector.create_catch(_selected_fish)
if _pending_catch == null or not _pending_catch.is_valid(): if _pending_catch == null or not _pending_catch.is_valid():
_cancel_attempt() _cancel_attempt()
return 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() _start_fight_audio()
bite_activated.emit() bite_activated.emit()
status_changed.emit("fish on!") status_changed.emit("fish on!")

View file

@ -22,6 +22,7 @@ var target: Vector3
var bobber_position: Vector3 var bobber_position: Vector3
var fish_id: StringName var fish_id: StringName
var rod_id: StringName var rod_id: StringName
var bait_id: StringName
var bait_tags: Array[StringName] = [] var bait_tags: Array[StringName] = []
var lure_effects: Array[StringName] = [] var lure_effects: Array[StringName] = []
var encounter_seed: int = 0 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(): if _session.is_host():
_cancel_attempt(peer_id, reason) _cancel_attempt(peer_id, reason)
else: else:
_pending_local_bait_by_request.erase(attempt.request_id)
submit_cancel_request.rpc_id(1, attempt.attempt_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 owns_authoritative_bag
and ( and (
avatar_bag == null 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.bobber_position = authoritative_target
attempt.fish_id = selected_fish.id attempt.fish_id = selected_fish.id
attempt.rod_id = rod.item_id attempt.rod_id = rod.item_id
attempt.bait_id = bait_id
attempt.bait_tags = _bait_tags_for_request(data) attempt.bait_tags = _bait_tags_for_request(data)
attempt.lure_effects.clear() attempt.lure_effects.clear()
if lure != null: if lure != null:
@ -564,7 +566,6 @@ func _start_bite(attempt: NetworkFishingAttempt) -> void:
_cancel_attempt(attempt.owner_peer_id, "Fishing attempt ended.") _cancel_attempt(attempt.owner_peer_id, "Fishing attempt ended.")
return return
attempt.bite_confirmation_pending = false attempt.bite_confirmation_pending = false
attempt.phase = NetworkFishingAttempt.Phase.FIGHTING
attempt.encounter_seed = _new_seed() attempt.encounter_seed = _new_seed()
var selector := FishSelectorType.new() var selector := FishSelectorType.new()
selector.active_rod = rod selector.active_rod = rod
@ -575,6 +576,17 @@ func _start_bite(attempt: NetworkFishingAttempt) -> void:
if fish_catch == null or not fish_catch.is_valid(): if fish_catch == null or not fish_catch.is_valid():
_cancel_attempt(attempt.owner_peer_id, "Fishing attempt ended.") _cancel_attempt(attempt.owner_peer_id, "Fishing attempt ended.")
return 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.catch_payload = fish_catch.to_network_dict()
attempt.controller.start_authoritative_encounter( attempt.controller.start_authoritative_encounter(
fish.catch_profile, fish.catch_profile,
@ -1031,6 +1043,13 @@ func _apply_public_outcome(data: Dictionary) -> void:
if peer_id == _session.get_local_peer_id(): if peer_id == _session.get_local_peer_id():
if str(data["outcome"]) != "catch": if str(data["outcome"]) != "catch":
if not _session.is_host(): 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) _attempts.erase(peer_id)
local_attempt_ended.emit( local_attempt_ended.emit(
StringName(str(data["outcome"])), 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. # On the host this replaces the same authoritative value with itself.
if not _session.is_host(): if not _session.is_host():
_attempts[peer_id] = attempt _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) local_cast_accepted.emit(attempt.attempt_id, target)
else: else:
var presentation := _get_remote_presentation(peer_id) var presentation := _get_remote_presentation(peer_id)
@ -1140,6 +1145,22 @@ func _apply_bite_started(data: Dictionary) -> void:
return return
var peer_id: int = data["owner_peer_id"] var peer_id: int = data["owner_peer_id"]
if peer_id == _session.get_local_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"])) local_bite_started.emit(str(data["attempt_id"]))
else: else:
var presentation := _get_remote_presentation(peer_id) var presentation := _get_remote_presentation(peer_id)
@ -1260,12 +1281,12 @@ func _dispose_attempt(peer_id: int) -> void:
attempt.controller.reset() attempt.controller.reset()
attempt.controller.queue_free() attempt.controller.queue_free()
var avatar: Player = _spawn_service.get_avatar(peer_id) 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() peer_id == _session.get_local_peer_id()
and _fishing_spot != null 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) avatar.set_movement_enabled(true)
@ -1307,6 +1328,7 @@ func _clear_all() -> void:
_result_acknowledgements.clear() _result_acknowledgements.clear()
_last_cast_time.clear() _last_cast_time.clear()
_last_input_time.clear() _last_input_time.clear()
_pending_local_bait_by_request.clear()
_snapshot_accumulator = 0.0 _snapshot_accumulator = 0.0
_local_input_sequence = 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_REFERENCE_LONG_SIDE_PX: float = 1280.0
const CATCH_PRESENTATION_MIN_TEXTURE_SCALE: float = 0.1 const CATCH_PRESENTATION_MIN_TEXTURE_SCALE: float = 0.1
const CATCH_PRESENTATION_MAX_TEXTURE_SCALE: float = 20.0 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 # Add future networked emote animation IDs here. The protocol accepts unknown
# safe IDs so newer clients can extend it, but Player only presents actions # safe IDs so newer clients can extend it, but Player only presents actions
# explicitly approved by this catalog. # 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 BLINK_DURATION_SECONDS: float = 0.11
const CHARACTER_CALL_MOUTH_ID: String = "open_ah" const CHARACTER_CALL_MOUTH_ID: String = "open_ah"
const CHARACTER_CALL_MOUTH_DURATION_SECONDS: float = 0.16 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 BASE_REEL_SPEED: float = 0.16
const LANDING_DUST_MIN_FALL_SPEED: float = 2.5 const LANDING_DUST_MIN_FALL_SPEED: float = 2.5
const NETWORK_EXTRAPOLATION_LIMIT_SECONDS: float = 0.25 const NETWORK_EXTRAPOLATION_LIMIT_SECONDS: float = 0.25
@ -227,6 +235,42 @@ func play_character_call_visual() -> void:
_apply_presented_appearance() _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: func set_fishing_visual(active: bool) -> void:
if ( if (
active active
@ -295,6 +339,10 @@ func _apply_presented_appearance() -> void:
if _fighting_visual_active: if _fighting_visual_active:
presented_appearance = appearance_snapshot.duplicate(true) presented_appearance = appearance_snapshot.duplicate(true)
presented_appearance["eyes"] = FIGHTING_EYES_ID 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 _character_call_mouth_active:
if presented_appearance == appearance_snapshot: if presented_appearance == appearance_snapshot:
presented_appearance = appearance_snapshot.duplicate(true) presented_appearance = appearance_snapshot.duplicate(true)
@ -505,6 +553,9 @@ var _blink_seconds_remaining: float = 0.0
var _blink_rng := RandomNumberGenerator.new() var _blink_rng := RandomNumberGenerator.new()
var _character_call_mouth_active: bool = false var _character_call_mouth_active: bool = false
var _character_call_visual_generation: int = 0 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_active: bool = false
var _fishing_visual_phase: FishingVisualPhase = FishingVisualPhase.NONE var _fishing_visual_phase: FishingVisualPhase = FishingVisualPhase.NONE
var _fishing_after_release_pending: bool = false 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: if _free_camera == null or _free_camera_body == null:
_set_free_camera_active(false) _set_free_camera_active(false)
return 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( var input_vector: Vector2 = Input.get_vector(
"move_left", "move_left",
"move_right", "move_right",
@ -2981,6 +3036,8 @@ func _begin_catch_showcase_now(
) -> void: ) -> void:
if _pocket_visual_target == PocketVisualTarget.CATCH_SHOWCASE: if _pocket_visual_target == PocketVisualTarget.CATCH_SHOWCASE:
_cancel_pocket_visual() _cancel_pocket_visual()
if local_control_enabled:
set_local_input_suppressed(CATCH_SHOWCASE_INPUT_OWNER, true)
_showcase_animation_active = true _showcase_animation_active = true
set_fishing_visual(false) set_fishing_visual(false)
_kill_showcase_camera_tween() _kill_showcase_camera_tween()
@ -3149,6 +3206,8 @@ func end_catch_showcase(
not _showcase_visual_rotation_stored not _showcase_visual_rotation_stored
and _showcase_camera_snapshot == null and _showcase_camera_snapshot == null
): ):
if local_control_enabled:
set_local_input_suppressed(CATCH_SHOWCASE_INPUT_OWNER, false)
if restored_callback.is_valid(): if restored_callback.is_valid():
restored_callback.call() restored_callback.call()
return return
@ -3310,6 +3369,8 @@ func _complete_showcase_restore(
and Input.is_action_pressed("camera_drag") and Input.is_action_pressed("camera_drag")
) )
_showcase_camera_snapshot = null _showcase_camera_snapshot = null
if local_control_enabled:
set_local_input_suppressed(CATCH_SHOWCASE_INPUT_OWNER, false)
if restored_callback.is_valid(): if restored_callback.is_valid():
restored_callback.call() restored_callback.call()

View file

@ -27,6 +27,7 @@ func _run() -> void:
await physics_frame await physics_frame
var player := main.get("_player") as Player var player := main.get("_player") as Player
var session := main.get_node("%NetworkSession") as NetworkSession
var sprint_dust := player.get_node("%SprintDust") as SprintDustTrail var sprint_dust := player.get_node("%SprintDust") as SprintDustTrail
assert(sprint_dust != null) assert(sprint_dust != null)
assert(sprint_dust.get_active_puff_count() == 0) assert(sprint_dust.get_active_puff_count() == 0)
@ -310,6 +311,55 @@ func _run() -> void:
right_stick_motion.axis_value = 0.0 right_stick_motion.axis_value = 0.0
Input.parse_input_event(right_stick_motion) Input.parse_input_event(right_stick_motion)
await process_frame 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") game_ui.call("_on_quick_action_selected", &"freecam")
assert(not player.is_free_camera_active()) assert(not player.is_free_camera_active())
game_ui.call("_on_quick_action_selected", &"hud") game_ui.call("_on_quick_action_selected", &"hud")
@ -754,7 +804,6 @@ func _run() -> void:
assert(not service.is_active() and not toolbar.visible) assert(not service.is_active() and not toolbar.visible)
print("Art tools validation: PASS") print("Art tools validation: PASS")
var session := main.get_node("%NetworkSession") as NetworkSession
session.disconnect_session("") session.disconnect_session("")
main.queue_free() main.queue_free()
for _frame: int in 4: 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_status := game_ui.get_node("%StatusLabel") as Label
var fishing_panel := game_ui.get_node("%FishingPanel") as PanelContainer var fishing_panel := game_ui.get_node("%FishingPanel") as PanelContainer
var player := main.get("_player") as Player var player := main.get("_player") as Player
var item_catalog := main.get("item_catalog") as ItemCatalog
assert(session.is_host()) assert(session.is_host())
assert(not session.is_open_host()) assert(not session.is_open_host())
assert(service != null and fishing_spot != null and player != null) assert(service != null and fishing_spot != null and player != null)
assert(player.hotbar.get_selected_item_id() == &"basic_fishing_rod") 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( var fresh_root := main.get_node(
"TestWorld/Regions/GeneratedWorldRegion/WaterBodies/FreshWaterBodies" "TestWorld/Regions/GeneratedWorldRegion/WaterBodies/FreshWaterBodies"
) as Node3D ) as Node3D
@ -90,6 +97,7 @@ func _run() -> void:
await process_frame await process_frame
assert(fishing_spot.state == FishingSpotType.FishingState.WAITING_FOR_BITE) assert(fishing_spot.state == FishingSpotType.FishingState.WAITING_FOR_BITE)
assert(service.has_local_attempt()) assert(service.has_local_attempt())
assert(player.bag.get_quantity(&"worms") == bait_quantity_before)
assert(fishing_status.text.is_empty()) assert(fishing_status.text.is_empty())
assert(not fishing_status.visible) assert(not fishing_status.visible)
assert(not fishing_panel.visible) assert(not fishing_panel.visible)
@ -124,6 +132,7 @@ func _run() -> void:
assert(fishing_spot.state == FishingSpotType.FishingState.READY) assert(fishing_spot.state == FishingSpotType.FishingState.READY)
assert(player.is_movement_enabled()) assert(player.is_movement_enabled())
assert(not bobber.visible) 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 # A private host rolls and retains the authoritative catch before the fight
# so its quality selects the barrier band clients receive in snapshots. # so its quality selects the barrier band clients receive in snapshots.
@ -141,9 +150,11 @@ func _run() -> void:
attempts = service.get("_attempts") attempts = service.get("_attempts")
attempt = attempts.get(session.get_local_peer_id()) attempt = attempts.get(session.get_local_peer_id())
assert(attempt != null) assert(attempt != null)
assert(player.bag.get_quantity(&"worms") == bait_quantity_before)
service.call("_start_bite", attempt) service.call("_start_bite", attempt)
await process_frame await process_frame
assert(attempt.phase == NetworkFishingAttempt.Phase.FIGHTING) 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 var catalog: FishPool = main.get("fish_catalog") as FishPool
assert(catalog != null) assert(catalog != null)
var fish: FishData = catalog.get_fish_by_id(attempt.fish_id) 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_change_hotbar_selection())
assert(fishing_spot.can_open_fishing_shop()) 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") print("Fishing authority validation: PASS")
session.disconnect_session("") session.disconnect_session("")
main.queue_free() main.queue_free()

View file

@ -302,7 +302,9 @@ func _run_client() -> void:
] ]
) )
assert(service.has_local_attempt()) 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 var fishing_deadline: int = Time.get_ticks_msec() + 5000
while ( while (
Time.get_ticks_msec() < fishing_deadline 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.present_external_catch(crab_catch))
assert(fishing_spot.state == FishingSpot.FishingState.SHOWING_CATCH) assert(fishing_spot.state == FishingSpot.FishingState.SHOWING_CATCH)
assert(not player.is_movement_enabled()) 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") Input.action_release("fish_primary")
await process_frame await process_frame
assert(not bool(fishing_spot.get("_put_away_press_armed"))) assert(not bool(fishing_spot.get("_put_away_press_armed")))
@ -85,6 +90,8 @@ func _run() -> void:
await process_frame await process_frame
assert(fishing_spot.state == FishingSpot.FishingState.READY) assert(fishing_spot.state == FishingSpot.FishingState.READY)
assert(player.is_movement_enabled()) 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(player.inventory.contains_catch_id(crab_catch.catch_id))
assert(bool(player.get("_active_item_is_net"))) assert(bool(player.get("_active_item_is_net")))
print("Gathering showcase validation: PASS") print("Gathering showcase validation: PASS")

View file

@ -181,6 +181,20 @@ func _run() -> void:
catch_profile.barrier_count_min = 0 catch_profile.barrier_count_min = 0
catch_profile.barrier_count_max = 0 catch_profile.barrier_count_max = 0
catch_controller.start_encounter(catch_profile, 1.0, 1) 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() var alternate_press := InputEventAction.new()
alternate_press.action = &"reel_alternate" alternate_press.action = &"reel_alternate"
alternate_press.pressed = true alternate_press.pressed = true

View file

@ -1068,6 +1068,8 @@ func _show_speech_bubble(
var voice_profile_id: String = VoiceProfilesType.DEFAULT_ID var voice_profile_id: String = VoiceProfilesType.DEFAULT_ID
var sample_set_id: String = VoiceProfilesType.DEFAULT_SAMPLE_SET_ID var sample_set_id: String = VoiceProfilesType.DEFAULT_SAMPLE_SET_ID
var speaker_avatar := _spawn.get_avatar(peer_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(): if not requested_voice_profile_id.is_empty():
voice_profile_id = VoiceProfilesType.sanitized_id( voice_profile_id = VoiceProfilesType.sanitized_id(
requested_voice_profile_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 or now >= float(state.get("expires", 0.0)):
if bubble != null: if bubble != null:
bubble.queue_free() bubble.queue_free()
var expired_avatar := _spawn.get_avatar(peer_id)
if expired_avatar != null:
expired_avatar.stop_speech_visual()
_speech.erase(peer_id) _speech.erase(peer_id)
continue continue
var avatar := _spawn.get_avatar(peer_id) 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: if avatar == null or camera == null:
bubble.hide() bubble.hide()
continue continue
@ -1824,4 +1829,7 @@ func _on_peer_removed(peer_id: int) -> void:
var bubble := state.get("bubble") as PanelContainer var bubble := state.get("bubble") as PanelContainer
if bubble != null: if bubble != null:
bubble.queue_free() bubble.queue_free()
var avatar := _spawn.get_avatar(peer_id)
if avatar != null:
avatar.stop_speech_visual()
_speech.erase(peer_id) _speech.erase(peer_id)

View file

@ -143,14 +143,17 @@ func _on_recovery_requested(
or not is_instance_valid(triggered_player) or not is_instance_valid(triggered_player)
): ):
return return
recovery_starting.emit()
_generation += 1 _generation += 1
_entry_position = _player.global_position _entry_position = _player.global_position
_recovery_root_basis = _player.global_basis _recovery_root_basis = _player.global_basis
_fishing_spot.begin_water_recovery() _fishing_spot.begin_water_recovery()
_player.prepare_for_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_movement_enabled = _player.is_movement_enabled()
_prior_camera_input_enabled = _player.is_camera_input_enabled() _prior_camera_input_enabled = _player.is_camera_input_enabled()
recovery_starting.emit()
_player.set_movement_enabled(false) _player.set_movement_enabled(false)
_player.set_camera_input_enabled(false) _player.set_camera_input_enabled(false)
_player.set_water_recovery_active(true) _player.set_water_recovery_active(true)