feat: add persistent inventory storage slots

This commit is contained in:
Alexander Sellite 2026-08-16 16:39:35 -04:00
parent 6db6fbba7f
commit a88e76848b
13 changed files with 875 additions and 65 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1 @@
uid://27tkdvxgkv3y

View file

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

View file

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

View file

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