From c0ff78322d787aa59d854f54d099eefc78c08f81 Mon Sep 17 00:00:00 2001 From: Voyager Date: Thu, 30 Jul 2026 14:01:07 -0400 Subject: [PATCH] Fix multiplayer parity and release regressions --- economy/fish_sale_result.gd | 3 + economy/fish_sale_service.gd | 9 ++ fishing/fishing_presentation.gd | 3 + fishing/fishing_spot.gd | 7 ++ inventory/fish_inventory.gd | 37 ++++++ main/main.gd | 58 ++++++++- main/main.tscn | 8 ++ network/network_mail_service.gd | 2 +- network/network_session.gd | 116 ++++++++++++++++++ player/player.gd | 32 +++++ .../player_asset_reservation_service.gd | 59 ++++++++- ui/chat_ui.gd | 3 + ui/fishing_shop.gd | 26 ++++ ui/game_ui.gd | 2 + ui/player_menu.gd | 17 +++ ui/player_menu.tscn | 11 -- world/water_recovery_controller.gd | 2 + 17 files changed, 378 insertions(+), 17 deletions(-) diff --git a/economy/fish_sale_result.gd b/economy/fish_sale_result.gd index e133245..74aa72b 100644 --- a/economy/fish_sale_result.gd +++ b/economy/fish_sale_result.gd @@ -9,6 +9,7 @@ enum Status { INVALID_BUYER, INVALID_OFFER, INVALID_SELECTION, + RESERVED, TRANSACTION_FAILED, } @@ -46,5 +47,7 @@ func get_message() -> String: return "invalid buyer offer." Status.INVALID_SELECTION: return "the fish selection is invalid." + Status.RESERVED: + return "reserved fish cannot be sold." _: return "transaction failed." diff --git a/economy/fish_sale_service.gd b/economy/fish_sale_service.gd index b5b81b2..4de1faf 100644 --- a/economy/fish_sale_service.gd +++ b/economy/fish_sale_service.gd @@ -11,14 +11,17 @@ signal sale_completed(result: FishSaleResultType) var _inventory: FishInventoryType var _wallet: PlayerWalletType +var _reservations: PlayerAssetReservationService func setup( inventory: FishInventoryType, wallet: PlayerWalletType, + reservations: PlayerAssetReservationService = null, ) -> void: _inventory = inventory _wallet = wallet + _reservations = reservations func can_sell( @@ -114,6 +117,12 @@ func _validate_batch( if fish_catch == null: result.status = FishSaleResultType.Status.NOT_FOUND return result + if ( + _reservations != null + and _reservations.is_fish_reserved(catch_id) + ): + result.status = FishSaleResultType.Status.RESERVED + return result if fish_catch.is_favorited: contains_favorite = true if fish_catch.sale_value < 0: diff --git a/fishing/fishing_presentation.gd b/fishing/fishing_presentation.gd index 62501e2..5ade317 100644 --- a/fishing/fishing_presentation.gd +++ b/fishing/fishing_presentation.gd @@ -243,6 +243,9 @@ func show_reel_position(world_position: Vector3, _input_held: bool) -> void: func play_outcome(outcome: StringName) -> void: if _mode == VisualMode.NONE: cleanup() + # Gameplay completion must not depend on a presentation tween still + # being active. The caller owns idempotency for the outcome. + outcome_completed.emit(outcome) return _kill_active_tween() diff --git a/fishing/fishing_spot.gd b/fishing/fishing_spot.gd index 1655c6d..1018a44 100644 --- a/fishing/fishing_spot.gd +++ b/fishing/fishing_spot.gd @@ -134,6 +134,7 @@ var _pending_catch: FishCatchType var _showcase_ready: bool = false var _put_away_press_armed: bool = false var _showcase_restore_generation: int = 0 +var _showcase_outcome_completed: bool = false var _network_auto_click_accumulator: float = 0.0 var _network_active_barrier_index: int = -1 @@ -874,6 +875,7 @@ func _on_catch_completed() -> void: return state = FishingState.SHOWING_CATCH _showcase_ready = false + _showcase_outcome_completed = false _put_away_press_armed = false _catch_controller.reset() _presentation.set_line_mode(FishingPresentationType.LineMode.TAUT) @@ -896,8 +898,10 @@ func _on_outcome_completed(outcome: StringName) -> void: or state != FishingState.SHOWING_CATCH or _active_player == null or _pending_catch == null + or _showcase_outcome_completed ): return + _showcase_outcome_completed = true _showcase_ready = true _active_player.begin_catch_showcase(_pending_catch) showcase_changed.emit( @@ -923,6 +927,7 @@ func _put_away_catch() -> void: _local_collection_log.mark_discovered(_pending_catch.fish_id) _pending_catch = null _showcase_ready = false + _showcase_outcome_completed = false _put_away_press_armed = false showcase_changed.emit("", "", 0.0, false) _showcase_restore_generation += 1 @@ -973,6 +978,7 @@ func _cleanup_attempt( _selected_fish = null _pending_catch = null _showcase_ready = false + _showcase_outcome_completed = false _put_away_press_armed = false showcase_changed.emit("", "", 0.0, false) _catch_controller.reset() @@ -1257,6 +1263,7 @@ func _on_network_catch_received(fish_catch: FishCatchType) -> void: _pending_catch = fish_catch state = FishingState.SHOWING_CATCH _showcase_ready = false + _showcase_outcome_completed = false _put_away_press_armed = false catch_display_changed.emit( 0.0, 0.0, PackedFloat32Array(), PackedInt32Array(), diff --git a/inventory/fish_inventory.gd b/inventory/fish_inventory.gd index 89e192d..a4052f5 100644 --- a/inventory/fish_inventory.gd +++ b/inventory/fish_inventory.gd @@ -8,6 +8,13 @@ signal catches_changed var _catches: Array[FishCatchType] = [] var _next_catch_sequence: int = 1 +var _reservation_service: PlayerAssetReservationService + + +func set_reservation_service( + reservation_service: PlayerAssetReservationService, +) -> void: + _reservation_service = reservation_service func add_catch(fish_catch: FishCatchType) -> void: @@ -101,6 +108,25 @@ func remove_catch_by_id(catch_id: StringName) -> FishCatchType: func remove_catches_by_ids( catch_ids: Array[StringName], +) -> Array[FishCatchType]: + return _remove_catches_by_ids(catch_ids, "", "") + + +func remove_reserved_catch_for_mail_transfer( + catch_id: StringName, + reservation_id: String, + transfer_id: String, +) -> FishCatchType: + var removed := _remove_catches_by_ids( + [catch_id], reservation_id, transfer_id + ) + return removed.front() if removed.size() == 1 else null + + +func _remove_catches_by_ids( + catch_ids: Array[StringName], + mail_reservation_id: String, + mail_transfer_id: String, ) -> Array[FishCatchType]: var removed: Array[FishCatchType] = [] if catch_ids.is_empty(): @@ -115,6 +141,17 @@ func remove_catches_by_ids( if fish_catch == null: removed.clear() return removed + if ( + _reservation_service != null + and _reservation_service.is_fish_reserved(catch_id) + and not _reservation_service.authorize_mail_fish_removal( + mail_reservation_id, + catch_id, + mail_transfer_id, + ) + ): + removed.clear() + return removed removed.append(fish_catch) var remaining: Array[FishCatchType] = [] diff --git a/main/main.gd b/main/main.gd index 0b37542..1b3023b 100644 --- a/main/main.gd +++ b/main/main.gd @@ -133,6 +133,7 @@ const TITLE_MUSIC_SILENCE_DB: float = -80.0 ) @onready var _players_root: Node3D = $Players @onready var _title_background: ColorRect = %TitleBackground +@onready var _player_menu_backdrop: ColorRect = %PlayerMenuBackdrop var _gameplay_started: bool = false var _shop_interaction: FishingShopInteractionType @@ -152,10 +153,13 @@ var _data_folder_picker_generation: int = 0 var _restore_data_setup_after_picker: bool = false var _application_initialized := false var _pending_existing_root_path := "" +var _local_recovery_attempt_id: String = "" func _ready() -> void: DisplayServer.window_set_title("NETfishing") + get_window().size_changed.connect(_resize_native_overlays) + _resize_native_overlays() if not _settings_manager.settings_changed.is_connected( _apply_runtime_settings ): @@ -254,11 +258,15 @@ func _initialize_after_data_root() -> void: _network_session.remote_recovery_requested.connect( _on_remote_recovery_requested ) + _network_session.remote_recovery_presentation_changed.connect( + _on_remote_recovery_presentation_changed + ) _player.fish_sale_service.setup( _player.inventory, - _player.wallet + _player.wallet, + _asset_reservations ) _player.bag.setup(item_catalog) _player.hotbar.setup(_player.bag, item_catalog) @@ -282,6 +290,7 @@ func _initialize_after_data_root() -> void: _asset_reservations.setup( _player.wallet, _player.inventory, _player.bag, item_catalog ) + _player.inventory.set_reservation_service(_asset_reservations) _network_mail.setup( _network_session, _asset_reservations, @@ -426,6 +435,9 @@ func _initialize_after_data_root() -> void: _game_ui.interactive_pointer_ui_changed.connect( _ui_pixelation.set_interactive_ui_open ) + _game_ui.player_menu_backdrop_visibility_changed.connect( + _set_player_menu_backdrop_visible + ) _pixelation_reset.return_to_settings_requested.connect( _game_ui.focus_open_settings_back_button ) @@ -475,6 +487,9 @@ func _initialize_after_data_root() -> void: _water_recovery.recovery_starting.connect( _on_water_recovery_starting ) + _water_recovery.recovery_finished.connect( + _on_water_recovery_finished + ) _water_recovery.local_respawn_completed.connect( _on_local_respawn_completed ) @@ -885,6 +900,19 @@ func _set_gameplay_active(active: bool) -> void: _save_manager.set_autosave_enabled(active) if active: _refresh_active_hotbar_item() + else: + _set_player_menu_backdrop_visible(false) + + +func _set_player_menu_backdrop_visible(is_visible: bool) -> void: + _player_menu_backdrop.visible = is_visible and _gameplay_started + + +func _resize_native_overlays() -> void: + if not is_node_ready(): + return + _player_menu_backdrop.position = Vector2.ZERO + _player_menu_backdrop.size = Vector2(get_window().size) func _on_new_game_requested() -> void: @@ -1128,11 +1156,39 @@ func _on_reset_progress_requested() -> void: func _on_water_recovery_starting() -> void: + _local_recovery_attempt_id = ( + "recovery:%s" + % Crypto.new().generate_random_bytes(16).hex_encode() + ) + _network_session.request_recovery_presentation( + true, _local_recovery_attempt_id + ) _game_ui.close_player_menu_for_water_recovery() _game_ui.get_pause_menu().close_for_water_recovery() _game_ui.get_fishing_shop().close_for_water_recovery() +func _on_water_recovery_finished() -> void: + if _local_recovery_attempt_id.is_empty(): + return + _network_session.request_recovery_presentation( + false, _local_recovery_attempt_id + ) + _local_recovery_attempt_id = "" + + +func _on_remote_recovery_presentation_changed( + peer_id: int, + active: bool, + _attempt_id: String, +) -> void: + var avatar: PlayerType = _player_spawn_service.get_avatar(peer_id) + if avatar == null or avatar == _player: + return + avatar.set_water_recovery_active(active) + avatar.set_remote_recovery_presentation(active) + + func _on_active_hotbar_item_changed( _slot_index: int, item_id: StringName, diff --git a/main/main.tscn b/main/main.tscn index 6131033..293805a 100644 --- a/main/main.tscn +++ b/main/main.tscn @@ -65,6 +65,13 @@ grow_horizontal = 2 grow_vertical = 2 mouse_filter = 2 +[node name="PlayerMenuBackdrop" type="ColorRect" parent="."] +unique_name_in_owner = true +visible = false +z_index = 10 +mouse_filter = 2 +color = Color(0.015, 0.02, 0.03, 0.72) + [node name="InterfaceFontController" type="Node" parent="."] unique_name_in_owner = true script = ExtResource("39_fonts") @@ -192,6 +199,7 @@ unique_name_in_owner = true [node name="UIPresentation" type="SubViewportContainer" parent="."] unique_name_in_owner = true +z_index = 20 texture_filter = 1 offset_right = 1152.0 offset_bottom = 648.0 diff --git a/network/network_mail_service.gd b/network/network_mail_service.gd index b2fa672..4d122b6 100644 --- a/network/network_mail_service.gd +++ b/network/network_mail_service.gd @@ -552,7 +552,7 @@ func _commit_sender(transfer_id: String, letter: Dictionary) -> void: var snapshot := _capture_assets() var applied := ( _reservations.has_reservation(reservation_id) - and _reservations.commit_removal(reservation_id) + and _reservations.commit_removal(reservation_id, transfer_id) and _save_manager.save_if_dirty() ) if not applied: diff --git a/network/network_session.gd b/network/network_session.gd index 71cb003..1f796e0 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -32,6 +32,11 @@ signal server_trust_required( signal peer_identity_observed(peer_id: int, status: String) signal server_lost signal remote_recovery_requested(peer_id: int, entry_position: Vector3) +signal remote_recovery_presentation_changed( + peer_id: int, + active: bool, + attempt_id: String, +) enum State { INACTIVE, @@ -84,6 +89,7 @@ var _pending_identity_challenges: Dictionary[int, Dictionary] = {} var _authenticated_identity_cache: Dictionary[int, Dictionary] = {} var _client_identity_attempt: Dictionary = {} var _pending_server_proof: Dictionary = {} +var _recovery_attempts: Dictionary[int, String] = {} var _server_identity_fingerprint: String = "" var _server_identity_public_key: String = "" var _session_identity_keys: Dictionary[String, String] = {} @@ -668,6 +674,12 @@ func _on_peer_disconnected(peer_id: int) -> void: _pending_authentication.erase(peer_id) _pending_identity_challenges.erase(peer_id) _authenticated_identity_cache.erase(peer_id) + var recovery_attempt: String = _recovery_attempts.get(peer_id, "") + if not recovery_attempt.is_empty(): + _recovery_attempts.erase(peer_id) + remote_recovery_presentation_changed.emit( + peer_id, false, recovery_attempt + ) if _registry.has_peer(peer_id): _registry.remove_peer(peer_id) _spawn_service.remove_peer(peer_id) @@ -1320,6 +1332,12 @@ func _send_local_input() -> void: submit_movement_input.rpc_id(1, input) +func submit_neutral_local_movement() -> void: + if state != State.JOINED_CLIENT: + return + _send_local_input() + + @rpc("any_peer", "call_remote", "unreliable_ordered", 1) func submit_movement_input(data: Dictionary) -> void: var sender_id: int = multiplayer.get_remote_sender_id() @@ -1400,6 +1418,99 @@ func publish_authoritative_teleport(peer_id: int) -> void: ) +func request_recovery_presentation( + active: bool, + attempt_id: String, +) -> void: + if ( + attempt_id.is_empty() + or attempt_id.length() > 96 + or not is_gameplay_session_active() + ): + return + if is_host(): + _set_authoritative_recovery_presentation( + get_local_peer_id(), active, attempt_id + ) + elif state == State.JOINED_CLIENT: + submit_recovery_presentation.rpc_id( + 1, _session_id, active, attempt_id + ) + + +@rpc("any_peer", "call_remote", "reliable", 0) +func submit_recovery_presentation( + session_id: String, + active: bool, + attempt_id: String, +) -> void: + var sender_id: int = multiplayer.get_remote_sender_id() + if ( + not is_host() + or not _registry.has_peer(sender_id) + or session_id != _session_id + or attempt_id.is_empty() + or attempt_id.length() > 96 + ): + return + _set_authoritative_recovery_presentation(sender_id, active, attempt_id) + + +func _set_authoritative_recovery_presentation( + peer_id: int, + active: bool, + attempt_id: String, +) -> void: + var current_attempt: String = _recovery_attempts.get(peer_id, "") + if active: + if not current_attempt.is_empty() and current_attempt != attempt_id: + return + elif current_attempt != attempt_id: + return + var data := { + "session_id": _session_id, + "generation": _operation_generation, + "peer_id": peer_id, + "active": active, + "attempt_id": attempt_id, + } + _apply_recovery_presentation(data) + receive_recovery_presentation.rpc(data) + + +@rpc("authority", "call_remote", "reliable", 0) +func receive_recovery_presentation(data: Dictionary) -> void: + _apply_recovery_presentation(data) + + +func _apply_recovery_presentation(data: Dictionary) -> void: + if ( + typeof(data.get("session_id")) != TYPE_STRING + or str(data["session_id"]) != _session_id + or typeof(data.get("generation")) != TYPE_INT + or int(data["generation"]) < 1 + or typeof(data.get("peer_id")) != TYPE_INT + or typeof(data.get("active")) != TYPE_BOOL + or typeof(data.get("attempt_id")) != TYPE_STRING + or str(data["attempt_id"]).is_empty() + or str(data["attempt_id"]).length() > 96 + ): + return + var peer_id: int = data["peer_id"] + var attempt_id: String = data["attempt_id"] + var active: bool = data["active"] + var current_attempt: String = _recovery_attempts.get(peer_id, "") + if active: + if not current_attempt.is_empty() and current_attempt != attempt_id: + return + _recovery_attempts[peer_id] = attempt_id + elif current_attempt != attempt_id: + return + else: + _recovery_attempts.erase(peer_id) + remote_recovery_presentation_changed.emit(peer_id, active, attempt_id) + + func request_safe_respawn(entry_position: Vector3) -> void: if not entry_position.is_finite(): return @@ -1535,6 +1646,11 @@ func _fail(message: String) -> void: func _teardown_peer() -> void: + for peer_id: int in _recovery_attempts.keys(): + remote_recovery_presentation_changed.emit( + peer_id, false, _recovery_attempts[peer_id] + ) + _recovery_attempts.clear() _pending_authentication.clear() _pending_identity_challenges.clear() _authenticated_identity_cache.clear() diff --git a/player/player.gd b/player/player.gd index d6662b2..dd04b41 100644 --- a/player/player.gd +++ b/player/player.gd @@ -105,6 +105,9 @@ var _camera_dragging: bool = false var _camera_input_enabled: bool = true var _movement_enabled: bool = true var _water_recovery_active: bool = false +var _remote_recovery_presentation_active: bool = false +var _remote_recovery_visual_origin: Vector3 +var _remote_recovery_elapsed: float = 0.0 var _target_zoom: float = 5.0 var _showcase_rod_visibility: bool = true var _remote_presentation_visible := true @@ -212,6 +215,14 @@ func _physics_process(delta: float) -> void: func _process(delta: float) -> void: + if _remote_recovery_presentation_active: + _remote_recovery_elapsed += delta + _visuals.position = ( + _remote_recovery_visual_origin + + Vector3.UP + * sin(_remote_recovery_elapsed * 2.0 * TAU) + * 0.08 + ) if not local_control_enabled: return @@ -338,6 +349,16 @@ func configure_network_remote(authoritative_simulation: bool) -> void: func capture_network_input(sequence: int) -> Dictionary: + if not _movement_enabled or _water_recovery_active: + return { + "sequence": sequence, + "axis": [0.0, 0.0], + "camera_yaw": _camera_yaw.global_rotation.y, + "jump": false, + "sprint": false, + "sneak": false, + "slow_walk": false, + } var axis: Vector2 = Input.get_vector( "move_left", "move_right", @@ -525,6 +546,17 @@ func is_water_recovery_active() -> bool: return _water_recovery_active +func set_remote_recovery_presentation(active: bool) -> void: + if local_control_enabled or active == _remote_recovery_presentation_active: + return + _remote_recovery_presentation_active = active + _remote_recovery_elapsed = 0.0 + if active: + _remote_recovery_visual_origin = _visuals.position + else: + _visuals.position = _remote_recovery_visual_origin + + func prepare_for_water_recovery() -> void: _restore_gameplay_presentation_for_recovery() diff --git a/progression/player_asset_reservation_service.gd b/progression/player_asset_reservation_service.gd index 2b7d38f..f66e672 100644 --- a/progression/player_asset_reservation_service.gd +++ b/progression/player_asset_reservation_service.gd @@ -10,6 +10,7 @@ var _inventory: FishInventory var _bag: PlayerBag var _catalog: ItemCatalog var _reservations: Dictionary[String, Dictionary] = {} +var _mail_transfer_authorizations: Dictionary[String, Dictionary] = {} func setup( @@ -63,6 +64,7 @@ func reserve(reservation_id: String, attachment: Dictionary) -> bool: func release(reservation_id: String) -> bool: if not _reservations.erase(reservation_id): return false + _mail_transfer_authorizations.erase(reservation_id) reservations_changed.emit() return true @@ -71,9 +73,46 @@ func release_all() -> void: if _reservations.is_empty(): return _reservations.clear() + _mail_transfer_authorizations.clear() reservations_changed.emit() +func authorize_mail_transfer( + reservation_id: String, + catch_id: StringName, + transfer_id: String, +) -> bool: + var attachment := get_reservation(reservation_id) + if ( + transfer_id.is_empty() + or transfer_id.length() > 96 + or attachment.is_empty() + or int(attachment.get("type", 0)) != AttachmentType.FISH + or StringName(str(attachment.get("catch_id", ""))) != catch_id + ): + return false + _mail_transfer_authorizations[reservation_id] = { + "catch_id": catch_id, + "transfer_id": transfer_id, + } + return true + + +func authorize_mail_fish_removal( + reservation_id: String, + catch_id: StringName, + transfer_id: String, +) -> bool: + var authorization: Dictionary = _mail_transfer_authorizations.get( + reservation_id, {} + ) + return ( + not authorization.is_empty() + and StringName(authorization.get("catch_id", StringName())) == catch_id + and str(authorization.get("transfer_id", "")) == transfer_id + ) + + func has_reservation(reservation_id: String) -> bool: return _reservations.has(reservation_id) @@ -138,7 +177,10 @@ func can_commit(reservation_id: String) -> bool: return false -func commit_removal(reservation_id: String) -> bool: +func commit_removal( + reservation_id: String, + mail_transfer_id: String = "", +) -> bool: var attachment := get_reservation(reservation_id) if attachment.is_empty() or not can_commit(reservation_id): return false @@ -147,9 +189,16 @@ func commit_removal(reservation_id: String) -> bool: AttachmentType.FISH_COIN: applied = _wallet.debit(int(attachment["amount"])) AttachmentType.FISH: - applied = _inventory.remove_catch_by_id( - StringName(str(attachment["catch_id"])) - ) != null + var catch_id := StringName(str(attachment["catch_id"])) + if authorize_mail_transfer( + reservation_id, catch_id, mail_transfer_id + ): + applied = ( + _inventory.remove_reserved_catch_for_mail_transfer( + catch_id, reservation_id, mail_transfer_id + ) + != null + ) AttachmentType.CONSUMABLE: applied = _bag.remove_item( StringName(str(attachment["item_id"])), @@ -157,6 +206,8 @@ func commit_removal(reservation_id: String) -> bool: ) if applied: release(reservation_id) + else: + _mail_transfer_authorizations.erase(reservation_id) return applied diff --git a/ui/chat_ui.gd b/ui/chat_ui.gd index d35c8a7..8d34a5d 100644 --- a/ui/chat_ui.gd +++ b/ui/chat_ui.gd @@ -60,6 +60,9 @@ func open_chat() -> void: _player.set_movement_enabled(false) _player.set_camera_input_enabled(false) _fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, true) + # Send the host a zeroed frame immediately so it cannot keep applying the + # last movement state while this LineEdit owns keyboard input. + _session.submit_neutral_local_movement() _panel.show() _entry.show() _entry.grab_focus() diff --git a/ui/fishing_shop.gd b/ui/fishing_shop.gd index 99540be..3b469c4 100644 --- a/ui/fishing_shop.gd +++ b/ui/fishing_shop.gd @@ -39,6 +39,9 @@ enum CloseReason { } @onready var _wallet_label: Label = %WalletLabel +@onready var _fish_sales: VBoxContainer = ( + $ShopPanel/Margin/Layout/Body/FishSales +) @onready var _fish_list: ItemList = %FishList @onready var _sales_title: Label = %SalesTitle @onready var _fish_empty: Label = %FishEmpty @@ -94,6 +97,9 @@ var _closing: bool = false func _ready() -> void: + # Catch selling belongs to the Pelican action in the Player Menu. Keep the + # legacy shop sales controls out of the active interface. + _fish_sales.hide() %CloseButton.pressed.connect(close_shop) _fish_list.item_selected.connect(_on_fish_selected) _fish_list.item_clicked.connect(_on_fish_clicked) @@ -425,8 +431,15 @@ func _refresh_upgrades() -> void: reel_cost < 0 or _transaction_in_progress or _closing + or _network_shop == null + or not _network_shop.can_request_purchase() or not _upgrades.can_purchase_reel_speed(_wallet) ) + _reel_purchase.tooltip_text = ( + "Purchases are unavailable in this session." + if _network_shop == null or not _network_shop.can_request_purchase() + else "" + ) if reel_cost < 0: _reel_effect.text = "%.2f×" % _upgrades.get_reel_speed_multiplier() _reel_cost.text = "max" @@ -447,8 +460,11 @@ func _refresh_upgrades() -> void: barrier_cost < 0 or _transaction_in_progress or _closing + or _network_shop == null + or not _network_shop.can_request_purchase() or not _upgrades.can_purchase_barrier_power(_wallet) ) + _barrier_purchase.tooltip_text = _reel_purchase.tooltip_text if barrier_cost < 0: _barrier_effect.text = "%d damage" % _upgrades.get_barrier_damage() _barrier_cost.text = "max" @@ -488,11 +504,18 @@ func _refresh_supplies() -> void: button.disabled = ( _transaction_in_progress or _closing + or _network_shop == null + or not _network_shop.can_request_purchase() or not _bag.can_add_item(item_id, 1) or not _wallet.can_afford( FishingShopStockType.get_price(item_id) ) ) + button.tooltip_text = ( + "Purchases are unavailable in this session." + if _network_shop == null or not _network_shop.can_request_purchase() + else item.description + ) button.pressed.connect(_purchase_supply.bind(item_id)) _supplies_list.add_child(button) @@ -508,8 +531,11 @@ func _refresh_cooler_capacity() -> void: cost < 0 or _transaction_in_progress or _closing + or _network_shop == null + or not _network_shop.can_request_purchase() or not _cooler_capacity.can_purchase(_wallet) ) + _cooler_purchase.tooltip_text = _reel_purchase.tooltip_text if cost < 0: _cooler_effect.text = "%d fish" % _cooler_capacity.get_capacity() _cooler_cost.text = "max" diff --git a/ui/game_ui.gd b/ui/game_ui.gd index 8bd2589..7d8d109 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -36,6 +36,7 @@ const ChatUIType = preload("res://ui/chat_ui.gd") signal pixelation_settings_visibility_changed(is_visible: bool) signal crisp_reset_focus_requested signal interactive_pointer_ui_changed(is_open: bool) +signal player_menu_backdrop_visibility_changed(is_visible: bool) @onready var _status_label: Label = %StatusLabel @onready var _catch_track: Control = %CatchTrack @@ -490,6 +491,7 @@ func _on_showcase_changed( func _on_player_menu_visibility_changed(is_open: bool) -> void: _player_menu_open = is_open + player_menu_backdrop_visibility_changed.emit(is_open) _refresh_chat_availability() _hotbar_ui.set_gameplay_input_enabled( _gameplay_ui_enabled and not is_open diff --git a/ui/player_menu.gd b/ui/player_menu.gd index ba64f0c..b6ea62a 100644 --- a/ui/player_menu.gd +++ b/ui/player_menu.gd @@ -229,6 +229,7 @@ var _sale_service: FishSaleServiceType var _network_session: NetworkSessionType var _network_sale_service: NetworkSaleService var _network_mail_service: NetworkMailService +var _reservations: PlayerAssetReservationService var _network_profile_service: NetworkProfileService var _network_player_list: NetworkPlayerListService var _default_buyer: FishBuyerProfileType @@ -389,6 +390,7 @@ func setup( _network_session = network_session _network_sale_service = network_sale_service _network_mail_service = network_mail_service + _reservations = reservations _network_profile_service = network_profile_service _network_player_list = network_player_list _mail_page.setup( @@ -2436,6 +2438,11 @@ func _update_inventory_detail(fish_catch: FishCatchType) -> void: else -1 ) _cooler_weight_value.text = "%.2f" % fish_catch.weight_lb + if ( + _reservations != null + and _reservations.is_fish_reserved(fish_catch.catch_id) + ): + _cooler_detail_name.text += " • reserved in mail" _cooler_weight_unit.text = "lb" if buyer_offer >= 0 and _default_buyer != null: _cooler_offer_label.text = "%s offer" % _default_buyer.display_name @@ -2734,6 +2741,16 @@ func _on_network_sale_finished( ) _refresh_all() if _current_section == Section.COOLER: + call_deferred("_restore_inventory_tab_focus") + + +func _restore_inventory_tab_focus() -> void: + if ( + visible + and _current_section == Section.COOLER + and _inventory_tab.focus_mode != Control.FOCUS_NONE + and _inventory_tab.is_visible_in_tree() + ): _inventory_tab.grab_focus() diff --git a/ui/player_menu.tscn b/ui/player_menu.tscn index 7affc70..ecb969c 100644 --- a/ui/player_menu.tscn +++ b/ui/player_menu.tscn @@ -44,17 +44,6 @@ mouse_filter = 2 theme = ExtResource("2_theme") script = ExtResource("1_menu") -[node name="Dimmer" type="ColorRect" parent="."] -z_index = -20 -layout_mode = 1 -anchors_preset = 15 -anchor_right = 1.0 -anchor_bottom = 1.0 -grow_horizontal = 2 -grow_vertical = 2 -mouse_filter = 2 -color = Color(0.015, 0.02, 0.03, 0.72) - [node name="ResponsivePlayerMenuStage" type="Control" parent="."] layout_mode = 1 anchors_preset = 15 diff --git a/world/water_recovery_controller.gd b/world/water_recovery_controller.gd index d5f46ae..9e11556 100644 --- a/world/water_recovery_controller.gd +++ b/world/water_recovery_controller.gd @@ -2,6 +2,7 @@ class_name WaterRecoveryController extends Node signal recovery_starting +signal recovery_finished signal local_respawn_completed(entry_position: Vector3) enum RecoveryState { @@ -177,3 +178,4 @@ func _finish_recovery() -> void: _fishing_spot.end_water_recovery() state = RecoveryState.IDLE _bob_elapsed = 0.0 + recovery_finished.emit()