diff --git a/economy/fishing_shop_stock.gd b/economy/fishing_shop_stock.gd index 4cbf863..c07c763 100644 --- a/economy/fishing_shop_stock.gd +++ b/economy/fishing_shop_stock.gd @@ -9,13 +9,33 @@ const WORM_MAX_STACK: int = 10 const ITEM_PRICES: Dictionary[StringName, int] = { &"worms": 1, + &"snails": 3, + &"shrimp": 7, + &"squid_chunks": 15, + &"whole_anchovy": 30, + &"whole_sardine": 60, + &"luminous_roe": 125, &"coffee": 20, &"energy_drink": 35, &"snack": 30, &"fish_finder": 60, } +const BAIT_UNLOCK_PRICES: Dictionary[StringName, int] = { + &"snails": 400, + &"shrimp": 1200, + &"squid_chunks": 3500, + &"whole_anchovy": 9000, + &"whole_sardine": 22000, + &"luminous_roe": 50000, +} const ITEM_ORDER: Array[StringName] = [ &"worms", + &"snails", + &"shrimp", + &"squid_chunks", + &"whole_anchovy", + &"whole_sardine", + &"luminous_roe", &"coffee", &"energy_drink", &"snack", @@ -31,14 +51,35 @@ static func get_stock_item_ids() -> Array[StringName]: return ITEM_ORDER.duplicate() -static func get_purchase_quantity(item_id: StringName, owned: int) -> int: - if item_id == &"worms": - return maxi(WORM_MAX_STACK - owned, 0) +static func get_unlock_price(item_id: StringName) -> int: + return BAIT_UNLOCK_PRICES.get(item_id, -1) + + +static func get_purchase_quantity( + item_id: StringName, + owned: int, + maximum_stack: int = WORM_MAX_STACK, +) -> int: + if is_bait_topoff(item_id): + return maxi(maximum_stack - owned, 0) return 1 static func is_bait_topoff(item_id: StringName) -> bool: - return item_id == &"worms" + return item_id == &"worms" or BAIT_UNLOCK_PRICES.has(item_id) + + +static func get_purchase_cost( + item_id: StringName, + quantity: int, + bait_unlocked: bool, +) -> int: + if quantity <= 0: + return -1 + if is_bait_topoff(item_id) and item_id != &"worms" and not bait_unlocked: + return get_unlock_price(item_id) + var unit_price: int = get_price(item_id) + return unit_price * quantity if unit_price >= 0 else -1 static func purchase_one( @@ -49,22 +90,32 @@ static func purchase_one( ) -> bool: if wallet == null or bag == null or catalog == null: return false - var price: int = get_price(item_id) var item: ItemDataType = catalog.get_item_by_id(item_id) + var bait_topoff: bool = is_bait_topoff(item_id) + var quantity: int = get_purchase_quantity( + item_id, + bag.get_quantity(item_id), + item.max_stack if item != null else WORM_MAX_STACK, + ) + var price: int = get_purchase_cost( + item_id, + quantity, + bag.is_bait_unlocked(item_id), + ) if ( price < 0 or item == null or not item.is_valid() - or (item.category != ItemDataType.Category.CONSUMABLE and not is_bait_topoff(item_id)) + or (item.category != ItemDataType.Category.CONSUMABLE and not bait_topoff) or not item.stackable - or (not item.usable and not is_bait_topoff(item_id)) - or not bag.can_add_item(item_id, 1) + or (not item.usable and not bait_topoff) + or not bag.can_add_item(item_id, quantity) or not wallet.can_afford(price) ): return false if not wallet.debit(price): return false - if bag.add_item(item_id, 1): + if bag.add_item(item_id, quantity): return true if not wallet.credit(price): push_error("Fishing Shop failed to roll back an item purchase.") diff --git a/fish/fish_quality.gd b/fish/fish_quality.gd index 0559679..2981491 100644 --- a/fish/fish_quality.gd +++ b/fish/fish_quality.gd @@ -16,6 +16,12 @@ const ALL_TIERS_MASK: int = (1 << TIER_COUNT) - 1 # multipliers later without changing catch serialization or tier identity. const BASE_ROLL_WEIGHTS: Array[float] = [40.0, 32.0, 18.0, 8.0, 2.0] const WORM_ROLL_WEIGHTS: Array[float] = [80.0, 10.0, 5.0, 4.0, 1.0] +const SNAIL_ROLL_WEIGHTS: Array[float] = [68.0, 16.0, 8.0, 6.0, 2.0] +const SHRIMP_ROLL_WEIGHTS: Array[float] = [56.0, 21.0, 12.0, 8.0, 3.0] +const SQUID_ROLL_WEIGHTS: Array[float] = [44.0, 25.0, 16.0, 11.0, 4.0] +const ANCHOVY_ROLL_WEIGHTS: Array[float] = [34.0, 27.0, 20.0, 13.0, 6.0] +const SARDINE_ROLL_WEIGHTS: Array[float] = [25.0, 28.0, 23.0, 16.0, 8.0] +const LUMINOUS_ROE_ROLL_WEIGHTS: Array[float] = [16.0, 26.0, 26.0, 20.0, 12.0] const SALE_MULTIPLIERS: Array[float] = [1.0, 1.1, 1.25, 1.5, 2.0] # Legacy profile multiplier retained for serialized/profile compatibility. # New encounters use the weighted quality/rarity/weight bands below. @@ -136,17 +142,34 @@ static func roll( static func roll_weights_for_bait(active_bait_tags: Array[StringName]) -> Array[float]: + var bait_weights: Array[float] = rarity_weights_for_bait(active_bait_tags) + var weights: Array[float] = [] + for quality: int in TIER_COUNT: + weights.append(bait_weights[quality] / BASE_ROLL_WEIGHTS[quality]) + return weights + + +static func rarity_weights_for_bait( + active_bait_tags: Array[StringName], +) -> Array[float]: var weights: Array[float] = [] if active_bait_tags.is_empty(): - weights.assign([2.5, 0.0, 0.0, 0.0, 0.0]) + weights.assign([100.0, 0.0, 0.0, 0.0, 0.0]) + elif active_bait_tags.has(&"bait_rarity_6"): + weights.assign(LUMINOUS_ROE_ROLL_WEIGHTS) + elif active_bait_tags.has(&"bait_rarity_5"): + weights.assign(SARDINE_ROLL_WEIGHTS) + elif active_bait_tags.has(&"bait_rarity_4"): + weights.assign(ANCHOVY_ROLL_WEIGHTS) + elif active_bait_tags.has(&"bait_rarity_3"): + weights.assign(SQUID_ROLL_WEIGHTS) + elif active_bait_tags.has(&"bait_rarity_2"): + weights.assign(SHRIMP_ROLL_WEIGHTS) + elif active_bait_tags.has(&"bait_rarity_1"): + weights.assign(SNAIL_ROLL_WEIGHTS) else: - weights.assign([ - WORM_ROLL_WEIGHTS[0] / BASE_ROLL_WEIGHTS[0], - WORM_ROLL_WEIGHTS[1] / BASE_ROLL_WEIGHTS[1], - WORM_ROLL_WEIGHTS[2] / BASE_ROLL_WEIGHTS[2], - WORM_ROLL_WEIGHTS[3] / BASE_ROLL_WEIGHTS[3], - WORM_ROLL_WEIGHTS[4] / BASE_ROLL_WEIGHTS[4], - ]) + # Unknown and legacy bait tags retain the original worms behavior. + weights.assign(WORM_ROLL_WEIGHTS) return weights diff --git a/fish/fish_selector.gd b/fish/fish_selector.gd index 17c3ff7..506a906 100644 --- a/fish/fish_selector.gd +++ b/fish/fish_selector.gd @@ -81,9 +81,9 @@ func select_fish( func _roll_rarity(context: FishingContextType) -> int: - var weights: Array[float] = [100.0, 0.0, 0.0, 0.0, 0.0] - if not context.active_bait_tags.is_empty(): - weights = [80.0, 10.0, 5.0, 4.0, 1.0] + var weights: Array[float] = FishQualityType.rarity_weights_for_bait( + context.active_bait_tags + ) for index: int in range(weights.size()): if index < rarity_weight_multipliers.size(): weights[index] *= maxf(rarity_weight_multipliers[index], 0.0) diff --git a/fishing/fishing_spot.gd b/fishing/fishing_spot.gd index 8062de5..6c7b434 100644 --- a/fishing/fishing_spot.gd +++ b/fishing/fishing_spot.gd @@ -100,14 +100,14 @@ enum FishingState { @export_range(0.0, 1.0, 0.01) var withdrawal_surface_clearance: float = 0.4 @export_category("Timing") -const BITE_QUICK_MIN_SECONDS: float = 8.0 -const BITE_QUICK_MAX_SECONDS: float = 24.0 -const BITE_TYPICAL_MAX_SECONDS: float = 72.0 -const BITE_LONG_MAX_SECONDS: float = 144.0 -const BITE_MAX_SECONDS: float = 192.0 -const BITE_QUICK_PROBABILITY: float = 0.25 +const BITE_QUICK_MIN_SECONDS: float = 6.0 +const BITE_QUICK_MAX_SECONDS: float = 18.0 +const BITE_TYPICAL_MAX_SECONDS: float = 48.0 +const BITE_LONG_MAX_SECONDS: float = 72.0 +const BITE_MAX_SECONDS: float = 90.0 +const BITE_QUICK_PROBABILITY: float = 0.30 const BITE_TYPICAL_PROBABILITY: float = 0.65 -const BITE_LONG_PROBABILITY: float = 0.08 +const BITE_LONG_PROBABILITY: float = 0.04 const NETWORK_INPUT_RESEND_INTERVAL_SECONDS: float = 0.1 @export_range(0.1, 10.0, 0.1) var cooldown_duration: float = 1.0 diff --git a/inventory/player_bag.gd b/inventory/player_bag.gd index 1e885ee..4fc12b6 100644 --- a/inventory/player_bag.gd +++ b/inventory/player_bag.gd @@ -5,10 +5,15 @@ const ItemCatalogType = preload("res://items/item_catalog.gd") const ItemDataType = preload("res://items/item_data.gd") const OwnedItemType = preload("res://items/owned_item.gd") +const DEFAULT_UNLOCKED_BAIT_IDS: Array[StringName] = [&"worms"] + signal contents_changed var _catalog: ItemCatalogType var _items: Array[OwnedItemType] = [] +var _unlocked_bait_ids: Array[StringName] = ( + DEFAULT_UNLOCKED_BAIT_IDS.duplicate() +) func setup(catalog: ItemCatalogType) -> void: @@ -27,6 +32,8 @@ func add_item(item_id: StringName, quantity: int = 1) -> bool: owned.item_id = item_id owned.quantity = quantity _items.append(owned) + if item.is_bait() and not _unlocked_bait_ids.has(item_id): + _unlocked_bait_ids.append(item_id) contents_changed.emit() return true @@ -85,6 +92,46 @@ func get_all_items() -> Array[OwnedItemType]: return result +func get_unlocked_bait_ids() -> Array[StringName]: + return _unlocked_bait_ids.duplicate() + + +func is_bait_unlocked(item_id: StringName) -> bool: + return _unlocked_bait_ids.has(item_id) + + +func get_unlocked_bait_items() -> Array[OwnedItemType]: + var result: Array[OwnedItemType] = [] + for item_id: StringName in _unlocked_bait_ids: + var owned: OwnedItemType = get_owned_item(item_id) + if owned != null: + result.append(owned.duplicate_record()) + continue + var empty_record := OwnedItemType.new() + empty_record.item_id = item_id + empty_record.quantity = 0 + result.append(empty_record) + return result + + +func replace_unlocked_bait_ids(item_ids: Array[StringName]) -> bool: + var validated: Array[StringName] = DEFAULT_UNLOCKED_BAIT_IDS.duplicate() + var seen: Dictionary[StringName, bool] = {} + for item_id: StringName in item_ids: + if item_id.is_empty() or seen.has(item_id): + return false + seen[item_id] = true + if validated.has(item_id): + continue + var item: ItemDataType = _resolve_valid_item(item_id) + if item == null or not item.is_bait(): + return false + validated.append(item_id) + _unlocked_bait_ids = validated + contents_changed.emit() + return true + + func replace_all_items(items: Array[OwnedItemType]) -> bool: var validated: Array[OwnedItemType] = [] var seen: Dictionary[StringName, bool] = {} diff --git a/items/catalog/item_catalog.tres b/items/catalog/item_catalog.tres index 95f130e..2cd0546 100644 --- a/items/catalog/item_catalog.tres +++ b/items/catalog/item_catalog.tres @@ -1,4 +1,4 @@ -[gd_resource type="Resource" script_class="ItemCatalog" load_steps=10 format=3] +[gd_resource type="Resource" script_class="ItemCatalog" load_steps=16 format=3] [ext_resource type="Script" path="res://items/item_catalog.gd" id="1_script"] [ext_resource type="Resource" path="res://items/catalog/basic_fishing_rod.tres" id="2_rod"] @@ -9,7 +9,13 @@ [ext_resource type="Resource" path="res://items/catalog/cooler_expansion.tres" id="7_cooler"] [ext_resource type="Resource" path="res://items/catalog/art_kit.tres" id="8_art_kit"] [ext_resource type="Resource" path="res://items/catalog/worms.tres" id="9_worms"] +[ext_resource type="Resource" path="res://items/catalog/snails.tres" id="10_snails"] +[ext_resource type="Resource" path="res://items/catalog/shrimp.tres" id="11_shrimp"] +[ext_resource type="Resource" path="res://items/catalog/squid_chunks.tres" id="12_squid"] +[ext_resource type="Resource" path="res://items/catalog/whole_anchovy.tres" id="13_anchovy"] +[ext_resource type="Resource" path="res://items/catalog/whole_sardine.tres" id="14_sardine"] +[ext_resource type="Resource" path="res://items/catalog/luminous_roe.tres" id="15_roe"] [resource] script = ExtResource("1_script") -items = [ExtResource("2_rod"), ExtResource("3_coffee"), ExtResource("4_energy"), ExtResource("5_snack"), ExtResource("6_finder"), ExtResource("7_cooler"), ExtResource("8_art_kit"), ExtResource("9_worms")] +items = [ExtResource("2_rod"), ExtResource("3_coffee"), ExtResource("4_energy"), ExtResource("5_snack"), ExtResource("6_finder"), ExtResource("7_cooler"), ExtResource("8_art_kit"), ExtResource("9_worms"), ExtResource("10_snails"), ExtResource("11_shrimp"), ExtResource("12_squid"), ExtResource("13_anchovy"), ExtResource("14_sardine"), ExtResource("15_roe")] diff --git a/items/catalog/luminous_roe.tres b/items/catalog/luminous_roe.tres new file mode 100644 index 0000000..aae9fc6 --- /dev/null +++ b/items/catalog/luminous_roe.tres @@ -0,0 +1,14 @@ +[gd_resource type="Resource" script_class="ItemData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://items/item_data.gd" id="1"] + +[resource] +script = ExtResource("1") +item_id = &"luminous_roe" +display_name = "luminous roe" +description = "rare glowing roe with the strongest rare catch odds." +category = 2 +bait_tags = Array[StringName]([&"bait_rarity_6"]) +stackable = true +max_stack = 10 +hotbar_allowed = false diff --git a/items/catalog/shrimp.tres b/items/catalog/shrimp.tres new file mode 100644 index 0000000..a9a63d3 --- /dev/null +++ b/items/catalog/shrimp.tres @@ -0,0 +1,14 @@ +[gd_resource type="Resource" script_class="ItemData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://items/item_data.gd" id="1"] + +[resource] +script = ExtResource("1") +item_id = &"shrimp" +display_name = "shrimp" +description = "lively bait that noticeably improves rare catch chances." +category = 2 +bait_tags = Array[StringName]([&"bait_rarity_2"]) +stackable = true +max_stack = 10 +hotbar_allowed = false diff --git a/items/catalog/snails.tres b/items/catalog/snails.tres new file mode 100644 index 0000000..6bddbd4 --- /dev/null +++ b/items/catalog/snails.tres @@ -0,0 +1,14 @@ +[gd_resource type="Resource" script_class="ItemData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://items/item_data.gd" id="1"] + +[resource] +script = ExtResource("1") +item_id = &"snails" +display_name = "snails" +description = "slow-moving bait with a better chance for rarer catches." +category = 2 +bait_tags = Array[StringName]([&"bait_rarity_1"]) +stackable = true +max_stack = 10 +hotbar_allowed = false diff --git a/items/catalog/squid_chunks.tres b/items/catalog/squid_chunks.tres new file mode 100644 index 0000000..108387b --- /dev/null +++ b/items/catalog/squid_chunks.tres @@ -0,0 +1,14 @@ +[gd_resource type="Resource" script_class="ItemData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://items/item_data.gd" id="1"] + +[resource] +script = ExtResource("1") +item_id = &"squid_chunks" +display_name = "squid chunks" +description = "rich cut bait that strongly favors rarer catches." +category = 2 +bait_tags = Array[StringName]([&"bait_rarity_3"]) +stackable = true +max_stack = 10 +hotbar_allowed = false diff --git a/items/catalog/whole_anchovy.tres b/items/catalog/whole_anchovy.tres new file mode 100644 index 0000000..f640941 --- /dev/null +++ b/items/catalog/whole_anchovy.tres @@ -0,0 +1,14 @@ +[gd_resource type="Resource" script_class="ItemData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://items/item_data.gd" id="1"] + +[resource] +script = ExtResource("1") +item_id = &"whole_anchovy" +display_name = "whole anchovy" +description = "premium whole bait with high rare catch potential." +category = 2 +bait_tags = Array[StringName]([&"bait_rarity_4"]) +stackable = true +max_stack = 10 +hotbar_allowed = false diff --git a/items/catalog/whole_sardine.tres b/items/catalog/whole_sardine.tres new file mode 100644 index 0000000..b5ccb52 --- /dev/null +++ b/items/catalog/whole_sardine.tres @@ -0,0 +1,14 @@ +[gd_resource type="Resource" script_class="ItemData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://items/item_data.gd" id="1"] + +[resource] +script = ExtResource("1") +item_id = &"whole_sardine" +display_name = "whole sardine" +description = "valuable whole bait tuned for exceptional catches." +category = 2 +bait_tags = Array[StringName]([&"bait_rarity_5"]) +stackable = true +max_stack = 10 +hotbar_allowed = false diff --git a/items/catalog/worms.tres b/items/catalog/worms.tres index 32827f6..f01258e 100644 --- a/items/catalog/worms.tres +++ b/items/catalog/worms.tres @@ -1,14 +1,16 @@ -[gd_resource type="Resource" script_class="ItemData" load_steps=2 format=3] +[gd_resource type="Resource" script_class="ItemData" load_steps=3 format=3] [ext_resource type="Script" path="res://items/item_data.gd" id="1"] +[ext_resource type="Texture2D" path="res://items/icons/bait/64_bait_worms.png" id="2_icon"] [resource] script = ExtResource("1") item_id = &"worms" display_name = "worms" -description = "common bait. fills your tacklebox at the fishing shop." +description = "basic bait that opens a small chance for rarer catches." +icon = ExtResource("2_icon") category = 2 -bait_tags = Array[StringName]([&"worm"]) +bait_tags = Array[StringName]([&"worm", &"bait_rarity_0"]) stackable = true max_stack = 10 hotbar_allowed = false diff --git a/items/icons/bait/64_bait_worms.png b/items/icons/bait/64_bait_worms.png new file mode 100644 index 0000000..51cb396 Binary files /dev/null and b/items/icons/bait/64_bait_worms.png differ diff --git a/items/icons/bait/64_bait_worms.png.import b/items/icons/bait/64_bait_worms.png.import new file mode 100644 index 0000000..5fbd6cd --- /dev/null +++ b/items/icons/bait/64_bait_worms.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bn6815x0rrspw" +path="res://.godot/imported/64_bait_worms.png-c9f31d77527e73888fc56963d4e78e30.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://items/icons/bait/64_bait_worms.png" +dest_files=["res://.godot/imported/64_bait_worms.png-c9f31d77527e73888fc56963d4e78e30.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/network/network_mail_service.gd b/network/network_mail_service.gd index f63c7ce..eb9fe0f 100644 --- a/network/network_mail_service.gd +++ b/network/network_mail_service.gd @@ -992,6 +992,7 @@ func _capture_assets() -> Dictionary: return { "wallet": _wallet.get_balance(), "bag": _bag.get_all_items(), + "unlocked_bait_ids": _bag.get_unlocked_bait_ids(), "catches": _inventory.get_all_catches(), "next_sequence": _inventory.get_next_catch_sequence(), "discovered": _collection_log.get_discovered_ids(), @@ -1002,6 +1003,7 @@ func _capture_assets() -> Dictionary: func _restore_assets(snapshot: Dictionary) -> void: _wallet.restore_balance(int(snapshot["wallet"])) _bag.replace_all_items(snapshot["bag"]) + _bag.replace_unlocked_bait_ids(snapshot["unlocked_bait_ids"]) _inventory.replace_all_catches( snapshot["catches"], int(snapshot["next_sequence"]) ) diff --git a/network/network_shop_protocol.gd b/network/network_shop_protocol.gd index 65a0395..11900e9 100644 --- a/network/network_shop_protocol.gd +++ b/network/network_shop_protocol.gd @@ -56,6 +56,11 @@ static func validate_request(data: Variant) -> String: var quantity: int = payload["quantity"] var wallet_balance: int = payload["wallet_balance"] var current_state: int = payload["current_state"] + if ( + payload.has("bait_unlocked") + and typeof(payload["bait_unlocked"]) != TYPE_BOOL + ): + return "Purchase could not be completed." if ( request_id.is_empty() or request_id.length() > MAX_ID_LENGTH diff --git a/network/network_shop_service.gd b/network/network_shop_service.gd index 88b821d..fd7240e 100644 --- a/network/network_shop_service.gd +++ b/network/network_shop_service.gd @@ -109,7 +109,15 @@ func is_local_purchase_pending() -> bool: func request_supply(item_id: StringName) -> String: var owned: int = _bag.get_quantity(item_id) if _bag != null else 0 - var quantity: int = FishingShopStockType.get_purchase_quantity(item_id, owned) + var item: ItemDataType = ( + _item_catalog.get_item_by_id(item_id) + if _item_catalog != null else null + ) + var quantity: int = FishingShopStockType.get_purchase_quantity( + item_id, + owned, + item.max_stack if item != null else FishingShopStockType.WORM_MAX_STACK, + ) return _request_purchase( item_id, NetworkShopProtocol.ProductCategory.SUPPLY, @@ -223,6 +231,13 @@ func _request_purchase( "wallet_balance": _wallet.get_balance() if _wallet != null else 0, "current_state": current_state, } + if ( + category == NetworkShopProtocol.ProductCategory.SUPPLY + and FishingShopStockType.is_bait_topoff(product_id) + ): + request["bait_unlocked"] = ( + _bag != null and _bag.is_bait_unlocked(product_id) + ) _pending_local_request = request.duplicate(true) local_purchase_pending.emit(request_id) if _session.is_host(): @@ -317,16 +332,18 @@ func _build_authoritative_result( var item: ItemDataType = _item_catalog.get_item_by_id( product_id ) if _item_catalog != null else null - cost = FishingShopStockType.get_price(product_id) + var bait_topoff: bool = ( + FishingShopStockType.is_bait_topoff(product_id) + ) if ( item == null or not item.is_valid() or product_id not in ( FishingShopStockType.get_stock_item_ids() ) - or (item.category != ItemDataType.Category.CONSUMABLE and not FishingShopStockType.is_bait_topoff(product_id)) + or (item.category != ItemDataType.Category.CONSUMABLE and not bait_topoff) or not item.stackable - or (not item.usable and not FishingShopStockType.is_bait_topoff(product_id)) + or (not item.usable and not bait_topoff) or current_state >= item.max_stack or current_state + quantity > item.max_stack ): @@ -336,10 +353,21 @@ func _build_authoritative_result( else "Purchase could not be completed." ) else: - if not FishingShopStockType.is_bait_topoff(product_id) and quantity != 1: + var expected_quantity: int = ( + FishingShopStockType.get_purchase_quantity( + product_id, current_state, item.max_stack + ) + ) + if quantity != expected_quantity: rejection = "Purchase could not be completed." else: - cost *= quantity + cost = FishingShopStockType.get_purchase_cost( + product_id, + quantity, + bool(request.get("bait_unlocked", true)), + ) + if cost < 0: + rejection = "Purchase could not be completed." resulting_state = current_state + quantity NetworkShopProtocol.ProductCategory.ROD: rejection = "This item is not sold here." @@ -532,6 +560,9 @@ func _apply_purchase_result(data: Dictionary) -> void: return var wallet_snapshot: int = _wallet.get_balance() var bag_snapshot: Array[OwnedItemType] = _bag.get_all_items() + var bait_unlock_snapshot: Array[StringName] = ( + _bag.get_unlocked_bait_ids() + ) var reel_snapshot: int = _upgrades.get_reel_speed_level() var barrier_snapshot: int = _upgrades.get_barrier_power_level() var cooler_snapshot: int = _cooler_capacity.get_level() @@ -539,6 +570,7 @@ func _apply_purchase_result(data: Dictionary) -> void: var applied: bool = _apply_local_product(data) if not applied or not _save_manager.save_if_dirty(): _bag.replace_all_items(bag_snapshot) + _bag.replace_unlocked_bait_ids(bait_unlock_snapshot) _upgrades.restore_levels(reel_snapshot, barrier_snapshot) _cooler_capacity.restore_level(cooler_snapshot) _art_unlocks.restore_mask(art_snapshot) @@ -590,9 +622,22 @@ func _validate_local_result(data: Dictionary) -> String: return "Not enough fish coin." match category: NetworkShopProtocol.ProductCategory.SUPPLY: - if _bag.get_quantity(product_id) != expected_state: + var item: ItemDataType = ( + _item_catalog.get_item_by_id(product_id) + if _item_catalog != null else null + ) + var bait_unlocked: bool = _bag.is_bait_unlocked(product_id) + if ( + item == null + or _bag.get_quantity(product_id) != expected_state + or int(data["quantity"]) != FishingShopStockType.get_purchase_quantity( + product_id, expected_state, item.max_stack + ) + ): return "Purchase could not be completed." - if cost != FishingShopStockType.get_price(product_id) * int(data["quantity"]): + if cost != FishingShopStockType.get_purchase_cost( + product_id, int(data["quantity"]), bait_unlocked + ): return "Purchase could not be completed." if not _bag.can_add_item(product_id, data["quantity"]): return "Your Bag is full." diff --git a/save/player_save_manager.gd b/save/player_save_manager.gd index 9a9a3be..57e355d 100644 --- a/save/player_save_manager.gd +++ b/save/player_save_manager.gd @@ -43,6 +43,7 @@ class LoadSnapshot: var wallet_balance: int = 0 var next_catch_sequence: int = 1 var bag_items: Array[OwnedItemType] = [] + var unlocked_bait_ids: Array[StringName] = [] var hotbar_slots: Array[StringName] = [] var fish_hotbar_slots: Array[StringName] = [] var selected_hotbar_slot: int = 0 @@ -258,7 +259,10 @@ func load_player_data() -> bool: var wallet_restored: bool = _wallet.restore_balance( snapshot.wallet_balance ) - var bag_restored: bool = _bag.replace_all_items(snapshot.bag_items) + var bag_restored: bool = ( + _bag.replace_all_items(snapshot.bag_items) + and _bag.replace_unlocked_bait_ids(snapshot.unlocked_bait_ids) + ) var hotbar_restored: bool = _hotbar.replace_state( snapshot.hotbar_slots, snapshot.selected_hotbar_slot, @@ -492,6 +496,12 @@ func _build_save_dictionary() -> Dictionary: if owned == null or not owned.is_valid(): return {} serialized_items.append(owned.to_save_dict()) + var serialized_unlocked_baits: Array[String] = [] + for item_id: StringName in _bag.get_unlocked_bait_ids(): + var item_data = _item_catalog.get_item_by_id(item_id) + if item_data == null or not item_data.is_valid() or not item_data.is_bait(): + return {} + serialized_unlocked_baits.append(String(item_id)) var serialized_slots: Array[String] = [] for item_id: StringName in _hotbar.get_slots(): serialized_slots.append(String(item_id)) @@ -524,6 +534,7 @@ func _build_save_dictionary() -> Dictionary: }, "bag": { "items": serialized_items, + "unlocked_bait_ids": serialized_unlocked_baits, }, "hotbar": { "selected_slot": _hotbar.get_selected_slot(), @@ -589,6 +600,10 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: or typeof(inventory_data.get("catches")) != TYPE_ARRAY or not inventory_data.has("next_catch_sequence") or typeof(bag_data.get("items")) != TYPE_ARRAY + or ( + bag_data.has("unlocked_bait_ids") + and typeof(bag_data.get("unlocked_bait_ids")) != TYPE_ARRAY + ) or typeof(hotbar_data.get("slots")) != TYPE_ARRAY or not hotbar_data.has("selected_slot") or not experience_data.has("total_experience") @@ -781,6 +796,34 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: seen_items[item_id] = true snapshot.bag_items.append(owned) + var seen_unlocked_baits: Dictionary[StringName, bool] = {} + if bag_data.has("unlocked_bait_ids"): + var unlocked_bait_values: Array = bag_data["unlocked_bait_ids"] + for value: Variant in unlocked_bait_values: + if typeof(value) not in [TYPE_STRING, TYPE_STRING_NAME]: + continue + var bait_id: StringName = StringName(str(value)) + var bait_data = _item_catalog.get_item_by_id(bait_id) + if ( + bait_id.is_empty() + or seen_unlocked_baits.has(bait_id) + or bait_data == null + or not bait_data.is_valid() + or not bait_data.is_bait() + ): + continue + seen_unlocked_baits[bait_id] = true + snapshot.unlocked_bait_ids.append(bait_id) + for owned: OwnedItemType in snapshot.bag_items: + var owned_item_data = _item_catalog.get_item_by_id(owned.item_id) + if ( + owned_item_data != null + and owned_item_data.is_bait() + and not seen_unlocked_baits.has(owned.item_id) + ): + seen_unlocked_baits[owned.item_id] = true + snapshot.unlocked_bait_ids.append(owned.item_id) + var slot_values: Array = hotbar_data["slots"] snapshot.hotbar_slots.resize(PlayerHotbarType.SLOT_COUNT) snapshot.hotbar_slots.fill(StringName()) @@ -1098,6 +1141,8 @@ func _restore_defaults() -> void: ) _wallet.restore_balance(0) _bag.replace_all_items(default_items) + var default_unlocked_baits: Array[StringName] = [] + _bag.replace_unlocked_bait_ids(default_unlocked_baits) _hotbar.replace_state(default_slots, 0) _fishing_upgrades.reset_to_defaults() _cooler_capacity.reset_to_defaults() diff --git a/ui/fishing_shop.gd b/ui/fishing_shop.gd index f984b77..563e692 100644 --- a/ui/fishing_shop.gd +++ b/ui/fishing_shop.gd @@ -423,34 +423,71 @@ func _refresh_supplies() -> void: button.icon = item.icon button.expand_icon = true button.alignment = HORIZONTAL_ALIGNMENT_LEFT + var owned: int = _bag.get_quantity(item_id) + var bait_topoff: bool = FishingShopStockType.is_bait_topoff(item_id) + var bait_unlocked: bool = ( + not bait_topoff or _bag.is_bait_unlocked(item_id) + ) + var quantity: int = FishingShopStockType.get_purchase_quantity( + item_id, owned, item.max_stack + ) + var total_cost: int = FishingShopStockType.get_purchase_cost( + item_id, quantity, bait_unlocked + ) button.text = "%s\n$%d • owned %d" % [ item.display_name, FishingShopStockType.get_price(item_id), - _bag.get_quantity(item_id), + owned, ] - if FishingShopStockType.is_bait_topoff(item_id): - button.text = "%s\n$%d each • %d/%d" % [ - item.display_name, - FishingShopStockType.get_price(item_id), - _bag.get_quantity(item_id), - FishingShopStockType.WORM_MAX_STACK, - ] - button.tooltip_text = item.description + var tooltip_text: String = item.description + if bait_topoff: + if item.icon != null: + button.custom_minimum_size = Vector2(72, 72) + button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + button.alignment = HORIZONTAL_ALIGNMENT_CENTER + button.text = "" + else: + button.text = ( + "%s\nunlock $%d" % [item.display_name, total_cost] + if not bait_unlocked + else "%s\n$%d each • %d/%d" % [ + item.display_name, + FishingShopStockType.get_price(item_id), + owned, + item.max_stack, + ] + ) + tooltip_text = ( + "%s\nunlock $%d • fills to %d/%d\n%s" % [ + item.display_name, + total_cost, + item.max_stack, + item.max_stack, + item.description, + ] + if not bait_unlocked + else "%s\n$%d each • %d/%d\n%s" % [ + item.display_name, + FishingShopStockType.get_price(item_id), + owned, + item.max_stack, + item.description, + ] + ) button.disabled = ( _transaction_in_progress or _closing or _network_shop == null or not _network_shop.can_request_purchase() - or FishingShopStockType.get_purchase_quantity(item_id, _bag.get_quantity(item_id)) <= 0 - or not _bag.can_add_item(item_id, FishingShopStockType.get_purchase_quantity(item_id, _bag.get_quantity(item_id))) - or not _wallet.can_afford( - FishingShopStockType.get_price(item_id) * FishingShopStockType.get_purchase_quantity(item_id, _bag.get_quantity(item_id)) - ) + or quantity <= 0 + or total_cost < 0 + or not _bag.can_add_item(item_id, quantity) + or not _wallet.can_afford(total_cost) ) button.tooltip_text = ( "Purchases are unavailable in this session." if _network_shop == null or not _network_shop.can_request_purchase() - else item.description + else tooltip_text ) UtilityPageStyleType.apply_ocean_button(button) button.pressed.connect(_purchase_supply.bind(item_id)) @@ -585,11 +622,20 @@ func _purchase_supply(item_id: StringName) -> void: _feedback.text = "unable to complete purchase." return var owned: int = _bag.get_quantity(item_id) - var quantity: int = FishingShopStockType.get_purchase_quantity(item_id, owned) + var item: ItemDataType = _item_catalog.get_item_by_id(item_id) + if item == null: + _feedback.text = "Purchase could not be completed." + return + var quantity: int = FishingShopStockType.get_purchase_quantity( + item_id, owned, item.max_stack + ) if quantity <= 0 or not _bag.can_add_item(item_id, quantity): _feedback.text = "Your Bag is full." return - if not _wallet.can_afford(FishingShopStockType.get_price(item_id) * quantity): + var total_cost: int = FishingShopStockType.get_purchase_cost( + item_id, quantity, _bag.is_bait_unlocked(item_id) + ) + if total_cost < 0 or not _wallet.can_afford(total_cost): _feedback.text = "Not enough fish coin." return if _network_shop == null: diff --git a/ui/player_menu.gd b/ui/player_menu.gd index 1fac21f..88ead8d 100644 --- a/ui/player_menu.gd +++ b/ui/player_menu.gd @@ -189,7 +189,7 @@ const SALE_CONFIRMATION_SIZE := Vector2(520.0, 190.0) @onready var _lures_filter: Button = %LuresFilter @onready var _tackle_empty: Label = %TackleEmpty @onready var _tackle_detail_text: Label = %TackleDetailText -@onready var _tackle_equip_button: Button = %TackleEquipButton +@onready var _tackle_equip_button: NotepadInkActionType = %TackleEquipButton @onready var _tackle_item_list: VBoxContainer = %TackleItemList @onready var _bag_outer_wall: PanelContainer = %BagOuterWall @onready var _bag_inner_liner: PanelContainer = %BagInnerLiner @@ -847,7 +847,7 @@ func mount_shop_cooler( _set_content_interactive(true) _update_cooler_water_mask() set_process(true) - call_deferred("_focus_shop_cooler") + _cooler_sort_option.call_deferred("grab_focus") return true @@ -1323,7 +1323,12 @@ func _refresh_tackle_box() -> void: child.queue_free() var matching_items: Array[OwnedItemType] = [] if _bag != null and _item_catalog != null: - for owned: OwnedItemType in _bag.get_all_items(): + var available_items: Array[OwnedItemType] = ( + _bag.get_unlocked_bait_items() + if _tackle_view == TackleView.BAIT + else _bag.get_all_items() + ) + for owned: OwnedItemType in available_items: var item: ItemDataType = _item_catalog.get_item_by_id( owned.item_id ) @@ -1343,12 +1348,23 @@ func _refresh_tackle_box() -> void: for owned: OwnedItemType in matching_items: var item: ItemDataType = _item_catalog.get_item_by_id(owned.item_id) var row := Button.new() - row.text = "%s ×%d" % [item.display_name, owned.quantity] row.icon = item.icon - row.alignment = HORIZONTAL_ALIGNMENT_LEFT + row.tooltip_text = "%s ×%d" % [item.display_name, owned.quantity] + if item.is_bait() and item.icon != null: + row.custom_minimum_size = Vector2(72, 72) + row.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + row.expand_icon = true + row.alignment = HORIZONTAL_ALIGNMENT_CENTER + else: + row.text = "%s ×%d" % [item.display_name, owned.quantity] + row.alignment = HORIZONTAL_ALIGNMENT_LEFT row.toggle_mode = true row.button_pressed = owned.item_id == _selected_tackle_item_id - UtilityPageStyle.apply_ocean_button(row) + if item.is_bait() and item.icon != null: + _apply_tackle_bait_button_style(row) + _add_tackle_quantity_badge(row, owned.quantity) + else: + UtilityPageStyle.apply_ocean_button(row) row.pressed.connect(_select_tackle_item.bind(owned.item_id)) _tackle_item_list.add_child(row) _tackle_empty.text = ( @@ -1360,6 +1376,68 @@ func _refresh_tackle_box() -> void: _update_tackle_detail() +func _add_tackle_quantity_badge(row: Button, quantity: int) -> void: + var badge := Panel.new() + badge.name = "QuantityBadge" + badge.anchor_left = 1.0 + badge.anchor_right = 1.0 + badge.offset_left = -27.0 + badge.offset_top = 3.0 + badge.offset_right = -3.0 + badge.offset_bottom = 27.0 + badge.mouse_filter = Control.MOUSE_FILTER_IGNORE + badge.z_index = 2 + var badge_style := StyleBoxFlat.new() + badge_style.bg_color = Color("0b5558") + badge_style.set_corner_radius_all(12) + badge_style.anti_aliasing = false + badge.add_theme_stylebox_override("panel", badge_style) + row.add_child(badge) + var quantity_label := Label.new() + quantity_label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + quantity_label.offset_left = 1.0 + quantity_label.offset_right = 1.0 + quantity_label.mouse_filter = Control.MOUSE_FILTER_IGNORE + quantity_label.text = str(quantity) + quantity_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + quantity_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + quantity_label.add_theme_font_override("font", UtilityPageStyle.TuffyFont) + quantity_label.add_theme_font_size_override( + "font_size", + 13 if quantity >= 100 else 15, + ) + quantity_label.add_theme_color_override("font_color", Color("e7f5f4")) + badge.add_child(quantity_label) + + +func _apply_tackle_bait_button_style(button: Button) -> void: + var profile := BubbleMenuProfile.new() + var normal_style: StyleBoxFlat = profile.make_normal_style() + var hover_style: StyleBoxFlat = profile.make_hover_style() + var pressed_style: StyleBoxFlat = profile.make_pressed_style() + var disabled_style: StyleBoxFlat = profile.make_disabled_style() + for style: StyleBoxFlat in [ + normal_style, + hover_style, + pressed_style, + disabled_style, + ]: + style.set_corner_radius_all(36) + style.content_margin_left = 13.5 + style.content_margin_top = 13.5 + style.content_margin_right = 13.5 + style.content_margin_bottom = 13.5 + button.add_theme_stylebox_override("normal", normal_style) + button.add_theme_stylebox_override("hover", hover_style) + button.add_theme_stylebox_override("focus", hover_style) + button.add_theme_stylebox_override("pressed", pressed_style) + button.add_theme_stylebox_override("disabled", disabled_style) + button.add_theme_color_override("icon_normal_color", Color.WHITE) + button.add_theme_color_override("icon_hover_color", Color.WHITE) + button.add_theme_color_override("icon_focus_color", Color.WHITE) + button.add_theme_color_override("icon_pressed_color", Color.WHITE) + + func _select_tackle_item(item_id: StringName) -> void: _selected_tackle_item_id = item_id _refresh_tackle_box() @@ -1373,6 +1451,7 @@ func _update_tackle_detail() -> void: ) if item == null: _tackle_equip_button.visible = false + _tackle_equip_button.persistent_mark = false _tackle_detail_text.text = ( "Select bait for details." if _tackle_view == TackleView.BAIT @@ -1387,28 +1466,28 @@ func _update_tackle_detail() -> void: if _hotbar.get_item_id(slot_index) == item.item_id: assigned_slot = slot_index break - var state_text: String = ( - "hotbar slot %d" % (assigned_slot + 1) - if assigned_slot >= 0 - else "not assigned" - ) - _tackle_detail_text.text = ( - "%s\n\nType: %s\nQuantity: %d\n%s\n\n%s" - % [ - item.display_name, - item.get_category_name(), - quantity, - state_text, - item.description, - ] - ) + var detail_lines: Array[String] = [ + item.display_name, + "", + "quantity: %d" % quantity, + ] + if assigned_slot >= 0: + detail_lines.append("hotbar slot %d" % (assigned_slot + 1)) + detail_lines.append("") + detail_lines.append(item.description) + _tackle_detail_text.text = "\n".join(detail_lines) if item.is_bait(): + var is_equipped: bool = ( + _player != null and _player.active_bait_id == item.item_id + ) _tackle_equip_button.text = ( "dequip %s" % item.display_name - if _player != null and _player.active_bait_id == item.item_id + if is_equipped else "equip %s" % item.display_name ) + _tackle_equip_button.persistent_mark = is_equipped _tackle_equip_button.disabled = quantity <= 0 + _tackle_equip_button.refresh_ink_state() func _toggle_active_bait() -> void: