From 49e7faaf8465d0b8dbdd158d0ed3b37d240939ba Mon Sep 17 00:00:00 2001 From: Voyager Date: Sat, 25 Jul 2026 20:12:29 -0400 Subject: [PATCH] Add Cooler, Bag, and compact hotbar systems --- fishing/fishing_spot.gd | 72 ++++++++-- inventory/player_bag.gd | 104 +++++++++++++++ inventory/player_bag.gd.uid | 1 + inventory/player_hotbar.gd | 171 ++++++++++++++++++++++++ inventory/player_hotbar.gd.uid | 1 + items/catalog/basic_fishing_rod.tres | 26 ++++ items/catalog/item_catalog.tres | 8 ++ items/item_catalog.gd | 30 +++++ items/item_catalog.gd.uid | 1 + items/item_data.gd | 36 +++++ items/item_data.gd.uid | 1 + items/owned_item.gd | 20 +++ items/owned_item.gd.uid | 1 + main/main.gd | 56 +++++++- main/main.tscn | 4 +- player/player.gd | 23 +++- player/player.tscn | 12 +- playtest/README-PLAYTEST.txt | 9 +- project.godot | 45 +++++++ save/player_save_manager.gd | 182 +++++++++++++++++++++++++- ui/game_ui.gd | 56 ++++++-- ui/game_ui.tscn | 29 +++-- ui/hotbar.gd | 185 ++++++++++++++++++++++++++ ui/hotbar.gd.uid | 1 + ui/hotbar.tscn | 74 +++++++++++ ui/hotbar_slot.gd | 187 ++++++++++++++++++++++++++ ui/hotbar_slot.gd.uid | 1 + ui/item_drag_source.gd | 46 +++++++ ui/item_drag_source.gd.uid | 1 + ui/pause_menu.gd | 3 + ui/player_menu.gd | 138 ++++++++++++++++++-- ui/player_menu.tscn | 188 +++++++++++++++++++++------ 32 files changed, 1613 insertions(+), 99 deletions(-) create mode 100644 inventory/player_bag.gd create mode 100644 inventory/player_bag.gd.uid create mode 100644 inventory/player_hotbar.gd create mode 100644 inventory/player_hotbar.gd.uid create mode 100644 items/catalog/basic_fishing_rod.tres create mode 100644 items/catalog/item_catalog.tres create mode 100644 items/item_catalog.gd create mode 100644 items/item_catalog.gd.uid create mode 100644 items/item_data.gd create mode 100644 items/item_data.gd.uid create mode 100644 items/owned_item.gd create mode 100644 items/owned_item.gd.uid create mode 100644 ui/hotbar.gd create mode 100644 ui/hotbar.gd.uid create mode 100644 ui/hotbar.tscn create mode 100644 ui/hotbar_slot.gd create mode 100644 ui/hotbar_slot.gd.uid create mode 100644 ui/item_drag_source.gd create mode 100644 ui/item_drag_source.gd.uid diff --git a/fishing/fishing_spot.gd b/fishing/fishing_spot.gd index f49bb77..ef7bfae 100644 --- a/fishing/fishing_spot.gd +++ b/fishing/fishing_spot.gd @@ -9,6 +9,10 @@ const CatchControllerType = preload("res://fishing/catch_controller.gd") const FishingContextType = preload("res://fishing/fishing_context.gd") const FishingPresentationType = preload("res://fishing/fishing_presentation.gd") const FishInventoryType = preload("res://inventory/fish_inventory.gd") +const ItemCatalogType = preload("res://items/item_catalog.gd") +const ItemDataType = preload("res://items/item_data.gd") +const PlayerBagType = preload("res://inventory/player_bag.gd") +const PlayerHotbarType = preload("res://inventory/player_hotbar.gd") const PlayerType = preload("res://player/player.gd") const FishableWaterRegionType = preload( "res://world/fishable_water_region.gd" @@ -31,6 +35,7 @@ signal showcase_changed( visible: bool, ) signal bite_activated +signal ready_for_equipment_refresh enum FishingState { READY, @@ -84,6 +89,9 @@ var _local_menu_input_owners: Dictionary[StringName, bool] = {} var _local_player: PlayerType var _local_inventory: FishInventoryType var _local_collection_log: CollectionLogType +var _local_bag: PlayerBagType +var _local_hotbar: PlayerHotbarType +var _item_catalog: ItemCatalogType var _active_player: PlayerType var _state_time_remaining: float = 0.0 var _cast_charge: float = 0.0 @@ -121,10 +129,16 @@ func setup( local_player: PlayerType, local_inventory: FishInventoryType, local_collection_log: CollectionLogType, + local_bag: PlayerBagType, + local_hotbar: PlayerHotbarType, + item_catalog: ItemCatalogType, ) -> void: _local_player = local_player _local_inventory = local_inventory _local_collection_log = local_collection_log + _local_bag = local_bag + _local_hotbar = local_hotbar + _item_catalog = item_catalog func can_open_player_menu() -> bool: @@ -151,6 +165,15 @@ func can_open_system_menu() -> bool: ) +func can_change_hotbar_selection() -> bool: + return ( + _gameplay_input_enabled + and not _external_input_blocked + and _local_menu_input_owners.is_empty() + and state == FishingState.READY + ) + + func handle_escape_action() -> bool: if not _gameplay_input_enabled or _external_input_blocked: return false @@ -301,6 +324,10 @@ func _unhandled_input(event: InputEvent) -> void: FishingState.READY: if not _new_cast_press_armed: return + if not has_active_fishing_rod(): + status_changed.emit("Select a fishing rod to cast.") + get_viewport().set_input_as_handled() + return _begin_aiming(_local_player) FishingState.WAITING_FOR_BITE: _withdrawal_input_held = true @@ -354,6 +381,35 @@ func _begin_aiming(player: PlayerType) -> void: ) +func has_active_fishing_rod() -> bool: + if ( + _local_bag == null + or _local_hotbar == null + or _item_catalog == null + ): + return false + var item_id: StringName = _local_hotbar.get_selected_item_id() + if item_id.is_empty() or not _local_bag.owns_item(item_id): + return false + var item: ItemDataType = _item_catalog.get_item_by_id(item_id) + return ( + item != null + and item.is_valid() + and item.category == ItemDataType.Category.ROD + and item.usable + and item.equippable + ) + + +func is_fighting() -> bool: + return state == FishingState.FIGHTING + + +func refresh_active_item_status() -> void: + if state == FishingState.READY and has_active_fishing_rod(): + status_changed.emit("") + + func _update_cast_charge(delta: float) -> void: if state != FishingState.AIMING_CAST: return @@ -581,20 +637,6 @@ func _on_catch_encounter_updated( _bobber_water_position, Input.is_action_pressed("fish_primary") ) - if ( - active_barrier_index >= 0 - and active_barrier_index < barrier_health.size() - and active_barrier_index < barrier_max_health.size() - ): - status_changed.emit( - "Barrier: %d / %d" - % [ - barrier_health[active_barrier_index], - barrier_max_health[active_barrier_index], - ] - ) - else: - status_changed.emit("Hold left click to reel") func _on_catch_completed() -> void: @@ -731,6 +773,7 @@ func _cleanup_attempt( state = FishingState.READY _cooldown_status = "" status_changed.emit("") + ready_for_equipment_refresh.emit() else: state = FishingState.COOLDOWN _state_time_remaining = cooldown_duration @@ -743,6 +786,7 @@ func _return_to_ready() -> void: _state_time_remaining = 0.0 _cooldown_status = "" status_changed.emit("") + ready_for_equipment_refresh.emit() func _cancel_attempt() -> void: diff --git a/inventory/player_bag.gd b/inventory/player_bag.gd new file mode 100644 index 0000000..0d6112a --- /dev/null +++ b/inventory/player_bag.gd @@ -0,0 +1,104 @@ +class_name PlayerBag +extends Node + +const ItemCatalogType = preload("res://items/item_catalog.gd") +const ItemDataType = preload("res://items/item_data.gd") +const OwnedItemType = preload("res://items/owned_item.gd") + +signal contents_changed + +var _catalog: ItemCatalogType +var _items: Array[OwnedItemType] = [] + + +func setup(catalog: ItemCatalogType) -> void: + _catalog = catalog + + +func add_item(item_id: StringName, quantity: int = 1) -> bool: + var item: ItemDataType = _resolve_valid_item(item_id) + if item == null or quantity <= 0: + return false + var existing: OwnedItemType = get_owned_item(item_id) + if existing != null: + if not item.stackable: + return false + if existing.quantity + quantity > item.max_stack: + return false + existing.quantity += quantity + else: + if quantity > (item.max_stack if item.stackable else 1): + return false + var owned := OwnedItemType.new() + owned.item_id = item_id + owned.quantity = quantity + _items.append(owned) + contents_changed.emit() + return true + + +func remove_item(item_id: StringName, quantity: int = 1) -> bool: + if quantity <= 0: + return false + for index: int in range(_items.size()): + var owned: OwnedItemType = _items[index] + if owned.item_id != item_id or owned.quantity < quantity: + continue + owned.quantity -= quantity + if owned.quantity == 0: + _items.remove_at(index) + contents_changed.emit() + return true + return false + + +func get_quantity(item_id: StringName) -> int: + var owned: OwnedItemType = get_owned_item(item_id) + return owned.quantity if owned != null else 0 + + +func owns_item(item_id: StringName) -> bool: + return get_quantity(item_id) > 0 + + +func get_owned_item(item_id: StringName) -> OwnedItemType: + if item_id.is_empty(): + return null + for owned: OwnedItemType in _items: + if owned != null and owned.item_id == item_id: + return owned + return null + + +func get_all_items() -> Array[OwnedItemType]: + var result: Array[OwnedItemType] = [] + for owned: OwnedItemType in _items: + if owned != null: + result.append(owned.duplicate_record()) + return result + + +func replace_all_items(items: Array[OwnedItemType]) -> bool: + var validated: Array[OwnedItemType] = [] + var seen: Dictionary[StringName, bool] = {} + for owned: OwnedItemType in items: + if owned == null or not owned.is_valid() or seen.has(owned.item_id): + return false + var item: ItemDataType = _resolve_valid_item(owned.item_id) + if item == null: + return false + var maximum: int = item.max_stack if item.stackable else 1 + if owned.quantity > maximum: + return false + seen[owned.item_id] = true + validated.append(owned.duplicate_record()) + _items = validated + contents_changed.emit() + return true + + +func _resolve_valid_item(item_id: StringName) -> ItemDataType: + if _catalog == null: + return null + var item: ItemDataType = _catalog.get_item_by_id(item_id) + return item if item != null and item.is_valid() else null diff --git a/inventory/player_bag.gd.uid b/inventory/player_bag.gd.uid new file mode 100644 index 0000000..7d0fc94 --- /dev/null +++ b/inventory/player_bag.gd.uid @@ -0,0 +1 @@ +uid://c2033c62stp2d diff --git a/inventory/player_hotbar.gd b/inventory/player_hotbar.gd new file mode 100644 index 0000000..94cbb79 --- /dev/null +++ b/inventory/player_hotbar.gd @@ -0,0 +1,171 @@ +class_name PlayerHotbar +extends Node + +const SLOT_COUNT: int = 9 +const ItemCatalogType = preload("res://items/item_catalog.gd") +const ItemDataType = preload("res://items/item_data.gd") +const PlayerBagType = preload("res://inventory/player_bag.gd") + +signal slots_changed +signal selected_slot_changed(slot_index: int, item_id: StringName) + +var _bag: PlayerBagType +var _catalog: ItemCatalogType +var _slots: Array[StringName] = [] +var _selected_slot: int = 0 + + +func _init() -> void: + _slots.resize(SLOT_COUNT) + _slots.fill(StringName()) + + +func setup(bag: PlayerBagType, catalog: ItemCatalogType) -> void: + _bag = bag + _catalog = catalog + if _bag != null and not _bag.contents_changed.is_connected( + _on_bag_contents_changed + ): + _bag.contents_changed.connect(_on_bag_contents_changed) + _validate_assignments() + + +func assign_item(slot_index: int, item_id: StringName) -> bool: + if not _is_slot_valid(slot_index) or not _can_assign(item_id): + return false + if _slots[slot_index] == item_id: + return true + for index: int in range(SLOT_COUNT): + if index != slot_index and _slots[index] == item_id: + _slots[index] = StringName() + _slots[slot_index] = item_id + slots_changed.emit() + if slot_index == _selected_slot: + selected_slot_changed.emit(_selected_slot, item_id) + return true + + +func clear_slot(slot_index: int) -> bool: + if not _is_slot_valid(slot_index) or _slots[slot_index].is_empty(): + return false + _slots[slot_index] = StringName() + slots_changed.emit() + if slot_index == _selected_slot: + selected_slot_changed.emit(_selected_slot, StringName()) + return true + + +func swap_slots(from_index: int, to_index: int) -> bool: + if ( + not _is_slot_valid(from_index) + or not _is_slot_valid(to_index) + or from_index == to_index + ): + return false + var temporary: StringName = _slots[from_index] + _slots[from_index] = _slots[to_index] + _slots[to_index] = temporary + slots_changed.emit() + if from_index == _selected_slot or to_index == _selected_slot: + selected_slot_changed.emit( + _selected_slot, + _slots[_selected_slot] + ) + return true + + +func select_slot(slot_index: int) -> bool: + if not _is_slot_valid(slot_index): + return false + if _selected_slot == slot_index: + return true + _selected_slot = slot_index + selected_slot_changed.emit(_selected_slot, _slots[_selected_slot]) + return true + + +func cycle_selection(direction: int) -> bool: + if direction == 0: + return false + return select_slot( + posmod(_selected_slot + signi(direction), SLOT_COUNT) + ) + + +func get_selected_slot() -> int: + return _selected_slot + + +func get_selected_item_id() -> StringName: + return _slots[_selected_slot] + + +func get_item_id(slot_index: int) -> StringName: + return _slots[slot_index] if _is_slot_valid(slot_index) else StringName() + + +func get_slots() -> Array[StringName]: + return _slots.duplicate() + + +func replace_state( + slots: Array[StringName], + selected_slot: int, +) -> bool: + var normalized: Array[StringName] = [] + normalized.resize(SLOT_COUNT) + normalized.fill(StringName()) + var seen_assignments: Dictionary[StringName, bool] = {} + for index: int in range(mini(slots.size(), SLOT_COUNT)): + var item_id: StringName = slots[index] + if item_id.is_empty(): + continue + if _can_assign(item_id) and not seen_assignments.has(item_id): + normalized[index] = item_id + seen_assignments[item_id] = true + _slots = normalized + _selected_slot = clampi(selected_slot, 0, SLOT_COUNT - 1) + slots_changed.emit() + selected_slot_changed.emit( + _selected_slot, + _slots[_selected_slot] + ) + return true + + +func has_any_assignment() -> bool: + for item_id: StringName in _slots: + if not item_id.is_empty(): + return true + return false + + +func _can_assign(item_id: StringName) -> bool: + if item_id.is_empty() or _bag == null or not _bag.owns_item(item_id): + return false + if _catalog == null: + return false + var item: ItemDataType = _catalog.get_item_by_id(item_id) + return item != null and item.is_valid() and item.hotbar_allowed + + +func _validate_assignments() -> void: + var changed: bool = false + for index: int in range(SLOT_COUNT): + if not _slots[index].is_empty() and not _can_assign(_slots[index]): + _slots[index] = StringName() + changed = true + if changed: + slots_changed.emit() + selected_slot_changed.emit( + _selected_slot, + _slots[_selected_slot] + ) + + +func _on_bag_contents_changed() -> void: + _validate_assignments() + + +func _is_slot_valid(slot_index: int) -> bool: + return slot_index >= 0 and slot_index < SLOT_COUNT diff --git a/inventory/player_hotbar.gd.uid b/inventory/player_hotbar.gd.uid new file mode 100644 index 0000000..4648c9f --- /dev/null +++ b/inventory/player_hotbar.gd.uid @@ -0,0 +1 @@ +uid://bl60rgr4hqxdc diff --git a/items/catalog/basic_fishing_rod.tres b/items/catalog/basic_fishing_rod.tres new file mode 100644 index 0000000..ed55f40 --- /dev/null +++ b/items/catalog/basic_fishing_rod.tres @@ -0,0 +1,26 @@ +[gd_resource type="Resource" script_class="ItemData" load_steps=4 format=3] + +[ext_resource type="Script" path="res://items/item_data.gd" id="1_script"] + +[sub_resource type="Gradient" id="Gradient_rod"] +colors = PackedColorArray(0.302, 0.184, 0.11, 1, 0.957, 0.827, 0.369, 1, 0.439, 0.839, 0.82, 1) + +[sub_resource type="GradientTexture2D" id="GradientTexture_rod"] +gradient = SubResource("Gradient_rod") +width = 128 +height = 128 +fill_from = Vector2(0.15, 0.85) +fill_to = Vector2(0.85, 0.15) + +[resource] +script = ExtResource("1_script") +item_id = &"basic_fishing_rod" +display_name = "Basic Fishing Rod" +description = "A dependable starter rod. Select it to cast." +category = 0 +icon = SubResource("GradientTexture_rod") +stackable = false +max_stack = 1 +usable = true +equippable = true +hotbar_allowed = true diff --git a/items/catalog/item_catalog.tres b/items/catalog/item_catalog.tres new file mode 100644 index 0000000..f4e3986 --- /dev/null +++ b/items/catalog/item_catalog.tres @@ -0,0 +1,8 @@ +[gd_resource type="Resource" script_class="ItemCatalog" load_steps=3 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"] + +[resource] +script = ExtResource("1_script") +items = [ExtResource("2_rod")] diff --git a/items/item_catalog.gd b/items/item_catalog.gd new file mode 100644 index 0000000..b4245a0 --- /dev/null +++ b/items/item_catalog.gd @@ -0,0 +1,30 @@ +class_name ItemCatalog +extends Resource + +const ItemDataType = preload("res://items/item_data.gd") + +@export var items: Array[ItemDataType] = [] + + +func get_item_by_id(item_id: StringName) -> ItemDataType: + if item_id.is_empty(): + return null + for item: ItemDataType in items: + if item != null and item.item_id == item_id: + return item + return null + + +func get_valid_items() -> Array[ItemDataType]: + var result: Array[ItemDataType] = [] + var seen: Dictionary[StringName, bool] = {} + for item: ItemDataType in items: + if ( + item == null + or not item.is_valid() + or seen.has(item.item_id) + ): + continue + seen[item.item_id] = true + result.append(item) + return result diff --git a/items/item_catalog.gd.uid b/items/item_catalog.gd.uid new file mode 100644 index 0000000..e85f2b0 --- /dev/null +++ b/items/item_catalog.gd.uid @@ -0,0 +1 @@ +uid://citlop0eiu8rx diff --git a/items/item_data.gd b/items/item_data.gd new file mode 100644 index 0000000..e487ca5 --- /dev/null +++ b/items/item_data.gd @@ -0,0 +1,36 @@ +class_name ItemData +extends Resource + +enum Category { + ROD, + TOOL, + BAIT, + CONSUMABLE, + UTILITY, + QUEST, + COSMETIC, +} + +@export var item_id: StringName +@export var display_name: String +@export_multiline var description: String +@export var category: Category = Category.UTILITY +@export var icon: Texture2D +@export var stackable: bool = false +@export_range(1, 999, 1) var max_stack: int = 1 +@export var usable: bool = false +@export var equippable: bool = false +@export var hotbar_allowed: bool = false + + +func is_valid() -> bool: + return ( + not item_id.is_empty() + and not display_name.strip_edges().is_empty() + and max_stack >= 1 + and (stackable or max_stack == 1) + ) + + +func get_category_name() -> String: + return Category.keys()[category].capitalize() diff --git a/items/item_data.gd.uid b/items/item_data.gd.uid new file mode 100644 index 0000000..566debf --- /dev/null +++ b/items/item_data.gd.uid @@ -0,0 +1 @@ +uid://cah5thda5aohb diff --git a/items/owned_item.gd b/items/owned_item.gd new file mode 100644 index 0000000..ead5b34 --- /dev/null +++ b/items/owned_item.gd @@ -0,0 +1,20 @@ +class_name OwnedItem +extends Resource + +@export var item_id: StringName +@export var quantity: int = 1 + + +func is_valid() -> bool: + return not item_id.is_empty() and quantity > 0 + + +func duplicate_record(): + return duplicate(true) + + +func to_save_dict() -> Dictionary: + return { + "item_id": String(item_id), + "quantity": quantity, + } diff --git a/items/owned_item.gd.uid b/items/owned_item.gd.uid new file mode 100644 index 0000000..a32a977 --- /dev/null +++ b/items/owned_item.gd.uid @@ -0,0 +1 @@ +uid://c7julskwitfxi diff --git a/main/main.gd b/main/main.gd index ee28cd3..3b6ab5f 100644 --- a/main/main.gd +++ b/main/main.gd @@ -18,9 +18,12 @@ const PlayerSettingsManagerType = preload( const PlayerSettingsType = preload("res://settings/player_settings.gd") const TitleScreenType = preload("res://ui/title_screen.gd") const PauseMenuType = preload("res://ui/pause_menu.gd") +const ItemCatalogType = preload("res://items/item_catalog.gd") +const ItemDataType = preload("res://items/item_data.gd") @export var fish_catalog: FishPoolType @export var pelican_buyer_profile: FishBuyerProfileType +@export var item_catalog: ItemCatalogType @onready var _test_world: TestWorldType = $TestWorld @onready var _player: PlayerType = %Player @@ -38,17 +41,25 @@ func _ready() -> void: _player.inventory, _player.wallet ) + _player.bag.setup(item_catalog) + _player.hotbar.setup(_player.bag, item_catalog) _save_manager.setup( _player.inventory, _player.collection_log, _player.wallet, - fish_catalog + fish_catalog, + _player.bag, + _player.hotbar, + item_catalog ) _save_manager.set_autosave_enabled(false) _fishing_spot.setup( _player, _player.inventory, - _player.collection_log + _player.collection_log, + _player.bag, + _player.hotbar, + item_catalog ) _game_ui.setup( _player, @@ -58,7 +69,10 @@ func _ready() -> void: _player.fish_sale_service, pelican_buyer_profile, fish_catalog, - _fishing_spot + _fishing_spot, + _player.bag, + _player.hotbar, + item_catalog ) _water_recovery.setup( _player, @@ -93,9 +107,22 @@ func _ready() -> void: _on_reset_progress_requested ) pause_menu.quit_requested.connect(_on_quit_requested) + pause_menu.menu_visibility_changed.connect( + _game_ui.set_system_menu_open + ) + _player.hotbar.selected_slot_changed.connect( + _on_active_hotbar_item_changed + ) + _fishing_spot.ready_for_equipment_refresh.connect( + _refresh_active_hotbar_item + ) _water_recovery.recovery_starting.connect( _on_water_recovery_starting ) + _on_active_hotbar_item_changed( + _player.hotbar.get_selected_slot(), + _player.hotbar.get_selected_item_id() + ) func _input(event: InputEvent) -> void: @@ -182,6 +209,29 @@ func _on_water_recovery_starting() -> void: _game_ui.get_pause_menu().close_for_water_recovery() +func _on_active_hotbar_item_changed( + _slot_index: int, + item_id: StringName, +) -> void: + if _fishing_spot.state != FishingSpotType.FishingState.READY: + return + var item = item_catalog.get_item_by_id(item_id) + var active_is_rod: bool = ( + item != null + and item.category == ItemDataType.Category.ROD + and _player.bag.owns_item(item_id) + ) + _player.set_active_item_is_rod(active_is_rod) + _fishing_spot.refresh_active_item_status() + + +func _refresh_active_hotbar_item() -> void: + _on_active_hotbar_item_changed( + _player.hotbar.get_selected_slot(), + _player.hotbar.get_selected_item_id() + ) + + func _on_quit_requested() -> void: _settings_manager.save_if_dirty() if _gameplay_started: diff --git a/main/main.tscn b/main/main.tscn index 8784651..a5532bb 100644 --- a/main/main.tscn +++ b/main/main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=11 format=3] +[gd_scene load_steps=12 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"] @@ -10,11 +10,13 @@ [ext_resource type="Script" path="res://world/water_recovery_controller.gd" id="8_recovery"] [ext_resource type="Script" path="res://save/player_save_manager.gd" id="9_save"] [ext_resource type="Script" path="res://settings/player_settings_manager.gd" id="10_settings"] +[ext_resource type="Resource" path="res://items/catalog/item_catalog.tres" id="11_items"] [node name="Main" type="Node3D"] script = ExtResource("3_main") fish_catalog = ExtResource("6_pool") pelican_buyer_profile = ExtResource("7_pelicans") +item_catalog = ExtResource("11_items") [node name="TestWorld" parent="." instance=ExtResource("1_world")] diff --git a/player/player.gd b/player/player.gd index 4f04f2d..9c60196 100644 --- a/player/player.gd +++ b/player/player.gd @@ -6,6 +6,8 @@ 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") +const PlayerBagType = preload("res://inventory/player_bag.gd") +const PlayerHotbarType = preload("res://inventory/player_hotbar.gd") class ShowcaseCameraSnapshot: extends RefCounted @@ -67,6 +69,8 @@ class ShowcaseCameraSnapshot: @onready var collection_log: CollectionLogType = %CollectionLog @onready var wallet: PlayerWalletType = %Wallet @onready var fish_sale_service: FishSaleServiceType = %FishSaleService +@onready var bag: PlayerBagType = %Bag +@onready var hotbar: PlayerHotbarType = %Hotbar @onready var _cast_origin: Marker3D = %CastOrigin @onready var _fishing_rod: Node3D = %FishingRod @onready var _fishing_rod_tip: Marker3D = %FishingRodTip @@ -198,10 +202,18 @@ func _unhandled_input(event: InputEvent) -> void: get_viewport().set_input_as_handled() return - if event.is_action_pressed("camera_zoom_in"): + if ( + event is InputEventMouseButton + and event.shift_pressed + and event.is_action_pressed("camera_zoom_in") + ): _set_target_zoom(_target_zoom - zoom_step) get_viewport().set_input_as_handled() - elif event.is_action_pressed("camera_zoom_out"): + elif ( + event is InputEventMouseButton + and event.shift_pressed + and event.is_action_pressed("camera_zoom_out") + ): _set_target_zoom(_target_zoom + zoom_step) get_viewport().set_input_as_handled() @@ -298,6 +310,13 @@ func get_fishing_rod() -> Node3D: return _fishing_rod +func set_active_item_is_rod(active_is_rod: bool) -> void: + if _showcase_rod_state_stored: + _showcase_rod_visibility = active_is_rod + else: + _fishing_rod.visible = active_is_rod + + func get_cast_origin_position() -> Vector3: return _cast_origin.global_position diff --git a/player/player.tscn b/player/player.tscn index ec27122..b592afd 100644 --- a/player/player.tscn +++ b/player/player.tscn @@ -1,10 +1,12 @@ -[gd_scene load_steps=13 format=3] +[gd_scene load_steps=15 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"] +[ext_resource type="Script" path="res://inventory/player_bag.gd" id="6_bag"] +[ext_resource type="Script" path="res://inventory/player_hotbar.gd" id="7_hotbar"] [sub_resource type="CapsuleShape3D" id="PlayerShape"] radius = 0.45 @@ -88,6 +90,14 @@ script = ExtResource("4_wallet") unique_name_in_owner = true script = ExtResource("5_sale_service") +[node name="Bag" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("6_bag") + +[node name="Hotbar" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("7_hotbar") + [node name="CatchDisplayAnchor" type="Marker3D" parent="Visuals"] unique_name_in_owner = true position = Vector3(0, 1.45, -1.15) diff --git a/playtest/README-PLAYTEST.txt b/playtest/README-PLAYTEST.txt index 03f5938..f8aab06 100644 --- a/playtest/README-PLAYTEST.txt +++ b/playtest/README-PLAYTEST.txt @@ -29,9 +29,11 @@ Sprint Shift Sneak Ctrl Slow walk Alt Rotate camera Hold right mouse, or use right stick -Zoom camera Mouse wheel +Cycle active hotbar slot Mouse wheel +Select hotbar slot 1 through 9 +Zoom camera Shift + mouse wheel Cast / withdraw / reel Left mouse -Inventory and Logbook Tab +Cooler, Bag, and Logbook Tab Game Menu / back Escape FISHING @@ -54,7 +56,8 @@ FEATURES TO TRY - Barrier-and-chase catching - Accessibility auto-click settings - Catch showcase and fish size variation -- Inventory, Logbook, favorites, and sorting +- Cooler, Bag, Logbook, favorites, and sorting +- Basic Fishing Rod equipment and the 1–9 hotbar - Pelican selling and wallet updates - Save, Continue, New Game, and Delete Save - Game Menu and persistent camera settings diff --git a/project.godot b/project.godot index 7278211..dd2dfd5 100644 --- a/project.godot +++ b/project.godot @@ -92,6 +92,51 @@ camera_zoom_out={ "events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":32,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":5,"canceled":false,"pressed":false,"double_click":false,"script":null) ] } +hotbar_1={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":16,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":49,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +hotbar_2={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":16,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":50,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +hotbar_3={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":16,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":51,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +hotbar_4={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":16,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":52,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +hotbar_5={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":16,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":53,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +hotbar_6={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":16,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":54,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +hotbar_7={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":16,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":55,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +hotbar_8={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":16,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":56,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +hotbar_9={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":16,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":57,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} open_backpack={ "deadzone": 0.2, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":16,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194306,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) diff --git a/save/player_save_manager.gd b/save/player_save_manager.gd index f0a2a53..848ea6c 100644 --- a/save/player_save_manager.gd +++ b/save/player_save_manager.gd @@ -7,8 +7,13 @@ const CollectionLogType = preload("res://collection/collection_log.gd") const PlayerWalletType = preload("res://economy/player_wallet.gd") const FishPoolType = preload("res://fish/fish_pool.gd") const SaveInspectionType = preload("res://save/player_save_inspection.gd") +const ItemCatalogType = preload("res://items/item_catalog.gd") +const OwnedItemType = preload("res://items/owned_item.gd") +const PlayerBagType = preload("res://inventory/player_bag.gd") +const PlayerHotbarType = preload("res://inventory/player_hotbar.gd") -const SAVE_VERSION: int = 1 +const SAVE_VERSION: int = 2 +const BASIC_ROD_ID: StringName = &"basic_fishing_rod" const SAVE_PATH: String = "user://player_save.json" const TEMP_PATH: String = "user://player_save.json.tmp" const BACKUP_PATH: String = "user://player_save.json.backup" @@ -21,6 +26,9 @@ class LoadSnapshot: var discovered_ids: Array[StringName] = [] var wallet_balance: int = 0 var next_catch_sequence: int = 1 + var bag_items: Array[OwnedItemType] = [] + var hotbar_slots: Array[StringName] = [] + var selected_hotbar_slot: int = 0 @export_range(0.05, 5.0, 0.05) var autosave_delay: float = 0.5 @@ -29,6 +37,9 @@ var _inventory: FishInventoryType var _collection_log: CollectionLogType var _wallet: PlayerWalletType var _catalog: FishPoolType +var _bag: PlayerBagType +var _hotbar: PlayerHotbarType +var _item_catalog: ItemCatalogType var _autosave_timer: Timer var _is_configured: bool = false var _is_restoring: bool = false @@ -49,16 +60,25 @@ func setup( collection_log: CollectionLogType, wallet: PlayerWalletType, catalog: FishPoolType, + bag: PlayerBagType, + hotbar: PlayerHotbarType, + item_catalog: ItemCatalogType, ) -> void: _inventory = inventory _collection_log = collection_log _wallet = wallet _catalog = catalog + _bag = bag + _hotbar = hotbar + _item_catalog = item_catalog _is_configured = ( _inventory != null and _collection_log != null and _wallet != null and _catalog != null + and _bag != null + and _hotbar != null + and _item_catalog != null ) if not _is_configured: push_error("PlayerSaveManager setup is missing required references.") @@ -71,6 +91,16 @@ func setup( _collection_log.fish_discovered.connect(_on_fish_discovered) if not _wallet.balance_changed.is_connected(_on_balance_changed): _wallet.balance_changed.connect(_on_balance_changed) + if not _bag.contents_changed.is_connected(_mark_dirty): + _bag.contents_changed.connect(_mark_dirty) + if not _hotbar.slots_changed.is_connected(_mark_dirty): + _hotbar.slots_changed.connect(_mark_dirty) + if not _hotbar.selected_slot_changed.is_connected( + _on_selected_hotbar_slot_changed + ): + _hotbar.selected_slot_changed.connect( + _on_selected_hotbar_slot_changed + ) func load_player_data() -> bool: @@ -131,11 +161,18 @@ 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 hotbar_restored: bool = _hotbar.replace_state( + snapshot.hotbar_slots, + snapshot.selected_hotbar_slot + ) _is_restoring = false if ( not inventory_restored or not collection_restored or not wallet_restored + or not bag_restored + or not hotbar_restored ): push_error("Validated player save could not be restored.") return false @@ -174,10 +211,16 @@ func inspect_save() -> SaveInspectionType: result.status = SaveInspectionType.Status.UNSUPPORTED_VERSION result.message = "This save belongs to a newer game version." return result - if result.detected_version != SAVE_VERSION: + if result.detected_version < 1: result.status = SaveInspectionType.Status.MALFORMED result.message = "The save version is unsupported." return result + if result.detected_version != SAVE_VERSION: + save_data = _migrate_save(save_data, result.detected_version) + if save_data.is_empty(): + result.status = SaveInspectionType.Status.MALFORMED + result.message = "The save version is unsupported." + return result var snapshot: LoadSnapshot = _build_load_snapshot(save_data) if snapshot == null: result.status = SaveInspectionType.Status.MALFORMED @@ -300,6 +343,14 @@ func _build_save_dictionary() -> Dictionary: if fish_id.is_empty(): return {} discovered_strings.append(String(fish_id)) + var serialized_items: Array[Dictionary] = [] + for owned: OwnedItemType in _bag.get_all_items(): + if owned == null or not owned.is_valid(): + return {} + serialized_items.append(owned.to_save_dict()) + var serialized_slots: Array[String] = [] + for item_id: StringName in _hotbar.get_slots(): + serialized_slots.append(String(item_id)) if ( _wallet.get_balance() < 0 or _wallet.get_balance() > MAX_SAFE_BALANCE @@ -323,6 +374,13 @@ func _build_save_dictionary() -> Dictionary: "next_catch_sequence": _inventory.get_next_catch_sequence(), "catches": serialized_catches, }, + "bag": { + "items": serialized_items, + }, + "hotbar": { + "selected_slot": _hotbar.get_selected_slot(), + "slots": serialized_slots, + }, } @@ -331,16 +389,23 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: typeof(save_data.get("wallet")) != TYPE_DICTIONARY or typeof(save_data.get("collection")) != TYPE_DICTIONARY or typeof(save_data.get("inventory")) != TYPE_DICTIONARY + or typeof(save_data.get("bag")) != TYPE_DICTIONARY + or typeof(save_data.get("hotbar")) != TYPE_DICTIONARY ): return null var wallet_data: Dictionary = save_data["wallet"] var collection_data: Dictionary = save_data["collection"] var inventory_data: Dictionary = save_data["inventory"] + var bag_data: Dictionary = save_data["bag"] + var hotbar_data: Dictionary = save_data["hotbar"] if ( not wallet_data.has("balance") or typeof(collection_data.get("discovered_fish_ids")) != TYPE_ARRAY 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 typeof(hotbar_data.get("slots")) != TYPE_ARRAY + or not hotbar_data.has("selected_slot") ): return null @@ -424,6 +489,81 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: requested_next_sequence, maximum_sequence + 1 ) + + var seen_items: Dictionary[StringName, bool] = {} + var item_values: Array = bag_data["items"] + for item_index: int in range(item_values.size()): + var value: Variant = item_values[item_index] + if typeof(value) != TYPE_DICTIONARY: + push_warning("Skipped invalid saved Bag item.") + continue + var item_record: Dictionary = value + var item_id: StringName = StringName( + str(item_record.get("item_id", "")) + ) + var item_data = _item_catalog.get_item_by_id(item_id) + var quantity: int = _read_integer( + item_record.get("quantity"), + -1, + 999 + ) + if ( + item_id.is_empty() + or item_data == null + or not item_data.is_valid() + or quantity <= 0 + or seen_items.has(item_id) + ): + push_warning( + "Skipped invalid or unknown saved Bag item '%s'." + % String(item_id) + ) + continue + var maximum: int = ( + item_data.max_stack if item_data.stackable else 1 + ) + if quantity > maximum: + push_warning( + "Skipped saved Bag item with invalid quantity '%s'." + % String(item_id) + ) + continue + var owned := OwnedItemType.new() + owned.item_id = item_id + owned.quantity = quantity + seen_items[item_id] = true + snapshot.bag_items.append(owned) + + var slot_values: Array = hotbar_data["slots"] + snapshot.hotbar_slots.resize(PlayerHotbarType.SLOT_COUNT) + snapshot.hotbar_slots.fill(StringName()) + for slot_index: int in range( + mini(slot_values.size(), PlayerHotbarType.SLOT_COUNT) + ): + var slot_value: Variant = slot_values[slot_index] + if typeof(slot_value) not in [TYPE_STRING, TYPE_STRING_NAME]: + continue + var slot_item_id: StringName = StringName(str(slot_value)) + if slot_item_id.is_empty(): + continue + var slot_item = _item_catalog.get_item_by_id(slot_item_id) + if ( + slot_item != null + and slot_item.is_valid() + and slot_item.hotbar_allowed + and seen_items.has(slot_item_id) + ): + snapshot.hotbar_slots[slot_index] = slot_item_id + var selected_slot: int = _read_integer( + hotbar_data["selected_slot"], + 0, + PlayerHotbarType.SLOT_COUNT - 1 + ) + snapshot.selected_hotbar_slot = clampi( + selected_slot, + 0, + PlayerHotbarType.SLOT_COUNT - 1 + ) return snapshot @@ -433,6 +573,26 @@ func _migrate_save( ) -> Dictionary: if from_version == SAVE_VERSION: return data + if from_version == 1: + var migrated: Dictionary = data.duplicate(true) + migrated["save_version"] = SAVE_VERSION + migrated["bag"] = { + "items": [ + { + "item_id": String(BASIC_ROD_ID), + "quantity": 1, + }, + ], + } + var slots: Array[String] = [] + slots.resize(PlayerHotbarType.SLOT_COUNT) + slots.fill("") + slots[0] = String(BASIC_ROD_ID) + migrated["hotbar"] = { + "selected_slot": 0, + "slots": slots, + } + return migrated return {} @@ -456,6 +616,13 @@ func _on_balance_changed(_new_balance: int, _delta: int) -> void: _mark_dirty() +func _on_selected_hotbar_slot_changed( + _slot_index: int, + _item_id: StringName, +) -> void: + _mark_dirty() + + func _on_autosave_timeout() -> void: if _is_dirty: save_now() @@ -496,9 +663,20 @@ func _restore_defaults() -> void: _is_restoring = true var empty_catches: Array[FishCatchType] = [] var empty_discoveries: Array[StringName] = [] + var default_items: Array[OwnedItemType] = [] + var basic_rod := OwnedItemType.new() + basic_rod.item_id = BASIC_ROD_ID + basic_rod.quantity = 1 + default_items.append(basic_rod) + var default_slots: Array[StringName] = [] + default_slots.resize(PlayerHotbarType.SLOT_COUNT) + default_slots.fill(StringName()) + default_slots[0] = BASIC_ROD_ID _inventory.replace_all_catches(empty_catches, 1) _collection_log.replace_discovered_ids(empty_discoveries) _wallet.restore_balance(0) + _bag.replace_all_items(default_items) + _hotbar.replace_state(default_slots, 0) _is_restoring = false _is_dirty = false diff --git a/ui/game_ui.gd b/ui/game_ui.gd index e8d1d70..c933a3c 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -10,6 +10,10 @@ 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") +const ItemCatalogType = preload("res://items/item_catalog.gd") +const PlayerBagType = preload("res://inventory/player_bag.gd") +const PlayerHotbarType = preload("res://inventory/player_hotbar.gd") +const HotbarUIType = preload("res://ui/hotbar.gd") const TitleScreenType = preload("res://ui/title_screen.gd") const PauseMenuType = preload("res://ui/pause_menu.gd") @@ -26,10 +30,12 @@ const PauseMenuType = preload("res://ui/pause_menu.gd") @onready var _screen_fade: ScreenFade = %ScreenFade @onready var _title_screen: TitleScreenType = %TitleScreen @onready var _pause_menu: PauseMenuType = %PauseMenu +@onready var _hotbar_ui: HotbarUIType = %Hotbar var _showcase_active: bool = false var _player_menu_open: bool = false var _gameplay_ui_enabled: bool = false +var _fishing_spot: FishingSpotType func setup( @@ -41,7 +47,11 @@ func setup( default_buyer: FishBuyerProfileType, catalog: FishPoolType, fishing_spot: FishingSpotType, + bag: PlayerBagType, + hotbar: PlayerHotbarType, + item_catalog: ItemCatalogType, ) -> void: + _fishing_spot = fishing_spot fishing_spot.status_changed.connect(_on_fishing_status_changed) fishing_spot.catch_display_changed.connect(_on_catch_display_changed) fishing_spot.showcase_changed.connect(_on_showcase_changed) @@ -56,8 +66,12 @@ func setup( sale_service, default_buyer, catalog, - fishing_spot + fishing_spot, + bag, + hotbar, + item_catalog ) + _hotbar_ui.setup(hotbar, bag, item_catalog, fishing_spot) func close_player_menu() -> void: @@ -89,10 +103,22 @@ func set_gameplay_ui_enabled(enabled: bool) -> void: if not enabled: close_player_menu_for_session_end() _fishing_panel.visible = false + _hotbar_ui.hide() + _hotbar_ui.set_gameplay_input_enabled(false) else: + _hotbar_ui.show() + _hotbar_ui.set_gameplay_input_enabled(true) _refresh_fishing_panel_visibility() +func set_system_menu_open(is_open: bool) -> void: + _hotbar_ui.visible = _gameplay_ui_enabled and not is_open + _hotbar_ui.set_gameplay_input_enabled( + _gameplay_ui_enabled and not is_open and not _player_menu_open + ) + _hotbar_ui.set_drag_enabled(_player_menu_open and not is_open) + + func get_screen_fade() -> ScreenFade: return _screen_fade @@ -104,6 +130,8 @@ func get_title_screen() -> TitleScreenType: func _on_fishing_status_changed(status: String) -> void: if _showcase_active: return + if _fishing_spot != null and _fishing_spot.is_fighting(): + return _set_fishing_status(status) @@ -125,7 +153,6 @@ func _refresh_fishing_panel_visibility() -> void: _fishing_panel.visible = ( _gameplay_ui_enabled and has_content - and not _player_menu_open ) @@ -138,17 +165,26 @@ func _on_catch_display_changed( active_barrier_index: int, visible: bool, ) -> void: + var encounter_visible: bool = ( + visible + and _fishing_spot != null + and _fishing_spot.is_fighting() + ) + _hotbar_ui.set_item_name_suppressed(encounter_visible) _green_catch_progress.value = progress * 100.0 _red_chase_progress.value = maxf(chase_progress, 0.0) * 100.0 - _catch_track.visible = visible - _barrier_summary.visible = visible - if not visible: + _catch_track.visible = encounter_visible + _barrier_summary.visible = encounter_visible + _barrier_health.visible = encounter_visible + if not encounter_visible: _barrier_health.visible = false _clear_barrier_markers() _barrier_summary.text = "" _barrier_health.text = "" _refresh_fishing_panel_visibility() return + _status_label.text = "" + _status_label.visible = false _update_barrier_markers( barrier_positions, @@ -183,7 +219,7 @@ func _on_catch_display_changed( ] ) else: - _barrier_health.text = "" + _barrier_health.text = "Hold left click to reel" var chase_gap: float = progress - chase_progress if chase_gap <= 0.05: @@ -194,10 +230,6 @@ func _on_catch_display_changed( "\nRed is closing in!", ] ).strip_edges() - _barrier_health.visible = ( - visible - and (active_barrier_index >= 0 or chase_gap <= 0.05) - ) _refresh_fishing_panel_visibility() @@ -262,4 +294,8 @@ func _on_showcase_changed( func _on_player_menu_visibility_changed(is_open: bool) -> void: _player_menu_open = is_open + _hotbar_ui.set_gameplay_input_enabled( + _gameplay_ui_enabled and not is_open + ) + _hotbar_ui.set_drag_enabled(is_open) _refresh_fishing_panel_visibility() diff --git a/ui/game_ui.tscn b/ui/game_ui.tscn index 7a344c6..ff33bb1 100644 --- a/ui/game_ui.tscn +++ b/ui/game_ui.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=11 format=3] +[gd_scene load_steps=12 format=3] [ext_resource type="Script" path="res://ui/game_ui.gd" id="1_ui"] [ext_resource type="PackedScene" path="res://ui/player_menu.tscn" id="2_menu"] @@ -6,6 +6,7 @@ [ext_resource type="Script" path="res://ui/screen_fade.gd" id="4_fade"] [ext_resource type="PackedScene" path="res://ui/title_screen.tscn" id="5_title"] [ext_resource type="PackedScene" path="res://ui/pause_menu.tscn" id="6_pause"] +[ext_resource type="PackedScene" path="res://ui/hotbar.tscn" id="7_hotbar"] [sub_resource type="StyleBoxFlat" id="StyleBox_chase_background"] bg_color = Color(0.055, 0.105, 0.125, 1) @@ -37,35 +38,36 @@ script = ExtResource("1_ui") [node name="FishingPanel" type="PanelContainer" parent="."] unique_name_in_owner = true visible = false +z_index = 60 anchors_preset = 7 anchor_left = 0.5 anchor_top = 1.0 anchor_right = 0.5 anchor_bottom = 1.0 -offset_left = -210.0 -offset_top = -130.0 -offset_right = 210.0 -offset_bottom = -24.0 +offset_left = -205.0 +offset_top = -216.0 +offset_right = 205.0 +offset_bottom = -128.0 grow_horizontal = 2 grow_vertical = 0 mouse_filter = 2 theme = ExtResource("3_theme") [node name="MarginContainer" type="MarginContainer" parent="FishingPanel"] -theme_override_constants/margin_left = 18 -theme_override_constants/margin_top = 12 -theme_override_constants/margin_right = 18 -theme_override_constants/margin_bottom = 12 +theme_override_constants/margin_left = 14 +theme_override_constants/margin_top = 8 +theme_override_constants/margin_right = 14 +theme_override_constants/margin_bottom = 8 [node name="VBoxContainer" type="VBoxContainer" parent="FishingPanel/MarginContainer"] -theme_override_constants/separation = 6 +theme_override_constants/separation = 4 [node name="StatusLabel" type="Label" parent="FishingPanel/MarginContainer/VBoxContainer"] unique_name_in_owner = true visible = false text = "" horizontal_alignment = 1 -theme_override_font_sizes/font_size = 17 +theme_override_font_sizes/font_size = 16 [node name="ShowcaseDetails" type="Label" parent="FishingPanel/MarginContainer/VBoxContainer"] unique_name_in_owner = true @@ -75,7 +77,7 @@ horizontal_alignment = 1 [node name="CatchTrack" type="Control" parent="FishingPanel/MarginContainer/VBoxContainer"] unique_name_in_owner = true visible = false -custom_minimum_size = Vector2(0, 22) +custom_minimum_size = Vector2(0, 20) [node name="GreenCatchProgress" type="ProgressBar" parent="FishingPanel/MarginContainer/VBoxContainer/CatchTrack"] unique_name_in_owner = true @@ -128,6 +130,9 @@ horizontal_alignment = 1 [node name="PlayerMenu" parent="." instance=ExtResource("2_menu")] unique_name_in_owner = true +[node name="Hotbar" parent="." instance=ExtResource("7_hotbar")] +unique_name_in_owner = true + [node name="ScreenFade" type="ColorRect" parent="."] unique_name_in_owner = true visible = false diff --git a/ui/hotbar.gd b/ui/hotbar.gd new file mode 100644 index 0000000..af6ade1 --- /dev/null +++ b/ui/hotbar.gd @@ -0,0 +1,185 @@ +class_name HotbarUI +extends Control + +const ItemCatalogType = preload("res://items/item_catalog.gd") +const PlayerBagType = preload("res://inventory/player_bag.gd") +const PlayerHotbarType = preload("res://inventory/player_hotbar.gd") +const HotbarSlotType = preload("res://ui/hotbar_slot.gd") +const FishingSpotType = preload("res://fishing/fishing_spot.gd") + +@onready var _slot_row: HBoxContainer = %SlotRow +@onready var _selected_item_label: Label = %SelectedItemLabel +@onready var _item_name_timer: Timer = %ItemNameTimer + +var _hotbar: PlayerHotbarType +var _bag: PlayerBagType +var _catalog: ItemCatalogType +var _fishing_spot: FishingSpotType +var _slots: Array[HotbarSlotType] = [] +var _gameplay_input_enabled: bool = false +var _drag_enabled: bool = false +var _hovered_slot_index: int = -1 +var _item_name_suppressed: bool = false + + +func _ready() -> void: + _item_name_timer.timeout.connect(_on_item_name_timer_timeout) + + +func setup( + hotbar: PlayerHotbarType, + bag: PlayerBagType, + catalog: ItemCatalogType, + fishing_spot: FishingSpotType, +) -> void: + _hotbar = hotbar + _bag = bag + _catalog = catalog + _fishing_spot = fishing_spot + if not _hotbar.slots_changed.is_connected(_refresh): + _hotbar.slots_changed.connect(_refresh) + if not _hotbar.selected_slot_changed.is_connected( + _on_selected_slot_changed + ): + _hotbar.selected_slot_changed.connect(_on_selected_slot_changed) + if not _bag.contents_changed.is_connected(_refresh): + _bag.contents_changed.connect(_refresh) + _build_slots() + _refresh() + + +func set_gameplay_input_enabled(enabled: bool) -> void: + _gameplay_input_enabled = enabled + + +func set_drag_enabled(enabled: bool) -> void: + _drag_enabled = enabled + for slot: HotbarSlotType in _slots: + slot.set_drag_enabled(enabled) + if not enabled: + _hovered_slot_index = -1 + _hide_item_name() + + +func set_item_name_suppressed(suppressed: bool) -> void: + _item_name_suppressed = suppressed + if suppressed: + _hovered_slot_index = -1 + _hide_item_name() + + +func _unhandled_input(event: InputEvent) -> void: + if ( + not _gameplay_input_enabled + or _hotbar == null + or _fishing_spot == null + or not _fishing_spot.can_change_hotbar_selection() + ): + return + if event is InputEventKey and event.pressed and not event.echo: + for index: int in range(PlayerHotbarType.SLOT_COUNT): + if event.is_action_pressed("hotbar_%d" % (index + 1)): + _hotbar.select_slot(index) + get_viewport().set_input_as_handled() + return + if ( + event is InputEventMouseButton + and event.pressed + and not event.shift_pressed + ): + if event.button_index == MOUSE_BUTTON_WHEEL_UP: + _hotbar.cycle_selection(-1) + get_viewport().set_input_as_handled() + elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN: + _hotbar.cycle_selection(1) + get_viewport().set_input_as_handled() + + +func _build_slots() -> void: + for child: Node in _slot_row.get_children(): + child.queue_free() + _slots.clear() + for index: int in range(PlayerHotbarType.SLOT_COUNT): + var slot := HotbarSlotType.new() + slot.toggle_mode = true + slot.setup(index, _hotbar, _bag, _catalog) + slot.set_drag_enabled(_drag_enabled) + slot.item_hovered.connect(_on_slot_item_hovered) + slot.item_hover_ended.connect(_on_slot_item_hover_ended) + slot.item_drag_started.connect(_on_slot_drag_started) + slot.item_drag_finished.connect(_on_slot_drag_finished) + _slot_row.add_child(slot) + _slots.append(slot) + + +func _refresh() -> void: + for slot: HotbarSlotType in _slots: + slot.refresh() + + +func _on_selected_slot_changed( + _slot_index: int, + _item_id: StringName, +) -> void: + _refresh() + if _hovered_slot_index < 0: + _show_selected_item_briefly() + + +func _on_slot_item_hovered( + slot_index: int, + item_id: StringName, +) -> void: + if _item_name_suppressed: + return + _hovered_slot_index = slot_index + _item_name_timer.stop() + _show_item_name(item_id) + + +func _on_slot_item_hover_ended(slot_index: int) -> void: + if slot_index != _hovered_slot_index: + return + _hovered_slot_index = -1 + _show_selected_item_briefly() + + +func _on_slot_drag_started() -> void: + _hovered_slot_index = -1 + _hide_item_name() + + +func _on_slot_drag_finished() -> void: + _show_selected_item_briefly() + + +func _show_selected_item_briefly() -> void: + if _item_name_suppressed or _hotbar == null: + _hide_item_name() + return + _show_item_name(_hotbar.get_selected_item_id()) + if _selected_item_label.visible: + _item_name_timer.start() + + +func _show_item_name(item_id: StringName) -> void: + if item_id.is_empty() or _catalog == null: + _hide_item_name() + return + var item := _catalog.get_item_by_id(item_id) + if item == null: + _hide_item_name() + return + _selected_item_label.text = item.display_name + _selected_item_label.visible = true + + +func _hide_item_name() -> void: + _item_name_timer.stop() + _selected_item_label.text = "" + _selected_item_label.visible = false + + +func _on_item_name_timer_timeout() -> void: + if _hovered_slot_index < 0: + _hide_item_name() diff --git a/ui/hotbar.gd.uid b/ui/hotbar.gd.uid new file mode 100644 index 0000000..9b4c1c6 --- /dev/null +++ b/ui/hotbar.gd.uid @@ -0,0 +1 @@ +uid://642w0srh4vwj diff --git a/ui/hotbar.tscn b/ui/hotbar.tscn new file mode 100644 index 0000000..7b73bc1 --- /dev/null +++ b/ui/hotbar.tscn @@ -0,0 +1,74 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://ui/hotbar.gd" id="1_script"] +[ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"] + +[node name="Hotbar" type="Control"] +unique_name_in_owner = true +visible = false +z_index = 20 +layout_mode = 3 +anchors_preset = 12 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_top = -84.0 +offset_bottom = -8.0 +grow_horizontal = 2 +grow_vertical = 0 +mouse_filter = 2 +theme = ExtResource("2_theme") +script = ExtResource("1_script") + +[node name="Center" type="CenterContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 + +[node name="Panel" type="PanelContainer" parent="Center"] +layout_mode = 2 +mouse_filter = 2 + +[node name="Margin" type="MarginContainer" parent="Center/Panel"] +layout_mode = 2 +mouse_filter = 2 +theme_override_constants/margin_left = 5 +theme_override_constants/margin_top = 5 +theme_override_constants/margin_right = 5 +theme_override_constants/margin_bottom = 5 + +[node name="SlotRow" type="HBoxContainer" parent="Center/Panel/Margin"] +unique_name_in_owner = true +layout_mode = 2 +mouse_filter = 2 +theme_override_constants/separation = 4 + +[node name="SelectedItemLabel" type="Label" parent="."] +unique_name_in_owner = true +visible = false +layout_mode = 1 +anchors_preset = 10 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -150.0 +offset_top = -28.0 +offset_right = 150.0 +offset_bottom = -8.0 +grow_horizontal = 2 +mouse_filter = 2 +clip_text = true +text_overrun_behavior = 3 +horizontal_alignment = 1 +vertical_alignment = 1 +theme_override_colors/font_color = Color(0.925, 0.953, 0.965, 1) +theme_override_colors/font_outline_color = Color(0.015, 0.02, 0.03, 0.95) +theme_override_constants/outline_size = 2 +theme_override_font_sizes/font_size = 12 + +[node name="ItemNameTimer" type="Timer" parent="."] +unique_name_in_owner = true +wait_time = 1.5 +one_shot = true diff --git a/ui/hotbar_slot.gd b/ui/hotbar_slot.gd new file mode 100644 index 0000000..1bb4a23 --- /dev/null +++ b/ui/hotbar_slot.gd @@ -0,0 +1,187 @@ +class_name HotbarSlot +extends Button + +signal item_hovered(slot_index: int, item_id: StringName) +signal item_hover_ended(slot_index: int) +signal item_drag_started +signal item_drag_finished + +const ItemCatalogType = preload("res://items/item_catalog.gd") +const ItemDataType = preload("res://items/item_data.gd") +const PlayerBagType = preload("res://inventory/player_bag.gd") +const PlayerHotbarType = preload("res://inventory/player_hotbar.gd") + +var slot_index: int = 0 +var _hotbar: PlayerHotbarType +var _bag: PlayerBagType +var _catalog: ItemCatalogType +var _drag_enabled: bool = false +var _drag_in_progress: bool = false +var _slot_number_label: Label +var _quantity_label: Label + + +func setup( + index: int, + hotbar: PlayerHotbarType, + bag: PlayerBagType, + catalog: ItemCatalogType, +) -> void: + slot_index = index + _hotbar = hotbar + _bag = bag + _catalog = catalog + custom_minimum_size = Vector2(50, 50) + expand_icon = true + focus_mode = Control.FOCUS_ALL + _slot_number_label = _create_corner_label(str(slot_index + 1)) + _quantity_label = _create_corner_label("", true) + pressed.connect(_select_slot) + mouse_entered.connect(_on_mouse_entered) + mouse_exited.connect(_on_mouse_exited) + refresh() + + +func set_drag_enabled(enabled: bool) -> void: + _drag_enabled = enabled + mouse_filter = ( + Control.MOUSE_FILTER_STOP + if enabled + else Control.MOUSE_FILTER_IGNORE + ) + + +func refresh() -> void: + if _hotbar == null: + return + var item_id: StringName = _hotbar.get_item_id(slot_index) + var item: ItemDataType = ( + _catalog.get_item_by_id(item_id) + if _catalog != null and not item_id.is_empty() + else null + ) + icon = item.icon if item != null else null + var quantity: int = ( + _bag.get_quantity(item_id) + if _bag != null and not item_id.is_empty() + else 0 + ) + var quantity_text: String = ( + "×%d" % quantity + if item != null and item.stackable and quantity > 1 + else "" + ) + text = "" + _quantity_label.text = quantity_text + _quantity_label.visible = not quantity_text.is_empty() + tooltip_text = ( + item.display_name if item != null else "Empty hotbar slot" + ) + button_pressed = slot_index == _hotbar.get_selected_slot() + + +func _create_corner_label( + label_text: String, + bottom_right: bool = false, +) -> Label: + var label := Label.new() + label.text = label_text + label.mouse_filter = Control.MOUSE_FILTER_IGNORE + label.add_theme_font_size_override("font_size", 11) + label.add_theme_color_override("font_color", UIPalette.TEXT) + if bottom_right: + label.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT) + label.position = Vector2(-22.0, -20.0) + else: + label.position = Vector2(5.0, 2.0) + add_child(label) + return label + + +func _gui_input(event: InputEvent) -> void: + if ( + _drag_enabled + and event is InputEventMouseButton + and event.button_index == MOUSE_BUTTON_RIGHT + and event.pressed + and _hotbar != null + ): + _hotbar.clear_slot(slot_index) + accept_event() + + +func _get_drag_data(_at_position: Vector2) -> Variant: + if not _drag_enabled or _hotbar == null: + return null + var item_id: StringName = _hotbar.get_item_id(slot_index) + if item_id.is_empty(): + return null + var item: ItemDataType = _catalog.get_item_by_id(item_id) + var preview := TextureRect.new() + preview.custom_minimum_size = Vector2(44, 44) + preview.texture = item.icon if item != null else null + preview.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + preview.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + preview.mouse_filter = Control.MOUSE_FILTER_IGNORE + set_drag_preview(preview) + _drag_in_progress = true + item_drag_started.emit() + return { + "kind": "hotbar_slot", + "slot_index": slot_index, + "item_id": String(item_id), + } + + +func _can_drop_data(_at_position: Vector2, data: Variant) -> bool: + if not _drag_enabled or typeof(data) != TYPE_DICTIONARY: + return false + var payload: Dictionary = data + var kind: String = str(payload.get("kind", "")) + if kind == "hotbar_slot": + var source_index: int = int(payload.get("slot_index", -1)) + return ( + source_index >= 0 + and source_index < PlayerHotbarType.SLOT_COUNT + and source_index != slot_index + ) + if kind != "bag_item": + return false + var item_id: StringName = StringName(str(payload.get("item_id", ""))) + if _bag == null or not _bag.owns_item(item_id) or _catalog == null: + return false + var item: ItemDataType = _catalog.get_item_by_id(item_id) + return item != null and item.is_valid() and item.hotbar_allowed + + +func _drop_data(_at_position: Vector2, data: Variant) -> void: + if not _can_drop_data(Vector2.ZERO, data) or _hotbar == null: + return + var payload: Dictionary = data + if str(payload.get("kind", "")) == "hotbar_slot": + _hotbar.swap_slots(int(payload["slot_index"]), slot_index) + else: + _hotbar.assign_item( + slot_index, + StringName(str(payload["item_id"])) + ) + + +func _select_slot() -> void: + if _hotbar != null: + _hotbar.select_slot(slot_index) + + +func _on_mouse_entered() -> void: + if _hotbar != null: + item_hovered.emit(slot_index, _hotbar.get_item_id(slot_index)) + + +func _on_mouse_exited() -> void: + item_hover_ended.emit(slot_index) + + +func _notification(what: int) -> void: + if what == NOTIFICATION_DRAG_END and _drag_in_progress: + _drag_in_progress = false + item_drag_finished.emit() diff --git a/ui/hotbar_slot.gd.uid b/ui/hotbar_slot.gd.uid new file mode 100644 index 0000000..dd28680 --- /dev/null +++ b/ui/hotbar_slot.gd.uid @@ -0,0 +1 @@ +uid://swcob5q48f8e diff --git a/ui/item_drag_source.gd b/ui/item_drag_source.gd new file mode 100644 index 0000000..19267d1 --- /dev/null +++ b/ui/item_drag_source.gd @@ -0,0 +1,46 @@ +class_name ItemDragSource +extends Button + +var item_id: StringName +var item_icon: Texture2D +var item_name: String + + +func setup( + new_item_id: StringName, + new_item_name: String, + new_icon: Texture2D, +) -> void: + item_id = new_item_id + item_name = new_item_name + item_icon = new_icon + + +func _get_drag_data(_at_position: Vector2) -> Variant: + if item_id.is_empty() or disabled: + return null + set_drag_preview(_create_drag_preview()) + return { + "kind": "bag_item", + "item_id": String(item_id), + } + + +func _create_drag_preview() -> Control: + var preview := VBoxContainer.new() + preview.mouse_filter = Control.MOUSE_FILTER_IGNORE + preview.add_theme_constant_override("separation", 2) + var texture := TextureRect.new() + texture.custom_minimum_size = Vector2(44, 44) + texture.texture = item_icon + texture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + texture.mouse_filter = Control.MOUSE_FILTER_IGNORE + preview.add_child(texture) + var label := Label.new() + label.text = item_name + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + label.mouse_filter = Control.MOUSE_FILTER_IGNORE + label.add_theme_font_size_override("font_size", 12) + preview.add_child(label) + return preview diff --git a/ui/item_drag_source.gd.uid b/ui/item_drag_source.gd.uid new file mode 100644 index 0000000..b0fc25c --- /dev/null +++ b/ui/item_drag_source.gd.uid @@ -0,0 +1 @@ +uid://cfv418ixq3eeb diff --git a/ui/pause_menu.gd b/ui/pause_menu.gd index ebe6eec..90420c9 100644 --- a/ui/pause_menu.gd +++ b/ui/pause_menu.gd @@ -13,6 +13,7 @@ const FishingSpotType = preload("res://fishing/fishing_spot.gd") signal return_to_title_requested signal reset_progress_requested signal quit_requested +signal menu_visibility_changed(is_open: bool) enum ConfirmationAction { NONE, @@ -100,6 +101,7 @@ func open_menu() -> void: _confirmation_action = ConfirmationAction.NONE show() %ResumeButton.grab_focus() + menu_visibility_changed.emit(true) func resume() -> void: @@ -138,6 +140,7 @@ func close_menu( else: _control_snapshot_stored = false _apply_mouse_close_policy(reason) + menu_visibility_changed.emit(false) func handle_escape() -> bool: diff --git a/ui/player_menu.gd b/ui/player_menu.gd index 1ab58bf..c5fb8a5 100644 --- a/ui/player_menu.gd +++ b/ui/player_menu.gd @@ -13,11 +13,18 @@ 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") +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 PlayerBagType = preload("res://inventory/player_bag.gd") +const PlayerHotbarType = preload("res://inventory/player_hotbar.gd") +const ItemDragSourceType = preload("res://ui/item_drag_source.gd") signal menu_visibility_changed(is_open: bool) enum Section { - INVENTORY, + COOLER, + BAG, LOGBOOK, } @@ -37,11 +44,18 @@ enum CloseReason { } @onready var _inventory_tab: Button = %InventoryTab +@onready var _bag_tab: Button = %BagTab @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 _bag_section: Control = %BagSection @onready var _logbook_section: Control = %LogbookSection +@onready var _bag_empty: Label = %BagEmpty +@onready var _bag_grid: GridContainer = %BagGrid +@onready var _bag_detail_texture: TextureRect = %BagDetailTexture +@onready var _bag_detail_name: Label = %BagDetailName +@onready var _bag_detail_data: Label = %BagDetailData @onready var _sort_option: OptionButton = %SortOption @onready var _sort_direction: Button = %SortDirection @onready var _held_value: Label = %HeldValue @@ -69,10 +83,14 @@ var _sale_service: FishSaleServiceType var _default_buyer: FishBuyerProfileType var _catalog: FishPoolType var _fishing_spot: FishingSpotType -var _current_section: Section = Section.INVENTORY +var _bag: PlayerBagType +var _hotbar: PlayerHotbarType +var _item_catalog: ItemCatalogType +var _current_section: Section = Section.COOLER var _sort_mode: SortMode = SortMode.CATCH_ORDER var _sort_descending: bool = true var _selected_catch_id: StringName +var _selected_bag_item_id: StringName var _prior_movement_enabled: bool = true var _prior_camera_input_enabled: bool = true var _prior_mouse_mode: Input.MouseMode = Input.MOUSE_MODE_VISIBLE @@ -87,8 +105,9 @@ var _sale_in_progress: bool = false func _ready() -> void: _inventory_tab.pressed.connect( - _show_section.bind(Section.INVENTORY) + _show_section.bind(Section.COOLER) ) + _bag_tab.pressed.connect(_show_section.bind(Section.BAG)) _logbook_tab.pressed.connect( _show_section.bind(Section.LOGBOOK) ) @@ -116,6 +135,9 @@ func setup( default_buyer: FishBuyerProfileType, catalog: FishPoolType, fishing_spot: FishingSpotType, + bag: PlayerBagType, + hotbar: PlayerHotbarType, + item_catalog: ItemCatalogType, ) -> void: _player = player _inventory = inventory @@ -125,6 +147,9 @@ func setup( _default_buyer = default_buyer _catalog = catalog _fishing_spot = fishing_spot + _bag = bag + _hotbar = hotbar + _item_catalog = item_catalog 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): @@ -133,6 +158,8 @@ func setup( _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) + if not _bag.contents_changed.is_connected(_on_bag_changed): + _bag.contents_changed.connect(_on_bag_changed) _refresh_all() @@ -194,6 +221,7 @@ func close_menu( ) -> void: if not visible: return + get_viewport().gui_cancel_drag() _close_sale_confirmation() var closing_generation: int = _menu_generation visible = false @@ -268,17 +296,21 @@ func _show_section(section: Section) -> void: _current_section = section if not is_node_ready(): return - _inventory_section.visible = section == Section.INVENTORY + _inventory_section.visible = section == Section.COOLER + _bag_section.visible = section == Section.BAG _logbook_section.visible = section == Section.LOGBOOK - _inventory_tab.button_pressed = section == Section.INVENTORY + _inventory_tab.button_pressed = section == Section.COOLER + _bag_tab.button_pressed = section == Section.BAG _logbook_tab.button_pressed = section == Section.LOGBOOK if visible: _focus_current_section() func _focus_current_section() -> void: - if _current_section == Section.INVENTORY: + if _current_section == Section.COOLER: _inventory_tab.grab_focus() + elif _current_section == Section.BAG: + _bag_tab.grab_focus() else: _logbook_tab.grab_focus() @@ -307,9 +339,99 @@ func _update_sort_direction_text() -> void: func _refresh_all() -> void: _refresh_economy_summary() _refresh_inventory() + _refresh_bag() _refresh_logbook() +func _on_bag_changed() -> void: + _refresh_bag() + + +func _refresh_bag() -> void: + if not is_node_ready(): + return + _clear_container(_bag_grid) + var owned_items: Array[OwnedItemType] = ( + _bag.get_all_items() if _bag != null else [] + ) + owned_items.sort_custom(_sort_bag_items) + _bag_empty.visible = owned_items.is_empty() + if ( + not _selected_bag_item_id.is_empty() + and (_bag == null or not _bag.owns_item(_selected_bag_item_id)) + ): + _selected_bag_item_id = StringName() + for owned: OwnedItemType in owned_items: + var item: ItemDataType = _item_catalog.get_item_by_id(owned.item_id) + if item == null: + continue + var card := ItemDragSourceType.new() + card.custom_minimum_size = Vector2(140, 88) + card.expand_icon = true + card.icon = item.icon + card.text = "%s%s\n%s" % [ + item.display_name, + " ×%d" % owned.quantity if owned.quantity > 1 else "", + item.get_category_name(), + ] + card.tooltip_text = ( + "Drag to a hotbar slot." + if item.hotbar_allowed + else "This item cannot be assigned to the hotbar." + ) + card.setup(item.item_id, item.display_name, item.icon) + card.pressed.connect(_select_bag_item.bind(item.item_id)) + _bag_grid.add_child(card) + _update_bag_detail() + + +func _sort_bag_items(a: OwnedItemType, b: OwnedItemType) -> bool: + var item_a: ItemDataType = _item_catalog.get_item_by_id(a.item_id) + var item_b: ItemDataType = _item_catalog.get_item_by_id(b.item_id) + if item_a == null: + return false + if item_b == null: + return true + if item_a.category != item_b.category: + return item_a.category < item_b.category + return item_a.display_name.naturalnocasecmp_to(item_b.display_name) < 0 + + +func _select_bag_item(item_id: StringName) -> void: + _selected_bag_item_id = item_id + _update_bag_detail() + + +func _update_bag_detail() -> void: + var item: ItemDataType = ( + _item_catalog.get_item_by_id(_selected_bag_item_id) + if _item_catalog != null and not _selected_bag_item_id.is_empty() + else null + ) + var quantity: int = ( + _bag.get_quantity(_selected_bag_item_id) + if _bag != null and item != null + else 0 + ) + _bag_detail_texture.texture = item.icon if item != null else null + _bag_detail_name.text = item.display_name if item != null else "" + _bag_detail_data.text = ( + "%s\nQuantity: %d\n%s\n%s" + % [ + item.get_category_name(), + quantity, + item.description, + ( + "Can be assigned to the hotbar." + if item.hotbar_allowed + else "Cannot be assigned to the hotbar." + ), + ] + if item != null + else "Select a Bag item for details." + ) + + func _on_inventory_changed() -> void: _refresh_economy_summary() _revalidate_confirmation() @@ -384,7 +506,7 @@ func _compare_catches(left: FishCatchType, right: FishCatchType) -> bool: func _create_inventory_card(fish_catch: FishCatchType) -> Button: var card := Button.new() card.theme_type_variation = &"CardButton" - card.custom_minimum_size = Vector2(152.0, 140.0) + card.custom_minimum_size = Vector2(132.0, 118.0) card.toggle_mode = true card.button_pressed = fish_catch.catch_id == _selected_catch_id card.pressed.connect(_select_catch.bind(fish_catch.catch_id)) @@ -400,7 +522,7 @@ func _create_inventory_card(fish_catch: FishCatchType) -> Button: card.add_child(content) content.add_child(_create_texture_frame( fish_catch.fish.display_texture, - Vector2(124.0, 78.0) + Vector2(108.0, 62.0) )) var name_label := Label.new() name_label.text = fish_catch.fish.display_name diff --git a/ui/player_menu.tscn b/ui/player_menu.tscn index 10db017..dbb1ea3 100644 --- a/ui/player_menu.tscn +++ b/ui/player_menu.tscn @@ -5,17 +5,19 @@ [node name="PlayerMenu" type="Control"] visible = false +z_index = 30 layout_mode = 3 anchors_preset = 15 anchor_right = 1.0 anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 -mouse_filter = 0 +mouse_filter = 2 theme = ExtResource("2_theme") script = ExtResource("1_menu") [node name="Dimmer" type="ColorRect" parent="."] +z_index = -20 layout_mode = 1 anchors_preset = 15 anchor_right = 1.0 @@ -27,23 +29,23 @@ color = Color(0.015, 0.02, 0.03, 0.72) [node name="MenuPanel" type="PanelContainer" parent="."] layout_mode = 1 -anchor_left = 0.055 -anchor_top = 0.045 -anchor_right = 0.945 -anchor_bottom = 0.955 +anchor_left = 0.07 +anchor_top = 0.055 +anchor_right = 0.93 +anchor_bottom = 0.72 grow_horizontal = 2 grow_vertical = 2 [node name="Margin" type="MarginContainer" parent="MenuPanel"] layout_mode = 2 -theme_override_constants/margin_left = 22 -theme_override_constants/margin_top = 18 -theme_override_constants/margin_right = 22 -theme_override_constants/margin_bottom = 20 +theme_override_constants/margin_left = 14 +theme_override_constants/margin_top = 12 +theme_override_constants/margin_right = 14 +theme_override_constants/margin_bottom = 12 [node name="Layout" type="VBoxContainer" parent="MenuPanel/Margin"] layout_mode = 2 -theme_override_constants/separation = 12 +theme_override_constants/separation = 8 [node name="Header" type="HBoxContainer" parent="MenuPanel/Margin/Layout"] layout_mode = 2 @@ -53,7 +55,7 @@ theme_override_constants/separation = 8 layout_mode = 2 size_flags_horizontal = 3 theme_override_colors/font_color = Color(0.208, 0.725, 0.78, 1) -theme_override_font_sizes/font_size = 26 +theme_override_font_sizes/font_size = 22 text = "Player Menu" [node name="WalletBalance" type="Label" parent="MenuPanel/Margin/Layout/Header"] @@ -66,21 +68,28 @@ theme_override_colors/font_color = Color(1, 0.82, 0.4, 1) unique_name_in_owner = true layout_mode = 2 toggle_mode = true -text = "Inventory" -custom_minimum_size = Vector2(104, 36) +text = "Cooler" +custom_minimum_size = Vector2(88, 30) + +[node name="BagTab" type="Button" parent="MenuPanel/Margin/Layout/Header"] +unique_name_in_owner = true +layout_mode = 2 +toggle_mode = true +text = "Bag" +custom_minimum_size = Vector2(76, 30) [node name="LogbookTab" type="Button" parent="MenuPanel/Margin/Layout/Header"] unique_name_in_owner = true layout_mode = 2 toggle_mode = true text = "Logbook" -custom_minimum_size = Vector2(104, 36) +custom_minimum_size = Vector2(88, 30) [node name="CloseButton" type="Button" parent="MenuPanel/Margin/Layout/Header"] unique_name_in_owner = true layout_mode = 2 text = "Close" -custom_minimum_size = Vector2(76, 36) +custom_minimum_size = Vector2(68, 30) [node name="Separator" type="HSeparator" parent="MenuPanel/Margin/Layout"] layout_mode = 2 @@ -91,10 +100,10 @@ layout_mode = 2 text = "" horizontal_alignment = 1 theme_override_colors/font_color = Color(1, 0.82, 0.4, 1) -theme_override_font_sizes/font_size = 16 +theme_override_font_sizes/font_size = 14 [node name="Content" type="Control" parent="MenuPanel/Margin/Layout"] -custom_minimum_size = Vector2(0, 390) +custom_minimum_size = Vector2(0, 260) layout_mode = 2 size_flags_vertical = 3 @@ -106,11 +115,11 @@ anchor_right = 1.0 anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 -theme_override_constants/separation = 10 +theme_override_constants/separation = 7 [node name="SortBar" type="HBoxContainer" parent="MenuPanel/Margin/Layout/Content/InventorySection"] layout_mode = 2 -theme_override_constants/separation = 8 +theme_override_constants/separation = 6 [node name="SortLabel" type="Label" parent="MenuPanel/Margin/Layout/Content/InventorySection/SortBar"] layout_mode = 2 @@ -119,12 +128,12 @@ text = "Sort:" [node name="SortOption" type="OptionButton" parent="MenuPanel/Margin/Layout/Content/InventorySection/SortBar"] unique_name_in_owner = true layout_mode = 2 -custom_minimum_size = Vector2(145, 36) +custom_minimum_size = Vector2(130, 30) [node name="SortDirection" type="Button" parent="MenuPanel/Margin/Layout/Content/InventorySection/SortBar"] unique_name_in_owner = true layout_mode = 2 -custom_minimum_size = Vector2(125, 36) +custom_minimum_size = Vector2(112, 30) text = "Newest first" [node name="SortSpacer" type="Control" parent="MenuPanel/Margin/Layout/Content/InventorySection/SortBar"] @@ -140,28 +149,28 @@ theme_override_colors/font_color = Color(0.682, 0.733, 0.761, 1) [node name="InventoryBody" type="HSplitContainer" parent="MenuPanel/Margin/Layout/Content/InventorySection"] layout_mode = 2 size_flags_vertical = 3 -split_offset = 560 -theme_override_constants/separation = 12 +split_offset = 510 +theme_override_constants/separation = 8 [node name="InventoryList" type="PanelContainer" parent="MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody"] -custom_minimum_size = Vector2(400, 0) +custom_minimum_size = Vector2(350, 0) layout_mode = 2 [node name="InventoryListMargin" type="MarginContainer" parent="MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/InventoryList"] layout_mode = 2 -theme_override_constants/margin_left = 12 -theme_override_constants/margin_top = 12 -theme_override_constants/margin_right = 12 -theme_override_constants/margin_bottom = 12 +theme_override_constants/margin_left = 8 +theme_override_constants/margin_top = 8 +theme_override_constants/margin_right = 8 +theme_override_constants/margin_bottom = 8 [node name="InventoryStack" type="VBoxContainer" parent="MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/InventoryList/InventoryListMargin"] layout_mode = 2 -theme_override_constants/separation = 10 +theme_override_constants/separation = 6 [node name="InventoryEmpty" type="Label" parent="MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/InventoryList/InventoryListMargin/InventoryStack"] unique_name_in_owner = true layout_mode = 2 -text = "No fish in your inventory." +text = "Your Cooler is empty." horizontal_alignment = 1 [node name="InventoryScroll" type="ScrollContainer" parent="MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/InventoryList/InventoryListMargin/InventoryStack"] @@ -173,28 +182,28 @@ horizontal_scroll_mode = 0 unique_name_in_owner = true layout_mode = 2 size_flags_horizontal = 3 -theme_override_constants/h_separation = 10 -theme_override_constants/v_separation = 10 +theme_override_constants/h_separation = 7 +theme_override_constants/v_separation = 7 columns = 3 [node name="DetailPanel" type="PanelContainer" parent="MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody"] -custom_minimum_size = Vector2(240, 0) +custom_minimum_size = Vector2(210, 0) layout_mode = 2 [node name="DetailMargin" type="MarginContainer" parent="MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/DetailPanel"] layout_mode = 2 -theme_override_constants/margin_left = 16 -theme_override_constants/margin_top = 14 -theme_override_constants/margin_right = 16 -theme_override_constants/margin_bottom = 14 +theme_override_constants/margin_left = 10 +theme_override_constants/margin_top = 9 +theme_override_constants/margin_right = 10 +theme_override_constants/margin_bottom = 9 [node name="DetailStack" type="VBoxContainer" parent="MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/DetailPanel/DetailMargin"] layout_mode = 2 -theme_override_constants/separation = 10 +theme_override_constants/separation = 6 [node name="DetailTexture" type="TextureRect" parent="MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/DetailPanel/DetailMargin/DetailStack"] unique_name_in_owner = true -custom_minimum_size = Vector2(208, 140) +custom_minimum_size = Vector2(178, 100) layout_mode = 2 expand_mode = 1 stretch_mode = 5 @@ -204,7 +213,7 @@ texture_filter = 1 unique_name_in_owner = true layout_mode = 2 theme_override_colors/font_color = Color(0.208, 0.725, 0.78, 1) -theme_override_font_sizes/font_size = 22 +theme_override_font_sizes/font_size = 19 horizontal_alignment = 1 [node name="DetailData" type="Label" parent="MenuPanel/Margin/Layout/Content/InventorySection/InventoryBody/DetailPanel/DetailMargin/DetailStack"] @@ -234,6 +243,98 @@ autowrap_mode = 2 horizontal_alignment = 1 theme_override_colors/font_color = Color(1, 0.702, 0.278, 1) +[node name="BagSection" type="VBoxContainer" parent="MenuPanel/Margin/Layout/Content"] +unique_name_in_owner = true +visible = false +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/separation = 7 + +[node name="BagHint" type="Label" parent="MenuPanel/Margin/Layout/Content/BagSection"] +layout_mode = 2 +text = "Drag hotbar-compatible items onto a slot below. Right-click a slot to clear it." +horizontal_alignment = 1 + +[node name="BagBody" type="HSplitContainer" parent="MenuPanel/Margin/Layout/Content/BagSection"] +layout_mode = 2 +size_flags_vertical = 3 +split_offset = 540 +theme_override_constants/separation = 8 + +[node name="BagList" type="PanelContainer" parent="MenuPanel/Margin/Layout/Content/BagSection/BagBody"] +custom_minimum_size = Vector2(360, 0) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="MenuPanel/Margin/Layout/Content/BagSection/BagBody/BagList"] +layout_mode = 2 +theme_override_constants/margin_left = 8 +theme_override_constants/margin_top = 8 +theme_override_constants/margin_right = 8 +theme_override_constants/margin_bottom = 8 + +[node name="Stack" type="VBoxContainer" parent="MenuPanel/Margin/Layout/Content/BagSection/BagBody/BagList/Margin"] +layout_mode = 2 +theme_override_constants/separation = 6 + +[node name="BagEmpty" type="Label" parent="MenuPanel/Margin/Layout/Content/BagSection/BagBody/BagList/Margin/Stack"] +unique_name_in_owner = true +layout_mode = 2 +text = "Your Bag is empty." +horizontal_alignment = 1 + +[node name="BagScroll" type="ScrollContainer" parent="MenuPanel/Margin/Layout/Content/BagSection/BagBody/BagList/Margin/Stack"] +layout_mode = 2 +size_flags_vertical = 3 +horizontal_scroll_mode = 0 + +[node name="BagGrid" type="GridContainer" parent="MenuPanel/Margin/Layout/Content/BagSection/BagBody/BagList/Margin/Stack/BagScroll"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/h_separation = 7 +theme_override_constants/v_separation = 7 +columns = 3 + +[node name="BagDetail" type="PanelContainer" parent="MenuPanel/Margin/Layout/Content/BagSection/BagBody"] +custom_minimum_size = Vector2(220, 0) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="MenuPanel/Margin/Layout/Content/BagSection/BagBody/BagDetail"] +layout_mode = 2 +theme_override_constants/margin_left = 10 +theme_override_constants/margin_top = 9 +theme_override_constants/margin_right = 10 +theme_override_constants/margin_bottom = 9 + +[node name="Stack" type="VBoxContainer" parent="MenuPanel/Margin/Layout/Content/BagSection/BagBody/BagDetail/Margin"] +layout_mode = 2 +theme_override_constants/separation = 6 + +[node name="BagDetailTexture" type="TextureRect" parent="MenuPanel/Margin/Layout/Content/BagSection/BagBody/BagDetail/Margin/Stack"] +unique_name_in_owner = true +custom_minimum_size = Vector2(180, 96) +layout_mode = 2 +expand_mode = 1 +stretch_mode = 5 +texture_filter = 1 + +[node name="BagDetailName" type="Label" parent="MenuPanel/Margin/Layout/Content/BagSection/BagBody/BagDetail/Margin/Stack"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_font_sizes/font_size = 19 +horizontal_alignment = 1 + +[node name="BagDetailData" type="Label" parent="MenuPanel/Margin/Layout/Content/BagSection/BagBody/BagDetail/Margin/Stack"] +unique_name_in_owner = true +layout_mode = 2 +text = "Select a Bag item for details." +horizontal_alignment = 1 +autowrap_mode = 2 + [node name="LogbookSection" type="VBoxContainer" parent="MenuPanel/Margin/Layout/Content"] unique_name_in_owner = true visible = false @@ -243,7 +344,7 @@ anchor_right = 1.0 anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 -theme_override_constants/separation = 10 +theme_override_constants/separation = 7 [node name="LogbookEmpty" type="Label" parent="MenuPanel/Margin/Layout/Content/LogbookSection"] unique_name_in_owner = true @@ -260,13 +361,14 @@ horizontal_scroll_mode = 0 unique_name_in_owner = true layout_mode = 2 size_flags_horizontal = 3 -theme_override_constants/h_separation = 10 -theme_override_constants/v_separation = 10 +theme_override_constants/h_separation = 7 +theme_override_constants/v_separation = 7 columns = 4 [node name="SaleConfirmation" type="PanelContainer" parent="MenuPanel/Margin/Layout/Content"] unique_name_in_owner = true visible = false +z_index = 40 layout_mode = 1 anchors_preset = 8 anchor_left = 0.5