From e560cc0b3ec50fb0ed5e93d0b60da177cdd487aa Mon Sep 17 00:00:00 2001 From: Voyager Date: Sun, 26 Jul 2026 23:18:51 -0400 Subject: [PATCH] Add multi-select batch fish selling --- economy/fish_sale_result.gd | 17 +- economy/fish_sale_service.gd | 120 ++++++++--- inventory/fish_inventory.gd | 46 +++-- ui/fish_batch_selection.gd | 106 ++++++++++ ui/fish_batch_selection.gd.uid | 1 + ui/fishing_shop.gd | 282 +++++++++++++++++++------- ui/fishing_shop.tscn | 51 +++-- ui/player_menu.gd | 352 ++++++++++++++++++++++----------- ui/player_menu.tscn | 44 +++-- 9 files changed, 747 insertions(+), 272 deletions(-) create mode 100644 ui/fish_batch_selection.gd create mode 100644 ui/fish_batch_selection.gd.uid diff --git a/economy/fish_sale_result.gd b/economy/fish_sale_result.gd index 0ef4a5b..e133245 100644 --- a/economy/fish_sale_result.gd +++ b/economy/fish_sale_result.gd @@ -8,12 +8,15 @@ enum Status { INVALID_VALUE, INVALID_BUYER, INVALID_OFFER, + INVALID_SELECTION, TRANSACTION_FAILED, } var success: bool = false var status: Status = Status.TRANSACTION_FAILED var catch_id: StringName +var catch_ids: Array[StringName] = [] +var fish_count: int = 0 var fish_name: String = "" var buyer_id: StringName var buyer_display_name: String = "" @@ -32,14 +35,16 @@ func get_message() -> String: Status.SUCCESS: return sale_message Status.NOT_FOUND: - return "Fish no longer exists." + return "fish no longer exists." Status.FAVORITED: - return "Favorited fish cannot be sold." + return "favorited fish cannot be sold." Status.INVALID_VALUE: - return "Invalid sale value." + return "invalid sale value." Status.INVALID_BUYER: - return "Buyer is unavailable." + return "buyer is unavailable." Status.INVALID_OFFER: - return "Invalid buyer offer." + return "invalid buyer offer." + Status.INVALID_SELECTION: + return "the fish selection is invalid." _: - return "Transaction failed." + return "transaction failed." diff --git a/economy/fish_sale_service.gd b/economy/fish_sale_service.gd index c046cc7..b5b81b2 100644 --- a/economy/fish_sale_service.gd +++ b/economy/fish_sale_service.gd @@ -25,24 +25,53 @@ func can_sell( catch_id: StringName, buyer: FishBuyerProfileType, ) -> bool: - return _validate_sale(catch_id, buyer).is_success() + return preview_batch([catch_id], buyer).is_success() + + +func can_sell_batch( + catch_ids: Array[StringName], + buyer: FishBuyerProfileType, +) -> bool: + return preview_batch(catch_ids, buyer).is_success() + + +func preview_batch( + catch_ids: Array[StringName], + buyer: FishBuyerProfileType, +) -> FishSaleResultType: + return _validate_batch(catch_ids, buyer) func sell( catch_id: StringName, buyer: FishBuyerProfileType, ) -> FishSaleResultType: - var result: FishSaleResultType = _validate_sale(catch_id, buyer) + return sell_batch([catch_id], buyer) + + +func sell_batch( + catch_ids: Array[StringName], + buyer: FishBuyerProfileType, +) -> FishSaleResultType: + var result: FishSaleResultType = _validate_batch(catch_ids, buyer) if not result.is_success(): return result - var removed_catch: FishCatchType = _inventory.remove_catch_by_id(catch_id) - if removed_catch == null: + var inventory_snapshot: Array[FishCatchType] = _inventory.get_all_catches() + var next_sequence_snapshot: int = _inventory.get_next_catch_sequence() + var removed_catches: Array[FishCatchType] = ( + _inventory.remove_catches_by_ids(result.catch_ids) + ) + if removed_catches.size() != result.catch_ids.size(): result.success = false - result.status = FishSaleResultType.Status.NOT_FOUND + result.status = FishSaleResultType.Status.TRANSACTION_FAILED return result if not _wallet.credit(result.payout): - _inventory.add_catch(removed_catch) + if not _inventory.replace_all_catches( + inventory_snapshot, + next_sequence_snapshot + ): + push_error("Unable to roll back a failed fish batch sale.") result.success = false result.status = FishSaleResultType.Status.TRANSACTION_FAILED return result @@ -50,27 +79,22 @@ func sell( return result -func _validate_sale( - catch_id: StringName, +func _validate_batch( + catch_ids: Array[StringName], buyer: FishBuyerProfileType, ) -> FishSaleResultType: var result := FishSaleResultType.new() - result.catch_id = catch_id - if ( - catch_id.is_empty() - or _inventory == null - or _wallet == null - ): - result.status = FishSaleResultType.Status.NOT_FOUND + result.catch_ids = catch_ids.duplicate() + result.fish_count = catch_ids.size() + if _inventory == null or _wallet == null: + result.status = FishSaleResultType.Status.TRANSACTION_FAILED + return result + if catch_ids.is_empty(): + result.status = FishSaleResultType.Status.INVALID_SELECTION return result if buyer == null or not buyer.is_valid(): result.status = FishSaleResultType.Status.INVALID_BUYER return result - var fish_catch: FishCatchType = _inventory.get_catch_by_id(catch_id) - if fish_catch == null: - result.status = FishSaleResultType.Status.NOT_FOUND - return result - result.fish_name = fish_catch.fish.display_name result.buyer_id = buyer.id result.buyer_display_name = buyer.display_name result.buyer_animal_name = ( @@ -78,21 +102,55 @@ func _validate_sale( if not buyer.animal_name_plural.is_empty() else buyer.display_name ) - result.base_value = fish_catch.sale_value - result.payout = buyer.get_offer(result.base_value) - if fish_catch.is_favorited: + var seen_ids: Dictionary[StringName, bool] = {} + var contains_favorite: bool = false + const MAX_SAFE_TOTAL: int = 9223372036854775807 + for catch_id: StringName in catch_ids: + if catch_id.is_empty() or seen_ids.has(catch_id): + result.status = FishSaleResultType.Status.INVALID_SELECTION + return result + seen_ids[catch_id] = true + var fish_catch: FishCatchType = _inventory.get_catch_by_id(catch_id) + if fish_catch == null: + result.status = FishSaleResultType.Status.NOT_FOUND + return result + if fish_catch.is_favorited: + contains_favorite = true + if fish_catch.sale_value < 0: + result.status = FishSaleResultType.Status.INVALID_VALUE + return result + var offer: int = buyer.get_offer(fish_catch.sale_value) + if offer < 0: + result.status = FishSaleResultType.Status.INVALID_OFFER + return result + if ( + result.base_value > MAX_SAFE_TOTAL - fish_catch.sale_value + or result.payout > MAX_SAFE_TOTAL - offer + ): + result.status = FishSaleResultType.Status.TRANSACTION_FAILED + return result + result.base_value += fish_catch.sale_value + result.payout += offer + if result.fish_count == 1: + result.catch_id = fish_catch.catch_id + result.fish_name = fish_catch.fish.display_name + if contains_favorite: result.status = FishSaleResultType.Status.FAVORITED - elif fish_catch.sale_value < 0: - result.status = FishSaleResultType.Status.INVALID_VALUE - elif result.payout < 0: - result.status = FishSaleResultType.Status.INVALID_OFFER - elif not _wallet.can_credit(result.payout): + return result + if not _wallet.can_credit(result.payout): result.status = FishSaleResultType.Status.TRANSACTION_FAILED - else: - result.status = FishSaleResultType.Status.SUCCESS - result.success = true + return result + result.status = FishSaleResultType.Status.SUCCESS + result.success = true + if result.fish_count == 1: result.sale_message = buyer.get_sale_message( result.fish_name, result.payout ) + else: + result.sale_message = "you sold %d fish to the %s for $%d." % [ + result.fish_count, + result.buyer_animal_name, + result.payout, + ] return result diff --git a/inventory/fish_inventory.gd b/inventory/fish_inventory.gd index 7304246..89e192d 100644 --- a/inventory/fish_inventory.gd +++ b/inventory/fish_inventory.gd @@ -95,18 +95,40 @@ func contains_catch_id(catch_id: StringName) -> bool: func remove_catch_by_id(catch_id: StringName) -> FishCatchType: if catch_id.is_empty(): return null - for index: int in range(_catches.size()): - var fish_catch: FishCatchType = _catches[index] - if fish_catch == null or fish_catch.catch_id != catch_id: - continue - _catches.remove_at(index) - contents_changed.emit( - fish_catch.fish_id, - get_count(fish_catch.fish_id) - ) - catches_changed.emit() - return fish_catch - return null + var removed: Array[FishCatchType] = remove_catches_by_ids([catch_id]) + return removed.front() if removed.size() == 1 else null + + +func remove_catches_by_ids( + catch_ids: Array[StringName], +) -> Array[FishCatchType]: + var removed: Array[FishCatchType] = [] + if catch_ids.is_empty(): + return removed + var requested_ids: Dictionary[StringName, bool] = {} + for catch_id: StringName in catch_ids: + if catch_id.is_empty() or requested_ids.has(catch_id): + return removed + requested_ids[catch_id] = true + for catch_id: StringName in catch_ids: + var fish_catch: FishCatchType = get_catch_by_id(catch_id) + if fish_catch == null: + removed.clear() + return removed + removed.append(fish_catch) + + var remaining: Array[FishCatchType] = [] + var affected_species: Dictionary[StringName, bool] = {} + for fish_catch: FishCatchType in _catches: + if requested_ids.has(fish_catch.catch_id): + affected_species[fish_catch.fish_id] = true + else: + remaining.append(fish_catch) + _catches = remaining + for fish_id: StringName in affected_species: + contents_changed.emit(fish_id, get_count(fish_id)) + catches_changed.emit() + return removed func set_catch_favorited( diff --git a/ui/fish_batch_selection.gd b/ui/fish_batch_selection.gd new file mode 100644 index 0000000..ab147ea --- /dev/null +++ b/ui/fish_batch_selection.gd @@ -0,0 +1,106 @@ +class_name FishBatchSelection +extends RefCounted + +var _selected_ids: Dictionary[StringName, bool] = {} +var _visible_ids: Array[StringName] = [] +var _focused_id: StringName +var _anchor_id: StringName + + +func set_visible_order(visible_ids: Array[StringName]) -> void: + _visible_ids = visible_ids.duplicate() + var visible_set: Dictionary[StringName, bool] = {} + for catch_id: StringName in _visible_ids: + if not catch_id.is_empty(): + visible_set[catch_id] = true + for catch_id: StringName in _selected_ids.keys(): + if not visible_set.has(catch_id): + _selected_ids.erase(catch_id) + if not visible_set.has(_focused_id): + _focused_id = StringName() + if not visible_set.has(_anchor_id): + _anchor_id = StringName() + + +func apply_click( + catch_id: StringName, + ctrl_pressed: bool, + shift_pressed: bool, +) -> void: + if catch_id.is_empty() or not _visible_ids.has(catch_id): + return + if not shift_pressed or _anchor_id.is_empty(): + if ctrl_pressed: + if _selected_ids.has(catch_id): + _selected_ids.erase(catch_id) + else: + _selected_ids[catch_id] = true + else: + _selected_ids.clear() + _selected_ids[catch_id] = true + _focused_id = catch_id + _anchor_id = catch_id + return + + var anchor_index: int = _visible_ids.find(_anchor_id) + var target_index: int = _visible_ids.find(catch_id) + if anchor_index < 0 or target_index < 0: + select_only(catch_id) + return + if not ctrl_pressed: + _selected_ids.clear() + var first_index: int = mini(anchor_index, target_index) + var last_index: int = maxi(anchor_index, target_index) + for index: int in range(first_index, last_index + 1): + _selected_ids[_visible_ids[index]] = true + _focused_id = catch_id + + +func select_only(catch_id: StringName) -> void: + _selected_ids.clear() + if catch_id.is_empty() or not _visible_ids.has(catch_id): + _focused_id = StringName() + _anchor_id = StringName() + return + _selected_ids[catch_id] = true + _focused_id = catch_id + _anchor_id = catch_id + + +func clear() -> void: + _selected_ids.clear() + _focused_id = StringName() + _anchor_id = StringName() + + +func remove_ids(catch_ids: Array[StringName]) -> void: + for catch_id: StringName in catch_ids: + _selected_ids.erase(catch_id) + if _focused_id == catch_id: + _focused_id = StringName() + if _anchor_id == catch_id: + _anchor_id = StringName() + + +func get_selected_ids() -> Array[StringName]: + var selected_in_order: Array[StringName] = [] + for catch_id: StringName in _visible_ids: + if _selected_ids.has(catch_id): + selected_in_order.append(catch_id) + return selected_in_order + + +func is_selected(catch_id: StringName) -> bool: + return _selected_ids.has(catch_id) + + +func get_selected_count() -> int: + return _selected_ids.size() + + +func get_focused_id() -> StringName: + return _focused_id + + +func get_anchor_id() -> StringName: + return _anchor_id diff --git a/ui/fish_batch_selection.gd.uid b/ui/fish_batch_selection.gd.uid new file mode 100644 index 0000000..c3ac218 --- /dev/null +++ b/ui/fish_batch_selection.gd.uid @@ -0,0 +1 @@ +uid://ct2140qw4pdug diff --git a/ui/fishing_shop.gd b/ui/fishing_shop.gd index 16774a3..1e7dfdd 100644 --- a/ui/fishing_shop.gd +++ b/ui/fishing_shop.gd @@ -24,6 +24,9 @@ const PlayerCoolerCapacityType = preload( const ShopInteractionType = preload( "res://world/fishing_shop_interaction.gd" ) +const FishBatchSelectionType = preload( + "res://ui/fish_batch_selection.gd" +) signal menu_visibility_changed(is_open: bool) @@ -42,6 +45,7 @@ enum CloseReason { @onready var _fish_texture: TextureRect = %FishTexture @onready var _fish_name: Label = %FishName @onready var _fish_details: Label = %FishDetails +@onready var _selection_summary: Label = %SelectionSummary @onready var _sell_button: Button = %SellButton @onready var _feedback: Label = %Feedback @onready var _reel_level: Label = %ReelLevel @@ -73,14 +77,16 @@ var _interaction: ShopInteractionType var _bag: PlayerBagType var _item_catalog: ItemCatalogType var _cooler_capacity: PlayerCoolerCapacityType -var _selected_catch_id: StringName -var _confirmation_catch_id: StringName +var _fish_selection := FishBatchSelectionType.new() +var _confirmation_catch_ids: Array[StringName] = [] +var _confirmation_generation: int = -1 var _prior_movement_enabled: bool = true var _prior_camera_enabled: bool = true var _prior_mouse_mode: Input.MouseMode = Input.MOUSE_MODE_VISIBLE var _snapshot_stored: bool = false var _mouse_snapshot_stored: bool = false var _generation: int = 0 +var _selection_input_generation: int = 0 var _transaction_in_progress: bool = false var _closing: bool = false @@ -88,6 +94,7 @@ var _closing: bool = false func _ready() -> void: %CloseButton.pressed.connect(close_shop) _fish_list.item_selected.connect(_on_fish_selected) + _fish_list.item_clicked.connect(_on_fish_clicked) _sell_button.pressed.connect(_open_sale_confirmation) _confirm_sale.pressed.connect(_on_confirm_sale) _cancel_sale.pressed.connect(_close_sale_confirmation) @@ -120,6 +127,7 @@ func setup( _bag = bag _item_catalog = item_catalog _cooler_capacity = cooler_capacity + _fish_selection.clear() if not _inventory.catches_changed.is_connected(_on_inventory_changed): _inventory.catches_changed.connect(_on_inventory_changed) if not _wallet.balance_changed.is_connected(_on_wallet_changed): @@ -189,7 +197,7 @@ func close_shop( _generation += 1 _transaction_in_progress = false _close_sale_confirmation() - _selected_catch_id = StringName() + _fish_selection.clear() hide() get_viewport().gui_release_focus() if _fishing_spot != null and is_instance_valid(_fishing_spot): @@ -242,9 +250,9 @@ func _refresh_all() -> void: func _refresh_wallet() -> void: _wallet_label.text = ( - "Wallet: $%d" % _wallet.get_balance() + "wallet: $%d" % _wallet.get_balance() if _wallet != null - else "Wallet: $0" + else "wallet: $0" ) @@ -253,7 +261,7 @@ func _refresh_fish_list() -> void: var catches: Array[FishCatchType] = ( _inventory.get_all_catches() if _inventory != null else [] ) - _sales_title.text = "Cooler %d / %d • Full base value" % [ + _sales_title.text = "cooler %d / %d • full base value" % [ catches.size(), _cooler_capacity.get_capacity() if _cooler_capacity != null else 0, ] @@ -261,11 +269,21 @@ func _refresh_fish_list() -> void: func(a: FishCatchType, b: FishCatchType) -> bool: return a.catch_sequence > b.catch_sequence ) - var selected_index: int = -1 + var visible_ids: Array[StringName] = [] + for fish_catch: FishCatchType in catches: + if fish_catch != null and fish_catch.is_valid(): + visible_ids.append(fish_catch.catch_id) + _fish_selection.set_visible_order(visible_ids) for fish_catch: FishCatchType in catches: if fish_catch == null or not fish_catch.is_valid(): continue - var marker: String = "★ " if fish_catch.is_favorited else "" + var marker: String = "" + if _fish_selection.is_selected(fish_catch.catch_id): + marker += "✓ " + if fish_catch.catch_id == _fish_selection.get_focused_id(): + marker += "◆ " + if fish_catch.is_favorited: + marker += "★ " var index: int = _fish_list.add_item( "%s%s — %.2f lb" % [marker, fish_catch.fish.display_name, fish_catch.weight_lb], @@ -277,16 +295,15 @@ func _refresh_fish_list() -> void: "%s • %s" % [ fish_catch.fish.get_rarity_name(), - "Favorited" if fish_catch.is_favorited else "Available", + "favorited" if fish_catch.is_favorited else "available", ] ) - if fish_catch.catch_id == _selected_catch_id: - selected_index = index _fish_empty.visible = _fish_list.item_count == 0 - if selected_index >= 0: - _fish_list.select(selected_index) - elif not _selected_catch_id.is_empty(): - _selected_catch_id = StringName() + _fish_list.deselect_all() + for index: int in range(_fish_list.item_count): + var catch_id := StringName(str(_fish_list.get_item_metadata(index))) + if _fish_selection.is_selected(catch_id): + _fish_list.select(index, false) func _refresh_selected_fish() -> void: @@ -295,27 +312,72 @@ func _refresh_selected_fish() -> void: fish_catch.fish.display_texture if fish_catch != null else null ) _fish_name.text = ( - fish_catch.fish.display_name if fish_catch != null else "Select a fish" + fish_catch.fish.display_name if fish_catch != null else "select a fish" ) if fish_catch == null: - _fish_details.text = "Choose one individual fish from your Cooler." - _sell_button.disabled = true + _fish_details.text = "choose one individual fish from your cooler." + _update_sale_summary() return - var offer: int = _buyer.get_offer(fish_catch.sale_value) + var individual_preview: FishSaleResultType = ( + _sale_service.preview_batch([fish_catch.catch_id], _buyer) + if _sale_service != null + else null + ) + var offer: int = ( + individual_preview.payout if individual_preview != null else -1 + ) _fish_details.text = ( - "%.2f lb • %s\nBase value: $%d\nMain-shop offer: $%d%s" + "%.2f lb • %s\nbase value: $%d\nmain-shop offer: $%d%s" % [ fish_catch.weight_lb, fish_catch.fish.get_rarity_name(), fish_catch.sale_value, offer, - "\nFavorited fish cannot be sold." + "\nfavorited fish cannot be sold." if fish_catch.is_favorited else "", ] ) + _update_sale_summary() + + +func _update_sale_summary() -> void: + var selected_ids: Array[StringName] = _fish_selection.get_selected_ids() + var selected_count: int = selected_ids.size() + _sell_button.text = ( + "sell fish" + if selected_count <= 1 + else "sell %d fish" % selected_count + ) + if selected_count == 0: + _selection_summary.text = "no fish selected" + _sell_button.disabled = true + return + var preview: FishSaleResultType = ( + _sale_service.preview_batch(selected_ids, _buyer) + if _sale_service != null + else null + ) + _selection_summary.text = ( + "1 fish selected" + if selected_count == 1 + else "%d fish selected" % selected_count + ) + if ( + preview != null + and ( + preview.is_success() + or preview.status == FishSaleResultType.Status.FAVORITED + ) + ): + _selection_summary.text += "\ntotal offer: $%d" % preview.payout + if preview != null and preview.status == FishSaleResultType.Status.FAVORITED: + _selection_summary.text += ( + "\nfavorited fish must be removed from the selection." + ) _sell_button.disabled = ( - fish_catch.is_favorited + preview == null + or not preview.is_success() or _transaction_in_progress or _closing ) @@ -326,7 +388,7 @@ func _refresh_upgrades() -> void: return var reel_level: int = _upgrades.get_reel_speed_level() var reel_cost: int = _upgrades.get_next_reel_speed_cost() - _reel_level.text = "Level %d" % reel_level + _reel_level.text = "level %d" % reel_level _reel_purchase.disabled = ( reel_cost < 0 or _transaction_in_progress @@ -335,7 +397,7 @@ func _refresh_upgrades() -> void: ) if reel_cost < 0: _reel_effect.text = "%.2f×" % _upgrades.get_reel_speed_multiplier() - _reel_cost.text = "MAX" + _reel_cost.text = "max" else: _reel_effect.text = ( "%.2f× → %.2f×" @@ -345,10 +407,10 @@ func _refresh_upgrades() -> void: ] ) _reel_cost.text = "$%d" % reel_cost - _reel_purchase.text = "MAX" if reel_cost < 0 else "Purchase" + _reel_purchase.text = "max" if reel_cost < 0 else "purchase" var barrier_level: int = _upgrades.get_barrier_power_level() var barrier_cost: int = _upgrades.get_next_barrier_power_cost() - _barrier_level.text = "Level %d" % barrier_level + _barrier_level.text = "level %d" % barrier_level _barrier_purchase.disabled = ( barrier_cost < 0 or _transaction_in_progress @@ -357,7 +419,7 @@ func _refresh_upgrades() -> void: ) if barrier_cost < 0: _barrier_effect.text = "%d damage" % _upgrades.get_barrier_damage() - _barrier_cost.text = "MAX" + _barrier_cost.text = "max" else: _barrier_effect.text = ( "%d damage → %d damage" @@ -367,7 +429,7 @@ func _refresh_upgrades() -> void: ] ) _barrier_cost.text = "$%d" % barrier_cost - _barrier_purchase.text = "MAX" if barrier_cost < 0 else "Purchase" + _barrier_purchase.text = "max" if barrier_cost < 0 else "purchase" func _refresh_supplies() -> void: @@ -385,7 +447,7 @@ func _refresh_supplies() -> void: button.icon = item.icon button.expand_icon = true button.alignment = HORIZONTAL_ALIGNMENT_LEFT - button.text = "%s\n$%d • Owned %d" % [ + button.text = "%s\n$%d • owned %d" % [ item.display_name, FishingShopStockType.get_price(item_id), _bag.get_quantity(item_id), @@ -409,7 +471,7 @@ func _refresh_cooler_capacity() -> void: var level: int = _cooler_capacity.get_level() var cost: int = _cooler_capacity.get_next_cost() var next_capacity: int = _cooler_capacity.get_next_capacity() - _cooler_level.text = "Level %d" % level + _cooler_level.text = "level %d" % level _cooler_purchase.disabled = ( cost < 0 or _transaction_in_progress @@ -418,46 +480,99 @@ func _refresh_cooler_capacity() -> void: ) if cost < 0: _cooler_effect.text = "%d fish" % _cooler_capacity.get_capacity() - _cooler_cost.text = "MAX" - _cooler_purchase.text = "MAX" + _cooler_cost.text = "max" + _cooler_purchase.text = "max" else: _cooler_effect.text = "%d → %d fish" % [ _cooler_capacity.get_capacity(), next_capacity, ] _cooler_cost.text = "$%d" % cost - _cooler_purchase.text = "Purchase" + _cooler_purchase.text = "purchase" func _on_fish_selected(index: int) -> void: if index < 0 or index >= _fish_list.item_count: return - _selected_catch_id = StringName(str(_fish_list.get_item_metadata(index))) + var input_generation: int = _selection_input_generation + call_deferred( + "_apply_keyboard_fish_selection", + index, + input_generation + ) + + +func _apply_keyboard_fish_selection( + index: int, + input_generation: int, +) -> void: + if ( + input_generation != _selection_input_generation + or index < 0 + or index >= _fish_list.item_count + ): + return + var catch_id := StringName(str(_fish_list.get_item_metadata(index))) + _fish_selection.select_only(catch_id) _feedback.text = "" + _refresh_fish_list() + _refresh_selected_fish() + + +func _on_fish_clicked( + index: int, + _position: Vector2, + mouse_button_index: int, +) -> void: + if ( + mouse_button_index != MOUSE_BUTTON_LEFT + or index < 0 + or index >= _fish_list.item_count + ): + return + _selection_input_generation += 1 + var catch_id := StringName(str(_fish_list.get_item_metadata(index))) + _fish_selection.apply_click( + catch_id, + Input.is_key_pressed(KEY_CTRL), + Input.is_key_pressed(KEY_SHIFT) + ) + _feedback.text = "" + _refresh_fish_list() _refresh_selected_fish() func _open_sale_confirmation() -> void: - var fish_catch: FishCatchType = _get_selected_catch() - if not _is_transaction_context_valid() or fish_catch == null: - _feedback.text = "This fish is no longer available." + var selected_ids: Array[StringName] = _fish_selection.get_selected_ids() + if not _is_transaction_context_valid() or selected_ids.is_empty(): + _feedback.text = "the fish selection is no longer available." _refresh_all() return - if fish_catch.is_favorited: - _feedback.text = "Favorited fish cannot be sold." + var preview: FishSaleResultType = _sale_service.preview_batch( + selected_ids, + _buyer + ) + if not preview.is_success(): + _feedback.text = ( + "favorited fish cannot be sold. " + + "remove them from the selection first." + if preview.status == FishSaleResultType.Status.FAVORITED + else preview.get_message() + ) return - _confirmation_catch_id = fish_catch.catch_id - var offer: int = _buyer.get_offer(fish_catch.sale_value) + _confirmation_catch_ids = selected_ids.duplicate() + _confirmation_generation = _generation _confirmation_text.text = ( - "Sell this %.2f lb %s to the Fishing Shop for $%d?" - % [fish_catch.weight_lb, fish_catch.fish.display_name, offer] + "sell %d fish to the fishing shop for $%d?" + % [preview.fish_count, preview.payout] ) _confirmation.show() _cancel_sale.grab_focus() func _close_sale_confirmation() -> void: - _confirmation_catch_id = StringName() + _confirmation_catch_ids.clear() + _confirmation_generation = -1 _confirmation.hide() @@ -465,18 +580,23 @@ func _on_confirm_sale() -> void: if _transaction_in_progress: return var transaction_generation: int = _generation - var catch_id: StringName = _confirmation_catch_id + var catch_ids: Array[StringName] = _confirmation_catch_ids.duplicate() + var confirmation_generation: int = _confirmation_generation _close_sale_confirmation() if ( - catch_id.is_empty() + catch_ids.is_empty() or not _is_transaction_context_valid() or transaction_generation != _generation + or confirmation_generation != transaction_generation or _buyer.id != MAIN_SHOP_BUYER_ID ): - _feedback.text = "Unable to complete sale." + _feedback.text = "unable to complete sale." return _transaction_in_progress = true - var result: FishSaleResultType = _sale_service.sell(catch_id, _buyer) + var result: FishSaleResultType = _sale_service.sell_batch( + catch_ids, + _buyer + ) _transaction_in_progress = false if ( transaction_generation != _generation @@ -484,8 +604,15 @@ func _on_confirm_sale() -> void: ): return if result.is_success(): - _feedback.text = "Fish sold for $%d." % result.payout - _selected_catch_id = StringName() + _feedback.text = ( + "fish sold for $%d." + if result.fish_count == 1 + else "%d fish sold for $%d." % [ + result.fish_count, + result.payout, + ] + ) + _fish_selection.remove_ids(catch_ids) else: _feedback.text = result.get_message() _refresh_all() @@ -501,14 +628,14 @@ func _purchase_barrier_power() -> void: func _purchase_supply(item_id: StringName) -> void: if _transaction_in_progress or not _is_transaction_context_valid(): - _feedback.text = "Unable to complete purchase." + _feedback.text = "unable to complete purchase." return var price: int = FishingShopStockType.get_price(item_id) if not _bag.can_add_item(item_id, 1): - _feedback.text = "Bag cannot accept this item." + _feedback.text = "bag cannot accept this item." return if not _wallet.can_afford(price): - _feedback.text = "Not enough money." + _feedback.text = "not enough money." return var transaction_generation: int = _generation _transaction_in_progress = true @@ -522,21 +649,21 @@ func _purchase_supply(item_id: StringName) -> void: if transaction_generation != _generation or not visible: return _feedback.text = ( - "Item purchased." if purchased else "Unable to complete purchase." + "item purchased." if purchased else "unable to complete purchase." ) _refresh_all() func _purchase_cooler_capacity() -> void: if _transaction_in_progress or not _is_transaction_context_valid(): - _feedback.text = "Unable to complete purchase." + _feedback.text = "unable to complete purchase." return var cost: int = _cooler_capacity.get_next_cost() if cost < 0: - _feedback.text = "Maximum level reached." + _feedback.text = "maximum level reached." return if not _wallet.can_afford(cost): - _feedback.text = "Not enough money." + _feedback.text = "not enough money." return var transaction_generation: int = _generation _transaction_in_progress = true @@ -545,16 +672,16 @@ func _purchase_cooler_capacity() -> void: if transaction_generation != _generation or not visible: return _feedback.text = ( - "Upgrade purchased." + "upgrade purchased." if purchased - else "Unable to complete purchase." + else "unable to complete purchase." ) _refresh_all() func _purchase_upgrade(is_reel_speed: bool) -> void: if _transaction_in_progress or not _is_transaction_context_valid(): - _feedback.text = "Unable to complete purchase." + _feedback.text = "unable to complete purchase." return var cost: int = ( _upgrades.get_next_reel_speed_cost() @@ -562,10 +689,10 @@ func _purchase_upgrade(is_reel_speed: bool) -> void: else _upgrades.get_next_barrier_power_cost() ) if cost < 0: - _feedback.text = "Maximum level reached." + _feedback.text = "maximum level reached." return if not _wallet.can_afford(cost): - _feedback.text = "Not enough money." + _feedback.text = "not enough money." return var transaction_generation: int = _generation _transaction_in_progress = true @@ -578,17 +705,18 @@ func _purchase_upgrade(is_reel_speed: bool) -> void: if transaction_generation != _generation or not visible: return _feedback.text = ( - "Upgrade purchased." + "upgrade purchased." if purchased - else "Unable to complete purchase." + else "unable to complete purchase." ) _refresh_all() func _get_selected_catch() -> FishCatchType: - if _inventory == null or _selected_catch_id.is_empty(): + var focused_id: StringName = _fish_selection.get_focused_id() + if _inventory == null or focused_id.is_empty(): return null - return _inventory.get_catch_by_id(_selected_catch_id) + return _inventory.get_catch_by_id(focused_id) func _is_transaction_context_valid() -> bool: @@ -607,12 +735,22 @@ func _is_transaction_context_valid() -> bool: func _on_inventory_changed() -> void: _refresh_fish_list() - if ( - not _confirmation_catch_id.is_empty() - and _inventory.get_catch_by_id(_confirmation_catch_id) == null - ): - _close_sale_confirmation() - _feedback.text = "This fish is no longer available." + if not _confirmation_catch_ids.is_empty(): + var preview: FishSaleResultType = _sale_service.preview_batch( + _confirmation_catch_ids, + _buyer + ) + if ( + _confirmation_generation != _generation + or not preview.is_success() + ): + _close_sale_confirmation() + _feedback.text = ( + "favorited fish cannot be sold. " + + "remove them from the selection first." + if preview.status == FishSaleResultType.Status.FAVORITED + else preview.get_message() + ) _refresh_selected_fish() diff --git a/ui/fishing_shop.tscn b/ui/fishing_shop.tscn index 4ecc8b5..11b062b 100644 --- a/ui/fishing_shop.tscn +++ b/ui/fishing_shop.tscn @@ -63,7 +63,7 @@ theme_override_constants/separation = 8 [node name="Title" type="Label" parent="ShopPanel/Margin/Layout/Header"] layout_mode = 2 size_flags_horizontal = 3 -text = "Fishing Shop" +text = "fishing shop" theme_override_font_sizes/font_size = 24 theme_override_colors/font_color = Color(0.208, 0.725, 0.78, 1) @@ -78,12 +78,12 @@ texture_filter = 1 [node name="WalletLabel" type="Label" parent="ShopPanel/Margin/Layout/Header"] unique_name_in_owner = true layout_mode = 2 -text = "Wallet: $0" +text = "wallet: $0" [node name="CloseButton" type="Button" parent="ShopPanel/Margin/Layout/Header"] unique_name_in_owner = true layout_mode = 2 -text = "Close" +text = "close" custom_minimum_size = Vector2(72, 30) [node name="Feedback" type="Label" parent="ShopPanel/Margin/Layout"] @@ -107,7 +107,7 @@ theme_override_constants/separation = 6 [node name="SalesTitle" type="Label" parent="ShopPanel/Margin/Layout/Body/FishSales"] unique_name_in_owner = true layout_mode = 2 -text = "Sell individual Cooler fish • Full base value" +text = "sell individual cooler fish • full base value" theme_override_font_sizes/font_size = 17 [node name="SalesBody" type="HSplitContainer" parent="ShopPanel/Margin/Layout/Body/FishSales"] @@ -133,7 +133,7 @@ layout_mode = 2 [node name="FishEmpty" type="Label" parent="ShopPanel/Margin/Layout/Body/FishSales/SalesBody/ListPanel/ListMargin/ListStack"] unique_name_in_owner = true layout_mode = 2 -text = "Your Cooler is empty." +text = "your cooler is empty." horizontal_alignment = 1 [node name="FishList" type="ItemList" parent="ShopPanel/Margin/Layout/Body/FishSales/SalesBody/ListPanel/ListMargin/ListStack"] @@ -142,6 +142,7 @@ layout_mode = 2 size_flags_vertical = 3 fixed_icon_size = Vector2i(48, 36) icon_mode = 1 +select_mode = 1 same_column_width = true [node name="FishDetail" type="PanelContainer" parent="ShopPanel/Margin/Layout/Body/FishSales/SalesBody"] @@ -170,22 +171,30 @@ texture_filter = 1 [node name="FishName" type="Label" parent="ShopPanel/Margin/Layout/Body/FishSales/SalesBody/FishDetail/DetailMargin/DetailStack"] unique_name_in_owner = true layout_mode = 2 -text = "Select a fish" +text = "select a fish" horizontal_alignment = 1 theme_override_font_sizes/font_size = 19 [node name="FishDetails" type="Label" parent="ShopPanel/Margin/Layout/Body/FishSales/SalesBody/FishDetail/DetailMargin/DetailStack"] unique_name_in_owner = true layout_mode = 2 -text = "Choose one individual fish from your Cooler." +text = "choose one individual fish from your cooler." horizontal_alignment = 1 autowrap_mode = 2 +[node name="SelectionSummary" type="Label" parent="ShopPanel/Margin/Layout/Body/FishSales/SalesBody/FishDetail/DetailMargin/DetailStack"] +unique_name_in_owner = true +layout_mode = 2 +text = "no fish selected" +horizontal_alignment = 1 +autowrap_mode = 2 +theme_override_colors/font_color = Color(0.208, 0.725, 0.78, 1) + [node name="SellButton" type="Button" parent="ShopPanel/Margin/Layout/Body/FishSales/SalesBody/FishDetail/DetailMargin/DetailStack"] unique_name_in_owner = true layout_mode = 2 disabled = true -text = "Sell Selected" +text = "sell selected" [node name="Upgrades" type="VBoxContainer" parent="ShopPanel/Margin/Layout/Body"] custom_minimum_size = Vector2(270, 0) @@ -194,7 +203,7 @@ theme_override_constants/separation = 8 [node name="UpgradeTitle" type="Label" parent="ShopPanel/Margin/Layout/Body/Upgrades"] layout_mode = 2 -text = "Fishing Upgrades" +text = "fishing upgrades" theme_override_font_sizes/font_size = 17 [node name="Supplies" type="VBoxContainer" parent="ShopPanel/Margin/Layout/Body"] @@ -204,7 +213,7 @@ theme_override_constants/separation = 6 [node name="Title" type="Label" parent="ShopPanel/Margin/Layout/Body/Supplies"] layout_mode = 2 -text = "Supplies" +text = "supplies" theme_override_font_sizes/font_size = 17 [node name="Scroll" type="ScrollContainer" parent="ShopPanel/Margin/Layout/Body/Supplies"] @@ -246,13 +255,13 @@ size_flags_horizontal = 3 [node name="Name" type="Label" parent="ShopPanel/Margin/Layout/Body/Upgrades/ReelCard/Margin/Row/Data"] layout_mode = 2 -text = "Reel Speed" +text = "reel speed" theme_override_font_sizes/font_size = 17 [node name="ReelLevel" type="Label" parent="ShopPanel/Margin/Layout/Body/Upgrades/ReelCard/Margin/Row/Data"] unique_name_in_owner = true layout_mode = 2 -text = "Level 0" +text = "level 0" [node name="ReelEffect" type="Label" parent="ShopPanel/Margin/Layout/Body/Upgrades/ReelCard/Margin/Row/Data"] unique_name_in_owner = true @@ -267,7 +276,7 @@ text = "$50" [node name="ReelPurchase" type="Button" parent="ShopPanel/Margin/Layout/Body/Upgrades/ReelCard/Margin/Row"] unique_name_in_owner = true layout_mode = 2 -text = "Purchase" +text = "purchase" [node name="BarrierCard" type="PanelContainer" parent="ShopPanel/Margin/Layout/Body/Upgrades"] layout_mode = 2 @@ -297,13 +306,13 @@ size_flags_horizontal = 3 [node name="Name" type="Label" parent="ShopPanel/Margin/Layout/Body/Upgrades/BarrierCard/Margin/Row/Data"] layout_mode = 2 -text = "Barrier Power" +text = "barrier power" theme_override_font_sizes/font_size = 17 [node name="BarrierLevel" type="Label" parent="ShopPanel/Margin/Layout/Body/Upgrades/BarrierCard/Margin/Row/Data"] unique_name_in_owner = true layout_mode = 2 -text = "Level 0" +text = "level 0" [node name="BarrierEffect" type="Label" parent="ShopPanel/Margin/Layout/Body/Upgrades/BarrierCard/Margin/Row/Data"] unique_name_in_owner = true @@ -318,7 +327,7 @@ text = "$100" [node name="BarrierPurchase" type="Button" parent="ShopPanel/Margin/Layout/Body/Upgrades/BarrierCard/Margin/Row"] unique_name_in_owner = true layout_mode = 2 -text = "Purchase" +text = "purchase" [node name="CoolerCard" type="PanelContainer" parent="ShopPanel/Margin/Layout/Body/Upgrades"] layout_mode = 2 @@ -348,13 +357,13 @@ size_flags_horizontal = 3 [node name="Name" type="Label" parent="ShopPanel/Margin/Layout/Body/Upgrades/CoolerCard/Margin/Row/Data"] layout_mode = 2 -text = "Cooler Capacity" +text = "cooler capacity" theme_override_font_sizes/font_size = 17 [node name="CoolerLevel" type="Label" parent="ShopPanel/Margin/Layout/Body/Upgrades/CoolerCard/Margin/Row/Data"] unique_name_in_owner = true layout_mode = 2 -text = "Level 0" +text = "level 0" [node name="CoolerEffect" type="Label" parent="ShopPanel/Margin/Layout/Body/Upgrades/CoolerCard/Margin/Row/Data"] unique_name_in_owner = true @@ -369,7 +378,7 @@ text = "$75" [node name="CoolerPurchase" type="Button" parent="ShopPanel/Margin/Layout/Body/Upgrades/CoolerCard/Margin/Row"] unique_name_in_owner = true layout_mode = 2 -text = "Purchase" +text = "purchase" [node name="SaleConfirmation" type="PanelContainer" parent="."] unique_name_in_owner = true @@ -413,10 +422,10 @@ theme_override_constants/separation = 8 [node name="ConfirmSale" type="Button" parent="SaleConfirmation/Margin/Stack/Buttons"] unique_name_in_owner = true layout_mode = 2 -text = "Sell Fish" +text = "sell fish" theme_type_variation = &"DangerButton" [node name="CancelSale" type="Button" parent="SaleConfirmation/Margin/Stack/Buttons"] unique_name_in_owner = true layout_mode = 2 -text = "Cancel" +text = "cancel" diff --git a/ui/player_menu.gd b/ui/player_menu.gd index ceaee4f..79b1fd0 100644 --- a/ui/player_menu.gd +++ b/ui/player_menu.gd @@ -22,6 +22,9 @@ const ItemDragSourceType = preload("res://ui/item_drag_source.gd") const PlayerCoolerCapacityType = preload( "res://progression/player_cooler_capacity.gd" ) +const FishBatchSelectionType = preload( + "res://ui/fish_batch_selection.gd" +) signal menu_visibility_changed(is_open: bool) @@ -68,6 +71,7 @@ enum CloseReason { @onready var _detail_texture: TextureRect = %DetailTexture @onready var _detail_name: Label = %DetailName @onready var _detail_data: Label = %DetailData +@onready var _selection_summary: Label = %SelectionSummary @onready var _favorite_button: Button = %FavoriteButton @onready var _sell_button: Button = %SellButton @onready var _sale_unavailable: Label = %SaleUnavailable @@ -94,7 +98,7 @@ var _cooler_capacity: PlayerCoolerCapacityType var _current_section: Section = Section.COOLER var _sort_mode: SortMode = SortMode.CATCH_ORDER var _sort_descending: bool = true -var _selected_catch_id: StringName +var _fish_selection := FishBatchSelectionType.new() var _selected_bag_item_id: StringName var _prior_movement_enabled: bool = true var _prior_camera_input_enabled: bool = true @@ -102,9 +106,10 @@ var _prior_mouse_mode: Input.MouseMode = Input.MOUSE_MODE_VISIBLE var _control_snapshot_stored: bool = false var _mouse_snapshot_stored: bool = false var _menu_generation: int = 0 -var _confirmation_catch_id: StringName +var _confirmation_catch_ids: Array[StringName] = [] var _confirmation_buyer: FishBuyerProfileType var _confirmation_buyer_id: StringName +var _confirmation_generation: int = -1 var _sale_in_progress: bool = false @@ -123,9 +128,9 @@ func _ready() -> void: _sell_button.pressed.connect(_on_sell_pressed) _confirm_sale_button.pressed.connect(_on_confirm_sale_pressed) _cancel_sale_button.pressed.connect(_close_sale_confirmation) - _sort_option.add_item("Catch order", SortMode.CATCH_ORDER) - _sort_option.add_item("Name", SortMode.NAME) - _sort_option.add_item("Rarity", SortMode.RARITY) + _sort_option.add_item("catch order", SortMode.CATCH_ORDER) + _sort_option.add_item("name", SortMode.NAME) + _sort_option.add_item("rarity", SortMode.RARITY) _sort_option.select(SortMode.CATCH_ORDER) _update_sort_direction_text() _show_section(_current_section) @@ -157,6 +162,7 @@ func setup( _hotbar = hotbar _item_catalog = item_catalog _cooler_capacity = cooler_capacity + _fish_selection.clear() if not _inventory.catches_changed.is_connected(_on_inventory_changed): _inventory.catches_changed.connect(_on_inventory_changed) if not _collection_log.fish_discovered.is_connected(_on_fish_discovered): @@ -234,6 +240,13 @@ func close_menu( return get_viewport().gui_cancel_drag() _close_sale_confirmation() + if reason in [ + CloseReason.BITE_STARTED, + CloseReason.WATER_RECOVERY, + CloseReason.SESSION_END, + CloseReason.TEARDOWN, + ]: + _fish_selection.clear() var closing_generation: int = _menu_generation visible = false get_viewport().gui_release_focus() @@ -249,6 +262,7 @@ func close_menu( func close_for_water_recovery() -> void: + _fish_selection.clear() close_menu(CloseReason.WATER_RECOVERY, false) @@ -257,13 +271,14 @@ func close_for_game_menu() -> void: func close_for_session_end() -> void: + _fish_selection.clear() close_menu(CloseReason.SESSION_END, false) func _exit_tree() -> void: if visible: close_menu(CloseReason.TEARDOWN, false) - _selected_catch_id = StringName() + _fish_selection.clear() _menu_generation += 1 @@ -340,11 +355,11 @@ func _on_sort_direction_pressed() -> void: func _update_sort_direction_text() -> void: match _sort_mode: SortMode.CATCH_ORDER: - _sort_direction.text = "Newest first" if _sort_descending else "Oldest first" + _sort_direction.text = "newest first" if _sort_descending else "oldest first" SortMode.NAME: - _sort_direction.text = "Z–A" if _sort_descending else "A–Z" + _sort_direction.text = "z–a" if _sort_descending else "a–z" SortMode.RARITY: - _sort_direction.text = "High to low" if _sort_descending else "Low to high" + _sort_direction.text = "high to low" if _sort_descending else "low to high" func _refresh_all() -> void: @@ -386,9 +401,9 @@ func _refresh_bag() -> void: item.get_category_name(), ] card.tooltip_text = ( - "Drag to a hotbar slot." + "drag to a hotbar slot." if item.hotbar_allowed - else "This item cannot be assigned to the hotbar." + else "this item cannot be assigned to the hotbar." ) card.setup(item.item_id, item.display_name, item.icon) card.pressed.connect(_select_bag_item.bind(item.item_id)) @@ -427,19 +442,19 @@ func _update_bag_detail() -> void: _bag_detail_texture.texture = item.icon if item != null else null _bag_detail_name.text = item.display_name if item != null else "" _bag_detail_data.text = ( - "%s\nQuantity: %d\n%s\n%s" + "%s\nquantity: %d\n%s\n%s" % [ item.get_category_name(), quantity, item.description, ( - "Can be assigned to the hotbar." + "can be assigned to the hotbar." if item.hotbar_allowed - else "Cannot be assigned to the hotbar." + else "cannot be assigned to the hotbar." ), ] if item != null - else "Select a Bag item for details." + else "select a bag item for details." ) @@ -470,8 +485,8 @@ func _refresh_economy_summary() -> void: if _inventory != null else 0 ) - _wallet_balance.text = "Wallet: $%d" % balance - _held_value.text = "Held fish base value: $%d" % held_total + _wallet_balance.text = "wallet: $%d" % balance + _held_value.text = "held fish base value: $%d" % held_total _cooler_count.text = "%d / %d" % [ _inventory.get_all_catches().size() if _inventory != null else 0, _cooler_capacity.get_capacity() if _cooler_capacity != null else 0, @@ -493,16 +508,18 @@ func _refresh_inventory() -> void: catches.append(fish_catch) catches.sort_custom(_compare_catches) _inventory_empty.visible = catches.is_empty() + var visible_ids: Array[StringName] = [] + for fish_catch: FishCatchType in catches: + visible_ids.append(fish_catch.catch_id) + _fish_selection.set_visible_order(visible_ids) var selected: FishCatchType if _inventory != null: - selected = _inventory.get_catch(_selected_catch_id) - if selected == null and not catches.is_empty(): - selected = catches.front() - _selected_catch_id = selected.catch_id + selected = _inventory.get_catch(_fish_selection.get_focused_id()) for fish_catch: FishCatchType in catches: _inventory_grid.add_child(_create_inventory_card(fish_catch)) _update_inventory_detail(selected) + _update_sale_summary() _update_sort_direction_text() @@ -527,11 +544,17 @@ func _create_inventory_card(fish_catch: FishCatchType) -> Button: card.theme_type_variation = &"CardButton" card.custom_minimum_size = Vector2(132.0, 118.0) card.toggle_mode = true - card.button_pressed = fish_catch.catch_id == _selected_catch_id - card.pressed.connect(_select_catch.bind(fish_catch.catch_id)) + var is_selected: bool = _fish_selection.is_selected(fish_catch.catch_id) + var is_focused: bool = ( + fish_catch.catch_id == _fish_selection.get_focused_id() + ) + card.button_pressed = is_selected + card.pressed.connect(_on_catch_card_pressed.bind(fish_catch.catch_id)) _apply_inventory_card_styles( card, - UIPalette.get_rarity_color(fish_catch.fish.rarity) + UIPalette.get_rarity_color(fish_catch.fish.rarity), + is_selected, + is_focused ) var content := VBoxContainer.new() @@ -573,34 +596,83 @@ func _create_inventory_card(fish_catch: FishCatchType) -> Button: favorite_marker.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER favorite_marker.mouse_filter = Control.MOUSE_FILTER_IGNORE card.add_child(favorite_marker) + if is_selected: + var selected_marker := Label.new() + selected_marker.text = "✓" + selected_marker.add_theme_color_override( + "font_color", + UIPalette.SUCCESS + ) + selected_marker.add_theme_font_size_override("font_size", 18) + selected_marker.set_anchors_preset(Control.PRESET_TOP_LEFT) + selected_marker.offset_left = 8.0 + selected_marker.offset_top = 5.0 + selected_marker.offset_right = 30.0 + selected_marker.offset_bottom = 29.0 + selected_marker.mouse_filter = Control.MOUSE_FILTER_IGNORE + card.add_child(selected_marker) + if is_focused: + var focus_marker := Label.new() + focus_marker.text = "◆" + focus_marker.add_theme_color_override( + "font_color", + UIPalette.PRIMARY + ) + focus_marker.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT) + focus_marker.offset_left = -26.0 + focus_marker.offset_top = -25.0 + focus_marker.offset_right = -8.0 + focus_marker.offset_bottom = -7.0 + focus_marker.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + focus_marker.mouse_filter = Control.MOUSE_FILTER_IGNORE + card.add_child(focus_marker) return card func _apply_inventory_card_styles( card: Button, rarity_color: Color, + is_selected: bool, + is_focused: bool, ) -> void: card.add_theme_stylebox_override( "normal", - _create_inventory_card_style(rarity_color, 0) + _create_inventory_card_style( + rarity_color, + 0, + is_selected, + is_focused + ) ) card.add_theme_stylebox_override( "hover", - _create_inventory_card_style(rarity_color, 1) + _create_inventory_card_style( + rarity_color, + 1, + is_selected, + is_focused + ) ) card.add_theme_stylebox_override( "focus", - _create_inventory_card_style(rarity_color, 1) + _create_inventory_card_style( + rarity_color, + 1, + is_selected, + true + ) ) card.add_theme_stylebox_override( "pressed", - _create_inventory_card_style(rarity_color, 2) + _create_inventory_card_style(rarity_color, 2, true, is_focused) ) func _create_inventory_card_style( rarity_color: Color, state_index: int, + is_selected: bool, + is_focused: bool, ) -> StyleBoxFlat: var style := StyleBoxFlat.new() match state_index: @@ -609,9 +681,13 @@ func _create_inventory_card_style( 2: style.bg_color = UIPalette.PRIMARY.darkened(0.48) _: - style.bg_color = UIPalette.ELEVATED_PANEL + style.bg_color = ( + UIPalette.PRIMARY.darkened(0.55) + if is_selected + else UIPalette.ELEVATED_PANEL + ) style.border_color = rarity_color - var border_width: int = 4 if state_index == 2 else 2 + var border_width: int = 4 if state_index == 2 or is_focused else 2 style.set_border_width_all(border_width) style.set_corner_radius_all(8) style.content_margin_left = 7.0 @@ -621,13 +697,18 @@ func _create_inventory_card_style( return style -func _select_catch(catch_id: StringName) -> void: +func _on_catch_card_pressed(catch_id: StringName) -> void: if _inventory == null: return var selected: FishCatchType = _inventory.get_catch(catch_id) if selected == null: return - _selected_catch_id = catch_id + _fish_selection.apply_click( + catch_id, + Input.is_key_pressed(KEY_CTRL), + Input.is_key_pressed(KEY_SHIFT) + ) + _transaction_feedback.text = "" _refresh_inventory() @@ -638,26 +719,28 @@ func _update_inventory_detail(fish_catch: FishCatchType) -> void: _detail_name.text = "" _detail_data.text = "" _favorite_button.disabled = true - _favorite_button.text = "Favorite" - _sell_button.disabled = true - _sale_unavailable.text = "" - _sale_unavailable.visible = false + _favorite_button.text = "favorite" return _detail_texture.texture = fish_catch.fish.display_texture _detail_texture.visible = fish_catch.fish.display_texture != null _detail_name.text = fish_catch.fish.display_name + var individual_preview: FishSaleResultType = ( + _sale_service.preview_batch([fish_catch.catch_id], _default_buyer) + if _sale_service != null + else null + ) var buyer_offer: int = ( - _default_buyer.get_offer(fish_catch.sale_value) - if _default_buyer != null + individual_preview.payout + if individual_preview != null else -1 ) - var buyer_offer_text: String = "Buyer unavailable" - if buyer_offer >= 0: + var buyer_offer_text: String = "buyer unavailable" + if buyer_offer >= 0 and _default_buyer != null: buyer_offer_text = "%s offer: $%d" % [ _default_buyer.display_name, buyer_offer, ] - _detail_data.text = "%.2f lb\n%s\nBase value: $%d\n%s" % [ + _detail_data.text = "%.2f lb\n%s\nbase value: $%d\n%s" % [ fish_catch.weight_lb, fish_catch.fish.get_rarity_name(), fish_catch.sale_value, @@ -665,35 +748,68 @@ func _update_inventory_detail(fish_catch: FishCatchType) -> void: ] _favorite_button.disabled = false _favorite_button.text = ( - "Unfavorite" if fish_catch.is_favorited else "Favorite" + "unfavorite" if fish_catch.is_favorited else "favorite" ) - var valid_sale_value: bool = fish_catch.sale_value >= 0 - var valid_buyer: bool = ( - _default_buyer != null - and _default_buyer.is_valid() - and buyer_offer >= 0 + + +func _update_sale_summary() -> void: + var selected_ids: Array[StringName] = _fish_selection.get_selected_ids() + var selected_count: int = selected_ids.size() + _sell_button.text = ( + "sell fish" + if selected_count == 1 + else "sell %d fish" % selected_count ) - _sell_button.disabled = ( - fish_catch.is_favorited - or not valid_sale_value - or not valid_buyer + if selected_count == 0: + _selection_summary.text = "no fish selected" + _sell_button.text = "sell fish" + _sell_button.disabled = true + _sale_unavailable.text = "" + _sale_unavailable.visible = false + return + var preview: FishSaleResultType = ( + _sale_service.preview_batch(selected_ids, _default_buyer) + if _sale_service != null + else null ) - if fish_catch.is_favorited: - _sale_unavailable.text = "Favorited fish cannot be sold." - elif not valid_sale_value: - _sale_unavailable.text = "Invalid sale value." - elif not valid_buyer: - _sale_unavailable.text = "Buyer is unavailable." + var count_text: String = ( + "1 fish selected" + if selected_count == 1 + else "%d fish selected" % selected_count + ) + _selection_summary.text = count_text + if ( + preview != null + and preview.payout >= 0 + and _default_buyer != null + and ( + preview.is_success() + or preview.status == FishSaleResultType.Status.FAVORITED + ) + ): + _selection_summary.text += "\n%s total offer: $%d" % [ + _default_buyer.display_name, + preview.payout, + ] + _sell_button.disabled = preview == null or not preview.is_success() + if preview != null and preview.status == FishSaleResultType.Status.FAVORITED: + _sale_unavailable.text = ( + "favorited fish cannot be sold. " + + "remove them from the selection first." + ) + elif preview != null and not preview.is_success(): + _sale_unavailable.text = preview.get_message() else: _sale_unavailable.text = "" _sale_unavailable.visible = not _sale_unavailable.text.is_empty() func _on_favorite_pressed() -> void: - if _inventory == null or _selected_catch_id.is_empty(): + var focused_id: StringName = _fish_selection.get_focused_id() + if _inventory == null or focused_id.is_empty(): return var fish_catch: FishCatchType = _inventory.get_catch_by_id( - _selected_catch_id + focused_id ) if fish_catch == null: _refresh_inventory() @@ -712,46 +828,40 @@ func _on_favorite_pressed() -> void: func _on_sell_pressed() -> void: + var selected_ids: Array[StringName] = _fish_selection.get_selected_ids() if ( _inventory == null or _sale_service == null or _default_buyer == null - or _selected_catch_id.is_empty() + or selected_ids.is_empty() ): return - var fish_catch: FishCatchType = _inventory.get_catch_by_id( - _selected_catch_id + var preview: FishSaleResultType = _sale_service.preview_batch( + selected_ids, + _default_buyer ) - if fish_catch == null: - _transaction_feedback.text = "Fish no longer exists." + if not preview.is_success(): + _transaction_feedback.text = ( + "favorited fish cannot be sold. " + + "remove them from the selection first." + if preview.status == FishSaleResultType.Status.FAVORITED + else preview.get_message() + ) _refresh_inventory() return - if fish_catch.is_favorited: - _transaction_feedback.text = "Favorited fish cannot be sold." - return - if fish_catch.sale_value < 0: - _transaction_feedback.text = "Invalid sale value." - return - if not _default_buyer.is_valid(): - _transaction_feedback.text = "Buyer is unavailable." - return - var buyer_offer: int = _default_buyer.get_offer( - fish_catch.sale_value - ) - if buyer_offer < 0: - _transaction_feedback.text = "Invalid buyer offer." - return - _confirmation_catch_id = fish_catch.catch_id + _confirmation_catch_ids = selected_ids.duplicate() _confirmation_buyer = _default_buyer _confirmation_buyer_id = _default_buyer.id + _confirmation_generation = _menu_generation + var fish_label: String = "fish" _confirmation_message.text = ( - "Sell this %.2f lb %s to the %s for $%d?\nBase value: $%d" + "sell %d %s to the %s for $%d?\ncombined base value: $%d" % [ - fish_catch.weight_lb, - fish_catch.fish.display_name, + preview.fish_count, + fish_label, _get_buyer_display_group(_default_buyer), - buyer_offer, - fish_catch.sale_value, + preview.payout, + preview.base_value, ] ) _sale_confirmation.visible = true @@ -763,31 +873,39 @@ func _on_confirm_sale_pressed() -> void: if ( _sale_in_progress or _sale_service == null - or _confirmation_catch_id.is_empty() + or _confirmation_catch_ids.is_empty() or _confirmation_buyer == null or _confirmation_buyer.id != _confirmation_buyer_id + or _confirmation_generation != _menu_generation ): return _sale_in_progress = true - var requested_catch_id: StringName = _confirmation_catch_id + var requested_catch_ids: Array[StringName] = ( + _confirmation_catch_ids.duplicate() + ) + var transaction_generation: int = _menu_generation + var transaction_buyer: FishBuyerProfileType = _confirmation_buyer _confirm_sale_button.disabled = true - var result: FishSaleResultType = _sale_service.sell( - requested_catch_id, - _confirmation_buyer + var result: FishSaleResultType = _sale_service.sell_batch( + requested_catch_ids, + transaction_buyer ) _close_sale_confirmation() - _transaction_feedback.text = result.get_message() _sale_in_progress = false - if result.is_success() and _selected_catch_id == requested_catch_id: - _selected_catch_id = StringName() + if transaction_generation != _menu_generation or not visible: + return + _transaction_feedback.text = result.get_message() + if result.is_success(): + _fish_selection.remove_ids(requested_catch_ids) _refresh_all() _inventory_tab.grab_focus() func _close_sale_confirmation() -> void: - _confirmation_catch_id = StringName() + _confirmation_catch_ids.clear() _confirmation_buyer = null _confirmation_buyer_id = StringName() + _confirmation_generation = -1 _sale_confirmation.visible = false _confirmation_message.text = "" _confirm_sale_button.disabled = false @@ -796,25 +914,35 @@ func _close_sale_confirmation() -> void: func _revalidate_confirmation() -> void: if not _sale_confirmation.visible: return - var fish_catch: FishCatchType - if _inventory != null: - fish_catch = _inventory.get_catch_by_id(_confirmation_catch_id) - if fish_catch == null: + if ( + _confirmation_generation != _menu_generation + or _confirmation_catch_ids.is_empty() + or _sale_service == null + or _confirmation_buyer == null + or _confirmation_buyer.id != _confirmation_buyer_id + ): _close_sale_confirmation() - _transaction_feedback.text = "Fish no longer exists." - elif fish_catch.is_favorited: - _close_sale_confirmation() - _transaction_feedback.text = "Favorited fish cannot be sold." - elif fish_catch.sale_value < 0: - _close_sale_confirmation() - _transaction_feedback.text = "Invalid sale value." - elif ( + _transaction_feedback.text = "sale selection is no longer available." + return + var preview: FishSaleResultType = _sale_service.preview_batch( + _confirmation_catch_ids, + _confirmation_buyer + ) + if ( + not preview.is_success() + or ( _confirmation_buyer == null or _confirmation_buyer.id != _confirmation_buyer_id or not _confirmation_buyer.is_valid() + ) ): _close_sale_confirmation() - _transaction_feedback.text = "Buyer is unavailable." + _transaction_feedback.text = ( + "favorited fish cannot be sold. " + + "remove them from the selection first." + if preview.status == FishSaleResultType.Status.FAVORITED + else preview.get_message() + ) func _get_buyer_display_group( @@ -840,9 +968,9 @@ func _refresh_logbook() -> void: valid_species.append(fish) _logbook_empty.visible = valid_species.is_empty() _logbook_empty.text = ( - "No fish catalog configured." + "no fish catalog configured." if valid_species.is_empty() - else "No species discovered yet." + else "no species discovered yet." ) if not valid_species.is_empty() and _collection_log != null: var any_discovered: bool = false @@ -881,12 +1009,12 @@ func _create_logbook_card(fish: FishDataType) -> Control: var owned_count: int = ( _inventory.get_count(fish.id) if _inventory != null else 0 ) - details.text = "%s\nOwned: %d" % [ + details.text = "%s\nowned: %d" % [ fish.get_rarity_name(), owned_count, ] else: - details.text = "Undiscovered" + details.text = "undiscovered" details.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER content.add_child(details) return card @@ -904,7 +1032,7 @@ func _create_texture_frame( texture_frame.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST texture_frame.mouse_filter = Control.MOUSE_FILTER_IGNORE if texture == null: - texture_frame.tooltip_text = "Display texture unavailable" + texture_frame.tooltip_text = "display texture unavailable" return texture_frame diff --git a/ui/player_menu.tscn b/ui/player_menu.tscn index 1fe08dc..3dccbbe 100644 --- a/ui/player_menu.tscn +++ b/ui/player_menu.tscn @@ -56,39 +56,39 @@ layout_mode = 2 size_flags_horizontal = 3 theme_override_colors/font_color = Color(0.208, 0.725, 0.78, 1) theme_override_font_sizes/font_size = 22 -text = "Player Menu" +text = "player menu" [node name="WalletBalance" type="Label" parent="MenuPanel/Margin/Layout/Header"] unique_name_in_owner = true layout_mode = 2 -text = "Wallet: $0" +text = "wallet: $0" theme_override_colors/font_color = Color(1, 0.82, 0.4, 1) [node name="InventoryTab" type="Button" parent="MenuPanel/Margin/Layout/Header"] unique_name_in_owner = true layout_mode = 2 toggle_mode = true -text = "Cooler" +text = "cooler" custom_minimum_size = Vector2(88, 30) [node name="BagTab" type="Button" parent="MenuPanel/Margin/Layout/Header"] unique_name_in_owner = true layout_mode = 2 toggle_mode = true -text = "Bag" +text = "bag" custom_minimum_size = Vector2(76, 30) [node name="LogbookTab" type="Button" parent="MenuPanel/Margin/Layout/Header"] unique_name_in_owner = true layout_mode = 2 toggle_mode = true -text = "Logbook" +text = "logbook" custom_minimum_size = Vector2(88, 30) [node name="CloseButton" type="Button" parent="MenuPanel/Margin/Layout/Header"] unique_name_in_owner = true layout_mode = 2 -text = "Close" +text = "close" custom_minimum_size = Vector2(68, 30) [node name="Separator" type="HSeparator" parent="MenuPanel/Margin/Layout"] @@ -123,7 +123,7 @@ theme_override_constants/separation = 6 [node name="SortLabel" type="Label" parent="MenuPanel/Margin/Layout/Content/InventorySection/SortBar"] layout_mode = 2 -text = "Sort:" +text = "sort:" [node name="SortOption" type="OptionButton" parent="MenuPanel/Margin/Layout/Content/InventorySection/SortBar"] unique_name_in_owner = true @@ -134,7 +134,7 @@ custom_minimum_size = Vector2(130, 30) unique_name_in_owner = true layout_mode = 2 custom_minimum_size = Vector2(112, 30) -text = "Newest first" +text = "newest first" [node name="SortSpacer" type="Control" parent="MenuPanel/Margin/Layout/Content/InventorySection/SortBar"] layout_mode = 2 @@ -149,7 +149,7 @@ theme_override_colors/font_color = Color(0.208, 0.725, 0.78, 1) [node name="HeldValue" type="Label" parent="MenuPanel/Margin/Layout/Content/InventorySection/SortBar"] unique_name_in_owner = true layout_mode = 2 -text = "Held fish base value: $0" +text = "held fish base value: $0" theme_override_colors/font_color = Color(0.682, 0.733, 0.761, 1) [node name="InventoryBody" type="HSplitContainer" parent="MenuPanel/Margin/Layout/Content/InventorySection"] @@ -176,7 +176,7 @@ theme_override_constants/separation = 6 [node name="InventoryEmpty" type="Label" parent="MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/InventoryList/InventoryListMargin/InventoryStack"] unique_name_in_owner = true layout_mode = 2 -text = "Your Cooler is empty." +text = "your cooler is empty." horizontal_alignment = 1 [node name="InventoryScroll" type="ScrollContainer" parent="MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/InventoryList/InventoryListMargin/InventoryStack"] @@ -228,17 +228,25 @@ layout_mode = 2 autowrap_mode = 2 horizontal_alignment = 1 +[node name="SelectionSummary" type="Label" parent="MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/DetailPanel/DetailMargin/DetailStack"] +unique_name_in_owner = true +layout_mode = 2 +text = "no fish selected" +autowrap_mode = 2 +horizontal_alignment = 1 +theme_override_colors/font_color = Color(0.208, 0.725, 0.78, 1) + [node name="FavoriteButton" type="Button" parent="MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/DetailPanel/DetailMargin/DetailStack"] unique_name_in_owner = true layout_mode = 2 disabled = true -text = "Favorite" +text = "favorite" [node name="SellButton" type="Button" parent="MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/DetailPanel/DetailMargin/DetailStack"] unique_name_in_owner = true layout_mode = 2 disabled = true -text = "Sell" +text = "sell" theme_type_variation = &"DangerButton" [node name="SaleUnavailable" type="Label" parent="MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/DetailPanel/DetailMargin/DetailStack"] @@ -262,7 +270,7 @@ theme_override_constants/separation = 7 [node name="BagHint" type="Label" parent="MenuPanel/Margin/Layout/Content/BagSection"] layout_mode = 2 -text = "Drag hotbar-compatible items onto a slot below. Right-click a slot to clear it." +text = "drag hotbar-compatible items onto a slot below. right-click a slot to clear it." horizontal_alignment = 1 [node name="BagBody" type="HSplitContainer" parent="MenuPanel/Margin/Layout/Content/BagSection"] @@ -289,7 +297,7 @@ theme_override_constants/separation = 6 [node name="BagEmpty" type="Label" parent="MenuPanel/Margin/Layout/Content/BagSection/BagBody/BagList/Margin/Stack"] unique_name_in_owner = true layout_mode = 2 -text = "Your Bag is empty." +text = "your bag is empty." horizontal_alignment = 1 [node name="BagScroll" type="ScrollContainer" parent="MenuPanel/Margin/Layout/Content/BagSection/BagBody/BagList/Margin/Stack"] @@ -337,7 +345,7 @@ horizontal_alignment = 1 [node name="BagDetailData" type="Label" parent="MenuPanel/Margin/Layout/Content/BagSection/BagBody/BagDetail/Margin/Stack"] unique_name_in_owner = true layout_mode = 2 -text = "Select a Bag item for details." +text = "select a bag item for details." horizontal_alignment = 1 autowrap_mode = 2 @@ -355,7 +363,7 @@ theme_override_constants/separation = 7 [node name="LogbookEmpty" type="Label" parent="MenuPanel/Margin/Layout/Content/LogbookSection"] unique_name_in_owner = true layout_mode = 2 -text = "No species discovered yet." +text = "no species discovered yet." horizontal_alignment = 1 [node name="LogbookScroll" type="ScrollContainer" parent="MenuPanel/Margin/Layout/Content/LogbookSection"] @@ -412,12 +420,12 @@ alignment = 1 [node name="ConfirmSaleButton" type="Button" parent="MenuPanel/Margin/Layout/Content/SaleConfirmation/ConfirmationMargin/ConfirmationLayout/ConfirmationButtons"] unique_name_in_owner = true layout_mode = 2 -text = "Confirm" +text = "confirm" theme_type_variation = &"DangerButton" custom_minimum_size = Vector2(120, 38) [node name="CancelSaleButton" type="Button" parent="MenuPanel/Margin/Layout/Content/SaleConfirmation/ConfirmationMargin/ConfirmationLayout/ConfirmationButtons"] unique_name_in_owner = true layout_mode = 2 -text = "Cancel" +text = "cancel" custom_minimum_size = Vector2(120, 38)