straywild/inventory/player_inventory_layout.gd

519 lines
15 KiB
GDScript

class_name PlayerInventoryLayout
extends Node
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 FishInventoryType = preload("res://inventory/fish_inventory.gd")
const PlayerCoolerCapacityType = preload(
"res://progression/player_cooler_capacity.gd"
)
const PlayerWalletType = preload("res://economy/player_wallet.gd")
const INVENTORY_COLUMNS: int = 9
const INVENTORY_CAPACITIES: Array[int] = [9, 18, 27, 36]
const BACKPACK_EXPANSION_COSTS: Array[int] = [1500, 4500, 9000]
const MAX_BACKPACK_LEVEL: int = 3
const MAX_INVENTORY_SLOT_COUNT: int = 36
const MAX_STORAGE_SLOT_COUNT: int = PlayerCoolerCapacityType.MAX_CAPACITY
const HOTBAR_SLOT_COUNT: int = 9
enum EntryKind {
ITEM,
CATCH,
}
enum InventoryContainer {
INVENTORY,
STORAGE,
HOTBAR,
}
signal layout_changed
signal backpack_capacity_changed(level: int, capacity: int)
var _bag: PlayerBagType
var _fish_inventory: FishInventoryType
var _item_catalog: ItemCatalogType
var _storage_capacity: PlayerCoolerCapacityType
var _placements: Dictionary[String, Dictionary] = {}
var _reconciling: bool = false
var _backpack_level: int = 0
func setup(
bag: PlayerBagType,
fish_inventory: FishInventoryType,
item_catalog: ItemCatalogType,
storage_capacity: PlayerCoolerCapacityType,
) -> void:
_bag = bag
_fish_inventory = fish_inventory
_item_catalog = item_catalog
_storage_capacity = storage_capacity
if _bag != null and not _bag.contents_changed.is_connected(_on_contents_changed):
_bag.contents_changed.connect(_on_contents_changed)
if (
_fish_inventory != null
and not _fish_inventory.catches_changed.is_connected(_on_contents_changed)
):
_fish_inventory.catches_changed.connect(_on_contents_changed)
if (
_storage_capacity != null
and not _storage_capacity.capacity_changed.is_connected(
_on_storage_capacity_changed
)
):
_storage_capacity.capacity_changed.connect(_on_storage_capacity_changed)
_reconcile(true)
func can_accept_item(item_id: StringName) -> bool:
if not is_managed_item(item_id):
return true
var key := item_key(item_id)
return _placements.has(key) or _first_free_slot(InventoryContainer.INVENTORY) >= 0
func can_accept_catch(catch_id: StringName = StringName()) -> bool:
if not catch_id.is_empty() and _placements.has(catch_key(catch_id)):
return true
return _first_free_slot(InventoryContainer.INVENTORY) >= 0
func is_managed_item(item_id: StringName) -> bool:
var item: ItemDataType = (
_item_catalog.get_item_by_id(item_id)
if _item_catalog != null else null
)
return item != null and not item.is_bait() and not item.is_lure()
func is_item_in_inventory(item_id: StringName) -> bool:
return _is_entry_in_container(item_key(item_id), InventoryContainer.INVENTORY)
func is_catch_in_inventory(catch_id: StringName) -> bool:
return _is_entry_in_container(catch_key(catch_id), InventoryContainer.INVENTORY)
func is_item_carried(item_id: StringName) -> bool:
return _is_entry_carried(item_key(item_id))
func is_catch_carried(catch_id: StringName) -> bool:
return _is_entry_carried(catch_key(catch_id))
func get_inventory_count() -> int:
return get_entries(InventoryContainer.INVENTORY).size()
func get_inventory_capacity() -> int:
return INVENTORY_CAPACITIES[_backpack_level]
func get_backpack_level() -> int:
return _backpack_level
func get_next_inventory_capacity() -> int:
if _backpack_level >= MAX_BACKPACK_LEVEL:
return -1
return INVENTORY_CAPACITIES[_backpack_level + 1]
func get_next_backpack_cost() -> int:
if _backpack_level >= MAX_BACKPACK_LEVEL:
return -1
return BACKPACK_EXPANSION_COSTS[_backpack_level]
func can_purchase_backpack(wallet: PlayerWalletType) -> bool:
var cost := get_next_backpack_cost()
return wallet != null and cost >= 0 and wallet.can_afford(cost)
func purchase_backpack(wallet: PlayerWalletType) -> bool:
if not can_purchase_backpack(wallet):
return false
var cost := get_next_backpack_cost()
if not wallet.debit(cost):
return false
_backpack_level += 1
backpack_capacity_changed.emit(_backpack_level, get_inventory_capacity())
layout_changed.emit()
return true
func restore_backpack_level(level: int) -> bool:
var validated := clampi(level, 0, MAX_BACKPACK_LEVEL)
var changed := _backpack_level != validated
_backpack_level = validated
if changed:
backpack_capacity_changed.emit(_backpack_level, get_inventory_capacity())
_reconcile(changed)
return true
func get_storage_count() -> int:
return get_entries(InventoryContainer.STORAGE).size()
func get_hotbar_count() -> int:
return get_entries(InventoryContainer.HOTBAR).size()
func get_storage_capacity() -> int:
return _storage_capacity.get_capacity() if _storage_capacity != null else 0
func get_entries(container: InventoryContainer) -> Array[Dictionary]:
var entries: Array[Dictionary] = []
for key: String in _placements:
var placement: Dictionary = _placements[key]
if int(placement.get("container", -1)) != int(container):
continue
var entry: Dictionary = placement.duplicate(true)
entry["key"] = key
entries.append(entry)
entries.sort_custom(
func(left: Dictionary, right: Dictionary) -> bool:
return int(left.get("slot", -1)) < int(right.get("slot", -1))
)
return entries
func get_entry(kind: EntryKind, identity: StringName) -> Dictionary:
var key := _entry_key(kind, identity)
return _placements.get(key, {}).duplicate(true)
func get_container(kind: EntryKind, identity: StringName) -> int:
return int(
get_entry(kind, identity).get("container", -1)
)
func get_key_at(container: InventoryContainer, slot: int) -> String:
return _key_at(container, slot)
func get_entry_at(container: InventoryContainer, slot: int) -> Dictionary:
var key := _key_at(container, slot)
if key.is_empty():
return {}
var entry: Dictionary = _placements[key].duplicate(true)
entry["key"] = key
return entry
func move_entry(
kind: EntryKind,
identity: StringName,
target_container: InventoryContainer,
target_slot: int,
) -> bool:
var key := _entry_key(kind, identity)
if not _placements.has(key) or not _is_slot_valid(target_container, target_slot):
return false
var source: Dictionary = _placements[key]
if (
int(source.get("container", -1)) == int(target_container)
and int(source.get("slot", -1)) == target_slot
):
return true
var displaced_key := _key_at(target_container, target_slot)
var source_container: int = int(source.get("container", InventoryContainer.INVENTORY))
var source_slot: int = int(source.get("slot", -1))
source["container"] = int(target_container)
source["slot"] = target_slot
_placements[key] = source
if not displaced_key.is_empty():
var displaced: Dictionary = _placements[displaced_key]
displaced["container"] = source_container
displaced["slot"] = source_slot
_placements[displaced_key] = displaced
layout_changed.emit()
return true
func move_entry_to_first_free(
kind: EntryKind,
identity: StringName,
target_container: InventoryContainer,
) -> bool:
var slot := _first_free_slot(target_container)
return slot >= 0 and move_entry(kind, identity, target_container, slot)
func return_hotbar_entry_to_inventory(slot: int) -> bool:
var key := _key_at(InventoryContainer.HOTBAR, slot)
if key.is_empty():
return true
var placement: Dictionary = _placements[key]
return move_entry_to_first_free(
int(placement.get("kind", -1)) as EntryKind,
StringName(str(placement.get("identity", ""))),
InventoryContainer.INVENTORY,
)
func return_all_hotbar_entries_to_inventory() -> bool:
var entries := get_entries(InventoryContainer.HOTBAR)
if get_inventory_count() + entries.size() > get_inventory_capacity():
return false
for entry: Dictionary in entries:
if not move_entry_to_first_free(
int(entry.get("kind", -1)) as EntryKind,
StringName(str(entry.get("identity", ""))),
InventoryContainer.INVENTORY,
):
return false
return true
func to_save_data() -> Dictionary:
var serialized: Array[Dictionary] = []
var keys: Array[String] = []
keys.assign(_placements.keys())
keys.sort()
for key: String in keys:
var placement: Dictionary = _placements[key]
serialized.append({
"kind": int(placement.get("kind", EntryKind.ITEM)),
"identity": str(placement.get("identity", "")),
"container": int(placement.get("container", InventoryContainer.INVENTORY)),
"slot": int(placement.get("slot", -1)),
})
return {
"backpack_level": _backpack_level,
"placements": serialized,
}
func restore_from_save_data(data: Dictionary) -> bool:
var level_value: Variant = data.get("backpack_level", 0)
if (
typeof(level_value) not in [TYPE_INT, TYPE_FLOAT]
or not is_equal_approx(float(level_value), floorf(float(level_value)))
):
return false
_backpack_level = clampi(int(level_value), 0, MAX_BACKPACK_LEVEL)
var restored: Dictionary[String, Dictionary] = {}
var values: Variant = data.get("placements", [])
if typeof(values) != TYPE_ARRAY:
return false
for value: Variant in values as Array:
if typeof(value) != TYPE_DICTIONARY:
continue
var record := value as Dictionary
var kind: int = int(record.get("kind", -1))
var identity := StringName(str(record.get("identity", "")))
var container: int = int(record.get("container", -1))
var slot: int = int(record.get("slot", -1))
if (
kind not in [EntryKind.ITEM, EntryKind.CATCH]
or identity.is_empty()
or container not in [
InventoryContainer.INVENTORY,
InventoryContainer.STORAGE,
InventoryContainer.HOTBAR,
]
or not _is_slot_valid(container as InventoryContainer, slot)
):
continue
var key := _entry_key(kind as EntryKind, identity)
if restored.has(key) or _placement_slot_occupied(restored, container, slot):
continue
restored[key] = {
"kind": kind,
"identity": identity,
"container": container,
"slot": slot,
}
_placements = restored
_reconcile(true)
return true
func reset_to_defaults() -> void:
var capacity_changed := _backpack_level != 0
_backpack_level = 0
_placements.clear()
_reconcile(true)
if capacity_changed:
backpack_capacity_changed.emit(_backpack_level, get_inventory_capacity())
static func item_key(item_id: StringName) -> String:
return "item:%s" % String(item_id)
static func catch_key(catch_id: StringName) -> String:
return "catch:%s" % String(catch_id)
func _entry_key(kind: EntryKind, identity: StringName) -> String:
return item_key(identity) if kind == EntryKind.ITEM else catch_key(identity)
func _is_entry_in_container(key: String, container: InventoryContainer) -> bool:
var placement: Dictionary = _placements.get(key, {})
return int(placement.get("container", -1)) == int(container)
func _is_entry_carried(key: String) -> bool:
var placement: Dictionary = _placements.get(key, {})
var container: int = int(placement.get("container", -1))
return container in [InventoryContainer.INVENTORY, InventoryContainer.HOTBAR]
func _on_contents_changed() -> void:
_reconcile(false)
func _on_storage_capacity_changed(_level: int, _capacity: int) -> void:
_reconcile(true)
func _reconcile(force_signal: bool) -> void:
if _reconciling or _bag == null or _fish_inventory == null:
return
_reconciling = true
var expected: Dictionary[String, Dictionary] = {}
for owned in _bag.get_all_items():
if owned == null or not is_managed_item(owned.item_id):
continue
var key := item_key(owned.item_id)
expected[key] = {"kind": EntryKind.ITEM, "identity": owned.item_id}
for fish_catch in _fish_inventory.get_all_catches():
if fish_catch == null or not fish_catch.is_valid():
continue
var key := catch_key(fish_catch.catch_id)
expected[key] = {"kind": EntryKind.CATCH, "identity": fish_catch.catch_id}
var changed: bool = false
for key: String in _placements.keys():
if expected.has(key):
continue
_placements.erase(key)
changed = true
_normalize_existing_placements()
for key: String in expected:
if _placements.has(key):
continue
var target_container := InventoryContainer.INVENTORY
var target_slot := _first_free_slot(target_container)
if target_slot < 0:
target_container = InventoryContainer.STORAGE
target_slot = _first_free_slot(target_container)
if target_slot < 0:
push_error("Inventory and storage cannot fit owned entry '%s'." % key)
continue
var placement: Dictionary = expected[key].duplicate(true)
placement["container"] = int(target_container)
placement["slot"] = target_slot
_placements[key] = placement
changed = true
_reconciling = false
if changed or force_signal:
layout_changed.emit()
func _normalize_existing_placements() -> void:
var occupied_inventory: Dictionary[int, bool] = {}
var occupied_storage: Dictionary[int, bool] = {}
var occupied_hotbar: Dictionary[int, bool] = {}
var invalid_keys: Array[String] = []
var keys: Array[String] = []
keys.assign(_placements.keys())
keys.sort()
for key: String in keys:
var placement: Dictionary = _placements[key]
var container: int = int(placement.get("container", -1))
var slot: int = int(placement.get("slot", -1))
var occupied: Dictionary[int, bool]
match container:
InventoryContainer.INVENTORY:
occupied = occupied_inventory
InventoryContainer.STORAGE:
occupied = occupied_storage
InventoryContainer.HOTBAR:
occupied = occupied_hotbar
_:
occupied = {}
if (
container not in [
InventoryContainer.INVENTORY,
InventoryContainer.STORAGE,
InventoryContainer.HOTBAR,
]
or not _is_slot_valid(container as InventoryContainer, slot)
or occupied.has(slot)
):
invalid_keys.append(key)
continue
occupied[slot] = true
for key: String in invalid_keys:
# Invalid records must stop occupying their old slot before free-space
# lookup. Otherwise a duplicate can incorrectly reserve a valid slot.
var placement: Dictionary = _placements[key]
_placements.erase(key)
var target := InventoryContainer.INVENTORY
var slot := _first_free_slot(target)
if slot < 0:
target = InventoryContainer.STORAGE
slot = _first_free_slot(target)
if slot < 0:
_placements.erase(key)
continue
placement["container"] = int(target)
placement["slot"] = slot
_placements[key] = placement
func _first_free_slot(container: InventoryContainer) -> int:
var capacity := (
get_inventory_capacity() if container == InventoryContainer.INVENTORY
else HOTBAR_SLOT_COUNT if container == InventoryContainer.HOTBAR
else get_storage_capacity()
)
for slot: int in capacity:
if _key_at(container, slot).is_empty():
return slot
return -1
func _key_at(container: InventoryContainer, slot: int) -> String:
for key: String in _placements:
var placement: Dictionary = _placements[key]
if (
int(placement.get("container", -1)) == int(container)
and int(placement.get("slot", -1)) == slot
):
return key
return ""
func _is_slot_valid(container: InventoryContainer, slot: int) -> bool:
if slot < 0:
return false
return slot < (
get_inventory_capacity() if container == InventoryContainer.INVENTORY
else HOTBAR_SLOT_COUNT if container == InventoryContainer.HOTBAR
else get_storage_capacity()
)
func _placement_slot_occupied(
placements: Dictionary[String, Dictionary],
container: int,
slot: int,
) -> bool:
for placement: Dictionary in placements.values():
if (
int(placement.get("container", -1)) == container
and int(placement.get("slot", -1)) == slot
):
return true
return false