extends CanvasLayer # Minimalistic Inventory UI with Stats Panel # Toggle with Tab key, shows stats, equipment slots and inventory items # Uses inventory_slot graphics like inspiration inventory system # Supports keyboard navigation with arrow keys var is_open: bool = false var local_player: Node = null var is_updating_ui: bool = false # Prevent recursive UI updates # Selection tracking var selected_item: Item = null # Selected inventory item var selected_slot: String = "" # Selected equipment slot name var selected_type: String = "" # "item" or "equipment" var is_first_open: bool = true # Track if this is the first time opening # Navigation tracking (for keyboard navigation) var inventory_selection_row: int = 0 # Current inventory row (0-based) var inventory_selection_col: int = 0 # Current inventory column (0-based) var equipment_selection_index: int = 0 # Current equipment slot index (0-5: mainhand, offhand, headgear, armour, boots, accessory) # UI Nodes (from scene) @onready var container: Control = $InventoryContainer @onready var stats_panel: Control = $InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/HBox/StatsPanel @onready var equipment_panel: GridContainer = $InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/HBox/InventoryPanel/EquipmentPanel @onready var scroll_container: ScrollContainer = $InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/HBox/InventoryPanel/InventoryScroll @onready var inventory_grid: VBoxContainer = $InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/HBox/InventoryPanel/InventoryScroll/InventoryVBox @onready var selection_rectangle: Panel = $InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/SelectionRectangle @onready var info_panel: Control = $InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/InfoPanel @onready var info_label: Label = $InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/InfoPanel/InfoLabel @onready var stats_label: Label = $InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/HBox/StatsPanel/StatsLabel @onready var label_base_stats: Label = $InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/HBox/StatsPanel/StatsHBox/LabelBaseStats @onready var label_base_stats_value: Label = $InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/HBox/StatsPanel/StatsHBox/LabelBaseStatsValue @onready var label_derived_stats: Label = $InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/HBox/StatsPanel/StatsHBox/LabelDerivedStats @onready var label_derived_stats_value: Label = $InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/HBox/StatsPanel/StatsHBox/LabelDerivedStatsValue @onready var sfx_potion: AudioStreamPlayer2D = $SfxPotion @onready var sfx_food: AudioStreamPlayer2D = $SfxFood @onready var sfx_armour: AudioStreamPlayer2D = $SfxArmour @onready var tab_row: HBoxContainer = $InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/TabRow @onready var inventory_tab_btn: Button = $InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/TabRow/InventoryTab @onready var spell_book_tab_btn: Button = $InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/TabRow/SpellBookTab @onready var spell_book_panel: Control = $InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/SpellBookPanel @onready var inventory_hbox: HBoxContainer = $InventoryContainer/MarginContainer/MarginContainer/VBoxContainer/HBox # Bar layout constants (align X/Y + bar across rows) const _BAR_WIDTH: int = 100 const _BAR_VALUE_MIN_WIDTH: int = 44 # "999/999" const _BAR_LABEL_MIN_WIDTH: int = 52 # "Weight:", "Exp:", "HP:", "MP:", "Coin:" # Weight UI elements (created programmatically) var weight_container: HBoxContainer = null var weight_label: Label = null var weight_value_label: Label = null var weight_progress_bar: ProgressBar = null # Exp UI elements (like weight) var exp_container: HBoxContainer = null var exp_label: Label = null var exp_value_label: Label = null var exp_progress_bar: ProgressBar = null # HP / MP bar elements var hp_container: HBoxContainer = null var hp_label: Label = null var hp_value_label: Label = null var hp_progress_bar: ProgressBar = null var mp_container: HBoxContainer = null var mp_label: Label = null var mp_value_label: Label = null var mp_progress_bar: ProgressBar = null # Coin UI elements ("Coin:" + 6-frame sprite + "X N") var coin_container: HBoxContainer = null var coin_label: Label = null var coin_sprite: Sprite2D = null var coin_value_label: Label = null var coin_anim_time: float = 0.0 # Store button/item mappings for selection highlighting var inventory_buttons: Dictionary = {} # item -> button var equipment_buttons: Dictionary = {} # slot_name -> button var inventory_items_list: Array = [] # Flat list of items for navigation var inventory_rows_list: Array = [] # List of HBoxContainers (rows) # Level-up stat allocation (when pending_level_up) var level_up_label: Label = null var level_up_stat_buttons: Array = [] # Buttons for STR, DEX, INT, END, WIS, LCK, PER var level_up_stat_container: HBoxContainer = null var selected_level_up_stat_index: int = -1 const STAT_DESCRIPTIONS: Dictionary = { "str": "STR (Strength): Increases melee and bow damage. Raises carry capacity so you can hold more items before becoming encumbered.", "dex": "DEX (Dexterity): Improves dodge chance and hit chance. Makes you move and attack faster.", "int": "INT (Intelligence): Boosts spell damage (flames, frost, heal). Increases max mana and vision range.", "end": "END (Endurance): Increases max HP. Each point raises your maximum health.", "wis": "WIS (Wisdom): Improves mana regeneration and resistances to certain effects.", "lck": "LCK (Luck): Increases critical hit chance. Critical hits deal bonus damage and partially ignore defense.", "per": "PER (Perception): Improves trap detection and perception. Helps you spot hazards and secrets." } # Equipment slot buttons var equipment_slots: Dictionary = { "mainhand": null, "offhand": null, "headgear": null, "armour": null, "boots": null, "accessory": null } var equipment_slots_list: Array = ["mainhand", "offhand", "headgear", "armour", "boots", "accessory"] # Order for navigation # StyleBoxes for inventory slots (like inspiration system) var style_box_hover: StyleBox = null var style_box_focused: StyleBox = null var style_box_pressed: StyleBox = null var style_box_empty: StyleBox = null var quantity_font: Font = null # Selection animation var selection_animation_time: float = 0.0 # Tab: "inventory" or "spell_book" var _current_tab: String = "inventory" func _ready(): # Set layer to be above game but below chat layer = 150 # Load styleboxes for inventory slots (like inspiration system) _setup_styleboxes() # Create equipment slot buttons (dynamically) _create_equipment_slots() # Create HP/MP bars, then weight, exp, coin (order in stats panel) _create_hp_ui() _create_mp_ui() _create_weight_ui() _create_exp_ui() _create_coin_ui() # Level-up stat allocation UI (label + stat buttons) _setup_level_up_ui() # Setup selection rectangle (already in scene, just configure it) _setup_selection_rectangle() # Spell Book / Inventory tabs if inventory_tab_btn: inventory_tab_btn.pressed.connect(_on_inventory_tab_pressed) if spell_book_tab_btn: spell_book_tab_btn.pressed.connect(_on_spell_book_tab_pressed) # Find local player call_deferred("_find_local_player") func _setup_styleboxes(): # Create styleboxes exactly like inspiration inventory system var selected_tex = preload("res://assets/gfx/ui/inventory_slot_kenny_white.png") style_box_empty = StyleBoxEmpty.new() if selected_tex: # Scale factor for the slot background (1.5x to match larger item sprites) # Since StyleBoxTexture doesn't support texture_scale, we use expand_margin # to make the texture fill more space # For 1.5x scale on a 36px button (which was 24px originally), we need to expand # The button is now 36px, so to make a 24px texture appear 1.5x (36px), we use negative margins # Actually, let's use smaller positive margins to avoid clipping var margin_scale = 3.0 # Smaller margin to avoid clipping in upper left corner style_box_hover = StyleBoxTexture.new() style_box_hover.texture = selected_tex style_box_hover.expand_margin_left = margin_scale style_box_hover.expand_margin_top = margin_scale style_box_hover.expand_margin_right = margin_scale style_box_hover.expand_margin_bottom = margin_scale style_box_focused = StyleBoxTexture.new() style_box_focused.texture = selected_tex style_box_focused.expand_margin_left = margin_scale style_box_focused.expand_margin_top = margin_scale style_box_focused.expand_margin_right = margin_scale style_box_focused.expand_margin_bottom = margin_scale style_box_pressed = StyleBoxTexture.new() style_box_pressed.texture = selected_tex style_box_pressed.expand_margin_left = margin_scale style_box_pressed.expand_margin_top = margin_scale style_box_pressed.expand_margin_right = margin_scale style_box_pressed.expand_margin_bottom = margin_scale else: # Fallback to empty styleboxes if texture not found style_box_hover = StyleBoxEmpty.new() style_box_focused = StyleBoxEmpty.new() style_box_pressed = StyleBoxEmpty.new() # Load quantity font (dmg_numbers.png) if ResourceLoader.exists("res://assets/fonts/dmg_numbers.png"): quantity_font = load("res://assets/fonts/dmg_numbers.png") func _setup_selection_rectangle(): # Selection rectangle is already in scene, just ensure it's configured correctly if selection_rectangle: selection_rectangle.visible = false # Ensure it's on top and visible selection_rectangle.z_index = 100 selection_rectangle.z_as_relative = false selection_rectangle.mouse_filter = Control.MOUSE_FILTER_IGNORE # Don't block mouse input # Ensure it's on top selection_rectangle.z_index = 100 selection_rectangle.z_as_relative = false func _find_local_player(): # Find the local player var game_world = get_tree().get_first_node_in_group("game_world") if game_world: var player_manager = game_world.get_node_or_null("PlayerManager") if player_manager: var local_players = player_manager.get_local_players() if local_players.size() > 0: local_player = local_players[0] if local_player and local_player.character_stats: # Connect to character_changed signal (for inventory/equipment changes) if local_player.character_stats.character_changed.is_connected(_on_character_changed): local_player.character_stats.character_changed.disconnect(_on_character_changed) local_player.character_stats.character_changed.connect(_on_character_changed) # Connect to mana_changed signal (for mana updates only - don't rebuild UI) if local_player.character_stats.mana_changed.is_connected(_on_mana_changed): local_player.character_stats.mana_changed.disconnect(_on_mana_changed) local_player.character_stats.mana_changed.connect(_on_mana_changed) # Connect to health_changed signal (for HP updates only - don't rebuild UI) if local_player.character_stats.health_changed.is_connected(_on_health_changed): local_player.character_stats.health_changed.disconnect(_on_health_changed) local_player.character_stats.health_changed.connect(_on_health_changed) # Initial update _update_ui() _update_stats() func _update_stats(): if not local_player or not local_player.character_stats: return var char_stats = local_player.character_stats # Update race/class in stats label if stats_label: var race_text = char_stats.race stats_label.text = "Stats - " + race_text # Level-up UI: "Level X - LEVEL UP" in green, stat allocation buttons var pending = char_stats.pending_level_up and char_stats.pending_stat_points > 0 if level_up_label: level_up_label.visible = pending if pending: level_up_label.text = "Level " + str(char_stats.level) + " - LEVEL UP" level_up_label.add_theme_color_override("font_color", Color(0.2, 1.0, 0.4)) if level_up_stat_container: level_up_stat_container.visible = pending # Base stats: Level, STR, DEX, END, INT, WIS, LCK, PER (HP/MP are bars below) if label_base_stats: label_base_stats.text = "Level\n\nSTR\nDEX\nEND\nINT\nWIS\nLCK\nPER" if label_base_stats_value: label_base_stats_value.text = str(char_stats.level) + "\n\n" + \ str(char_stats.baseStats.str) + "\n" + \ str(char_stats.baseStats.dex) + "\n" + \ str(char_stats.baseStats.end) + "\n" + \ str(char_stats.baseStats.int) + "\n" + \ str(char_stats.baseStats.wis) + "\n" + \ str(char_stats.baseStats.lck) + "\n" + \ str(char_stats.baseStats.get("per", 10)) # Derived stats: DMG, DEF, MovSpd, AtkSpd, Sight, SpellAmp, Crit%, Dodge% (XP/Coin moved to exp meter & coin UI) if label_derived_stats: label_derived_stats.text = "DMG\nDEF\nMovSpd\nAtkSpd\nSight\nSpellAmp\nCrit%\nDodge%" if label_derived_stats_value: label_derived_stats_value.text = "%.1f\n%.1f\n%.2f\n%.2f\n%.1f\n%.1f\n%.1f%%\n%.1f%%" % [ char_stats.damage, char_stats.defense, char_stats.move_speed, char_stats.attack_speed, char_stats.sight, char_stats.spell_amp, char_stats.crit_chance, char_stats.dodge_chance * 100.0 ] # HP bar if hp_progress_bar and hp_value_label: hp_progress_bar.max_value = max(1.0, char_stats.maxhp) hp_progress_bar.value = char_stats.hp hp_value_label.text = str(int(char_stats.hp)) + "/" + str(int(char_stats.maxhp)) # MP bar if mp_progress_bar and mp_value_label: mp_progress_bar.max_value = max(1.0, char_stats.maxmp) mp_progress_bar.value = char_stats.mp mp_value_label.text = str(int(char_stats.mp)) + "/" + str(int(char_stats.maxmp)) # Exp meter (like weight) if exp_progress_bar and exp_value_label: var xp = char_stats.xp var xp_next = char_stats.xp_to_next_level exp_progress_bar.max_value = max(1.0, xp_next) exp_progress_bar.value = xp exp_value_label.text = str(int(xp)) + "/" + str(int(xp_next)) var fill_exp = StyleBoxFlat.new() fill_exp.bg_color = Color(0.55, 0.35, 0.95) exp_progress_bar.add_theme_stylebox_override("fill", fill_exp) # Coin: "Coin:" + 6-frame sprite + "X " if coin_value_label: coin_value_label.text = "X " + str(char_stats.coin) # Weight progress bar if weight_progress_bar and weight_value_label: var current_weight = char_stats.get_total_weight() var max_weight = char_stats.get_carrying_capacity() weight_progress_bar.max_value = max_weight weight_progress_bar.value = current_weight weight_value_label.text = str(int(current_weight)) + "/" + str(int(max_weight)) var weight_ratio = current_weight / max_weight var fill_style = StyleBoxFlat.new() if weight_ratio < 0.7: fill_style.bg_color = Color(0.6, 0.8, 0.3) elif weight_ratio < 0.9: fill_style.bg_color = Color(0.9, 0.8, 0.2) else: fill_style.bg_color = Color(0.9, 0.3, 0.2) weight_progress_bar.add_theme_stylebox_override("fill", fill_style) func _setup_level_up_ui() -> void: if not stats_panel: return level_up_label = Label.new() level_up_label.name = "LevelUpLabel" level_up_label.add_theme_font_size_override("font_size", 14) level_up_label.add_theme_color_override("font_color", Color(0.2, 1.0, 0.4)) if ResourceLoader.exists("res://assets/fonts/standard_font.png"): var fr = load("res://assets/fonts/standard_font.png") if fr: level_up_label.add_theme_font_override("font", fr) level_up_label.visible = false stats_panel.add_child(level_up_label) stats_panel.move_child(level_up_label, 1) level_up_stat_container = HBoxContainer.new() level_up_stat_container.name = "LevelUpStatButtons" level_up_stat_container.add_theme_constant_override("separation", 4) level_up_stat_container.visible = false for stat_name in CharacterStats.LEVEL_UP_STAT_NAMES: var btn = Button.new() btn.name = "LevelUp_" + stat_name btn.text = stat_name.to_upper() btn.custom_minimum_size = Vector2(32, 24) btn.flat = true btn.add_theme_color_override("font_color", Color(0.85, 0.85, 0.85)) btn.add_theme_color_override("font_hover_color", Color(0.4, 1.0, 0.5)) btn.add_theme_color_override("font_focus_color", Color(0.4, 1.0, 0.5)) if ResourceLoader.exists("res://assets/fonts/standard_font.png"): var fr = load("res://assets/fonts/standard_font.png") if fr: btn.add_theme_font_override("font", fr) btn.add_theme_font_size_override("font_size", 9) btn.focus_mode = Control.FOCUS_ALL btn.pressed.connect(_on_level_up_stat_pressed.bind(stat_name)) btn.mouse_entered.connect(_on_level_up_stat_hover_entered.bind(stat_name)) btn.mouse_exited.connect(_on_level_up_stat_hover_exited) btn.gui_input.connect(_on_level_up_stat_gui_input.bind(stat_name, btn)) btn.focus_entered.connect(_on_level_up_stat_focus_entered.bind(stat_name)) level_up_stat_container.add_child(btn) level_up_stat_buttons.append(btn) stats_panel.add_child(level_up_stat_container) stats_panel.move_child(level_up_stat_container, 2) func _on_level_up_stat_pressed(stat_name: String) -> void: if not local_player or not local_player.character_stats: return if not _can_use_inventory(): return if local_player.character_stats.allocate_stat_point(stat_name): if sfx_armour: sfx_armour.play() _update_stats() if not local_player.character_stats.pending_level_up: selected_type = "equipment" selected_level_up_stat_index = -1 var next_index = _find_next_filled_equipment_slot(-1, 1) if next_index >= 0: equipment_selection_index = next_index selected_slot = equipment_slots_list[next_index] selected_item = local_player.character_stats.equipment[selected_slot] else: selected_slot = "" selected_item = null if inventory_items_list.size() > 0: selected_type = "item" inventory_selection_row = 0 inventory_selection_col = 0 _update_ui() func _on_level_up_stat_hover_entered(stat_name: String) -> void: if info_label and stat_name in STAT_DESCRIPTIONS: info_label.text = STAT_DESCRIPTIONS[stat_name] func _on_level_up_stat_hover_exited() -> void: if not info_label: return var fc = get_viewport().gui_get_focus_owner() if level_up_stat_container and fc and is_instance_valid(level_up_stat_container) and fc.get_parent() == level_up_stat_container: var idx = level_up_stat_buttons.find(fc) if idx >= 0 and idx < CharacterStats.LEVEL_UP_STAT_NAMES.size(): var sn = CharacterStats.LEVEL_UP_STAT_NAMES[idx] if sn in STAT_DESCRIPTIONS: info_label.text = STAT_DESCRIPTIONS[sn] return _update_info_panel() func _on_level_up_stat_focus_entered(stat_name: String) -> void: if info_label and stat_name in STAT_DESCRIPTIONS: info_label.text = STAT_DESCRIPTIONS[stat_name] func _on_level_up_stat_gui_input(event: InputEvent, stat_name: String, _btn: Button) -> void: if event is InputEventKey and event.pressed and not event.echo: if event.keycode == KEY_ENTER or event.keycode == KEY_KP_ENTER or event.keycode == KEY_SPACE: _on_level_up_stat_pressed(stat_name) func _create_equipment_slots(): # Equipment slot order: mainhand, offhand, headgear, armour, boots, accessory var slot_names = ["mainhand", "offhand", "headgear", "armour", "boots", "accessory"] var slot_labels = ["Main-hand", "Off-hand", "Head", "Armour", "Boots", "Accessory"] for i in range(slot_names.size()): var slot_name = slot_names[i] var slot_label = slot_labels[i] # Container for slot var slot_container = VBoxContainer.new() slot_container.name = slot_name + "_slot" equipment_panel.add_child(slot_container) # Label var label = Label.new() label.text = slot_label label.add_theme_font_size_override("font_size", 10) label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER if ResourceLoader.exists("res://assets/fonts/standard_font.png"): var font_resource = load("res://assets/fonts/standard_font.png") if font_resource: label.add_theme_font_override("font", font_resource) slot_container.add_child(label) # Button (use styleboxes like inspiration system) var button = Button.new() button.name = slot_name + "_btn" # Button size increased to accommodate 1.5x scaled texture (24 * 1.5 = 36) button.custom_minimum_size = Vector2(36, 36) # Increased to fit 1.5x scaled texture button.size = Vector2(36, 36) if style_box_empty: button.add_theme_stylebox_override("normal", style_box_empty) if style_box_hover: button.add_theme_stylebox_override("hover", style_box_hover) if style_box_focused: button.add_theme_stylebox_override("focus", style_box_focused) if style_box_pressed: button.add_theme_stylebox_override("pressed", style_box_pressed) button.flat = false # Use styleboxes instead of flat button.focus_mode = Control.FOCUS_ALL # Allow button to receive focus button.size_flags_horizontal = 0 button.size_flags_vertical = 0 button.connect("pressed", _on_equipment_slot_pressed.bind(slot_name)) button.connect("gui_input", _on_equipment_slot_gui_input.bind(slot_name)) # Connect focus_entered like inspiration system (for keyboard navigation) if local_player and local_player.character_stats: var equipped_item = local_player.character_stats.equipment[slot_name] if equipped_item: button.connect("focus_entered", _on_equipment_slot_pressed.bind(slot_name)) slot_container.add_child(button) equipment_slots[slot_name] = button equipment_buttons[slot_name] = button func _style_bar_font(lbl: Label) -> void: lbl.add_theme_font_size_override("font_size", 10) if ResourceLoader.exists("res://assets/fonts/standard_font.png"): var fr = load("res://assets/fonts/standard_font.png") if fr: lbl.add_theme_font_override("font", fr) func _make_progress_bar_background() -> StyleBoxFlat: var bg = StyleBoxFlat.new() bg.bg_color = Color(0.2, 0.2, 0.2, 0.8) bg.border_color = Color(0.4, 0.4, 0.4) bg.set_border_width_all(1) return bg func _create_bar_row(p_name: String, p_label_text: String) -> Dictionary: var row = HBoxContainer.new() row.name = p_name row.add_theme_constant_override("separation", 4) var left = Label.new() left.text = p_label_text left.custom_minimum_size.x = _BAR_LABEL_MIN_WIDTH _style_bar_font(left) var spacer = Control.new() spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL var value_lbl = Label.new() value_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT value_lbl.custom_minimum_size.x = _BAR_VALUE_MIN_WIDTH _style_bar_font(value_lbl) var bar = ProgressBar.new() bar.custom_minimum_size = Vector2(_BAR_WIDTH, 12) bar.show_percentage = false bar.add_theme_stylebox_override("background", _make_progress_bar_background()) row.add_child(left) row.add_child(spacer) row.add_child(value_lbl) row.add_child(bar) stats_panel.add_child(row) return {"container": row, "label": left, "value_label": value_lbl, "progress_bar": bar} func _create_hp_ui(): if not stats_panel: return var d = _create_bar_row("HPContainer", "HP:") hp_container = d.container hp_label = d.label hp_value_label = d.value_label hp_progress_bar = d.progress_bar var fill = StyleBoxFlat.new() fill.bg_color = Color(0.85, 0.2, 0.2) hp_progress_bar.add_theme_stylebox_override("fill", fill) func _create_mp_ui(): if not stats_panel: return var d = _create_bar_row("MPContainer", "MP:") mp_container = d.container mp_label = d.label mp_value_label = d.value_label mp_progress_bar = d.progress_bar var fill = StyleBoxFlat.new() fill.bg_color = Color(0.25, 0.45, 0.9) mp_progress_bar.add_theme_stylebox_override("fill", fill) func _create_weight_ui(): if not stats_panel: return var d = _create_bar_row("WeightContainer", "Weight:") weight_container = d.container weight_label = d.label weight_value_label = d.value_label weight_progress_bar = d.progress_bar var fill = StyleBoxFlat.new() fill.bg_color = Color(0.6, 0.8, 0.3) weight_progress_bar.add_theme_stylebox_override("fill", fill) func _create_exp_ui(): if not stats_panel: return var d = _create_bar_row("ExpContainer", "Exp:") exp_container = d.container exp_label = d.label exp_value_label = d.value_label exp_progress_bar = d.progress_bar var fill = StyleBoxFlat.new() fill.bg_color = Color(0.55, 0.35, 0.95) exp_progress_bar.add_theme_stylebox_override("fill", fill) func _create_coin_ui(): if not stats_panel: return coin_container = HBoxContainer.new() coin_container.name = "CoinContainer" coin_container.add_theme_constant_override("separation", 4) coin_label = Label.new() coin_label.name = "CoinLabel" coin_label.text = "Coin:" coin_label.custom_minimum_size.x = _BAR_LABEL_MIN_WIDTH _style_bar_font(coin_label) coin_container.add_child(coin_label) var coin_spacer = Control.new() coin_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL coin_container.add_child(coin_spacer) var coin_wrap = Control.new() coin_wrap.custom_minimum_size = Vector2(16, 16) coin_sprite = Sprite2D.new() coin_sprite.name = "CoinSprite" var tex = load("res://assets/gfx/pickups/gold_coin.png") as Texture2D if tex: coin_sprite.texture = tex coin_sprite.hframes = 6 coin_sprite.vframes = 1 coin_sprite.frame = 0 coin_sprite.centered = false # Scale down to fit; texture may be multi-frame var tw = tex.get_width() / 6.0 var th = tex.get_height() if tw > 0 and th > 0: var s = min(16.0 / tw, 16.0 / th) coin_sprite.scale = Vector2(s, s) coin_wrap.add_child(coin_sprite) coin_container.add_child(coin_wrap) coin_value_label = Label.new() coin_value_label.name = "CoinValueLabel" coin_value_label.text = "X 0" coin_value_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT _style_bar_font(coin_value_label) coin_container.add_child(coin_value_label) stats_panel.add_child(coin_container) func _has_equipment_in_slot(slot_name: String) -> bool: # Check if there's an item equipped in this slot if not local_player or not local_player.character_stats: return false return local_player.character_stats.equipment[slot_name] != null func _can_use_inventory() -> bool: # Block equip/unequip/use/drop/level-up when dead if not local_player: return false if "is_dead" in local_player and local_player.is_dead: return false if "is_processing_death" in local_player and local_player.is_processing_death: return false return true func _find_next_filled_equipment_slot(start_index: int, direction: int) -> int: # Find next filled equipment slot, or -1 if none found var current_index = start_index for _i in range(equipment_slots_list.size()): current_index += direction if current_index < 0: current_index = equipment_slots_list.size() - 1 elif current_index >= equipment_slots_list.size(): current_index = 0 var slot_name = equipment_slots_list[current_index] if _has_equipment_in_slot(slot_name): return current_index return -1 func _on_equipment_slot_pressed(slot_name: String): if not local_player or not local_player.character_stats: return # Prevent updates during UI refresh (prevents infinite loops from focus_entered) if is_updating_ui: return # Only select if there's an item equipped (same as arrow-key navigation) if not _has_equipment_in_slot(slot_name): return # Select this slot (equivalent to arrow-key selecting this equipment) selected_slot = slot_name selected_item = local_player.character_stats.equipment[slot_name] selected_type = "equipment" if selected_item else "" # Update navigation position if slot_name in equipment_slots_list: equipment_selection_index = equipment_slots_list.find(slot_name) _update_selection_highlight() _update_selection_rectangle() _update_info_panel() func _on_equipment_slot_gui_input(event: InputEvent, slot_name: String): # Handle double-click to unequip if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT: if event.double_click and event.pressed: # Double-click detected - unequip the item selected_slot = slot_name selected_item = local_player.character_stats.equipment[slot_name] if local_player and local_player.character_stats else null selected_type = "equipment" if selected_item else "" if selected_type == "equipment" and selected_slot != "": # Use the same logic as F key to unequip _handle_f_key() func _update_selection_highlight(): # This function is kept for compatibility but now uses _update_selection_rectangle() _update_selection_rectangle() # Removed _clear_button_highlight and _apply_button_highlight - using focus system instead func _update_selection_rectangle(): # Update visual selection indicator - use button focus like inspiration system # Hide the old selection rectangle if selection_rectangle: selection_rectangle.visible = false # Find and focus the selected button (like inspiration system uses grab_focus()) var target_button: Button = null if selected_type == "equipment" and selected_slot != "": # Focus equipment slot (only if it has an item) if _has_equipment_in_slot(selected_slot): target_button = equipment_buttons.get(selected_slot) elif selected_type == "item" and inventory_selection_row >= 0 and inventory_selection_row < inventory_rows_list.size(): # Focus inventory item var row = inventory_rows_list[inventory_selection_row] if row and inventory_selection_col >= 0 and inventory_selection_col < row.get_child_count(): target_button = row.get_child(inventory_selection_col) as Button # Grab focus on selected button (this will automatically show the focus stylebox) if target_button: if target_button.is_inside_tree(): # Don't grab focus if button already has focus (prevents infinite loops) if target_button.has_focus(): return # Ensure button is visible and ready if not target_button.visible: target_button.visible = true # Wait a frame to ensure button is fully ready for focus await get_tree().process_frame # Check again if already focused (might have changed during await) if target_button.has_focus(): return # Ensure button can receive focus if target_button.focus_mode == Control.FOCUS_NONE: target_button.focus_mode = Control.FOCUS_ALL # Try direct grab_focus (only if not already focused) if not target_button.has_focus(): target_button.grab_focus() # Also use call_deferred as backup to ensure it's set target_button.call_deferred("grab_focus") print("InventoryUI: Focus grabbed on button - has_focus: ", target_button.has_focus()) else: # If not in tree yet, wait a frame and try again await get_tree().process_frame if target_button.is_inside_tree(): target_button.grab_focus() target_button.call_deferred("grab_focus") print("InventoryUI: Focus grabbed on button (after wait)") else: print("InventoryUI: Button still not in tree after wait") else: print("InventoryUI: No button to focus - selected_type: ", selected_type) func _process(delta): if is_open: # Animate selection highlight border color on selected button selection_animation_time += delta * 2.0 # Speed of animation # Animate between yellow and orange var color1 = Color.YELLOW var color2 = Color(1.0, 0.7, 0.0) # Orange-yellow var t = (sin(selection_animation_time) + 1.0) / 2.0 # 0 to 1 var animated_color = color1.lerp(color2, t) # Find the selected button and update its highlight color var selected_button: Button = null if selected_type == "equipment" and selected_slot != "": if _has_equipment_in_slot(selected_slot): selected_button = equipment_buttons.get(selected_slot) elif selected_type == "item" and inventory_selection_row >= 0 and inventory_selection_row < inventory_rows_list.size(): var row = inventory_rows_list[inventory_selection_row] if row and inventory_selection_col >= 0 and inventory_selection_col < row.get_child_count(): selected_button = row.get_child(inventory_selection_col) as Button if selected_button and selected_button.has_meta("highlight_stylebox"): var stylebox = selected_button.get_meta("highlight_stylebox") as StyleBoxFlat if stylebox: stylebox.border_color = animated_color # Animate 6-frame coin sprite if coin_sprite and coin_sprite.hframes >= 6: coin_anim_time += delta * 10.0 coin_sprite.frame = int(coin_anim_time) % 6 func _update_ui(): if not local_player or not local_player.character_stats: return # Prevent recursive updates if is_updating_ui: return is_updating_ui = true var char_stats = local_player.character_stats # Ensure containers don't clip their children (allows expand_margin to show properly) if scroll_container: scroll_container.clip_contents = false # Allow buttons to extend beyond scroll bounds if inventory_grid: inventory_grid.clip_contents = false # Allow buttons to extend beyond grid bounds if equipment_panel: equipment_panel.clip_contents = false # Allow buttons to extend beyond grid bounds # Debug: Print inventory contents print("InventoryUI: Updating UI - inventory size: ", char_stats.inventory.size()) for i in range(char_stats.inventory.size()): var item = char_stats.inventory[i] print(" Item ", i, ": ", item.item_name if item else "null") # Clear button mappings inventory_buttons.clear() inventory_items_list.clear() inventory_rows_list.clear() # Wait for old buttons to be fully freed before creating new ones await get_tree().process_frame # Update equipment slots for slot_name in equipment_slots.keys(): var button = equipment_slots[slot_name] if not button: continue # Clear existing children (sprite, highlight) for child in button.get_children(): child.queue_free() var equipped_item = char_stats.equipment[slot_name] if equipped_item: var sprite = Sprite2D.new() # Equipment slots should ONLY use spritePath (items_n_shit.png), NOT equipmentPath # equipmentPath is for character rendering only! var texture_path = equipped_item.spritePath var texture = load(texture_path) if texture: sprite.texture = texture sprite.hframes = equipped_item.spriteFrames.x if equipped_item.spriteFrames.x > 0 else 20 sprite.vframes = equipped_item.spriteFrames.y if equipped_item.spriteFrames.y > 0 else 14 sprite.frame = equipped_item.spriteFrame sprite.centered = false # Like inspiration system sprite.position = Vector2(4, 4) # Like inspiration system sprite.scale = Vector2(2.0, 2.0) # 2x size as requested ItemDatabase.apply_item_colors_to_sprite(sprite, equipped_item) button.add_child(sprite) # Add quantity label if item can have multiple (like arrows, bombs) var show_qty = equipped_item.can_have_multiple_of and (equipped_item.quantity > 1 or equipped_item.weapon_type == Item.WeaponType.BOMB) if show_qty: var quantity_label = Label.new() quantity_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT quantity_label.vertical_alignment = VERTICAL_ALIGNMENT_TOP quantity_label.size = Vector2(36, 36) quantity_label.custom_minimum_size = Vector2(36, 36) quantity_label.position = Vector2(0, 0) quantity_label.text = str(equipped_item.quantity) # Use dmg_numbers.png font (same as inventory items) var dmg_font_resource = load("res://assets/fonts/dmg_numbers.png") if dmg_font_resource: var font_file = FontFile.new() font_file.font_data = dmg_font_resource quantity_label.add_theme_font_override("font", font_file) quantity_label.add_theme_font_size_override("font_size", 16) quantity_label.add_theme_color_override("font_color", Color.GREEN) quantity_label.z_index = 100 # High z-index to show above item sprite quantity_label.z_as_relative = false # Absolute z-index button.add_child(quantity_label) # Update inventory grid - clear existing HBoxContainers for child in inventory_grid.get_children(): child.queue_free() # Wait for old buttons to be fully freed before creating new ones await get_tree().process_frame # Add inventory items using HBoxContainers (like inspiration system) var current_hbox: HBoxContainer = null var items_per_row = 8 # Items per row (3 rows = 24 total items max) var items_in_current_row = 0 for item in char_stats.inventory: # Create new HBoxContainer if needed if current_hbox == null or items_in_current_row >= items_per_row: current_hbox = HBoxContainer.new() current_hbox.add_theme_constant_override("separation", 0) # No separation like inspiration # Ensure HBoxContainer doesn't clip child buttons current_hbox.clip_contents = false inventory_grid.add_child(current_hbox) inventory_rows_list.append(current_hbox) items_in_current_row = 0 # Create button with styleboxes (like inspiration system) var button = Button.new() # Button size increased to accommodate 1.5x scaled texture (24 * 1.5 = 36) button.custom_minimum_size = Vector2(36, 36) # Increased to fit 1.5x scaled texture button.size = Vector2(36, 36) if style_box_empty: button.add_theme_stylebox_override("normal", style_box_empty) if style_box_hover: button.add_theme_stylebox_override("hover", style_box_hover) if style_box_focused: button.add_theme_stylebox_override("focus", style_box_focused) if style_box_pressed: button.add_theme_stylebox_override("pressed", style_box_pressed) button.flat = false # Use styleboxes button.focus_mode = Control.FOCUS_ALL # Allow button to receive focus button.connect("pressed", _on_inventory_item_pressed.bind(item)) button.connect("gui_input", _on_inventory_item_gui_input.bind(item)) # Connect focus_entered like inspiration system (for keyboard navigation) # Note: focus_entered will trigger when we call grab_focus(), but _on_inventory_item_pressed # just updates selection state, so it should be safe button.connect("focus_entered", _on_inventory_item_pressed.bind(item)) current_hbox.add_child(button) # Add item sprite (like inspiration system - positioned at 4,4 with centered=false) if item.spritePath != "": var sprite = Sprite2D.new() var texture = load(item.spritePath) if texture: sprite.texture = texture sprite.hframes = item.spriteFrames.x if item.spriteFrames.x > 0 else 20 sprite.vframes = item.spriteFrames.y if item.spriteFrames.y > 0 else 14 sprite.frame = item.spriteFrame sprite.centered = false # Like inspiration system sprite.position = Vector2(4, 4) # Like inspiration system sprite.scale = Vector2(2.0, 2.0) # 2x size as requested ItemDatabase.apply_item_colors_to_sprite(sprite, item) button.add_child(sprite) # Add quantity label if item quantity > 1 (show for all stacked items) if item.quantity > 1: var quantity_label = Label.new() quantity_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT quantity_label.vertical_alignment = VERTICAL_ALIGNMENT_TOP quantity_label.size = Vector2(36, 36) quantity_label.custom_minimum_size = Vector2(36, 36) quantity_label.position = Vector2(0, 0) quantity_label.text = str(item.quantity) # Use dmg_numbers.png font (same as damage_number.gd) var dmg_font_resource = load("res://assets/fonts/dmg_numbers.png") if dmg_font_resource: var font_file = FontFile.new() font_file.font_data = dmg_font_resource quantity_label.add_theme_font_override("font", font_file) quantity_label.add_theme_font_size_override("font_size", 16) quantity_label.add_theme_color_override("font_color", Color.GREEN) quantity_label.z_index = 100 # High z-index to show above item sprite button.add_child(quantity_label) inventory_buttons[item] = button inventory_items_list.append(item) items_in_current_row += 1 # Clamp selection to valid range if inventory_selection_row >= inventory_rows_list.size(): inventory_selection_row = max(0, inventory_rows_list.size() - 1) if inventory_selection_row >= 0 and inventory_selection_row < inventory_rows_list.size(): var row = inventory_rows_list[inventory_selection_row] if inventory_selection_col >= row.get_child_count(): inventory_selection_col = max(0, row.get_child_count() - 1) # Update selection only if selected_type is already set (don't auto-update during initialization) # Don't call _set_selection() here if we already have a valid selection - it will reset to 0,0 # Only call it if selection is empty or invalid var should_reset_selection = false if selected_type == "": should_reset_selection = true elif selected_type == "item": # Check if current selection is still valid if inventory_selection_row < 0 or inventory_selection_row >= inventory_rows_list.size(): should_reset_selection = true elif inventory_selection_row >= 0 and inventory_selection_row < inventory_rows_list.size(): var row = inventory_rows_list[inventory_selection_row] if inventory_selection_col < 0 or inventory_selection_col >= row.get_child_count(): should_reset_selection = true if should_reset_selection: _set_selection() elif selected_type != "": # Selection is valid, just update it _update_selection_from_navigation() _update_selection_rectangle() _update_info_panel() # Reset update flag is_updating_ui = false func _set_selection(): # NOW check for items AFTER UI is updated # Initialize selection - prefer inventory, but if empty, check equipment # Only set initial selection if not already set, or if current selection is invalid var needs_initial_selection = false if selected_type == "": needs_initial_selection = true elif selected_type == "item": # Check if current selection is still valid if inventory_selection_row >= inventory_rows_list.size() or inventory_selection_row < 0: needs_initial_selection = true elif inventory_selection_row >= 0 and inventory_selection_row < inventory_rows_list.size(): var row = inventory_rows_list[inventory_selection_row] if inventory_selection_col >= row.get_child_count() or inventory_selection_col < 0: needs_initial_selection = true # Check if we have inventory items if inventory_rows_list.size() > 0 and inventory_items_list.size() > 0: if needs_initial_selection: # Only reset to 0 if we need initial selection selected_type = "item" inventory_selection_row = 0 inventory_selection_col = 0 # Ensure selection is set correctly (preserves existing selection if valid) _update_selection_from_navigation() # Debug: Print selection state print("InventoryUI: Selection - type: ", selected_type, " row: ", inventory_selection_row, " col: ", inventory_selection_col, " item: ", selected_item) # Now set focus - buttons should be ready await _update_selection_rectangle() # Await to ensure focus is set _update_info_panel() else: # No inventory items, try equipment var first_filled_slot = _find_next_filled_equipment_slot(-1, 1) if first_filled_slot >= 0: selected_type = "equipment" equipment_selection_index = first_filled_slot selected_slot = equipment_slots_list[first_filled_slot] # Ensure selection is set correctly _update_selection_from_navigation() # Debug: Print selection state print("InventoryUI: Initial selection - type: ", selected_type, " slot: ", selected_slot, " item: ", selected_item) # Now set focus - buttons should be ready await _update_selection_rectangle() # Await to ensure focus is set _update_info_panel() else: # Nothing to select (only print this AFTER UI is updated) selected_type = "" if selection_rectangle: selection_rectangle.visible = false if info_label: info_label.text = "" print("InventoryUI: No items to select") pass func _update_selection_from_navigation(): # Update selected_item/selected_slot based on navigation position # Early return if selected_type is not set yet (prevents errors during initialization) if selected_type == "": print("InventoryUI: _update_selection_from_navigation() - selected_type is empty, skipping") return print("InventoryUI: _update_selection_from_navigation() - selected_type: ", selected_type, " inventory_rows_list.size(): ", inventory_rows_list.size(), " inventory_items_list.size(): ", inventory_items_list.size()) if selected_type == "equipment" and equipment_selection_index >= 0 and equipment_selection_index < equipment_slots_list.size(): var slot_name = equipment_slots_list[equipment_selection_index] if _has_equipment_in_slot(slot_name): selected_slot = slot_name if local_player and local_player.character_stats: selected_item = local_player.character_stats.equipment[slot_name] else: selected_item = null print("InventoryUI: Selected equipment slot: ", slot_name, " item: ", selected_item) else: # Empty slot - switch to inventory selected_type = "item" selected_slot = "" selected_item = null if inventory_rows_list.size() > 0: inventory_selection_row = 0 inventory_selection_col = 0 _update_selection_from_navigation() elif selected_type == "item" and inventory_selection_row >= 0 and inventory_selection_row < inventory_rows_list.size(): var row = inventory_rows_list[inventory_selection_row] print("InventoryUI: Checking item selection - row: ", inventory_selection_row, " col: ", inventory_selection_col, " row exists: ", row != null, " row child count: ", row.get_child_count() if row else 0) if row and inventory_selection_col >= 0 and inventory_selection_col < row.get_child_count(): var items_per_row = 8 # Must match the items_per_row used when building rows var item_index = inventory_selection_row * items_per_row + inventory_selection_col print("InventoryUI: Calculated item_index: ", item_index, " inventory_items_list.size(): ", inventory_items_list.size()) if item_index >= 0 and item_index < inventory_items_list.size(): selected_item = inventory_items_list[item_index] selected_slot = "" print("InventoryUI: Selected inventory item: ", selected_item.item_name if selected_item else "null") else: selected_item = null selected_slot = "" print("InventoryUI: item_index out of range!") else: selected_item = null selected_slot = "" print("InventoryUI: Row or column invalid!") else: print("InventoryUI: selected_type invalid or row out of range!") func _format_item_info(item: Item) -> String: # Format item description, stats modifiers, and controls var text = "" # Item name (always show) text += item.item_name # Description if item.description != "": text += "\n" + item.description text += "\n\n" # Stats modifiers var stat_lines = [] if item.modifiers.has("str"): stat_lines.append("STR: +%d" % item.modifiers["str"]) if item.modifiers.has("dex"): stat_lines.append("DEX: +%d" % item.modifiers["dex"]) if item.modifiers.has("end"): stat_lines.append("END: +%d" % item.modifiers["end"]) if item.modifiers.has("int"): stat_lines.append("INT: +%d" % item.modifiers["int"]) if item.modifiers.has("wis"): stat_lines.append("WIS: +%d" % item.modifiers["wis"]) if item.modifiers.has("lck"): stat_lines.append("LCK: +%d" % item.modifiers["lck"]) if item.modifiers.has("dmg"): stat_lines.append("DMG: +%d" % item.modifiers["dmg"]) if item.modifiers.has("def"): stat_lines.append("DEF: +%d" % item.modifiers["def"]) if item.modifiers.has("hp"): stat_lines.append("HP: +%d" % item.modifiers["hp"]) if item.modifiers.has("mp"): stat_lines.append("MP: +%d" % item.modifiers["mp"]) if item.modifiers.has("maxhp"): stat_lines.append("MAXHP: +%d" % item.modifiers["maxhp"]) if item.modifiers.has("maxmp"): stat_lines.append("MAXMP: +%d" % item.modifiers["maxmp"]) if stat_lines.size() > 0: text += ", ".join(stat_lines) text += "\n\n" # Weight var item_weight = item.weight if item.can_have_multiple_of: item_weight *= item.quantity text += "Weight: %.1f" % item_weight text += "\n\n" # Controls if item.weapon_type == Item.WeaponType.SPELLBOOK: if local_player and local_player.character_stats: var spell_id = local_player.character_stats._tome_to_spell_id(item) if local_player.character_stats.has_method("_tome_to_spell_id") else "" if spell_id != "" and spell_id in local_player.character_stats.learnt_spells: text += "Already learnt." else: text += "Press F to learn spell" else: text += "Press F to learn spell" elif item.item_type == Item.ItemType.Equippable: if selected_type == "equipment": text += "Press F to unequip" else: text += "Press F to equip" elif item.item_type == Item.ItemType.Restoration: text += "Press F to consume" # Only show "Press E to drop" for inventory items, not equipment if selected_type == "item": text += ", Press E to drop" return text func _update_info_panel(): # Update info panel based on selected item if not info_label: print("InventoryUI: _update_info_panel() - info_label is null!") return print("InventoryUI: _update_info_panel() - selected_item: ", selected_item, " selected_type: ", selected_type) if selected_item: info_label.text = _format_item_info(selected_item) print("InventoryUI: Info panel text set: ", info_label.text.substr(0, 50) if info_label.text.length() > 50 else info_label.text) else: info_label.text = "" print("InventoryUI: Info panel text cleared (no selected_item)") func _navigate_inventory(direction: String): # Handle navigation within inventory match direction: "left": if inventory_selection_col > 0: inventory_selection_col -= 1 else: if inventory_selection_row > 0: inventory_selection_row -= 1 var row = inventory_rows_list[inventory_selection_row] inventory_selection_col = row.get_child_count() - 1 elif local_player and local_player.character_stats and local_player.character_stats.pending_level_up and local_player.character_stats.pending_stat_points > 0 and level_up_stat_buttons.size() > 0: selected_type = "level_up_stat" selected_level_up_stat_index = 0 selected_slot = "" selected_item = null level_up_stat_buttons[0].call_deferred("grab_focus") if info_label and CharacterStats.LEVEL_UP_STAT_NAMES.size() > 0: var sn = CharacterStats.LEVEL_UP_STAT_NAMES[0] if sn in STAT_DESCRIPTIONS: info_label.text = STAT_DESCRIPTIONS[sn] _update_selection_rectangle() return "right": if inventory_selection_row < inventory_rows_list.size(): var row = inventory_rows_list[inventory_selection_row] if inventory_selection_col < row.get_child_count() - 1: inventory_selection_col += 1 else: # Wrap to start of next row if inventory_selection_row < inventory_rows_list.size() - 1: inventory_selection_row += 1 inventory_selection_col = 0 "up": if inventory_selection_row > 0: inventory_selection_row -= 1 # Clamp column to valid range var row = inventory_rows_list[inventory_selection_row] if inventory_selection_col >= row.get_child_count(): inventory_selection_col = row.get_child_count() - 1 else: # Move to equipment slots (only if there are filled slots) var next_equip_index = _find_next_filled_equipment_slot(-1, 1) # Start from end, go forward if next_equip_index >= 0: selected_type = "equipment" equipment_selection_index = next_equip_index selected_slot = equipment_slots_list[next_equip_index] _update_selection_from_navigation() _update_selection_rectangle() _update_info_panel() return "down": if inventory_selection_row < inventory_rows_list.size() - 1: inventory_selection_row += 1 # Clamp column to valid range var row = inventory_rows_list[inventory_selection_row] if inventory_selection_col >= row.get_child_count(): inventory_selection_col = row.get_child_count() - 1 # Can't go down from inventory (already at bottom) _update_selection_from_navigation() _update_selection_rectangle() _update_info_panel() func _navigate_equipment(direction: String): # Handle navigation within equipment slots (only filled slots) # Equipment layout: 3 columns, 2 rows # Row 1: mainhand(0), offhand(1), headgear(2) # Row 2: armour(3), boots(4), accessory(5) match direction: "left": var next_index = _find_next_filled_equipment_slot(equipment_selection_index, -1) if next_index >= 0: equipment_selection_index = next_index elif local_player and local_player.character_stats and local_player.character_stats.pending_level_up and local_player.character_stats.pending_stat_points > 0 and level_up_stat_buttons.size() > 0: selected_type = "level_up_stat" selected_level_up_stat_index = 0 selected_slot = "" selected_item = null level_up_stat_buttons[0].call_deferred("grab_focus") if info_label and CharacterStats.LEVEL_UP_STAT_NAMES.size() > 0: var sn = CharacterStats.LEVEL_UP_STAT_NAMES[0] if sn in STAT_DESCRIPTIONS: info_label.text = STAT_DESCRIPTIONS[sn] return "right": var next_index = _find_next_filled_equipment_slot(equipment_selection_index, 1) if next_index >= 0: equipment_selection_index = next_index "up": # Find next filled slot in row above (same column), or go to stats var current_row: int = floor(equipment_selection_index / 3.0) var current_col = equipment_selection_index % 3 if current_row > 0: var target_index = (current_row - 1) * 3 + current_col var target_slot = equipment_slots_list[target_index] if _has_equipment_in_slot(target_slot): equipment_selection_index = target_index else: var next_index = _find_next_filled_equipment_slot(target_index - 1, 1) if next_index >= 0 and next_index < 3: equipment_selection_index = next_index elif local_player and local_player.character_stats and local_player.character_stats.pending_level_up and local_player.character_stats.pending_stat_points > 0 and level_up_stat_buttons.size() > 0: selected_type = "level_up_stat" selected_level_up_stat_index = 0 selected_slot = "" selected_item = null level_up_stat_buttons[0].call_deferred("grab_focus") if info_label and CharacterStats.LEVEL_UP_STAT_NAMES.size() > 0: var sn = CharacterStats.LEVEL_UP_STAT_NAMES[0] if sn in STAT_DESCRIPTIONS: info_label.text = STAT_DESCRIPTIONS[sn] return "down": # Find next filled slot in row below (same column), or move to inventory var current_row: int = floor(equipment_selection_index / 3.0) var current_col = equipment_selection_index % 3 if current_row < 1: var target_index = (current_row + 1) * 3 + current_col var target_slot = equipment_slots_list[target_index] if _has_equipment_in_slot(target_slot): equipment_selection_index = target_index else: # No filled slot below, move to inventory (only if inventory has items) if inventory_rows_list.size() > 0 and inventory_items_list.size() > 0: selected_type = "item" inventory_selection_row = 0 inventory_selection_col = current_col # Clamp to valid range var inv_row = inventory_rows_list[0] if inventory_selection_col >= inv_row.get_child_count(): inventory_selection_col = inv_row.get_child_count() - 1 _update_selection_from_navigation() _update_selection_rectangle() _update_info_panel() return # No inventory items, stay on equipment else: # Already at bottom row, move to inventory (only if inventory has items) if inventory_rows_list.size() > 0 and inventory_items_list.size() > 0: selected_type = "item" inventory_selection_row = 0 inventory_selection_col = current_col # Clamp to valid range var inv_row = inventory_rows_list[0] if inventory_selection_col >= inv_row.get_child_count(): inventory_selection_col = inv_row.get_child_count() - 1 _update_selection_from_navigation() _update_selection_rectangle() _update_info_panel() return # No inventory items, stay on equipment _update_selection_from_navigation() _update_selection_rectangle() _update_info_panel() func _navigate_level_up_stats(direction: String) -> void: var n = level_up_stat_buttons.size() if n == 0: return match direction: "left": selected_level_up_stat_index = (selected_level_up_stat_index - 1 + n) % n level_up_stat_buttons[selected_level_up_stat_index].call_deferred("grab_focus") if info_label and selected_level_up_stat_index < CharacterStats.LEVEL_UP_STAT_NAMES.size(): var sn = CharacterStats.LEVEL_UP_STAT_NAMES[selected_level_up_stat_index] if sn in STAT_DESCRIPTIONS: info_label.text = STAT_DESCRIPTIONS[sn] "right": if selected_level_up_stat_index >= n - 1: # Past last stat: go to equipment selected_type = "equipment" selected_level_up_stat_index = -1 selected_slot = "" selected_item = null var next_index = _find_next_filled_equipment_slot(-1, 1) if next_index >= 0: equipment_selection_index = next_index selected_slot = equipment_slots_list[next_index] selected_item = local_player.character_stats.equipment[selected_slot] if local_player and local_player.character_stats else null _update_selection_from_navigation() _update_selection_rectangle() _update_info_panel() return selected_level_up_stat_index += 1 level_up_stat_buttons[selected_level_up_stat_index].call_deferred("grab_focus") if info_label and selected_level_up_stat_index < CharacterStats.LEVEL_UP_STAT_NAMES.size(): var sn = CharacterStats.LEVEL_UP_STAT_NAMES[selected_level_up_stat_index] if sn in STAT_DESCRIPTIONS: info_label.text = STAT_DESCRIPTIONS[sn] "up": pass "down": selected_type = "equipment" selected_level_up_stat_index = -1 var next_index = _find_next_filled_equipment_slot(-1, 1) if next_index >= 0: equipment_selection_index = next_index selected_slot = equipment_slots_list[next_index] selected_item = local_player.character_stats.equipment[selected_slot] if local_player and local_player.character_stats else null _update_selection_from_navigation() _update_selection_rectangle() _update_info_panel() return # Don't call _update_info_panel - we've set stat description above func _on_inventory_item_pressed(item: Item): if not local_player or not local_player.character_stats: return # Prevent updates during UI refresh (prevents infinite loops from focus_entered) if is_updating_ui: return selected_item = item selected_slot = "" selected_type = "item" # Update navigation position var item_index = inventory_items_list.find(item) if item_index >= 0: var items_per_row: int = 8 inventory_selection_row = floor(item_index / float(items_per_row)) inventory_selection_col = item_index % items_per_row _update_selection_highlight() _update_selection_rectangle() _update_info_panel() # Show item description on single-click func _on_inventory_item_gui_input(event: InputEvent, item: Item): # Handle double-click to equip/consume and right-click to drop if event is InputEventMouseButton: if event.button_index == MOUSE_BUTTON_LEFT and event.double_click and event.pressed: # Double-click detected - equip or consume the item selected_item = item selected_slot = "" selected_type = "item" # Update navigation position first var item_index = inventory_items_list.find(item) if item_index >= 0: var items_per_row: int = 8 inventory_selection_row = floor(item_index / float(items_per_row)) inventory_selection_col = item_index % items_per_row # Use the same logic as F key to equip/consume _handle_f_key() elif event.button_index == MOUSE_BUTTON_RIGHT and event.pressed: # Right-click detected - drop the item selected_item = item selected_slot = "" selected_type = "item" # Update navigation position first var item_index = inventory_items_list.find(item) if item_index >= 0: var items_per_row: int = 8 inventory_selection_row = floor(item_index / float(items_per_row)) inventory_selection_col = item_index % items_per_row # Use the same logic as E key to drop _handle_e_key() func _on_mana_changed(new_mana: float, max_mana: float): # Update only MP bar and label when mana changes (don't rebuild entire UI) if mp_progress_bar and mp_value_label: mp_progress_bar.max_value = max(1.0, max_mana) mp_progress_bar.value = new_mana mp_value_label.text = str(int(new_mana)) + "/" + str(int(max_mana)) func _on_health_changed(new_hp: float, max_hp: float): # Update only HP bar and label when health changes (don't rebuild entire UI) if hp_progress_bar and hp_value_label: hp_progress_bar.max_value = max(1.0, max_hp) hp_progress_bar.value = new_hp hp_value_label.text = str(int(new_hp)) + "/" + str(int(max_hp)) func _on_character_changed(_char: CharacterStats): # Always update stats when character changes (even if inventory is closed) # Equipment changes affect max HP/MP which should be reflected everywhere _update_stats() # Only update UI if inventory is open (prevents unnecessary updates) if not is_open: return # Prevent recursive updates if is_updating_ui: return _update_ui() func _input(event): # Toggle with Tab key if event is InputEventKey and event.keycode == KEY_TAB and event.pressed and not event.echo: if is_open: _close_inventory() else: _open_inventory() get_viewport().set_input_as_handled() return if not is_open: return # B key: switch to Spell Book tab when inventory is open if event is InputEventKey and event.keycode == KEY_B and event.pressed and not event.echo: _current_tab = "spell_book" if _current_tab == "inventory" else "inventory" _apply_tab_visibility() get_viewport().set_input_as_handled() return # Arrow key navigation (use ui_left/right/up/down so keybindings work) var direction = "" var skip_repeat = event is InputEventKey and event.echo if not skip_repeat and event.is_action_pressed("ui_left"): direction = "left" elif not skip_repeat and event.is_action_pressed("ui_right"): direction = "right" elif not skip_repeat and event.is_action_pressed("ui_up"): direction = "up" elif not skip_repeat and event.is_action_pressed("ui_down"): direction = "down" if direction != "": if _current_tab == "spell_book": pass elif selected_type == "level_up_stat": _navigate_level_up_stats(direction) elif selected_type == "equipment": _navigate_equipment(direction) else: _navigate_inventory(direction) get_viewport().set_input_as_handled() return # F key: Unequip/equip items if event is InputEventKey and event.keycode == KEY_F and event.pressed and not event.echo: _handle_f_key() get_viewport().set_input_as_handled() return # E key: Drop selected item if event is InputEventKey and event.keycode == KEY_E and event.pressed and not event.echo: _handle_e_key() get_viewport().set_input_as_handled() return # Attack: Allocate level-up stat when a stat button is focused if event.is_action_pressed("attack") and not event.is_echo(): var fc = get_viewport().gui_get_focus_owner() if level_up_stat_container and fc and is_instance_valid(level_up_stat_container) and fc.get_parent() == level_up_stat_container: var idx = level_up_stat_buttons.find(fc) if idx >= 0 and idx < CharacterStats.LEVEL_UP_STAT_NAMES.size(): _on_level_up_stat_pressed(CharacterStats.LEVEL_UP_STAT_NAMES[idx]) get_viewport().set_input_as_handled() return func _handle_f_key(): if not local_player or not local_player.character_stats: return if not _can_use_inventory(): return var char_stats = local_player.character_stats if selected_type == "equipment" and selected_slot != "": var equipped_item = char_stats.equipment[selected_slot] if equipped_item: # Tome in offhand: use (F) to learn spell and consume tome if equipped_item.weapon_type == Item.WeaponType.SPELLBOOK and char_stats.has_method("learn_spell_from_tome"): if char_stats.learn_spell_from_tome(equipped_item): _update_ui() _update_selection_from_navigation() _update_selection_rectangle() _update_info_panel() if sfx_armour: sfx_armour.play() return char_stats.unequip_item(equipped_item) # Play armour sound when unequipping if sfx_armour: sfx_armour.play() # After unequipping, if all equipment is empty, go to inventory var has_any_equipment = false for slot in equipment_slots_list: if _has_equipment_in_slot(slot): has_any_equipment = true break if not has_any_equipment: # No equipment left, go to inventory selected_type = "item" selected_slot = "" selected_item = null if inventory_rows_list.size() > 0: inventory_selection_row = 0 inventory_selection_col = 0 else: selected_type = "" else: # Find next filled equipment slot var next_index = _find_next_filled_equipment_slot(equipment_selection_index, 1) if next_index >= 0: equipment_selection_index = next_index selected_slot = equipment_slots_list[next_index] selected_item = char_stats.equipment[selected_slot] else: # No more equipment, go to inventory selected_type = "item" selected_slot = "" selected_item = null if inventory_rows_list.size() > 0: inventory_selection_row = 0 inventory_selection_col = 0 _update_selection_from_navigation() _update_selection_rectangle() return if selected_type == "item" and selected_item: # Tome in inventory: use (F) to learn spell and consume tome if selected_item.weapon_type == Item.WeaponType.SPELLBOOK and char_stats.has_method("learn_spell_from_tome"): if char_stats.learn_spell_from_tome(selected_item): var current_item_index = inventory_selection_row * 8 + inventory_selection_col _update_ui() if current_item_index < char_stats.inventory.size(): inventory_selection_row = current_item_index / 8 inventory_selection_col = current_item_index % 8 elif char_stats.inventory.size() > 0: inventory_selection_row = 0 inventory_selection_col = 0 _update_selection_from_navigation() _update_selection_rectangle() _update_info_panel() if sfx_armour: sfx_armour.play() return if selected_item.item_type == Item.ItemType.Equippable and selected_item.equipment_type != Item.EquipmentType.NONE: # Remember which slot the item will be equipped to var target_slot_name = "" match selected_item.equipment_type: Item.EquipmentType.MAINHAND: target_slot_name = "mainhand" Item.EquipmentType.OFFHAND: target_slot_name = "offhand" Item.EquipmentType.HEADGEAR: target_slot_name = "headgear" Item.EquipmentType.ARMOUR: target_slot_name = "armour" Item.EquipmentType.BOOTS: target_slot_name = "boots" Item.EquipmentType.ACCESSORY: target_slot_name = "accessory" # Remember current item position before equipping var items_per_row = 8 var current_item_index = inventory_selection_row * items_per_row + inventory_selection_col # Check if target slot has an item (will be placed back in inventory) var slot_has_item = char_stats.equipment[target_slot_name] != null # Check if this is the last item in inventory (before equipping) var was_last_item = char_stats.inventory.size() == 1 # Equip the item, placing old item at the same position if slot had an item var insert_index = current_item_index if slot_has_item else -1 char_stats.equip_item(selected_item, insert_index) # Play armour sound when equipping if sfx_armour: sfx_armour.play() # Update UI first _update_ui() # If slot had an item, keep selection at the same position (old item is now there) if slot_has_item and current_item_index < char_stats.inventory.size(): # Keep selection at the same position selected_type = "item" selected_slot = "" # Recalculate row/col from index (may have changed if rows shifted) inventory_selection_row = floor(current_item_index / float(items_per_row)) inventory_selection_col = current_item_index % items_per_row # Ensure row/col are within bounds if inventory_selection_row >= inventory_rows_list.size(): inventory_selection_row = max(0, inventory_rows_list.size() - 1) if inventory_selection_row >= 0 and inventory_selection_row < inventory_rows_list.size(): var row = inventory_rows_list[inventory_selection_row] if inventory_selection_col >= row.get_child_count(): inventory_selection_col = max(0, row.get_child_count() - 1) _update_selection_from_navigation() elif was_last_item and target_slot_name != "": # Last item was equipped, move selection to equipment slot if target_slot_name in equipment_slots_list: selected_type = "equipment" selected_slot = target_slot_name equipment_selection_index = equipment_slots_list.find(target_slot_name) selected_item = char_stats.equipment[target_slot_name] _update_selection_from_navigation() else: # Item was removed, try to keep selection at same position if possible if current_item_index < char_stats.inventory.size(): # Item at next position moved up, keep selection there selected_type = "item" selected_slot = "" inventory_selection_row = floor(current_item_index / float(items_per_row)) inventory_selection_col = current_item_index % items_per_row if inventory_selection_row >= inventory_rows_list.size(): inventory_selection_row = max(0, inventory_rows_list.size() - 1) if inventory_selection_row >= 0 and inventory_selection_row < inventory_rows_list.size(): var row = inventory_rows_list[inventory_selection_row] if inventory_selection_col >= row.get_child_count(): inventory_selection_col = max(0, row.get_child_count() - 1) _update_selection_from_navigation() elif current_item_index > 0: # Move to previous position (current_item_index - 1) if current is out of bounds var previous_index = current_item_index - 1 inventory_selection_row = floor(previous_index / float(items_per_row)) inventory_selection_col = previous_index % items_per_row if inventory_selection_row >= 0 and inventory_selection_row < inventory_rows_list.size(): var row = inventory_rows_list[inventory_selection_row] if inventory_selection_col >= row.get_child_count(): inventory_selection_col = max(0, row.get_child_count() - 1) selected_type = "item" selected_slot = "" _update_selection_from_navigation() else: # Previous position is also out of bounds, move to equipment if available selected_type = "" selected_slot = "" selected_item = null _update_selection_from_navigation() else: # No items left, move to equipment if available selected_type = "" selected_slot = "" selected_item = null _update_selection_from_navigation() # Update selection rectangle and info panel _update_selection_rectangle() _update_info_panel() elif selected_item.item_type == Item.ItemType.Restoration: _use_consumable_item(selected_item) _update_info_panel() func _use_consumable_item(item: Item): if not local_player or not local_player.character_stats: return if not _can_use_inventory(): return var char_stats = local_player.character_stats # Remember current item position before consuming var items_per_row = 8 var current_item_index = inventory_selection_row * items_per_row + inventory_selection_col # Determine if it's a potion or food based on item name var is_potion = "potion" in item.item_name.to_lower() # Play appropriate sound if is_potion: if sfx_potion: sfx_potion.play() else: # Food item if sfx_food: sfx_food.play() if item.modifiers.has("hp"): var hp_heal = item.modifiers["hp"] if local_player.has_method("heal"): local_player.heal(hp_heal) if item.modifiers.has("mp"): var mana_amount = item.modifiers["mp"] char_stats.restore_mana(mana_amount) # Dodge potion (and any consumable with dodge_chance + duration): apply temporary dodge buff if item.modifiers.has("dodge_chance") and item.duration > 0: char_stats.add_buff_dodge_chance(item.modifiers["dodge_chance"], item.duration) var index = char_stats.inventory.find(item) if index >= 0: char_stats.inventory.remove_at(index) char_stats.character_changed.emit(char_stats) # Update UI first _update_ui() # Try to keep selection at the same position if possible if current_item_index < char_stats.inventory.size(): # Item at next position moved up, keep selection there selected_type = "item" selected_slot = "" inventory_selection_row = floor(current_item_index / float(items_per_row)) inventory_selection_col = current_item_index % items_per_row if inventory_selection_row >= inventory_rows_list.size(): inventory_selection_row = max(0, inventory_rows_list.size() - 1) if inventory_selection_row >= 0 and inventory_selection_row < inventory_rows_list.size(): var row = inventory_rows_list[inventory_selection_row] if inventory_selection_col >= row.get_child_count(): inventory_selection_col = max(0, row.get_child_count() - 1) _update_selection_from_navigation() _update_selection_rectangle() elif current_item_index > 0: # Move to previous position if current is out of bounds current_item_index = char_stats.inventory.size() - 1 inventory_selection_row = floor(current_item_index / float(items_per_row)) inventory_selection_col = current_item_index % items_per_row if inventory_selection_row >= 0 and inventory_selection_row < inventory_rows_list.size(): var row = inventory_rows_list[inventory_selection_row] if inventory_selection_col >= row.get_child_count(): inventory_selection_col = max(0, row.get_child_count() - 1) _update_selection_from_navigation() _update_selection_rectangle() else: # No items left, clear selection selected_type = "" selected_slot = "" selected_item = null _update_selection_from_navigation() _update_selection_rectangle() print(local_player.name, " used item: ", item.item_name) func _handle_e_key(): if not local_player or not local_player.character_stats: return if not _can_use_inventory(): return if selected_type != "item" or not selected_item: return var char_stats = local_player.character_stats # Play armour sound when dropping equipment if selected_item.item_type == Item.ItemType.Equippable: if sfx_armour: sfx_armour.play() if not selected_item in char_stats.inventory: return var index = char_stats.inventory.find(selected_item) if index >= 0: char_stats.inventory.remove_at(index) var drop_position = local_player.global_position + Vector2(randf_range(-20, 20), randf_range(-20, 20)) var game_world = get_tree().get_first_node_in_group("game_world") var entities_node = null if game_world: entities_node = game_world.get_node_or_null("Entities") # In multiplayer, clients need to request server to spawn loot if multiplayer.has_multiplayer_peer() and not multiplayer.is_server(): # Client: send drop request to server if game_world and game_world.has_method("_request_item_drop"): game_world._request_item_drop.rpc_id(1, selected_item.save(), drop_position, local_player.get_multiplayer_authority()) else: # Fallback: try to spawn locally (won't sync) if entities_node: var loot = ItemLootHelper.spawn_item_loot(selected_item, drop_position, entities_node, game_world) if loot and local_player.has_method("get_multiplayer_authority"): var player_peer_id = local_player.get_multiplayer_authority() loot.set_meta("dropped_by_peer_id", player_peer_id) loot.set_meta("drop_time", Time.get_ticks_msec()) else: # Server or single-player: spawn directly if entities_node: var loot = ItemLootHelper.spawn_item_loot(selected_item, drop_position, entities_node, game_world) if loot and local_player.has_method("get_multiplayer_authority"): var player_peer_id = local_player.get_multiplayer_authority() loot.set_meta("dropped_by_peer_id", player_peer_id) loot.set_meta("drop_time", Time.get_ticks_msec()) selected_item = null selected_slot = "" selected_type = "" char_stats.character_changed.emit(char_stats) print(local_player.name, " dropped item") func _open_inventory(): if is_open: return # Workaround: On first open, immediately close and reopen to ensure proper initialization if is_first_open: is_first_open = false is_open = true if container: container.visible = true _lock_player_controls(true) _update_ui() # Wait a frame await get_tree().process_frame # Close immediately _close_inventory() # Wait a frame await get_tree().process_frame # Now reopen properly (will continue with normal flow below) is_open = true if container: container.visible = true _lock_player_controls(true) # Reset selection state BEFORE updating UI (so _update_ui doesn't try to update selection) selected_type = "" selected_item = null selected_slot = "" _update_ui() _update_stats() _apply_tab_visibility() if not local_player: _find_local_player() func _close_inventory(): if not is_open: return is_open = false if container: container.visible = false if selection_rectangle: selection_rectangle.visible = false selected_item = null selected_slot = "" selected_type = "" _lock_player_controls(false) func _on_inventory_tab_pressed(): _current_tab = "inventory" # Reset selection so first item gets selected (same as TAB-open flow) selected_type = "" selected_item = null selected_slot = "" inventory_selection_row = 0 inventory_selection_col = 0 _apply_tab_visibility() _update_ui() func _on_spell_book_tab_pressed(): _current_tab = "spell_book" _apply_tab_visibility() func _apply_tab_visibility(): var show_inv = (_current_tab == "inventory") if inventory_hbox: inventory_hbox.visible = show_inv if spell_book_panel: spell_book_panel.visible = not show_inv if not show_inv and spell_book_panel.has_method("set_character_stats"): if local_player and local_player.character_stats: spell_book_panel.set_character_stats(local_player.character_stats) if spell_book_panel.has_method("refresh"): spell_book_panel.refresh() if selection_rectangle: selection_rectangle.visible = show_inv if info_panel: info_panel.visible = show_inv func _lock_player_controls(lock: bool): var game_world = get_tree().get_first_node_in_group("game_world") if game_world: var player_manager = game_world.get_node_or_null("PlayerManager") if player_manager: var local_players = player_manager.get_local_players() for player in local_players: player.controls_disabled = lock if lock: # Stop movement immediately when opening inventory (physics may have already run this frame) player.velocity = Vector2.ZERO