From d2fe36789160a61b3fdb0ed74f22b00146bd2ad9 Mon Sep 17 00:00:00 2001 From: Voyager Date: Sat, 25 Jul 2026 16:27:07 -0400 Subject: [PATCH] Add fish selling, buyer profiles, and player wallet --- economy/buyers/main_fishing_shop.tres | 11 ++ economy/buyers/pelicans.tres | 12 ++ economy/buyers/trading_post.tres | 11 ++ economy/fish_buyer_profile.gd | 46 +++++ economy/fish_buyer_profile.gd.uid | 1 + economy/fish_sale_result.gd | 45 +++++ economy/fish_sale_result.gd.uid | 1 + economy/fish_sale_service.gd | 98 ++++++++++ economy/fish_sale_service.gd.uid | 1 + economy/player_wallet.gd | 45 +++++ economy/player_wallet.gd.uid | 1 + fish/fish_catch.gd | 3 + fish/fish_data.gd | 29 +++ fish/fish_selector.gd | 3 + fish/species/bass/bass.tres | 3 + fish/species/bluegill/bluegill.tres | 3 + fish/species/carp/carp.tres | 3 + inventory/fish_inventory.gd | 52 +++++- main/main.gd | 9 + main/main.tscn | 4 +- player/player.gd | 4 + player/player.tscn | 12 +- ui/game_ui.gd | 9 + ui/player_menu.gd | 251 +++++++++++++++++++++++++- ui/player_menu.tscn | 86 +++++++++ 25 files changed, 738 insertions(+), 5 deletions(-) create mode 100644 economy/buyers/main_fishing_shop.tres create mode 100644 economy/buyers/pelicans.tres create mode 100644 economy/buyers/trading_post.tres create mode 100644 economy/fish_buyer_profile.gd create mode 100644 economy/fish_buyer_profile.gd.uid create mode 100644 economy/fish_sale_result.gd create mode 100644 economy/fish_sale_result.gd.uid create mode 100644 economy/fish_sale_service.gd create mode 100644 economy/fish_sale_service.gd.uid create mode 100644 economy/player_wallet.gd create mode 100644 economy/player_wallet.gd.uid diff --git a/economy/buyers/main_fishing_shop.tres b/economy/buyers/main_fishing_shop.tres new file mode 100644 index 0000000..ee5424a --- /dev/null +++ b/economy/buyers/main_fishing_shop.tres @@ -0,0 +1,11 @@ +[gd_resource type="Resource" load_steps=2 format=3] + +[ext_resource type="Script" path="res://economy/fish_buyer_profile.gd" id="1_profile"] + +[resource] +script = ExtResource("1_profile") +id = &"main_fishing_shop" +display_name = "Main Fishing Shop" +animal_name_singular = "shopkeeper" +animal_name_plural = "shopkeepers" +payout_multiplier = 1.0 diff --git a/economy/buyers/pelicans.tres b/economy/buyers/pelicans.tres new file mode 100644 index 0000000..d126545 --- /dev/null +++ b/economy/buyers/pelicans.tres @@ -0,0 +1,12 @@ +[gd_resource type="Resource" load_steps=2 format=3] + +[ext_resource type="Script" path="res://economy/fish_buyer_profile.gd" id="1_profile"] + +[resource] +script = ExtResource("1_profile") +id = &"pelicans" +display_name = "Pelicans" +animal_name_singular = "pelican" +animal_name_plural = "pelicans" +payout_multiplier = 0.7 +sale_message_template = "You sold your {fish} to the pelicans for ${payout}." diff --git a/economy/buyers/trading_post.tres b/economy/buyers/trading_post.tres new file mode 100644 index 0000000..b1be6ce --- /dev/null +++ b/economy/buyers/trading_post.tres @@ -0,0 +1,11 @@ +[gd_resource type="Resource" load_steps=2 format=3] + +[ext_resource type="Script" path="res://economy/fish_buyer_profile.gd" id="1_profile"] + +[resource] +script = ExtResource("1_profile") +id = &"trading_post" +display_name = "Trading Post" +animal_name_singular = "otter" +animal_name_plural = "otters" +payout_multiplier = 0.85 diff --git a/economy/fish_buyer_profile.gd b/economy/fish_buyer_profile.gd new file mode 100644 index 0000000..bb1906f --- /dev/null +++ b/economy/fish_buyer_profile.gd @@ -0,0 +1,46 @@ +class_name FishBuyerProfile +extends Resource + +@export var id: StringName +@export var display_name: String +@export var animal_name_singular: String +@export var animal_name_plural: String +@export_range(0.0, 10.0, 0.01) var payout_multiplier: float = 1.0 +@export_multiline var sale_message_template: String + + +func is_valid() -> bool: + return ( + not id.is_empty() + and not display_name.is_empty() + and payout_multiplier >= 0.0 + and is_finite(payout_multiplier) + ) + + +func get_offer(base_value: int) -> int: + if not is_valid() or base_value < 0: + return -1 + var offer: int = roundi(float(base_value) * payout_multiplier) + return maxi(offer, 0) + + +func get_sale_message( + fish_name: String, + payout: int, +) -> String: + if not sale_message_template.is_empty(): + return sale_message_template.format( + { + "fish": fish_name, + "payout": payout, + } + ) + var buyer_name: String = animal_name_plural + if buyer_name.is_empty(): + buyer_name = display_name + return "You sold your %s to the %s for $%d." % [ + fish_name, + buyer_name, + payout, + ] diff --git a/economy/fish_buyer_profile.gd.uid b/economy/fish_buyer_profile.gd.uid new file mode 100644 index 0000000..b572682 --- /dev/null +++ b/economy/fish_buyer_profile.gd.uid @@ -0,0 +1 @@ +uid://cq1b2avld4qay diff --git a/economy/fish_sale_result.gd b/economy/fish_sale_result.gd new file mode 100644 index 0000000..0ef4a5b --- /dev/null +++ b/economy/fish_sale_result.gd @@ -0,0 +1,45 @@ +class_name FishSaleResult +extends RefCounted + +enum Status { + SUCCESS, + NOT_FOUND, + FAVORITED, + INVALID_VALUE, + INVALID_BUYER, + INVALID_OFFER, + TRANSACTION_FAILED, +} + +var success: bool = false +var status: Status = Status.TRANSACTION_FAILED +var catch_id: StringName +var fish_name: String = "" +var buyer_id: StringName +var buyer_display_name: String = "" +var buyer_animal_name: String = "" +var base_value: int = 0 +var payout: int = 0 +var sale_message: String = "" + + +func is_success() -> bool: + return success and status == Status.SUCCESS + + +func get_message() -> String: + match status: + Status.SUCCESS: + return sale_message + Status.NOT_FOUND: + return "Fish no longer exists." + Status.FAVORITED: + return "Favorited fish cannot be sold." + Status.INVALID_VALUE: + return "Invalid sale value." + Status.INVALID_BUYER: + return "Buyer is unavailable." + Status.INVALID_OFFER: + return "Invalid buyer offer." + _: + return "Transaction failed." diff --git a/economy/fish_sale_result.gd.uid b/economy/fish_sale_result.gd.uid new file mode 100644 index 0000000..4933ed6 --- /dev/null +++ b/economy/fish_sale_result.gd.uid @@ -0,0 +1 @@ +uid://c02hcw8fkn5i8 diff --git a/economy/fish_sale_service.gd b/economy/fish_sale_service.gd new file mode 100644 index 0000000..c046cc7 --- /dev/null +++ b/economy/fish_sale_service.gd @@ -0,0 +1,98 @@ +class_name FishSaleService +extends Node + +const FishCatchType = preload("res://fish/fish_catch.gd") +const FishBuyerProfileType = preload("res://economy/fish_buyer_profile.gd") +const FishInventoryType = preload("res://inventory/fish_inventory.gd") +const FishSaleResultType = preload("res://economy/fish_sale_result.gd") +const PlayerWalletType = preload("res://economy/player_wallet.gd") + +signal sale_completed(result: FishSaleResultType) + +var _inventory: FishInventoryType +var _wallet: PlayerWalletType + + +func setup( + inventory: FishInventoryType, + wallet: PlayerWalletType, +) -> void: + _inventory = inventory + _wallet = wallet + + +func can_sell( + catch_id: StringName, + buyer: FishBuyerProfileType, +) -> bool: + return _validate_sale(catch_id, buyer).is_success() + + +func sell( + catch_id: StringName, + buyer: FishBuyerProfileType, +) -> FishSaleResultType: + var result: FishSaleResultType = _validate_sale(catch_id, buyer) + if not result.is_success(): + return result + + var removed_catch: FishCatchType = _inventory.remove_catch_by_id(catch_id) + if removed_catch == null: + result.success = false + result.status = FishSaleResultType.Status.NOT_FOUND + return result + if not _wallet.credit(result.payout): + _inventory.add_catch(removed_catch) + result.success = false + result.status = FishSaleResultType.Status.TRANSACTION_FAILED + return result + sale_completed.emit(result) + return result + + +func _validate_sale( + catch_id: 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 + 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 = ( + buyer.animal_name_plural + 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: + 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): + result.status = FishSaleResultType.Status.TRANSACTION_FAILED + else: + result.status = FishSaleResultType.Status.SUCCESS + result.success = true + result.sale_message = buyer.get_sale_message( + result.fish_name, + result.payout + ) + return result diff --git a/economy/fish_sale_service.gd.uid b/economy/fish_sale_service.gd.uid new file mode 100644 index 0000000..e92000e --- /dev/null +++ b/economy/fish_sale_service.gd.uid @@ -0,0 +1 @@ +uid://cg3ec2ypo26xh diff --git a/economy/player_wallet.gd b/economy/player_wallet.gd new file mode 100644 index 0000000..7297bd3 --- /dev/null +++ b/economy/player_wallet.gd @@ -0,0 +1,45 @@ +class_name PlayerWallet +extends Node + +signal balance_changed(new_balance: int, delta: int) + +@export var current_balance: int = 0 + + +func _ready() -> void: + current_balance = maxi(current_balance, 0) + + +func get_balance() -> int: + return current_balance + + +func can_afford(amount: int) -> bool: + return amount >= 0 and current_balance >= amount + + +func can_credit(amount: int) -> bool: + return ( + amount >= 0 + and current_balance <= 9223372036854775807 - amount + ) + + +func credit(amount: int) -> bool: + if not can_credit(amount): + return false + if amount == 0: + return true + current_balance += amount + balance_changed.emit(current_balance, amount) + return true + + +func debit(amount: int) -> bool: + if not can_afford(amount): + return false + if amount == 0: + return true + current_balance -= amount + balance_changed.emit(current_balance, -amount) + return true diff --git a/economy/player_wallet.gd.uid b/economy/player_wallet.gd.uid new file mode 100644 index 0000000..dfac57d --- /dev/null +++ b/economy/player_wallet.gd.uid @@ -0,0 +1 @@ +uid://dp1uf8h8eevs diff --git a/fish/fish_catch.gd b/fish/fish_catch.gd index a4ec224..c2ed60b 100644 --- a/fish/fish_catch.gd +++ b/fish/fish_catch.gd @@ -9,6 +9,8 @@ const FishDataType = preload("res://fish/fish_data.gd") @export var catch_sequence: int = 0 @export var weight_lb: float = 0.0 @export var display_scale: float = 1.0 +@export var sale_value: int = 0 +@export var is_favorited: bool = false func ensure_identity() -> void: @@ -29,4 +31,5 @@ func is_valid() -> bool: and weight_lb >= fish.get_minimum_weight() and weight_lb <= fish.get_maximum_weight() and display_scale > 0.0 + and sale_value >= 0 ) diff --git a/fish/fish_data.gd b/fish/fish_data.gd index 8979864..0207e7b 100644 --- a/fish/fish_data.gd +++ b/fish/fish_data.gd @@ -25,6 +25,9 @@ enum Rarity { @export_range(0.01, 20.0, 0.01) var display_scale_min: float = 0.8 @export_range(0.01, 20.0, 0.01) var display_scale_max: float = 1.2 @export_range(0.01, 10.0, 0.01) var display_scale_curve: float = 1.0 +@export_range(0, 1000000, 1) var sell_value_min: int = 0 +@export_range(0, 1000000, 1) var sell_value_max: int = 1 +@export_range(0.01, 10.0, 0.01) var sell_value_curve: float = 1.0 @export var display_texture: Texture2D @@ -38,6 +41,9 @@ func is_selectable() -> bool: and display_scale_min > 0.0 and display_scale_max >= display_scale_min and display_scale_curve > 0.0 + and sell_value_min >= 0 + and sell_value_max >= sell_value_min + and sell_value_curve > 0.0 ) @@ -68,5 +74,28 @@ func get_display_scale_for_weight(weight_lb: float) -> float: return lerpf(minimum_scale, maximum_scale, normalized_weight) +func get_sale_value_for_weight(weight_lb: float) -> int: + var minimum_value: int = maxi(sell_value_min, 0) + var maximum_value: int = maxi(sell_value_max, minimum_value) + var normalized_weight: float = 0.0 + var minimum_weight: float = get_minimum_weight() + var maximum_weight: float = get_maximum_weight() + if maximum_weight > minimum_weight: + normalized_weight = inverse_lerp( + minimum_weight, + maximum_weight, + weight_lb + ) + normalized_weight = pow( + clampf(normalized_weight, 0.0, 1.0), + maxf(sell_value_curve, 0.01) + ) + return clampi( + roundi(lerpf(minimum_value, maximum_value, normalized_weight)), + minimum_value, + maximum_value + ) + + func get_rarity_name() -> String: return Rarity.keys()[rarity].capitalize() diff --git a/fish/fish_selector.gd b/fish/fish_selector.gd index 25966e5..7764ad3 100644 --- a/fish/fish_selector.gd +++ b/fish/fish_selector.gd @@ -76,4 +76,7 @@ func create_catch(fish: FishDataType) -> FishCatchType: caught_fish.display_scale = fish.get_display_scale_for_weight( caught_fish.weight_lb ) + caught_fish.sale_value = fish.get_sale_value_for_weight( + caught_fish.weight_lb + ) return caught_fish diff --git a/fish/species/bass/bass.tres b/fish/species/bass/bass.tres index 028c2cd..65c2801 100644 --- a/fish/species/bass/bass.tres +++ b/fish/species/bass/bass.tres @@ -25,4 +25,7 @@ weight_max_lb = 8.0 display_scale_min = 0.9 display_scale_max = 1.5 display_scale_curve = 0.9 +sell_value_min = 12 +sell_value_max = 45 +sell_value_curve = 0.9 display_texture = ExtResource("4_texture") diff --git a/fish/species/bluegill/bluegill.tres b/fish/species/bluegill/bluegill.tres index 5681fb0..0f47849 100644 --- a/fish/species/bluegill/bluegill.tres +++ b/fish/species/bluegill/bluegill.tres @@ -18,4 +18,7 @@ weight_max_lb = 2.0 display_scale_min = 0.7 display_scale_max = 1.1 display_scale_curve = 1.0 +sell_value_min = 3 +sell_value_max = 12 +sell_value_curve = 1.0 display_texture = ExtResource("4_texture") diff --git a/fish/species/carp/carp.tres b/fish/species/carp/carp.tres index 20c3194..e6794f5 100644 --- a/fish/species/carp/carp.tres +++ b/fish/species/carp/carp.tres @@ -25,4 +25,7 @@ weight_max_lb = 18.0 display_scale_min = 1.0 display_scale_max = 1.8 display_scale_curve = 0.85 +sell_value_min = 25 +sell_value_max = 90 +sell_value_curve = 0.85 display_texture = ExtResource("4_texture") diff --git a/inventory/fish_inventory.gd b/inventory/fish_inventory.gd index ed4db36..ac1ae6e 100644 --- a/inventory/fish_inventory.gd +++ b/inventory/fish_inventory.gd @@ -13,7 +13,7 @@ var _next_catch_sequence: int = 1 func add_catch(fish_catch: FishCatchType) -> void: if fish_catch == null or not fish_catch.is_valid(): return - if get_catch(fish_catch.catch_id) != null: + if get_catch_by_id(fish_catch.catch_id) != null: return if fish_catch.catch_sequence <= 0: fish_catch.catch_sequence = _next_catch_sequence @@ -37,7 +37,7 @@ func get_all_catches() -> Array[FishCatchType]: return _catches.duplicate() -func get_catch(catch_id: StringName) -> FishCatchType: +func get_catch_by_id(catch_id: StringName) -> FishCatchType: if catch_id.is_empty(): return null for fish_catch: FishCatchType in _catches: @@ -46,6 +46,54 @@ func get_catch(catch_id: StringName) -> FishCatchType: return null +func get_catch(catch_id: StringName) -> FishCatchType: + return get_catch_by_id(catch_id) + + +func contains_catch_id(catch_id: StringName) -> bool: + return get_catch_by_id(catch_id) != null + + +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 + + +func set_catch_favorited( + catch_id: StringName, + is_favorited: bool, +) -> bool: + var fish_catch: FishCatchType = get_catch_by_id(catch_id) + if fish_catch == null: + return false + if fish_catch.is_favorited == is_favorited: + return true + fish_catch.is_favorited = is_favorited + catches_changed.emit() + return true + + +func get_total_sale_value() -> int: + var total: int = 0 + for fish_catch: FishCatchType in _catches: + if fish_catch == null or fish_catch.sale_value < 0: + continue + total += fish_catch.sale_value + return total + + func get_catches_by_fish_id(fish_id: StringName) -> Array[FishCatchType]: var matching: Array[FishCatchType] = [] for fish_catch: FishCatchType in _catches: diff --git a/main/main.gd b/main/main.gd index 2aafa6b..da8cf15 100644 --- a/main/main.gd +++ b/main/main.gd @@ -1,11 +1,13 @@ extends Node3D const FishingSpotType = preload("res://fishing/fishing_spot.gd") +const FishBuyerProfileType = preload("res://economy/fish_buyer_profile.gd") const FishPoolType = preload("res://fish/fish_pool.gd") const GameUIType = preload("res://ui/game_ui.gd") const PlayerType = preload("res://player/player.gd") @export var fish_catalog: FishPoolType +@export var pelican_buyer_profile: FishBuyerProfileType @onready var _player: PlayerType = %Player @onready var _fishing_spot: FishingSpotType = %FishingSpot @@ -13,6 +15,10 @@ const PlayerType = preload("res://player/player.gd") func _ready() -> void: + _player.fish_sale_service.setup( + _player.inventory, + _player.wallet + ) _fishing_spot.setup( _player, _player.inventory, @@ -22,6 +28,9 @@ func _ready() -> void: _player, _player.inventory, _player.collection_log, + _player.wallet, + _player.fish_sale_service, + pelican_buyer_profile, fish_catalog, _fishing_spot ) diff --git a/main/main.tscn b/main/main.tscn index 191c9de..32fcc36 100644 --- a/main/main.tscn +++ b/main/main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=7 format=3] +[gd_scene load_steps=8 format=3] [ext_resource type="PackedScene" path="res://world/test_world.tscn" id="1_world"] [ext_resource type="PackedScene" path="res://player/player.tscn" id="2_player"] @@ -6,10 +6,12 @@ [ext_resource type="PackedScene" path="res://fishing/fishing_spot.tscn" id="4_spot"] [ext_resource type="PackedScene" path="res://ui/game_ui.tscn" id="5_ui"] [ext_resource type="Resource" path="res://fish/pools/test_water_pool.tres" id="6_pool"] +[ext_resource type="Resource" path="res://economy/buyers/pelicans.tres" id="7_pelicans"] [node name="Main" type="Node3D"] script = ExtResource("3_main") fish_catalog = ExtResource("6_pool") +pelican_buyer_profile = ExtResource("7_pelicans") [node name="TestWorld" parent="." instance=ExtResource("1_world")] diff --git a/player/player.gd b/player/player.gd index 3610ebd..bd0c68c 100644 --- a/player/player.gd +++ b/player/player.gd @@ -4,6 +4,8 @@ extends CharacterBody3D const FishInventoryType = preload("res://inventory/fish_inventory.gd") const CollectionLogType = preload("res://collection/collection_log.gd") const FishCatchType = preload("res://fish/fish_catch.gd") +const FishSaleServiceType = preload("res://economy/fish_sale_service.gd") +const PlayerWalletType = preload("res://economy/player_wallet.gd") class ShowcaseCameraSnapshot: extends RefCounted @@ -59,6 +61,8 @@ class ShowcaseCameraSnapshot: @onready var _camera: Camera3D = %Camera3D @onready var inventory: FishInventoryType = %Inventory @onready var collection_log: CollectionLogType = %CollectionLog +@onready var wallet: PlayerWalletType = %Wallet +@onready var fish_sale_service: FishSaleServiceType = %FishSaleService @onready var _cast_origin: Marker3D = %CastOrigin @onready var _fishing_rod: Node3D = %FishingRod @onready var _fishing_rod_tip: Marker3D = %FishingRodTip diff --git a/player/player.tscn b/player/player.tscn index a311247..ec27122 100644 --- a/player/player.tscn +++ b/player/player.tscn @@ -1,8 +1,10 @@ -[gd_scene load_steps=11 format=3] +[gd_scene load_steps=13 format=3] [ext_resource type="Script" path="res://player/player.gd" id="1_script"] [ext_resource type="Script" path="res://inventory/fish_inventory.gd" id="2_inventory"] [ext_resource type="Script" path="res://collection/collection_log.gd" id="3_collection"] +[ext_resource type="Script" path="res://economy/player_wallet.gd" id="4_wallet"] +[ext_resource type="Script" path="res://economy/fish_sale_service.gd" id="5_sale_service"] [sub_resource type="CapsuleShape3D" id="PlayerShape"] radius = 0.45 @@ -78,6 +80,14 @@ script = ExtResource("2_inventory") unique_name_in_owner = true script = ExtResource("3_collection") +[node name="Wallet" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("4_wallet") + +[node name="FishSaleService" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("5_sale_service") + [node name="CatchDisplayAnchor" type="Marker3D" parent="Visuals"] unique_name_in_owner = true position = Vector3(0, 1.45, -1.15) diff --git a/ui/game_ui.gd b/ui/game_ui.gd index 318c86b..58a8c24 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -2,11 +2,14 @@ class_name GameUI extends CanvasLayer const CollectionLogType = preload("res://collection/collection_log.gd") +const FishBuyerProfileType = preload("res://economy/fish_buyer_profile.gd") +const FishSaleServiceType = preload("res://economy/fish_sale_service.gd") const FishPoolType = preload("res://fish/fish_pool.gd") const FishInventoryType = preload("res://inventory/fish_inventory.gd") const FishingSpotType = preload("res://fishing/fishing_spot.gd") const PlayerMenuType = preload("res://ui/player_menu.gd") const PlayerType = preload("res://player/player.gd") +const PlayerWalletType = preload("res://economy/player_wallet.gd") @onready var _status_label: Label = %StatusLabel @onready var _catch_track: Control = %CatchTrack @@ -26,6 +29,9 @@ func setup( player: PlayerType, inventory: FishInventoryType, collection_log: CollectionLogType, + wallet: PlayerWalletType, + sale_service: FishSaleServiceType, + default_buyer: FishBuyerProfileType, catalog: FishPoolType, fishing_spot: FishingSpotType, ) -> void: @@ -39,6 +45,9 @@ func setup( player, inventory, collection_log, + wallet, + sale_service, + default_buyer, catalog, fishing_spot ) diff --git a/ui/player_menu.gd b/ui/player_menu.gd index f6a82fa..baa42b8 100644 --- a/ui/player_menu.gd +++ b/ui/player_menu.gd @@ -4,10 +4,14 @@ extends Control const CollectionLogType = preload("res://collection/collection_log.gd") const FishCatchType = preload("res://fish/fish_catch.gd") const FishDataType = preload("res://fish/fish_data.gd") +const FishBuyerProfileType = preload("res://economy/fish_buyer_profile.gd") +const FishSaleResultType = preload("res://economy/fish_sale_result.gd") +const FishSaleServiceType = preload("res://economy/fish_sale_service.gd") const FishInventoryType = preload("res://inventory/fish_inventory.gd") const FishPoolType = preload("res://fish/fish_pool.gd") const FishingSpotType = preload("res://fishing/fishing_spot.gd") const PlayerType = preload("res://player/player.gd") +const PlayerWalletType = preload("res://economy/player_wallet.gd") signal menu_visibility_changed(is_open: bool) @@ -25,21 +29,34 @@ enum SortMode { @onready var _inventory_tab: Button = %InventoryTab @onready var _logbook_tab: Button = %LogbookTab @onready var _close_button: Button = %CloseButton +@onready var _wallet_balance: Label = %WalletBalance @onready var _inventory_section: Control = %InventorySection @onready var _logbook_section: Control = %LogbookSection @onready var _sort_option: OptionButton = %SortOption @onready var _sort_direction: Button = %SortDirection +@onready var _held_value: Label = %HeldValue @onready var _inventory_empty: Label = %InventoryEmpty @onready var _inventory_grid: GridContainer = %InventoryGrid @onready var _detail_texture: TextureRect = %DetailTexture @onready var _detail_name: Label = %DetailName @onready var _detail_data: Label = %DetailData +@onready var _favorite_button: Button = %FavoriteButton +@onready var _sell_button: Button = %SellButton +@onready var _sale_unavailable: Label = %SaleUnavailable +@onready var _transaction_feedback: Label = %TransactionFeedback +@onready var _sale_confirmation: PanelContainer = %SaleConfirmation +@onready var _confirmation_message: Label = %ConfirmationMessage +@onready var _confirm_sale_button: Button = %ConfirmSaleButton +@onready var _cancel_sale_button: Button = %CancelSaleButton @onready var _logbook_empty: Label = %LogbookEmpty @onready var _logbook_grid: GridContainer = %LogbookGrid var _player: PlayerType var _inventory: FishInventoryType var _collection_log: CollectionLogType +var _wallet: PlayerWalletType +var _sale_service: FishSaleServiceType +var _default_buyer: FishBuyerProfileType var _catalog: FishPoolType var _fishing_spot: FishingSpotType var _current_section: Section = Section.INVENTORY @@ -50,6 +67,10 @@ var _prior_movement_enabled: bool = true var _prior_camera_input_enabled: bool = true var _control_snapshot_stored: bool = false var _menu_generation: int = 0 +var _confirmation_catch_id: StringName +var _confirmation_buyer: FishBuyerProfileType +var _confirmation_buyer_id: StringName +var _sale_in_progress: bool = false func _ready() -> void: @@ -62,6 +83,10 @@ func _ready() -> void: _close_button.pressed.connect(close_menu) _sort_option.item_selected.connect(_on_sort_selected) _sort_direction.pressed.connect(_on_sort_direction_pressed) + _favorite_button.pressed.connect(_on_favorite_pressed) + _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) @@ -74,18 +99,26 @@ func setup( player: PlayerType, inventory: FishInventoryType, collection_log: CollectionLogType, + wallet: PlayerWalletType, + sale_service: FishSaleServiceType, + default_buyer: FishBuyerProfileType, catalog: FishPoolType, fishing_spot: FishingSpotType, ) -> void: _player = player _inventory = inventory _collection_log = collection_log + _wallet = wallet + _sale_service = sale_service + _default_buyer = default_buyer _catalog = catalog _fishing_spot = fishing_spot 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): _collection_log.fish_discovered.connect(_on_fish_discovered) + if not _wallet.balance_changed.is_connected(_on_wallet_balance_changed): + _wallet.balance_changed.connect(_on_wallet_balance_changed) if not _fishing_spot.bite_activated.is_connected(_on_bite_activated): _fishing_spot.bite_activated.connect(_on_bite_activated) _refresh_all() @@ -95,6 +128,13 @@ func _input(event: InputEvent) -> void: if event is InputEventKey and event.echo: return if visible: + if ( + event.is_action_pressed("ui_cancel") + and _sale_confirmation.visible + ): + _close_sale_confirmation() + get_viewport().set_input_as_handled() + return if ( event.is_action_pressed("open_backpack") or event.is_action_pressed("ui_cancel") @@ -135,6 +175,7 @@ func open_menu() -> void: func close_menu() -> void: if not visible: return + _close_sale_confirmation() var closing_generation: int = _menu_generation visible = false get_viewport().gui_release_focus() @@ -209,11 +250,14 @@ func _update_sort_direction_text() -> void: func _refresh_all() -> void: + _refresh_economy_summary() _refresh_inventory() _refresh_logbook() func _on_inventory_changed() -> void: + _refresh_economy_summary() + _revalidate_confirmation() _refresh_inventory() _refresh_logbook() @@ -222,6 +266,26 @@ func _on_fish_discovered(_fish_id: StringName) -> void: _refresh_logbook() +func _on_wallet_balance_changed( + _new_balance: int, + _delta: int, +) -> void: + _refresh_economy_summary() + + +func _refresh_economy_summary() -> void: + if not is_node_ready(): + return + var balance: int = _wallet.get_balance() if _wallet != null else 0 + var held_total: int = ( + _inventory.get_total_sale_value() + if _inventory != null + else 0 + ) + _wallet_balance.text = "Wallet: $%d" % balance + _held_value.text = "Held fish base value: $%d" % held_total + + func _refresh_inventory() -> void: if not is_node_ready(): return @@ -286,6 +350,9 @@ func _create_inventory_card(fish_catch: FishCatchType) -> Button: fish_catch.fish.get_rarity_name(), fish_catch.catch_sequence, ] + data_label.text += "\nBase value: $%d" % fish_catch.sale_value + if fish_catch.is_favorited: + data_label.text += " • ★" data_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER data_label.mouse_filter = Control.MOUSE_FILTER_IGNORE content.add_child(data_label) @@ -308,16 +375,198 @@ func _update_inventory_detail(fish_catch: FishCatchType) -> void: _detail_texture.visible = false _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 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 - _detail_data.text = "%.2f lb\n%s\nCatch #%d\n%s" % [ + var buyer_offer: int = ( + _default_buyer.get_offer(fish_catch.sale_value) + if _default_buyer != null + else -1 + ) + var buyer_offer_text: String = "Buyer unavailable" + if buyer_offer >= 0: + buyer_offer_text = "%s offer: $%d" % [ + _default_buyer.display_name, + buyer_offer, + ] + _detail_data.text = "%.2f lb\n%s\nBase value: $%d\n%s\nCatch #%d\n%s" % [ fish_catch.weight_lb, fish_catch.fish.get_rarity_name(), + fish_catch.sale_value, + buyer_offer_text, fish_catch.catch_sequence, String(fish_catch.catch_id), ] + _favorite_button.disabled = false + _favorite_button.text = ( + "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 + ) + _sell_button.disabled = ( + fish_catch.is_favorited + or not valid_sale_value + or not valid_buyer + ) + 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." + 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(): + return + var fish_catch: FishCatchType = _inventory.get_catch_by_id( + _selected_catch_id + ) + if fish_catch == null: + _refresh_inventory() + return + if _inventory.set_catch_favorited( + fish_catch.catch_id, + not fish_catch.is_favorited + ): + _transaction_feedback.text = ( + "%s %s." + % [ + fish_catch.fish.display_name, + "favorited" if fish_catch.is_favorited else "unfavorited", + ] + ) + + +func _on_sell_pressed() -> void: + if ( + _inventory == null + or _sale_service == null + or _default_buyer == null + or _selected_catch_id.is_empty() + ): + return + var fish_catch: FishCatchType = _inventory.get_catch_by_id( + _selected_catch_id + ) + if fish_catch == null: + _transaction_feedback.text = "Fish no longer exists." + _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_buyer = _default_buyer + _confirmation_buyer_id = _default_buyer.id + _confirmation_message.text = ( + "Sell this %.2f lb %s to the %s for $%d?\nBase value: $%d" + % [ + fish_catch.weight_lb, + fish_catch.fish.display_name, + _get_buyer_display_group(_default_buyer), + buyer_offer, + fish_catch.sale_value, + ] + ) + _sale_confirmation.visible = true + _confirm_sale_button.disabled = false + _confirm_sale_button.grab_focus() + + +func _on_confirm_sale_pressed() -> void: + if ( + _sale_in_progress + or _sale_service == null + or _confirmation_catch_id.is_empty() + or _confirmation_buyer == null + or _confirmation_buyer.id != _confirmation_buyer_id + ): + return + _sale_in_progress = true + var requested_catch_id: StringName = _confirmation_catch_id + _confirm_sale_button.disabled = true + var result: FishSaleResultType = _sale_service.sell( + requested_catch_id, + _confirmation_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() + _refresh_all() + _inventory_tab.grab_focus() + + +func _close_sale_confirmation() -> void: + _confirmation_catch_id = StringName() + _confirmation_buyer = null + _confirmation_buyer_id = StringName() + _sale_confirmation.visible = false + _confirmation_message.text = "" + _confirm_sale_button.disabled = false + + +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: + _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 ( + _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." + + +func _get_buyer_display_group( + buyer: FishBuyerProfileType, +) -> String: + if buyer == null: + return "buyer" + if not buyer.animal_name_plural.is_empty(): + return buyer.animal_name_plural + if not buyer.display_name.is_empty(): + return buyer.display_name + return "buyer" func _refresh_logbook() -> void: diff --git a/ui/player_menu.tscn b/ui/player_menu.tscn index a3d71d9..30dccb6 100644 --- a/ui/player_menu.tscn +++ b/ui/player_menu.tscn @@ -58,6 +58,11 @@ size_flags_horizontal = 3 theme_override_font_sizes/font_size = 24 text = "Player Menu" +[node name="WalletBalance" type="Label" parent="MenuPanel/Margin/Layout/Header"] +unique_name_in_owner = true +layout_mode = 2 +text = "Wallet: $0" + [node name="InventoryTab" type="Button" parent="MenuPanel/Margin/Layout/Header"] unique_name_in_owner = true layout_mode = 2 @@ -78,6 +83,12 @@ text = "Close" [node name="Separator" type="HSeparator" parent="MenuPanel/Margin/Layout"] layout_mode = 2 +[node name="TransactionFeedback" type="Label" parent="MenuPanel/Margin/Layout"] +unique_name_in_owner = true +layout_mode = 2 +text = "" +horizontal_alignment = 1 + [node name="Content" type="Control" parent="MenuPanel/Margin/Layout"] custom_minimum_size = Vector2(0, 530) layout_mode = 2 @@ -110,6 +121,15 @@ layout_mode = 2 custom_minimum_size = Vector2(130, 0) text = "Newest first" +[node name="SortSpacer" type="Control" parent="MenuPanel/Margin/Layout/Content/InventorySection/SortBar"] +layout_mode = 2 +size_flags_horizontal = 3 + +[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" + [node name="InventoryBody" type="HSplitContainer" parent="MenuPanel/Margin/Layout/Content/InventorySection"] layout_mode = 2 size_flags_vertical = 3 @@ -183,6 +203,25 @@ layout_mode = 2 autowrap_mode = 2 horizontal_alignment = 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" + +[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" + +[node name="SaleUnavailable" type="Label" parent="MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/DetailPanel/DetailMargin/DetailStack"] +unique_name_in_owner = true +visible = false +layout_mode = 2 +autowrap_mode = 2 +horizontal_alignment = 1 + [node name="LogbookSection" type="VBoxContainer" parent="MenuPanel/Margin/Layout/Content"] unique_name_in_owner = true visible = false @@ -211,3 +250,50 @@ size_flags_horizontal = 3 theme_override_constants/h_separation = 12 theme_override_constants/v_separation = 12 columns = 4 + +[node name="SaleConfirmation" type="PanelContainer" parent="MenuPanel/Margin/Layout/Content"] +unique_name_in_owner = true +visible = false +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -220.0 +offset_top = -90.0 +offset_right = 220.0 +offset_bottom = 90.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="ConfirmationMargin" type="MarginContainer" parent="MenuPanel/Margin/Layout/Content/SaleConfirmation"] +layout_mode = 2 +theme_override_constants/margin_left = 18 +theme_override_constants/margin_top = 16 +theme_override_constants/margin_right = 18 +theme_override_constants/margin_bottom = 16 + +[node name="ConfirmationLayout" type="VBoxContainer" parent="MenuPanel/Margin/Layout/Content/SaleConfirmation/ConfirmationMargin"] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="ConfirmationMessage" type="Label" parent="MenuPanel/Margin/Layout/Content/SaleConfirmation/ConfirmationMargin/ConfirmationLayout"] +unique_name_in_owner = true +layout_mode = 2 +autowrap_mode = 2 +horizontal_alignment = 1 + +[node name="ConfirmationButtons" type="HBoxContainer" parent="MenuPanel/Margin/Layout/Content/SaleConfirmation/ConfirmationMargin/ConfirmationLayout"] +layout_mode = 2 +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" + +[node name="CancelSaleButton" type="Button" parent="MenuPanel/Margin/Layout/Content/SaleConfirmation/ConfirmationMargin/ConfirmationLayout/ConfirmationButtons"] +unique_name_in_owner = true +layout_mode = 2 +text = "Cancel"