Add gathering, unified inventory, and real-time world

This commit is contained in:
Alexander Sellite 2026-08-18 18:19:36 -04:00
parent 173371e6fc
commit fa57edca83
113 changed files with 6700 additions and 1307 deletions

View file

@ -0,0 +1,159 @@
class_name GeneralInventoryGrid
extends Control
signal entry_selected(kind: int, identity: StringName)
signal slot_activated(slot: GeneralInventorySlot)
signal context_requested(slot: GeneralInventorySlot)
signal context_changed(slot: GeneralInventorySlot, text: String, active: bool)
const DEFAULT_SLOT_SIZE := Vector2(52.0, 52.0)
const DEFAULT_SLOT_SEPARATION: int = 5
const COLUMNS: int = PlayerInventoryLayout.INVENTORY_COLUMNS
var _layout: PlayerInventoryLayout
var _bag: PlayerBag
var _fish_inventory: FishInventory
var _hotbar: PlayerHotbar
var _item_catalog: ItemCatalog
var _container: int = PlayerInventoryLayout.InventoryContainer.INVENTORY
var _grid: GridContainer
var _slots: Array[GeneralInventorySlot] = []
var _slot_size: Vector2 = DEFAULT_SLOT_SIZE
var _slot_separation: int = DEFAULT_SLOT_SEPARATION
func _ready() -> void:
_grid = GridContainer.new()
_grid.columns = COLUMNS
_apply_grid_separation()
add_child(_grid)
func set_slot_presentation(slot_size: Vector2, separation: int) -> void:
_slot_size = Vector2(maxf(1.0, slot_size.x), maxf(1.0, slot_size.y))
_slot_separation = maxi(0, separation)
if _grid != null:
_apply_grid_separation()
if is_node_ready() and _layout != null:
var active_capacity := (
_layout.get_inventory_capacity()
if _container == PlayerInventoryLayout.InventoryContainer.INVENTORY
else _layout.get_storage_capacity()
)
_rebuild(
PlayerInventoryLayout.MAX_INVENTORY_SLOT_COUNT
if _container == PlayerInventoryLayout.InventoryContainer.INVENTORY
else PlayerInventoryLayout.MAX_STORAGE_SLOT_COUNT,
active_capacity,
)
func setup(
layout: PlayerInventoryLayout,
bag: PlayerBag,
fish_inventory: FishInventory,
hotbar: PlayerHotbar,
item_catalog: ItemCatalog,
container: int,
) -> void:
_layout = layout
_bag = bag
_fish_inventory = fish_inventory
_hotbar = hotbar
_item_catalog = item_catalog
_container = container
if _layout != null and not _layout.layout_changed.is_connected(refresh):
_layout.layout_changed.connect(refresh)
refresh()
func refresh() -> void:
if not is_node_ready() or _layout == null:
return
var active_capacity := (
_layout.get_inventory_capacity()
if _container == PlayerInventoryLayout.InventoryContainer.INVENTORY
else _layout.get_storage_capacity()
)
var visible_capacity := (
PlayerInventoryLayout.MAX_INVENTORY_SLOT_COUNT
if _container == PlayerInventoryLayout.InventoryContainer.INVENTORY
else PlayerInventoryLayout.MAX_STORAGE_SLOT_COUNT
)
if _slots.size() != visible_capacity:
_rebuild(visible_capacity, active_capacity)
else:
for index: int in _slots.size():
_slots[index].set_locked(index >= active_capacity)
for slot: GeneralInventorySlot in _slots:
slot.refresh()
func get_slots() -> Array[GeneralInventorySlot]:
var active: Array[GeneralInventorySlot] = []
for slot: GeneralInventorySlot in _slots:
if not slot.disabled:
active.append(slot)
return active
func get_first_occupied_slot() -> GeneralInventorySlot:
for slot: GeneralInventorySlot in _slots:
if not slot.entry_identity.is_empty():
return slot
return _slots.front() if not _slots.is_empty() else null
func _rebuild(visible_capacity: int, active_capacity: int) -> void:
for child: Node in _grid.get_children():
child.queue_free()
_slots.clear()
for slot_index: int in visible_capacity:
var slot := GeneralInventorySlot.new()
slot.custom_minimum_size = _slot_size
slot.entry_selected.connect(
func(kind: int, identity: StringName) -> void:
entry_selected.emit(kind, identity)
)
slot.pressed.connect(func() -> void: slot_activated.emit(slot))
slot.context_requested.connect(
func(source: GeneralInventorySlot) -> void:
context_requested.emit(source)
)
slot.context_changed.connect(
func(
source: GeneralInventorySlot,
text: String,
active: bool,
) -> void:
context_changed.emit(source, text, active)
)
_grid.add_child(slot)
slot.set_presentation_size(_slot_size)
slot.configure(
slot_index,
_container,
slot_index >= active_capacity,
_layout,
_bag,
_fish_inventory,
_hotbar,
_item_catalog,
)
_slots.append(slot)
custom_minimum_size = Vector2(
float(COLUMNS) * _slot_size.x
+ float(COLUMNS - 1) * _slot_separation,
ceilf(float(visible_capacity) / float(COLUMNS)) * _slot_size.y
+ maxf(
0.0,
ceilf(float(visible_capacity) / float(COLUMNS)) - 1.0,
) * _slot_separation,
)
size = custom_minimum_size
ControllerFocusNavigation.configure_spatial_neighbors(_slots)
func _apply_grid_separation() -> void:
_grid.add_theme_constant_override("h_separation", _slot_separation)
_grid.add_theme_constant_override("v_separation", _slot_separation)

View file

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

View file

@ -0,0 +1,346 @@
class_name GeneralInventorySlot
extends Button
signal entry_selected(kind: int, identity: StringName)
signal entry_moved
signal context_requested(slot: GeneralInventorySlot)
signal context_changed(slot: GeneralInventorySlot, text: String, active: bool)
const ItemDataType = preload("res://items/item_data.gd")
const LOCK_ICON: Texture2D = preload("res://ui/icons/pictograms/lock_light.png")
var slot_index: int = -1
var container: int = -1
var entry_kind: int = -1
var entry_identity: StringName
var _locked: bool = false
var _layout: PlayerInventoryLayout
var _bag: PlayerBag
var _fish_inventory: FishInventory
var _hotbar: PlayerHotbar
var _item_catalog: ItemCatalog
var _icon: TextureRect
var _quantity: Label
var _presentation_size := Vector2(52.0, 52.0)
var _context_text: String = ""
var _context_hovered: bool = false
var _context_focused: bool = false
var _staged: bool = false
func _ready() -> void:
focus_mode = Control.FOCUS_ALL
mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
pressed.connect(_on_pressed)
focus_entered.connect(_set_context_focused.bind(true))
focus_exited.connect(_set_context_focused.bind(false))
mouse_entered.connect(_set_context_hovered.bind(true))
mouse_exited.connect(_set_context_hovered.bind(false))
gui_input.connect(_on_gui_input)
_icon = TextureRect.new()
_icon.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
_icon.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_icon)
_quantity = Label.new()
_quantity.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
_quantity.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_quantity.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_quantity.add_theme_color_override(
"font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
)
_quantity.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_quantity)
_apply_presentation()
func set_presentation_size(presentation_size: Vector2) -> void:
_presentation_size = presentation_size
custom_minimum_size = presentation_size
if is_node_ready():
_apply_presentation()
func configure(
new_slot_index: int,
new_container: int,
is_locked: bool,
layout: PlayerInventoryLayout,
bag: PlayerBag,
fish_inventory: FishInventory,
hotbar: PlayerHotbar,
item_catalog: ItemCatalog,
) -> void:
slot_index = new_slot_index
container = new_container
_locked = is_locked
_layout = layout
_bag = bag
_fish_inventory = fish_inventory
_hotbar = hotbar
_item_catalog = item_catalog
name = "InventorySlot%d" % slot_index
refresh()
func set_locked(is_locked: bool) -> void:
if _locked == is_locked:
return
_locked = is_locked
refresh()
func refresh() -> void:
call_deferred("_update_context_presence")
entry_kind = -1
entry_identity = StringName()
_context_text = ""
_icon.texture = null
_quantity.text = ""
disabled = _locked
_apply_icon_geometry()
if _locked:
_icon.texture = LOCK_ICON
_icon.modulate = Color(UtilityPageStyle.OCEAN_DISABLED, 0.18)
var storage_slot := (
container == PlayerInventoryLayout.InventoryContainer.STORAGE
)
_context_text = (
"locked storage slot" if storage_slot else "locked backpack slot"
)
tooltip_text = ""
accessibility_name = "%s %d" % [_context_text, slot_index + 1]
return
_icon.modulate = Color.WHITE
_context_text = "empty slot"
tooltip_text = ""
accessibility_name = "empty %s slot %d" % [
"storage"
if container == PlayerInventoryLayout.InventoryContainer.STORAGE
else "inventory",
slot_index + 1,
]
if _layout == null:
return
var entry := _layout.get_entry_at(container, slot_index)
if entry.is_empty():
return
entry_kind = int(entry.get("kind", -1))
entry_identity = StringName(str(entry.get("identity", "")))
if entry_kind == PlayerInventoryLayout.EntryKind.ITEM:
var item: ItemDataType = (
_item_catalog.get_item_by_id(entry_identity)
if _item_catalog != null else null
)
if item == null:
return
_icon.texture = item.icon
var quantity := _bag.get_quantity(entry_identity) if _bag != null else 0
_quantity.text = "×%d" % quantity if quantity > 1 else ""
_context_text = _item_context_text(item, quantity)
accessibility_name = "%s, slot %d" % [item.display_name, slot_index + 1]
elif entry_kind == PlayerInventoryLayout.EntryKind.CATCH:
var fish_catch = (
_fish_inventory.get_catch_by_id(entry_identity)
if _fish_inventory != null else null
)
if fish_catch == null:
return
_icon.texture = fish_catch.fish.display_texture
_context_text = _catch_context_text(fish_catch)
var catch_name: String = FishQuality.qualified_name(
fish_catch.fish.display_name, fish_catch.quality
)
accessibility_name = "%s, slot %d" % [catch_name, slot_index + 1]
func _on_pressed() -> void:
if _locked:
return
entry_selected.emit(entry_kind, entry_identity)
func _on_gui_input(event: InputEvent) -> void:
var mouse_event := event as InputEventMouseButton
if (
mouse_event != null
and mouse_event.button_index == MOUSE_BUTTON_RIGHT
and mouse_event.pressed
and not _locked
and not entry_identity.is_empty()
):
context_requested.emit(self)
accept_event()
func _set_context_hovered(active: bool) -> void:
_context_hovered = active
_update_context_presence()
func _set_context_focused(active: bool) -> void:
_context_focused = active
_update_context_presence()
func _update_context_presence() -> void:
if _locked or entry_identity.is_empty():
context_changed.emit(self, "", false)
return
var active: bool = _context_hovered or _context_focused
context_changed.emit(self, _context_text if active else "", active)
func set_move_source(active: bool) -> void:
modulate = Color(1.0, 1.0, 1.0, 0.42) if active else Color.WHITE
func set_staged(active: bool) -> void:
if _staged == active:
return
_staged = active
_apply_style()
func _item_context_text(item: ItemDataType, quantity: int) -> String:
var lines: Array[String] = [item.display_name]
lines.append("%s • quantity %d" % [item.get_category_name(), quantity])
if item.equippable:
lines.append("equippable")
elif item.usable:
lines.append("usable")
if not item.description.strip_edges().is_empty():
lines.append(item.description.strip_edges())
return "\n".join(lines)
func _catch_context_text(fish_catch: FishCatch) -> String:
var catch_name: String = FishQuality.qualified_name(
fish_catch.fish.display_name,
fish_catch.quality,
)
return "%s\n%0.2f lb • value %d\n%s" % [
catch_name,
fish_catch.weight_lb,
fish_catch.sale_value,
fish_catch.fish.logbook_fact,
]
func _get_drag_data(_at_position: Vector2) -> Variant:
if _locked or entry_identity.is_empty() or _icon.texture == null:
return null
var preview := TextureRect.new()
preview.custom_minimum_size = Vector2(56.0, 56.0)
preview.texture = _icon.texture
preview.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
preview.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
preview.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
preview.mouse_filter = Control.MOUSE_FILTER_IGNORE
set_drag_preview(preview)
if entry_kind == PlayerInventoryLayout.EntryKind.CATCH:
return {
"kind": "cooler_fish",
"catch_id": String(entry_identity),
}
return {
"kind": "bag_item",
"item_id": String(entry_identity),
}
func _can_drop_data(_at_position: Vector2, data: Variant) -> bool:
if _locked or typeof(data) != TYPE_DICTIONARY or _layout == null:
return false
var kind := str((data as Dictionary).get("kind", ""))
return kind in ["bag_item", "cooler_fish", "hotbar_slot"]
func _drop_data(_at_position: Vector2, data: Variant) -> void:
if not _can_drop_data(Vector2.ZERO, data):
return
var payload := data as Dictionary
var payload_kind := str(payload.get("kind", ""))
var moved: bool = false
if payload_kind == "hotbar_slot":
moved = (
_hotbar != null
and _hotbar.move_slot_to_container(
int(payload.get("slot_index", -1)),
container,
slot_index,
)
)
else:
var kind := (
PlayerInventoryLayout.EntryKind.CATCH
if payload_kind == "cooler_fish"
else PlayerInventoryLayout.EntryKind.ITEM
)
var identity := StringName(str(
payload.get(
"catch_id" if payload_kind == "cooler_fish" else "item_id",
"",
)
))
moved = _layout.move_entry(kind, identity, container, slot_index)
if moved:
entry_moved.emit()
func _apply_style() -> void:
var radius: int = roundi(minf(_presentation_size.x, _presentation_size.y) * 0.5)
var normal := UtilityPageStyle.rounded_style(
Color(
UtilityPageStyle.OCEAN_SELECTED
if _staged else UtilityPageStyle.OCEAN_FIELD,
0.92 if _staged else 0.88,
),
radius,
)
var hover := UtilityPageStyle.rounded_style(
Color(UtilityPageStyle.OCEAN_SELECTED, 0.92), radius
)
var locked := UtilityPageStyle.rounded_style(
Color(UtilityPageStyle.OCEAN_FIELD, 0.38), radius
)
for state: StringName in [&"normal", &"pressed"]:
add_theme_stylebox_override(state, normal)
add_theme_stylebox_override("disabled", locked)
for state: StringName in [&"hover", &"focus"]:
add_theme_stylebox_override(state, hover)
func _apply_presentation() -> void:
_apply_icon_geometry()
var is_large: bool = _presentation_size.x >= 70.0
var quantity_width: float = 42.0 if is_large else 34.0
var quantity_height: float = 20.0 if is_large else 17.0
_quantity.position = Vector2(
-quantity_width - (8.0 if is_large else 5.0),
-quantity_height - (6.0 if is_large else 4.0),
)
_quantity.size = Vector2(quantity_width, quantity_height)
_quantity.add_theme_font_size_override("font_size", 14 if is_large else 11)
_apply_style()
func _apply_icon_geometry() -> void:
var is_large: bool = _presentation_size.x >= 70.0
var margin: float
if _locked:
var lock_size: float = 24.0 if is_large else 18.0
margin = maxf(
(_presentation_size.x - lock_size) * 0.5,
0.0,
)
else:
margin = 10.0 if is_large else 7.0
_icon.offset_left = margin
_icon.offset_top = margin
_icon.offset_right = -margin
_icon.offset_bottom = -margin

View file

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

View file

@ -6,7 +6,10 @@ const CONTENT_MARGIN_LEFT := 18.0
const CONTENT_MARGIN_TOP := 98.0
const CONTENT_MARGIN_RIGHT := 18.0
const CONTENT_MARGIN_BOTTOM := 16.0
# Retained for the Logbook's separate handwritten presentation. Inventory
# notepads deliberately prioritize legibility with Tuffy.
const HANDWRITTEN_FONT: Font = preload("res://ui/fonts/seattle_avenue.otf")
const NOTEPAD_FONT: Font = preload("res://ui/fonts/Tuffy_Bold.otf")
const NOTEPAD_TEXTURE: Texture2D = preload("res://art/ui/ui_notepad.png")
const NOTEPAD_CANONICAL_OVERSCAN: float = 1.2
const NOTEPAD_ART_OFFSET := Vector2(10.0, 20.0)
@ -24,11 +27,11 @@ func _ready() -> void:
static func apply_handwritten_to(root: Control) -> void:
root.add_theme_font_override("font", HANDWRITTEN_FONT)
root.add_theme_font_override("font", NOTEPAD_FONT)
for descendant: Node in root.find_children("*", "Control", true, false):
var control := descendant as Control
control.add_theme_font_override(
"font", HANDWRITTEN_FONT
"font", NOTEPAD_FONT
)
if control is Label:
control.add_theme_color_override("font_color", INK_COLOR)
@ -37,7 +40,7 @@ static func apply_handwritten_to(root: Control) -> void:
func _draw() -> void:
draw_texture_rect(NOTEPAD_TEXTURE, get_art_rect(), false)
var font: Font = HANDWRITTEN_FONT
var font: Font = NOTEPAD_FONT
draw_string(
font,
Vector2(20.0, 58.0),

View file

@ -0,0 +1,71 @@
class_name ShopSaleTraySlot
extends Button
signal remove_requested(key: String)
signal drop_requested(payload: Dictionary)
var entry_key: String = ""
var _icon: TextureRect
var _quantity: Label
func _ready() -> void:
custom_minimum_size = GeneralInventoryGrid.DEFAULT_SLOT_SIZE
focus_mode = Control.FOCUS_ALL
texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
pressed.connect(func() -> void: remove_requested.emit(entry_key))
_icon = TextureRect.new()
_icon.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_icon.offset_left = 6.0
_icon.offset_top = 4.0
_icon.offset_right = -6.0
_icon.offset_bottom = -4.0
_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
_icon.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_icon)
_quantity = Label.new()
_quantity.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
_quantity.position = Vector2(-34.0, -20.0)
_quantity.size = Vector2(30.0, 16.0)
_quantity.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_quantity.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_quantity)
var normal := UtilityPageStyle.rounded_style(
Color(UtilityPageStyle.OCEAN_FIELD, 0.96), 26
)
var hover := UtilityPageStyle.rounded_style(
Color(UtilityPageStyle.OCEAN_SELECTED, 0.96), 26
)
for state: StringName in [&"normal", &"pressed", &"disabled"]:
add_theme_stylebox_override(state, normal)
for state: StringName in [&"hover", &"focus"]:
add_theme_stylebox_override(state, hover)
func configure(
key: String,
icon: Texture2D,
label: String,
quantity: int = 1,
) -> void:
entry_key = key
_icon.texture = icon
_quantity.text = "×%d" % quantity if quantity > 1 else ""
tooltip_text = "%s · select to remove" % label
accessibility_name = tooltip_text
func _can_drop_data(_at_position: Vector2, data: Variant) -> bool:
return (
typeof(data) == TYPE_DICTIONARY
and str((data as Dictionary).get("kind", "")) in [
"bag_item", "cooler_fish"
]
)
func _drop_data(_at_position: Vector2, data: Variant) -> void:
if _can_drop_data(Vector2.ZERO, data):
drop_requested.emit((data as Dictionary).duplicate(true))

View file

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