Add fish selling, buyer profiles, and player wallet

This commit is contained in:
Alexander Sellite 2026-07-25 16:27:07 -04:00
parent fb04615600
commit d2fe367891
25 changed files with 738 additions and 5 deletions

View file

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

View file

@ -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}."

View file

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

View file

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

View file

@ -0,0 +1 @@
uid://cq1b2avld4qay

View file

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

View file

@ -0,0 +1 @@
uid://c02hcw8fkn5i8

View file

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

View file

@ -0,0 +1 @@
uid://cg3ec2ypo26xh

45
economy/player_wallet.gd Normal file
View file

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

View file

@ -0,0 +1 @@
uid://dp1uf8h8eevs

View file

@ -9,6 +9,8 @@ const FishDataType = preload("res://fish/fish_data.gd")
@export var catch_sequence: int = 0 @export var catch_sequence: int = 0
@export var weight_lb: float = 0.0 @export var weight_lb: float = 0.0
@export var display_scale: float = 1.0 @export var display_scale: float = 1.0
@export var sale_value: int = 0
@export var is_favorited: bool = false
func ensure_identity() -> void: func ensure_identity() -> void:
@ -29,4 +31,5 @@ func is_valid() -> bool:
and weight_lb >= fish.get_minimum_weight() and weight_lb >= fish.get_minimum_weight()
and weight_lb <= fish.get_maximum_weight() and weight_lb <= fish.get_maximum_weight()
and display_scale > 0.0 and display_scale > 0.0
and sale_value >= 0
) )

View file

@ -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_min: float = 0.8
@export_range(0.01, 20.0, 0.01) var display_scale_max: float = 1.2 @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.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 @export var display_texture: Texture2D
@ -38,6 +41,9 @@ func is_selectable() -> bool:
and display_scale_min > 0.0 and display_scale_min > 0.0
and display_scale_max >= display_scale_min and display_scale_max >= display_scale_min
and display_scale_curve > 0.0 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) 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: func get_rarity_name() -> String:
return Rarity.keys()[rarity].capitalize() return Rarity.keys()[rarity].capitalize()

View file

@ -76,4 +76,7 @@ func create_catch(fish: FishDataType) -> FishCatchType:
caught_fish.display_scale = fish.get_display_scale_for_weight( caught_fish.display_scale = fish.get_display_scale_for_weight(
caught_fish.weight_lb caught_fish.weight_lb
) )
caught_fish.sale_value = fish.get_sale_value_for_weight(
caught_fish.weight_lb
)
return caught_fish return caught_fish

View file

@ -25,4 +25,7 @@ weight_max_lb = 8.0
display_scale_min = 0.9 display_scale_min = 0.9
display_scale_max = 1.5 display_scale_max = 1.5
display_scale_curve = 0.9 display_scale_curve = 0.9
sell_value_min = 12
sell_value_max = 45
sell_value_curve = 0.9
display_texture = ExtResource("4_texture") display_texture = ExtResource("4_texture")

View file

@ -18,4 +18,7 @@ weight_max_lb = 2.0
display_scale_min = 0.7 display_scale_min = 0.7
display_scale_max = 1.1 display_scale_max = 1.1
display_scale_curve = 1.0 display_scale_curve = 1.0
sell_value_min = 3
sell_value_max = 12
sell_value_curve = 1.0
display_texture = ExtResource("4_texture") display_texture = ExtResource("4_texture")

View file

@ -25,4 +25,7 @@ weight_max_lb = 18.0
display_scale_min = 1.0 display_scale_min = 1.0
display_scale_max = 1.8 display_scale_max = 1.8
display_scale_curve = 0.85 display_scale_curve = 0.85
sell_value_min = 25
sell_value_max = 90
sell_value_curve = 0.85
display_texture = ExtResource("4_texture") display_texture = ExtResource("4_texture")

View file

@ -13,7 +13,7 @@ var _next_catch_sequence: int = 1
func add_catch(fish_catch: FishCatchType) -> void: func add_catch(fish_catch: FishCatchType) -> void:
if fish_catch == null or not fish_catch.is_valid(): if fish_catch == null or not fish_catch.is_valid():
return return
if get_catch(fish_catch.catch_id) != null: if get_catch_by_id(fish_catch.catch_id) != null:
return return
if fish_catch.catch_sequence <= 0: if fish_catch.catch_sequence <= 0:
fish_catch.catch_sequence = _next_catch_sequence fish_catch.catch_sequence = _next_catch_sequence
@ -37,7 +37,7 @@ func get_all_catches() -> Array[FishCatchType]:
return _catches.duplicate() return _catches.duplicate()
func get_catch(catch_id: StringName) -> FishCatchType: func get_catch_by_id(catch_id: StringName) -> FishCatchType:
if catch_id.is_empty(): if catch_id.is_empty():
return null return null
for fish_catch: FishCatchType in _catches: for fish_catch: FishCatchType in _catches:
@ -46,6 +46,54 @@ func get_catch(catch_id: StringName) -> FishCatchType:
return null 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]: func get_catches_by_fish_id(fish_id: StringName) -> Array[FishCatchType]:
var matching: Array[FishCatchType] = [] var matching: Array[FishCatchType] = []
for fish_catch: FishCatchType in _catches: for fish_catch: FishCatchType in _catches:

View file

@ -1,11 +1,13 @@
extends Node3D extends Node3D
const FishingSpotType = preload("res://fishing/fishing_spot.gd") 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 FishPoolType = preload("res://fish/fish_pool.gd")
const GameUIType = preload("res://ui/game_ui.gd") const GameUIType = preload("res://ui/game_ui.gd")
const PlayerType = preload("res://player/player.gd") const PlayerType = preload("res://player/player.gd")
@export var fish_catalog: FishPoolType @export var fish_catalog: FishPoolType
@export var pelican_buyer_profile: FishBuyerProfileType
@onready var _player: PlayerType = %Player @onready var _player: PlayerType = %Player
@onready var _fishing_spot: FishingSpotType = %FishingSpot @onready var _fishing_spot: FishingSpotType = %FishingSpot
@ -13,6 +15,10 @@ const PlayerType = preload("res://player/player.gd")
func _ready() -> void: func _ready() -> void:
_player.fish_sale_service.setup(
_player.inventory,
_player.wallet
)
_fishing_spot.setup( _fishing_spot.setup(
_player, _player,
_player.inventory, _player.inventory,
@ -22,6 +28,9 @@ func _ready() -> void:
_player, _player,
_player.inventory, _player.inventory,
_player.collection_log, _player.collection_log,
_player.wallet,
_player.fish_sale_service,
pelican_buyer_profile,
fish_catalog, fish_catalog,
_fishing_spot _fishing_spot
) )

View file

@ -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://world/test_world.tscn" id="1_world"]
[ext_resource type="PackedScene" path="res://player/player.tscn" id="2_player"] [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://fishing/fishing_spot.tscn" id="4_spot"]
[ext_resource type="PackedScene" path="res://ui/game_ui.tscn" id="5_ui"] [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://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"] [node name="Main" type="Node3D"]
script = ExtResource("3_main") script = ExtResource("3_main")
fish_catalog = ExtResource("6_pool") fish_catalog = ExtResource("6_pool")
pelican_buyer_profile = ExtResource("7_pelicans")
[node name="TestWorld" parent="." instance=ExtResource("1_world")] [node name="TestWorld" parent="." instance=ExtResource("1_world")]

View file

@ -4,6 +4,8 @@ extends CharacterBody3D
const FishInventoryType = preload("res://inventory/fish_inventory.gd") const FishInventoryType = preload("res://inventory/fish_inventory.gd")
const CollectionLogType = preload("res://collection/collection_log.gd") const CollectionLogType = preload("res://collection/collection_log.gd")
const FishCatchType = preload("res://fish/fish_catch.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: class ShowcaseCameraSnapshot:
extends RefCounted extends RefCounted
@ -59,6 +61,8 @@ class ShowcaseCameraSnapshot:
@onready var _camera: Camera3D = %Camera3D @onready var _camera: Camera3D = %Camera3D
@onready var inventory: FishInventoryType = %Inventory @onready var inventory: FishInventoryType = %Inventory
@onready var collection_log: CollectionLogType = %CollectionLog @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 _cast_origin: Marker3D = %CastOrigin
@onready var _fishing_rod: Node3D = %FishingRod @onready var _fishing_rod: Node3D = %FishingRod
@onready var _fishing_rod_tip: Marker3D = %FishingRodTip @onready var _fishing_rod_tip: Marker3D = %FishingRodTip

View file

@ -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://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://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://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"] [sub_resource type="CapsuleShape3D" id="PlayerShape"]
radius = 0.45 radius = 0.45
@ -78,6 +80,14 @@ script = ExtResource("2_inventory")
unique_name_in_owner = true unique_name_in_owner = true
script = ExtResource("3_collection") 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"] [node name="CatchDisplayAnchor" type="Marker3D" parent="Visuals"]
unique_name_in_owner = true unique_name_in_owner = true
position = Vector3(0, 1.45, -1.15) position = Vector3(0, 1.45, -1.15)

View file

@ -2,11 +2,14 @@ class_name GameUI
extends CanvasLayer extends CanvasLayer
const CollectionLogType = preload("res://collection/collection_log.gd") 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 FishPoolType = preload("res://fish/fish_pool.gd")
const FishInventoryType = preload("res://inventory/fish_inventory.gd") const FishInventoryType = preload("res://inventory/fish_inventory.gd")
const FishingSpotType = preload("res://fishing/fishing_spot.gd") const FishingSpotType = preload("res://fishing/fishing_spot.gd")
const PlayerMenuType = preload("res://ui/player_menu.gd") const PlayerMenuType = preload("res://ui/player_menu.gd")
const PlayerType = preload("res://player/player.gd") const PlayerType = preload("res://player/player.gd")
const PlayerWalletType = preload("res://economy/player_wallet.gd")
@onready var _status_label: Label = %StatusLabel @onready var _status_label: Label = %StatusLabel
@onready var _catch_track: Control = %CatchTrack @onready var _catch_track: Control = %CatchTrack
@ -26,6 +29,9 @@ func setup(
player: PlayerType, player: PlayerType,
inventory: FishInventoryType, inventory: FishInventoryType,
collection_log: CollectionLogType, collection_log: CollectionLogType,
wallet: PlayerWalletType,
sale_service: FishSaleServiceType,
default_buyer: FishBuyerProfileType,
catalog: FishPoolType, catalog: FishPoolType,
fishing_spot: FishingSpotType, fishing_spot: FishingSpotType,
) -> void: ) -> void:
@ -39,6 +45,9 @@ func setup(
player, player,
inventory, inventory,
collection_log, collection_log,
wallet,
sale_service,
default_buyer,
catalog, catalog,
fishing_spot fishing_spot
) )

View file

@ -4,10 +4,14 @@ extends Control
const CollectionLogType = preload("res://collection/collection_log.gd") const CollectionLogType = preload("res://collection/collection_log.gd")
const FishCatchType = preload("res://fish/fish_catch.gd") const FishCatchType = preload("res://fish/fish_catch.gd")
const FishDataType = preload("res://fish/fish_data.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 FishInventoryType = preload("res://inventory/fish_inventory.gd")
const FishPoolType = preload("res://fish/fish_pool.gd") const FishPoolType = preload("res://fish/fish_pool.gd")
const FishingSpotType = preload("res://fishing/fishing_spot.gd") const FishingSpotType = preload("res://fishing/fishing_spot.gd")
const PlayerType = preload("res://player/player.gd") const PlayerType = preload("res://player/player.gd")
const PlayerWalletType = preload("res://economy/player_wallet.gd")
signal menu_visibility_changed(is_open: bool) signal menu_visibility_changed(is_open: bool)
@ -25,21 +29,34 @@ enum SortMode {
@onready var _inventory_tab: Button = %InventoryTab @onready var _inventory_tab: Button = %InventoryTab
@onready var _logbook_tab: Button = %LogbookTab @onready var _logbook_tab: Button = %LogbookTab
@onready var _close_button: Button = %CloseButton @onready var _close_button: Button = %CloseButton
@onready var _wallet_balance: Label = %WalletBalance
@onready var _inventory_section: Control = %InventorySection @onready var _inventory_section: Control = %InventorySection
@onready var _logbook_section: Control = %LogbookSection @onready var _logbook_section: Control = %LogbookSection
@onready var _sort_option: OptionButton = %SortOption @onready var _sort_option: OptionButton = %SortOption
@onready var _sort_direction: Button = %SortDirection @onready var _sort_direction: Button = %SortDirection
@onready var _held_value: Label = %HeldValue
@onready var _inventory_empty: Label = %InventoryEmpty @onready var _inventory_empty: Label = %InventoryEmpty
@onready var _inventory_grid: GridContainer = %InventoryGrid @onready var _inventory_grid: GridContainer = %InventoryGrid
@onready var _detail_texture: TextureRect = %DetailTexture @onready var _detail_texture: TextureRect = %DetailTexture
@onready var _detail_name: Label = %DetailName @onready var _detail_name: Label = %DetailName
@onready var _detail_data: Label = %DetailData @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_empty: Label = %LogbookEmpty
@onready var _logbook_grid: GridContainer = %LogbookGrid @onready var _logbook_grid: GridContainer = %LogbookGrid
var _player: PlayerType var _player: PlayerType
var _inventory: FishInventoryType var _inventory: FishInventoryType
var _collection_log: CollectionLogType var _collection_log: CollectionLogType
var _wallet: PlayerWalletType
var _sale_service: FishSaleServiceType
var _default_buyer: FishBuyerProfileType
var _catalog: FishPoolType var _catalog: FishPoolType
var _fishing_spot: FishingSpotType var _fishing_spot: FishingSpotType
var _current_section: Section = Section.INVENTORY var _current_section: Section = Section.INVENTORY
@ -50,6 +67,10 @@ var _prior_movement_enabled: bool = true
var _prior_camera_input_enabled: bool = true var _prior_camera_input_enabled: bool = true
var _control_snapshot_stored: bool = false var _control_snapshot_stored: bool = false
var _menu_generation: int = 0 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: func _ready() -> void:
@ -62,6 +83,10 @@ func _ready() -> void:
_close_button.pressed.connect(close_menu) _close_button.pressed.connect(close_menu)
_sort_option.item_selected.connect(_on_sort_selected) _sort_option.item_selected.connect(_on_sort_selected)
_sort_direction.pressed.connect(_on_sort_direction_pressed) _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("Catch order", SortMode.CATCH_ORDER)
_sort_option.add_item("Name", SortMode.NAME) _sort_option.add_item("Name", SortMode.NAME)
_sort_option.add_item("Rarity", SortMode.RARITY) _sort_option.add_item("Rarity", SortMode.RARITY)
@ -74,18 +99,26 @@ func setup(
player: PlayerType, player: PlayerType,
inventory: FishInventoryType, inventory: FishInventoryType,
collection_log: CollectionLogType, collection_log: CollectionLogType,
wallet: PlayerWalletType,
sale_service: FishSaleServiceType,
default_buyer: FishBuyerProfileType,
catalog: FishPoolType, catalog: FishPoolType,
fishing_spot: FishingSpotType, fishing_spot: FishingSpotType,
) -> void: ) -> void:
_player = player _player = player
_inventory = inventory _inventory = inventory
_collection_log = collection_log _collection_log = collection_log
_wallet = wallet
_sale_service = sale_service
_default_buyer = default_buyer
_catalog = catalog _catalog = catalog
_fishing_spot = fishing_spot _fishing_spot = fishing_spot
if not _inventory.catches_changed.is_connected(_on_inventory_changed): if not _inventory.catches_changed.is_connected(_on_inventory_changed):
_inventory.catches_changed.connect(_on_inventory_changed) _inventory.catches_changed.connect(_on_inventory_changed)
if not _collection_log.fish_discovered.is_connected(_on_fish_discovered): if not _collection_log.fish_discovered.is_connected(_on_fish_discovered):
_collection_log.fish_discovered.connect(_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): if not _fishing_spot.bite_activated.is_connected(_on_bite_activated):
_fishing_spot.bite_activated.connect(_on_bite_activated) _fishing_spot.bite_activated.connect(_on_bite_activated)
_refresh_all() _refresh_all()
@ -95,6 +128,13 @@ func _input(event: InputEvent) -> void:
if event is InputEventKey and event.echo: if event is InputEventKey and event.echo:
return return
if visible: if visible:
if (
event.is_action_pressed("ui_cancel")
and _sale_confirmation.visible
):
_close_sale_confirmation()
get_viewport().set_input_as_handled()
return
if ( if (
event.is_action_pressed("open_backpack") event.is_action_pressed("open_backpack")
or event.is_action_pressed("ui_cancel") or event.is_action_pressed("ui_cancel")
@ -135,6 +175,7 @@ func open_menu() -> void:
func close_menu() -> void: func close_menu() -> void:
if not visible: if not visible:
return return
_close_sale_confirmation()
var closing_generation: int = _menu_generation var closing_generation: int = _menu_generation
visible = false visible = false
get_viewport().gui_release_focus() get_viewport().gui_release_focus()
@ -209,11 +250,14 @@ func _update_sort_direction_text() -> void:
func _refresh_all() -> void: func _refresh_all() -> void:
_refresh_economy_summary()
_refresh_inventory() _refresh_inventory()
_refresh_logbook() _refresh_logbook()
func _on_inventory_changed() -> void: func _on_inventory_changed() -> void:
_refresh_economy_summary()
_revalidate_confirmation()
_refresh_inventory() _refresh_inventory()
_refresh_logbook() _refresh_logbook()
@ -222,6 +266,26 @@ func _on_fish_discovered(_fish_id: StringName) -> void:
_refresh_logbook() _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: func _refresh_inventory() -> void:
if not is_node_ready(): if not is_node_ready():
return return
@ -286,6 +350,9 @@ func _create_inventory_card(fish_catch: FishCatchType) -> Button:
fish_catch.fish.get_rarity_name(), fish_catch.fish.get_rarity_name(),
fish_catch.catch_sequence, 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.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
data_label.mouse_filter = Control.MOUSE_FILTER_IGNORE data_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
content.add_child(data_label) content.add_child(data_label)
@ -308,16 +375,198 @@ func _update_inventory_detail(fish_catch: FishCatchType) -> void:
_detail_texture.visible = false _detail_texture.visible = false
_detail_name.text = "" _detail_name.text = ""
_detail_data.text = "" _detail_data.text = ""
_favorite_button.disabled = true
_favorite_button.text = "Favorite"
_sell_button.disabled = true
_sale_unavailable.text = ""
_sale_unavailable.visible = false
return return
_detail_texture.texture = fish_catch.fish.display_texture _detail_texture.texture = fish_catch.fish.display_texture
_detail_texture.visible = fish_catch.fish.display_texture != null _detail_texture.visible = fish_catch.fish.display_texture != null
_detail_name.text = fish_catch.fish.display_name _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.weight_lb,
fish_catch.fish.get_rarity_name(), fish_catch.fish.get_rarity_name(),
fish_catch.sale_value,
buyer_offer_text,
fish_catch.catch_sequence, fish_catch.catch_sequence,
String(fish_catch.catch_id), 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: func _refresh_logbook() -> void:

View file

@ -58,6 +58,11 @@ size_flags_horizontal = 3
theme_override_font_sizes/font_size = 24 theme_override_font_sizes/font_size = 24
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"
[node name="InventoryTab" type="Button" parent="MenuPanel/Margin/Layout/Header"] [node name="InventoryTab" type="Button" parent="MenuPanel/Margin/Layout/Header"]
unique_name_in_owner = true unique_name_in_owner = true
layout_mode = 2 layout_mode = 2
@ -78,6 +83,12 @@ text = "Close"
[node name="Separator" type="HSeparator" parent="MenuPanel/Margin/Layout"] [node name="Separator" type="HSeparator" parent="MenuPanel/Margin/Layout"]
layout_mode = 2 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"] [node name="Content" type="Control" parent="MenuPanel/Margin/Layout"]
custom_minimum_size = Vector2(0, 530) custom_minimum_size = Vector2(0, 530)
layout_mode = 2 layout_mode = 2
@ -110,6 +121,15 @@ layout_mode = 2
custom_minimum_size = Vector2(130, 0) custom_minimum_size = Vector2(130, 0)
text = "Newest first" 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"] [node name="InventoryBody" type="HSplitContainer" parent="MenuPanel/Margin/Layout/Content/InventorySection"]
layout_mode = 2 layout_mode = 2
size_flags_vertical = 3 size_flags_vertical = 3
@ -183,6 +203,25 @@ layout_mode = 2
autowrap_mode = 2 autowrap_mode = 2
horizontal_alignment = 1 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"] [node name="LogbookSection" type="VBoxContainer" parent="MenuPanel/Margin/Layout/Content"]
unique_name_in_owner = true unique_name_in_owner = true
visible = false visible = false
@ -211,3 +250,50 @@ size_flags_horizontal = 3
theme_override_constants/h_separation = 12 theme_override_constants/h_separation = 12
theme_override_constants/v_separation = 12 theme_override_constants/v_separation = 12
columns = 4 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"