diff --git a/economy/fishing_shop_stock.gd b/economy/fishing_shop_stock.gd index e8104f4..697a402 100644 --- a/economy/fishing_shop_stock.gd +++ b/economy/fishing_shop_stock.gd @@ -64,6 +64,10 @@ static func get_stock_item_ids() -> Array[StringName]: return ITEM_ORDER.duplicate() +static func get_stock_order_index(item_id: StringName) -> int: + return ITEM_ORDER.find(item_id) + + static func get_rod_stock( catalog: ItemCatalogType, ) -> Array[FishingRodDataType]: diff --git a/inventory/player_bag.gd b/inventory/player_bag.gd index ffb734d..3f947ed 100644 --- a/inventory/player_bag.gd +++ b/inventory/player_bag.gd @@ -6,6 +6,14 @@ const ItemDataType = preload("res://items/item_data.gd") const OwnedItemType = preload("res://items/owned_item.gd") const DEFAULT_UNLOCKED_BAIT_IDS: Array[StringName] = [&"worms"] +const MIN_STORAGE_SLOT_COUNT: int = 15 +const MAX_STORAGE_SLOT_INDEX: int = 254 + +enum StorageGroup { + NONE, + EQUIPMENT, + ITEMS, +} signal contents_changed @@ -31,6 +39,7 @@ func add_item(item_id: StringName, quantity: int = 1) -> bool: var owned := OwnedItemType.new() owned.item_id = item_id owned.quantity = quantity + owned.storage_slot = _first_available_storage_slot(item, _items) _items.append(owned) if item.is_bait() and not _unlocked_bait_ids.has(item_id): _unlocked_bait_ids.append(item_id) @@ -92,6 +101,42 @@ func get_all_items() -> Array[OwnedItemType]: return result +func get_storage_slot(item_id: StringName) -> int: + var owned: OwnedItemType = get_owned_item(item_id) + return owned.storage_slot if owned != null else -1 + + +func move_item_to_storage_slot(item_id: StringName, target_slot: int) -> bool: + if target_slot < 0 or target_slot > MAX_STORAGE_SLOT_INDEX: + return false + var owned: OwnedItemType = get_owned_item(item_id) + var item: ItemDataType = _resolve_valid_item(item_id) + var group: StorageGroup = _storage_group_for_item(item) + if owned == null or group == StorageGroup.NONE: + return false + if owned.storage_slot == target_slot: + return true + var displaced: OwnedItemType + for candidate: OwnedItemType in _items: + if candidate == null or candidate == owned: + continue + var candidate_item: ItemDataType = _resolve_valid_item( + candidate.item_id + ) + if ( + _storage_group_for_item(candidate_item) == group + and candidate.storage_slot == target_slot + ): + displaced = candidate + break + var previous_slot: int = owned.storage_slot + owned.storage_slot = target_slot + if displaced != null: + displaced.storage_slot = previous_slot + contents_changed.emit() + return true + + func get_unlocked_bait_ids() -> Array[StringName]: return _unlocked_bait_ids.duplicate() @@ -146,11 +191,80 @@ func replace_all_items(items: Array[OwnedItemType]) -> bool: return false seen[owned.item_id] = true validated.append(owned.duplicate_record()) + _normalize_storage_slots(validated) _items = validated contents_changed.emit() return true +func _normalize_storage_slots(items: Array[OwnedItemType]) -> void: + var occupied: Dictionary[String, bool] = {} + for owned: OwnedItemType in items: + var item: ItemDataType = _resolve_valid_item(owned.item_id) + var group: StorageGroup = _storage_group_for_item(item) + if group == StorageGroup.NONE: + owned.storage_slot = -1 + continue + var key := _storage_slot_key(group, owned.storage_slot) + if ( + owned.storage_slot < 0 + or owned.storage_slot > MAX_STORAGE_SLOT_INDEX + or occupied.has(key) + ): + owned.storage_slot = -1 + continue + occupied[key] = true + for owned: OwnedItemType in items: + if owned.storage_slot >= 0: + continue + var item: ItemDataType = _resolve_valid_item(owned.item_id) + var group: StorageGroup = _storage_group_for_item(item) + if group == StorageGroup.NONE: + continue + for slot_index: int in range(MAX_STORAGE_SLOT_INDEX + 1): + var key := _storage_slot_key(group, slot_index) + if occupied.has(key): + continue + owned.storage_slot = slot_index + occupied[key] = true + break + + +func _first_available_storage_slot( + item: ItemDataType, + items: Array[OwnedItemType], +) -> int: + var group: StorageGroup = _storage_group_for_item(item) + if group == StorageGroup.NONE: + return -1 + var occupied: Dictionary[int, bool] = {} + for owned: OwnedItemType in items: + var owned_item: ItemDataType = _resolve_valid_item(owned.item_id) + if ( + _storage_group_for_item(owned_item) == group + and owned.storage_slot >= 0 + ): + occupied[owned.storage_slot] = true + for slot_index: int in range(MAX_STORAGE_SLOT_INDEX + 1): + if not occupied.has(slot_index): + return slot_index + return -1 + + +func _storage_group_for_item(item: ItemDataType) -> StorageGroup: + if item == null or item.is_bait() or item.is_lure(): + return StorageGroup.NONE + return ( + StorageGroup.ITEMS + if item.category == ItemDataType.Category.CONSUMABLE + else StorageGroup.EQUIPMENT + ) + + +func _storage_slot_key(group: StorageGroup, slot_index: int) -> String: + return "%d:%d" % [int(group), slot_index] + + func _resolve_valid_item(item_id: StringName) -> ItemDataType: if _catalog == null: return null diff --git a/items/owned_item.gd b/items/owned_item.gd index ead5b34..ade8cd1 100644 --- a/items/owned_item.gd +++ b/items/owned_item.gd @@ -1,12 +1,20 @@ class_name OwnedItem extends Resource +const MAX_STORAGE_SLOT_INDEX: int = 254 + @export var item_id: StringName @export var quantity: int = 1 +@export var storage_slot: int = -1 func is_valid() -> bool: - return not item_id.is_empty() and quantity > 0 + return ( + not item_id.is_empty() + and quantity > 0 + and storage_slot >= -1 + and storage_slot <= MAX_STORAGE_SLOT_INDEX + ) func duplicate_record(): @@ -17,4 +25,5 @@ func to_save_dict() -> Dictionary: return { "item_id": String(item_id), "quantity": quantity, + "storage_slot": storage_slot, } diff --git a/save/player_save_manager.gd b/save/player_save_manager.gd index 57e355d..5147709 100644 --- a/save/player_save_manager.gd +++ b/save/player_save_manager.gd @@ -793,6 +793,11 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: var owned := OwnedItemType.new() owned.item_id = item_id owned.quantity = quantity + owned.storage_slot = _read_integer( + item_record.get("storage_slot", -1), + -1, + PlayerBagType.MAX_STORAGE_SLOT_INDEX, + ) seen_items[item_id] = true snapshot.bag_items.append(owned) diff --git a/tests/inventory_storage_persistence_validation.gd b/tests/inventory_storage_persistence_validation.gd new file mode 100644 index 0000000..3af05fe --- /dev/null +++ b/tests/inventory_storage_persistence_validation.gd @@ -0,0 +1,67 @@ +extends SceneTree + +const MainScene: PackedScene = preload("res://main/main.tscn") + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + root.size = Vector2i(1280, 720) + var main: Node = MainScene.instantiate() + root.add_child(main) + for _frame: int in 4: + await process_frame + if not bool(main.get("_application_initialized")): + main.call("_activate_selected_data_path", "", true) + for _frame: int in 8: + await process_frame + assert(bool(main.get("_application_initialized"))) + + var save_manager := main.get("_save_manager") as PlayerSaveManager + var player := main.get("_player") as Player + assert(save_manager != null and player != null) + assert(save_manager.initialize_new_game()) + save_manager.set_autosave_enabled(true) + assert(player.bag.add_item(&"art_kit")) + assert(player.bag.add_item(&"coffee")) + assert(player.bag.move_item_to_storage_slot(&"basic_fishing_rod", 14)) + assert(player.bag.move_item_to_storage_slot(&"coffee", 12)) + assert(save_manager.save_now()) + + var save_file := FileAccess.open( + str(save_manager.get("_save_path")), + FileAccess.READ, + ) + assert(save_file != null) + var parsed: Variant = JSON.parse_string(save_file.get_as_text()) + save_file.close() + assert(typeof(parsed) == TYPE_DICTIONARY) + var records: Array = (parsed as Dictionary)["bag"]["items"] + assert(_saved_slot(records, &"basic_fishing_rod") == 14) + assert(_saved_slot(records, &"coffee") == 12) + + assert(player.bag.move_item_to_storage_slot(&"basic_fishing_rod", 0)) + assert(player.bag.move_item_to_storage_slot(&"coffee", 0)) + assert(save_manager.load_player_data()) + assert(player.bag.get_storage_slot(&"basic_fishing_rod") == 14) + assert(player.bag.get_storage_slot(&"coffee") == 12) + + main.queue_free() + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout + print("Inventory storage persistence validation: PASS") + quit() + + +func _saved_slot(records: Array, item_id: StringName) -> int: + for value: Variant in records: + if ( + typeof(value) == TYPE_DICTIONARY + and str((value as Dictionary).get("item_id", "")) + == String(item_id) + ): + return int((value as Dictionary).get("storage_slot", -1)) + return -1 diff --git a/tests/inventory_storage_persistence_validation.gd.uid b/tests/inventory_storage_persistence_validation.gd.uid new file mode 100644 index 0000000..6edda3b --- /dev/null +++ b/tests/inventory_storage_persistence_validation.gd.uid @@ -0,0 +1 @@ +uid://bwp2jhs116hmw diff --git a/tests/inventory_storage_validation.gd b/tests/inventory_storage_validation.gd new file mode 100644 index 0000000..b3438f0 --- /dev/null +++ b/tests/inventory_storage_validation.gd @@ -0,0 +1,172 @@ +extends SceneTree + +const BagItemSpriteType = preload( + "res://ui/components/bubble_menu/bag_item_sprite.gd" +) +const BagStorageSlotType = preload( + "res://ui/components/bubble_menu/bag_storage_slot.gd" +) +const Catalog: ItemCatalog = preload( + "res://items/catalog/item_catalog.tres" +) +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 PlayerMenuScene = preload("res://ui/player_menu.tscn") +const PlayerMenuType = preload("res://ui/player_menu.gd") + +const EQUIPMENT_IDS: Array[StringName] = [ + &"basic_fishing_rod", + &"art_kit", + &"crab_net", + &"magnet", +] +const ITEM_IDS: Array[StringName] = [ + &"coffee", + &"energy_drink", + &"snack", +] + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + root.size = Vector2i(1280, 720) + var bag := PlayerBagType.new() + bag.setup(Catalog) + for item_id: StringName in EQUIPMENT_IDS + ITEM_IDS: + assert(bag.add_item(item_id)) + for index: int in EQUIPMENT_IDS.size(): + assert(bag.get_storage_slot(EQUIPMENT_IDS[index]) == index) + for index: int in ITEM_IDS.size(): + assert(bag.get_storage_slot(ITEM_IDS[index]) == index) + + assert(bag.move_item_to_storage_slot(&"basic_fishing_rod", 14)) + assert(bag.get_storage_slot(&"basic_fishing_rod") == 14) + assert(bag.move_item_to_storage_slot(&"crab_net", 14)) + assert(bag.get_storage_slot(&"crab_net") == 14) + assert(bag.get_storage_slot(&"basic_fishing_rod") == 2) + var saved_record: Dictionary = bag.get_owned_item( + &"crab_net" + ).to_save_dict() + assert(int(saved_record.get("storage_slot", -1)) == 14) + + var legacy_equipment := OwnedItemType.new() + legacy_equipment.item_id = &"basic_fishing_rod" + var legacy_item := OwnedItemType.new() + legacy_item.item_id = &"coffee" + var legacy_records: Array[OwnedItemType] = [ + legacy_equipment, + legacy_item, + ] + var legacy_bag := PlayerBagType.new() + legacy_bag.setup(Catalog) + assert(legacy_bag.replace_all_items(legacy_records)) + assert(legacy_bag.get_storage_slot(&"basic_fishing_rod") == 0) + assert(legacy_bag.get_storage_slot(&"coffee") == 0) + legacy_bag.free() + + var menu := PlayerMenuScene.instantiate() as PlayerMenu + root.add_child(menu) + await process_frame + menu.set("_bag", bag) + var hotbar := PlayerHotbarType.new() + hotbar.setup(bag, Catalog) + menu.set("_hotbar", hotbar) + menu.set("_item_catalog", Catalog) + menu.set("_bag_view", PlayerMenuType.BagView.EQUIPMENT) + menu.visible = true + menu.call("_show_section_immediate", PlayerMenuType.Section.BAG) + menu.call("_set_content_interactive", true) + menu.call("_refresh_bag") + await process_frame + _validate_grid(menu, EQUIPMENT_IDS.size()) + + var item_nodes: Dictionary = menu.get("_bag_item_nodes") + var crab_node := item_nodes.get(&"crab_net") as BagItemSpriteType + assert(crab_node != null) + menu.set( + "_controller_ownership", + PlayerMenuType.ControllerOwnership.ITEM_LIST, + ) + menu.call("_apply_inventory_controller_zone_focus_modes") + crab_node.grab_focus() + assert(bool(menu.call("_try_begin_controller_storage_placement"))) + await process_frame + assert( + menu.get("_controller_ownership") + == PlayerMenuType.ControllerOwnership.STORAGE_PLACEMENT + ) + var slots: Array = menu.get("_bag_slot_nodes") + var bottom_slot := slots[12] as BagStorageSlotType + bottom_slot.grab_focus() + var down := InputEventAction.new() + down.action = &"ui_down" + down.pressed = true + assert(bool(menu.call("_handle_controller_ownership_input", down))) + assert( + menu.get("_controller_ownership") + == PlayerMenuType.ControllerOwnership.HOTBAR_PLACEMENT + ) + var up := InputEventAction.new() + up.action = &"ui_up" + up.pressed = true + assert(bool(menu.call("_handle_controller_ownership_input", up))) + assert( + menu.get("_controller_ownership") + == PlayerMenuType.ControllerOwnership.STORAGE_PLACEMENT + ) + await process_frame + assert(root.gui_get_focus_owner() == bottom_slot) + assert(StringName(menu.get("_controller_storage_identity")) == &"crab_net") + var target_slot := slots[7] as BagStorageSlotType + target_slot.grab_focus() + menu.call("_confirm_controller_storage_placement") + await process_frame + assert(bag.get_storage_slot(&"crab_net") == 7) + assert( + menu.get("_controller_ownership") + == PlayerMenuType.ControllerOwnership.ITEM_LIST + ) + + menu.call("_show_bag_view", PlayerMenuType.BagView.CONSUMABLES) + await process_frame + _validate_grid(menu, ITEM_IDS.size()) + menu.call("_on_bag_item_dropped", &"coffee", 12) + await process_frame + assert(bag.get_storage_slot(&"coffee") == 12) + + menu.queue_free() + hotbar.free() + bag.free() + await process_frame + print("Inventory storage validation: PASS") + quit() + + +func _validate_grid(menu: PlayerMenu, expected_items: int) -> void: + var slots: Array = menu.get("_bag_slot_nodes") + assert(slots.size() == PlayerBagType.MIN_STORAGE_SLOT_COUNT) + var seen_positions: Dictionary[Vector2, bool] = {} + for index: int in slots.size(): + var slot := slots[index] as BagStorageSlotType + assert(slot != null) + assert(slot.storage_slot_index == index) + assert(not seen_positions.has(slot.position)) + seen_positions[slot.position] = true + var normal := slot.get_theme_stylebox("normal") as StyleBoxFlat + assert(normal != null) + assert(normal.bg_color.a >= 0.7) + assert((slots[1] as Control).position.x > (slots[0] as Control).position.x) + assert((slots[5] as Control).position.y > (slots[0] as Control).position.y) + var item_nodes := menu.get("_bag_item_nodes") as Dictionary + assert( + item_nodes.size() == expected_items, + "expected %d visible bag items, found %d: %s" % [ + expected_items, + item_nodes.size(), + str(item_nodes.keys()), + ], + ) diff --git a/tests/inventory_storage_validation.gd.uid b/tests/inventory_storage_validation.gd.uid new file mode 100644 index 0000000..1c8717c --- /dev/null +++ b/tests/inventory_storage_validation.gd.uid @@ -0,0 +1 @@ +uid://w6e06q8yfwp6 diff --git a/tests/tackle_order_validation.gd b/tests/tackle_order_validation.gd new file mode 100644 index 0000000..43da0e9 --- /dev/null +++ b/tests/tackle_order_validation.gd @@ -0,0 +1,50 @@ +extends SceneTree + +const Catalog: ItemCatalog = preload("res://items/catalog/item_catalog.tres") +const PlayerMenuType = preload("res://ui/player_menu.gd") + +const EXPECTED_BAIT_ORDER: Array[StringName] = [ + &"worms", + &"snails", + &"shrimp", + &"squid_chunks", + &"whole_sardine", + &"whole_anchovy", + &"luminous_roe", +] + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var player_menu := PlayerMenuType.new() as PlayerMenu + player_menu.set("_item_catalog", Catalog) + var tackle_items: Array[OwnedItem] = [] + for item_id: StringName in [ + &"luminous_roe", + &"whole_anchovy", + &"shrimp", + &"snails", + &"whole_sardine", + &"squid_chunks", + &"worms", + ]: + var owned := OwnedItem.new() + owned.item_id = item_id + owned.quantity = 1 + tackle_items.append(owned) + tackle_items.sort_custom( + func(first: OwnedItem, second: OwnedItem) -> bool: + return bool(player_menu.call( + "_sort_tackle_items", first, second + )) + ) + var sorted_ids: Array[StringName] = [] + for owned: OwnedItem in tackle_items: + sorted_ids.append(owned.item_id) + assert(sorted_ids == EXPECTED_BAIT_ORDER) + player_menu.free() + print("Tackle order validation: PASS") + quit() diff --git a/tests/tackle_order_validation.gd.uid b/tests/tackle_order_validation.gd.uid new file mode 100644 index 0000000..353ba7e --- /dev/null +++ b/tests/tackle_order_validation.gd.uid @@ -0,0 +1 @@ +uid://27tkdvxgkv3y diff --git a/ui/components/bubble_menu/bag_storage_slot.gd b/ui/components/bubble_menu/bag_storage_slot.gd new file mode 100644 index 0000000..21f859e --- /dev/null +++ b/ui/components/bubble_menu/bag_storage_slot.gd @@ -0,0 +1,72 @@ +class_name BagStorageSlot +extends Button + +signal bag_item_dropped(item_id: StringName, slot_index: int) + +var storage_slot_index: int = -1 +var _preview: TextureRect + + +func _ready() -> void: + focus_mode = Control.FOCUS_NONE + toggle_mode = false + mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND + _apply_style() + _preview = TextureRect.new() + _preview.name = "PlacementPreview" + _preview.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _preview.offset_left = 8.0 + _preview.offset_top = 7.0 + _preview.offset_right = -8.0 + _preview.offset_bottom = -7.0 + _preview.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + _preview.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + _preview.mouse_filter = Control.MOUSE_FILTER_IGNORE + _preview.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + _preview.visible = false + add_child(_preview) + + +func configure(slot_index: int) -> void: + storage_slot_index = slot_index + name = "BagStorageSlot%d" % slot_index + accessibility_name = "storage slot %d" % (slot_index + 1) + + +func set_placement_preview(texture: Texture2D, active: bool) -> void: + if _preview == null: + return + _preview.texture = texture + _preview.visible = active and texture != null + z_index = 2 if _preview.visible else 0 + + +func _can_drop_data(_at_position: Vector2, data: Variant) -> bool: + return ( + typeof(data) == TYPE_DICTIONARY + and str((data as Dictionary).get("kind", "")) == "bag_item" + and not str((data as Dictionary).get("item_id", "")).is_empty() + ) + + +func _drop_data(_at_position: Vector2, data: Variant) -> void: + if not _can_drop_data(Vector2.ZERO, data): + return + bag_item_dropped.emit( + StringName(str((data as Dictionary)["item_id"])), + storage_slot_index, + ) + + +func _apply_style() -> void: + var normal := UtilityPageStyle.rounded_style( + Color(UtilityPageStyle.OCEAN_FIELD, 0.72), + 14, + ) + var hover := UtilityPageStyle.rounded_style( + Color(UtilityPageStyle.OCEAN_SELECTED, 0.68), + 14, + ) + for state: StringName in [&"normal", &"pressed", &"focus", &"disabled"]: + add_theme_stylebox_override(state, normal) + add_theme_stylebox_override(&"hover", hover) diff --git a/ui/components/bubble_menu/bag_storage_slot.gd.uid b/ui/components/bubble_menu/bag_storage_slot.gd.uid new file mode 100644 index 0000000..605acc0 --- /dev/null +++ b/ui/components/bubble_menu/bag_storage_slot.gd.uid @@ -0,0 +1 @@ +uid://ofac40nwf1oa diff --git a/ui/player_menu.gd b/ui/player_menu.gd index fd53d45..5d46f07 100644 --- a/ui/player_menu.gd +++ b/ui/player_menu.gd @@ -9,6 +9,9 @@ const FishQualityType = preload("res://fish/fish_quality.gd") const FishBuyerProfileType = preload("res://economy/fish_buyer_profile.gd") const FishSaleResultType = preload("res://economy/fish_sale_result.gd") const FishSaleServiceType = preload("res://economy/fish_sale_service.gd") +const FishingShopStockType = preload( + "res://economy/fishing_shop_stock.gd" +) const NetworkSessionType = preload("res://network/network_session.gd") const FishInventoryType = preload("res://inventory/fish_inventory.gd") const InventoryNotepadType = preload( @@ -72,6 +75,9 @@ const BagItemSpriteType = preload( const BagItemSpriteScene = preload( "res://ui/components/bubble_menu/bag_item_sprite.tscn" ) +const BagStorageSlotType = preload( + "res://ui/components/bubble_menu/bag_storage_slot.gd" +) const LogbookEntryType = preload( "res://ui/components/bubble_menu/logbook_entry.gd" ) @@ -140,6 +146,7 @@ enum ControllerOwnership { SORT_FILTER, HOTBAR_MANAGEMENT, HOTBAR_PLACEMENT, + STORAGE_PLACEMENT, PAGE_CONTENT, } @@ -154,6 +161,8 @@ const INVENTORY_NOTEPAD_SIZE := Vector2(278.0, 484.0) const INVENTORY_MAIN_CORNER_RADIUS: int = 58 const INVENTORY_INNER_CORNER_RADIUS: int = 45 const TACKLE_GRID_COLUMNS: int = 3 +const BAG_STORAGE_COLUMNS: int = 5 +const BAG_STORAGE_MINIMUM_SLOTS: int = 15 const SALE_CONFIRMATION_SIZE := Vector2(520.0, 190.0) const CONTROLLER_PICKUP_HOLD_SECONDS: float = 0.42 @@ -325,7 +334,9 @@ var _controller_hotbar_assignment_kind: PlayerHotbarType.AssignmentKind = ( PlayerHotbarType.AssignmentKind.EMPTY ) var _controller_hotbar_identity: StringName +var _controller_storage_identity: StringName var _controller_previous_hotbar_slot: int = 0 +var _controller_previous_storage_slot: int = 0 var _controller_accept_held: bool = false var _controller_accept_hold_elapsed: float = 0.0 var _content_interactive_enabled: bool = false @@ -367,7 +378,7 @@ var _fish_nodes: Dictionary[StringName, CoolerFishSpriteType] = {} var _cooler_slot_nodes: Array[Panel] = [] var _sorted_catches: Array[FishCatchType] = [] var _bag_item_nodes: Dictionary[StringName, BagItemSpriteType] = {} -var _bag_slot_nodes: Array[Panel] = [] +var _bag_slot_nodes: Array[BagStorageSlotType] = [] var _sorted_bag_items: Array[OwnedItemType] = [] var _bag_drag_active: bool = false var _motion_elapsed: float = 0.0 @@ -693,16 +704,45 @@ func _handle_controller_ownership_input(event: InputEvent) -> bool: return true return false if _controller_ownership == ControllerOwnership.HOTBAR_MANAGEMENT: + if event.is_action_pressed("ui_up"): + _release_controller_ownership(true, false) + return true if accept_pressed: if _hotbar != null: _hotbar.clear_slot(_hotbar.get_selected_slot()) return true return false if _controller_ownership == ControllerOwnership.HOTBAR_PLACEMENT: + if event.is_action_pressed("ui_up"): + if _return_controller_hotbar_placement_to_storage(): + return true + _release_controller_ownership(true, true) + return true if accept_pressed: _confirm_controller_hotbar_placement() return true return false + if _controller_ownership == ControllerOwnership.STORAGE_PLACEMENT: + if accept_pressed: + _confirm_controller_storage_placement() + return true + if accept_event: + return true + if ( + event.is_action_pressed("ui_down") + and _controller_storage_focus_is_on_last_row() + ): + var focused_slot := ( + get_viewport().gui_get_focus_owner() as BagStorageSlotType + ) + if focused_slot != null: + _controller_previous_storage_slot = ( + focused_slot.storage_slot_index + ) + return _begin_controller_hotbar_placement_for_item( + _controller_storage_identity + ) + return false if _controller_ownership == ControllerOwnership.NOTEPAD_ACTIONS: return false if _controller_ownership == ControllerOwnership.SORT_FILTER: @@ -886,13 +926,26 @@ func _controller_focus_is_on_last_inventory_row() -> bool: var item_node := focus_owner as BagItemSpriteType if item_node == null: return false - var item_index: int = _sorted_bag_items.find_custom( - func(owned: OwnedItemType) -> bool: - return owned.item_id == item_node.item_id + if _bag == null: + var legacy_index: int = _sorted_bag_items.find_custom( + func(owned: OwnedItemType) -> bool: + return owned.item_id == item_node.item_id + ) + return ( + legacy_index >= 0 + and legacy_index + BAG_STORAGE_COLUMNS + >= _sorted_bag_items.size() + ) + var item_slot: int = ( + _bag.get_storage_slot(item_node.item_id) ) + var maximum_slot: int = -1 + for owned: OwnedItemType in _sorted_bag_items: + maximum_slot = maxi(maximum_slot, owned.storage_slot) return ( - item_index >= 0 - and item_index + 3 >= _sorted_bag_items.size() + item_slot >= 0 + and item_slot / BAG_STORAGE_COLUMNS + >= maximum_slot / BAG_STORAGE_COLUMNS ) return false @@ -956,6 +1009,28 @@ func _try_begin_controller_hotbar_placement() -> bool: identity = item_node.item_id else: return false + return _begin_controller_hotbar_placement(assignment_kind, identity) + + +func _begin_controller_hotbar_placement_for_item(item_id: StringName) -> bool: + var item: ItemDataType = ( + _item_catalog.get_item_by_id(item_id) + if _item_catalog != null else null + ) + if item == null or not item.hotbar_allowed: + return true + return _begin_controller_hotbar_placement( + PlayerHotbarType.AssignmentKind.ITEM, + item_id, + ) + + +func _begin_controller_hotbar_placement( + assignment_kind: PlayerHotbarType.AssignmentKind, + identity: StringName, +) -> bool: + if _hotbar == null or identity.is_empty(): + return false _controller_source_section = _current_section _controller_source_identity = identity _controller_hotbar_assignment_kind = assignment_kind @@ -977,6 +1052,53 @@ func _try_begin_controller_hotbar_placement() -> bool: return true +func _try_begin_controller_storage_placement() -> bool: + if _current_section != Section.BAG or _bag == null: + return false + var item_node := get_viewport().gui_get_focus_owner() as BagItemSpriteType + if item_node == null or item_node.item_id.is_empty(): + return false + var source_slot: int = _bag.get_storage_slot(item_node.item_id) + if source_slot < 0 or source_slot >= _bag_slot_nodes.size(): + return false + _controller_source_section = Section.BAG + _controller_source_identity = item_node.item_id + _controller_storage_identity = item_node.item_id + _controller_ownership = ControllerOwnership.STORAGE_PLACEMENT + _apply_inventory_controller_zone_focus_modes() + _configure_bag_storage_slot_focus() + _bag_slot_nodes[source_slot].call_deferred("grab_focus") + return true + + +func _confirm_controller_storage_placement() -> void: + if ( + _controller_ownership != ControllerOwnership.STORAGE_PLACEMENT + or _bag == null + or _controller_storage_identity.is_empty() + ): + return + var slot := get_viewport().gui_get_focus_owner() as BagStorageSlotType + if slot == null or slot.storage_slot_index < 0: + return + var moved: bool = _bag.move_item_to_storage_slot( + _controller_storage_identity, + slot.storage_slot_index, + ) + if moved: + _release_controller_ownership(true, false) + + +func _controller_storage_focus_is_on_last_row() -> bool: + var slot := get_viewport().gui_get_focus_owner() as BagStorageSlotType + if slot == null: + return false + return ( + slot.storage_slot_index / BAG_STORAGE_COLUMNS + >= (_bag_slot_nodes.size() - 1) / BAG_STORAGE_COLUMNS + ) + + func _find_controller_hotbar_assignment( assignment_kind: PlayerHotbarType.AssignmentKind, identity: StringName, @@ -1023,6 +1145,31 @@ func _confirm_controller_hotbar_placement() -> void: _release_controller_ownership(true, false) +func _return_controller_hotbar_placement_to_storage() -> bool: + if ( + _controller_ownership != ControllerOwnership.HOTBAR_PLACEMENT + or _controller_source_section != Section.BAG + or _controller_storage_identity.is_empty() + or _bag_slot_nodes.is_empty() + ): + return false + if _hotbar != null: + _hotbar.select_slot(_controller_previous_hotbar_slot) + controller_hotbar_placement_ended.emit() + _controller_ownership = ControllerOwnership.STORAGE_PLACEMENT + _controller_hotbar_assignment_kind = PlayerHotbarType.AssignmentKind.EMPTY + _controller_hotbar_identity = StringName() + _apply_inventory_controller_zone_focus_modes() + _configure_bag_storage_slot_focus() + var target_index: int = clampi( + _controller_previous_storage_slot, + 0, + _bag_slot_nodes.size() - 1, + ) + _bag_slot_nodes[target_index].call_deferred("grab_focus") + return true + + func _release_controller_ownership( restore_source_focus: bool, restore_previous_hotbar_slot: bool, @@ -1037,6 +1184,7 @@ func _release_controller_ownership( _controller_source_identity = StringName() _controller_hotbar_assignment_kind = PlayerHotbarType.AssignmentKind.EMPTY _controller_hotbar_identity = StringName() + _controller_storage_identity = StringName() _apply_inventory_controller_zone_focus_modes() if prior_ownership == ControllerOwnership.HOTBAR_PLACEMENT: if restore_previous_hotbar_slot and _hotbar != null: @@ -1320,6 +1468,8 @@ func _consume_player_menu_back() -> bool: _release_controller_ownership(true, false) ControllerOwnership.HOTBAR_PLACEMENT: _release_controller_ownership(true, true) + ControllerOwnership.STORAGE_PLACEMENT: + _release_controller_ownership(true, false) ControllerOwnership.ITEM_LIST: _enter_inventory_tabs_zone() ControllerOwnership.INVENTORY_TABS: @@ -1773,7 +1923,10 @@ func _process(delta: float) -> void: >= CONTROLLER_PICKUP_HOLD_SECONDS ): _cancel_controller_accept_hold() - _try_begin_controller_hotbar_placement() + if _current_section == Section.BAG: + _try_begin_controller_storage_placement() + else: + _try_begin_controller_hotbar_placement() _motion_elapsed += delta if visible: _navigation_cluster.advance_motion(delta) @@ -1907,6 +2060,11 @@ func _apply_inventory_controller_zone_focus_modes() -> void: regular_active and _controller_ownership == ControllerOwnership.SORT_FILTER ) + var storage_active: bool = ( + regular_active + and _current_section == Section.BAG + and _controller_ownership == ControllerOwnership.STORAGE_PLACEMENT + ) for sort_control: Control in [ _cooler_sort_option, _cooler_sort_direction, @@ -1952,6 +2110,27 @@ func _apply_inventory_controller_zone_focus_modes() -> void: ) else Control.FOCUS_NONE ) + item_node.modulate.a = ( + 0.24 + if ( + storage_active + and item_node.item_id == _controller_storage_identity + ) + else 1.0 + ) + for slot: BagStorageSlotType in _bag_slot_nodes: + if not is_instance_valid(slot): + continue + slot.focus_mode = ( + Control.FOCUS_ALL if storage_active else Control.FOCUS_NONE + ) + var preview_active: bool = ( + storage_active and slot.has_focus() + ) + slot.set_placement_preview( + _controller_storage_texture(), + preview_active, + ) for button: Button in _tackle_item_buttons.values(): button.focus_mode = ( Control.FOCUS_ALL @@ -2124,8 +2303,8 @@ func _refresh_tackle_box() -> void: ) if item != null and item.category == ItemDataType.Category.LURE: lure_items.append(owned) - bait_items.sort_custom(_sort_bag_items) - lure_items.sort_custom(_sort_bag_items) + bait_items.sort_custom(_sort_tackle_items) + lure_items.sort_custom(_sort_tackle_items) _populate_tackle_column(bait_items, _bait_item_list) _populate_tackle_column(lure_items, _lure_item_list) if ( @@ -3411,10 +3590,10 @@ func _refresh_bag() -> void: ): filtered_items.append(owned) owned_items = filtered_items - owned_items.sort_custom(_sort_bag_items) + owned_items.sort_custom(_sort_bag_storage_items) _sorted_bag_items = owned_items - _bag_empty.visible = owned_items.is_empty() - _bag_empty_state.visible = owned_items.is_empty() + _bag_empty.visible = false + _bag_empty_state.visible = false _bag_empty_state.text = ( "No equipment in your Bag." if _bag_view == BagView.EQUIPMENT @@ -3462,9 +3641,9 @@ func _sync_bag_item_nodes(owned_items: Array[OwnedItemType]) -> void: 0.97 + float(identity_hash % 7) * 0.01, ) item_node.tooltip_text = ( - "drag to a hotbar slot." + "drag to another storage slot or the hotbar." if item.hotbar_allowed - else "this item cannot be assigned to the hotbar." + else "drag to another storage slot." ) item_node.disabled = false item_node.set_selected(item.item_id == _selected_bag_item_id) @@ -3482,63 +3661,104 @@ func _sync_bag_item_nodes(owned_items: Array[OwnedItemType]) -> void: func _layout_bag_items() -> void: if not is_node_ready(): return - _bag_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED - var columns: int = 3 + _bag_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO + var columns: int = BAG_STORAGE_COLUMNS var cell_size := ( - Vector2(166.0, 72.0) + Vector2(102.0, 48.0) if _compact_layout - else Vector2(246.0, 154.0) + else Vector2(154.0, 110.0) + ) + var slot_size := ( + Vector2(90.0, 40.0) + if _compact_layout + else Vector2(140.0, 96.0) ) var item_size := ( - Vector2(86.0, 62.0) + Vector2(78.0, 36.0) if _compact_layout - else Vector2(138.0, 102.0) + else Vector2(122.0, 82.0) ) var origin := ( - Vector2(16.0, 4.0) + Vector2(5.0, 4.0) if _compact_layout - else Vector2(54.0, 44.0) + else Vector2(10.0, 9.0) ) - _bag_item_field.custom_minimum_size = ( - Vector2(520.0, 140.0) - if _compact_layout - else Vector2(820.0, 240.0) + var highest_slot: int = -1 + for owned: OwnedItemType in _sorted_bag_items: + highest_slot = maxi(highest_slot, owned.storage_slot) + var slot_count: int = maxi( + BAG_STORAGE_MINIMUM_SLOTS, + highest_slot + 1, ) - _sync_inventory_slot_visuals( - _bag_item_field, - _bag_slot_nodes, - maxi(9, _sorted_bag_items.size()), + slot_count = ceili(float(slot_count) / float(columns)) * columns + var required_rows: int = ceili(float(slot_count) / float(columns)) + var content_size := Vector2( + 520.0 if _compact_layout else 788.0, + maxf( + 152.0 if _compact_layout else 358.0, + origin.y * 2.0 + float(required_rows) * cell_size.y, + ), + ) + _bag_host.custom_minimum_size = content_size + _bag_item_field.custom_minimum_size = content_size + _sync_bag_storage_slots( + slot_count, columns, cell_size, - item_size, + slot_size, origin, - 18.0, ) - for index: int in _sorted_bag_items.size(): - var owned: OwnedItemType = _sorted_bag_items[index] + for owned: OwnedItemType in _sorted_bag_items: var item_node := _bag_item_nodes.get( owned.item_id ) as BagItemSpriteType - if item_node == null: + if item_node == null or owned.storage_slot < 0: continue - var identity_hash: int = absi(String(owned.item_id).hash()) - var column: int = index % columns - var row: int = floori(float(index) / float(columns)) - var stable_offset := Vector2( - float(identity_hash % 13) - 6.0, - float(floori(float(identity_hash) / 19.0) % 11) - 5.0, + var column: int = owned.storage_slot % columns + var row: int = floori(float(owned.storage_slot) / float(columns)) + var slot_position := origin + Vector2( + float(column) * cell_size.x, + float(row) * cell_size.y, ) - var lane_offset: float = 18.0 if row % 2 == 1 else 0.0 item_node.custom_minimum_size = item_size item_node.size = item_size item_node.position = ( - origin - + Vector2( - float(column) * cell_size.x + lane_offset, - float(row) * cell_size.y, - ) - + stable_offset + slot_position + (slot_size - item_size) * 0.5 ) + item_node.z_index = 1 + + +func _sync_bag_storage_slots( + slot_count: int, + columns: int, + cell_size: Vector2, + slot_size: Vector2, + origin: Vector2, +) -> void: + for slot: BagStorageSlotType in _bag_slot_nodes: + if is_instance_valid(slot): + slot.queue_free() + _bag_slot_nodes.clear() + for slot_index: int in slot_count: + var row: int = floori(float(slot_index) / float(columns)) + var column: int = slot_index % columns + var slot := BagStorageSlotType.new() + slot.configure(slot_index) + slot.size = slot_size + slot.custom_minimum_size = slot_size + slot.position = origin + Vector2( + float(column) * cell_size.x, + float(row) * cell_size.y, + ) + slot.bag_item_dropped.connect(_on_bag_item_dropped) + slot.pressed.connect(_on_bag_storage_slot_pressed.bind(slot)) + slot.focus_entered.connect( + _on_bag_storage_slot_focus_entered.bind(slot) + ) + _bag_item_field.add_child(slot) + _bag_item_field.move_child(slot, 0) + _bag_slot_nodes.append(slot) + _configure_bag_storage_slot_focus() func _sync_inventory_slot_visuals( @@ -3596,30 +3816,44 @@ func _configure_bag_item_focus() -> void: active_tab.focus_neighbor_bottom = active_tab.get_path_to( controls.front() ) - for index: int in controls.size(): - var control: BagItemSpriteType = controls[index] - var column: int = index % 3 + ControllerFocusNavigationType.configure_spatial_neighbors(controls) + + +func _configure_bag_storage_slot_focus() -> void: + for index: int in _bag_slot_nodes.size(): + var slot: BagStorageSlotType = _bag_slot_nodes[index] + if not is_instance_valid(slot): + continue + var column: int = index % BAG_STORAGE_COLUMNS var left_index: int = index - 1 if column > 0 else index var right_index: int = ( index + 1 - if column < 2 and index + 1 < controls.size() + if ( + column < BAG_STORAGE_COLUMNS - 1 + and index + 1 < _bag_slot_nodes.size() + ) else index ) - control.focus_neighbor_left = control.get_path_to( - controls[left_index] + var top_index: int = ( + index - BAG_STORAGE_COLUMNS + if index >= BAG_STORAGE_COLUMNS else index ) - control.focus_neighbor_right = control.get_path_to( - controls[right_index] + var bottom_index: int = ( + index + BAG_STORAGE_COLUMNS + if index + BAG_STORAGE_COLUMNS < _bag_slot_nodes.size() + else index ) - control.focus_neighbor_top = ( - control.get_path_to(control) - if index < 3 - else control.get_path_to(controls[index - 3]) + slot.focus_neighbor_left = slot.get_path_to( + _bag_slot_nodes[left_index] ) - control.focus_neighbor_bottom = control.get_path_to( - controls[index + 3] - if index + 3 < controls.size() - else control + slot.focus_neighbor_right = slot.get_path_to( + _bag_slot_nodes[right_index] + ) + slot.focus_neighbor_top = slot.get_path_to( + _bag_slot_nodes[top_index] + ) + slot.focus_neighbor_bottom = slot.get_path_to( + _bag_slot_nodes[bottom_index] ) @@ -3643,6 +3877,28 @@ func _sort_bag_items(a: OwnedItemType, b: OwnedItemType) -> bool: return item_a.display_name.naturalnocasecmp_to(item_b.display_name) < 0 +func _sort_bag_storage_items(a: OwnedItemType, b: OwnedItemType) -> bool: + if a.storage_slot >= 0 and b.storage_slot >= 0: + return a.storage_slot < b.storage_slot + if a.storage_slot >= 0: + return true + if b.storage_slot >= 0: + return false + return _sort_bag_items(a, b) + + +func _sort_tackle_items(a: OwnedItemType, b: OwnedItemType) -> bool: + var order_a := FishingShopStockType.get_stock_order_index(a.item_id) + var order_b := FishingShopStockType.get_stock_order_index(b.item_id) + if order_a >= 0 and order_b >= 0 and order_a != order_b: + return order_a < order_b + if order_a >= 0 and order_b < 0: + return true + if order_b >= 0 and order_a < 0: + return false + return _sort_bag_items(a, b) + + func _select_bag_item(item_id: StringName) -> void: if _bag_drag_active or get_viewport().gui_is_dragging(): return @@ -3743,6 +3999,42 @@ func _on_bag_drag_started() -> void: _set_content_interactive(false) +func _on_bag_item_dropped(item_id: StringName, slot_index: int) -> void: + if _bag == null or item_id.is_empty() or slot_index < 0: + return + if _bag.move_item_to_storage_slot(item_id, slot_index): + _select_bag_item(item_id) + + +func _on_bag_storage_slot_pressed(slot: BagStorageSlotType) -> void: + if ( + _controller_ownership == ControllerOwnership.STORAGE_PLACEMENT + and slot != null + ): + _confirm_controller_storage_placement() + + +func _on_bag_storage_slot_focus_entered(slot: BagStorageSlotType) -> void: + if _controller_ownership != ControllerOwnership.STORAGE_PLACEMENT: + return + var texture := _controller_storage_texture() + for candidate: BagStorageSlotType in _bag_slot_nodes: + if is_instance_valid(candidate): + candidate.set_placement_preview(texture, candidate == slot) + + +func _controller_storage_texture() -> Texture2D: + var item: ItemDataType = ( + _item_catalog.get_item_by_id(_controller_storage_identity) + if ( + _item_catalog != null + and not _controller_storage_identity.is_empty() + ) + else null + ) + return item.icon if item != null else null + + func _on_bag_drag_finished() -> void: _bag_drag_active = false if visible and _current_section == Section.BAG: @@ -3839,6 +4131,9 @@ func _sync_cooler_fish_nodes(catches: Array[FishCatchType]) -> void: fish_node.pressed.connect( _on_catch_card_pressed.bind(fish_catch.catch_id) ) + fish_node.focus_entered.connect( + _on_catch_card_focused.bind(fish_catch.catch_id) + ) var identity_hash: int = absi(String(fish_catch.catch_id).hash()) fish_node.configure( fish_catch.catch_id, @@ -4067,6 +4362,24 @@ func _on_catch_card_pressed(catch_id: StringName) -> void: _refresh_inventory() +func _on_catch_card_focused(catch_id: StringName) -> void: + if _inventory == null or _inventory.get_catch(catch_id) == null: + return + _fish_selection.focus_only(catch_id) + for visible_catch: FishCatchType in _sorted_catches: + var fish_node := _fish_nodes.get( + visible_catch.catch_id + ) as CoolerFishSpriteType + if fish_node == null: + continue + fish_node.set_item_state( + _fish_selection.is_selected(visible_catch.catch_id), + visible_catch.catch_id == catch_id, + visible_catch.is_favorited, + ) + _update_inventory_detail(_inventory.get_catch(catch_id)) + + func _on_fish_field_gui_input(event: InputEvent) -> void: if ( event is InputEventMouseButton