extends Control ## Inventory screen (Tab): your 40 slots, the crafting list, and an open ## chest beside them. ## ## Click picks up or puts down a stack, right-click splits it or drops one, ## shift-click quick-moves between you and the chest. Crafting shows every ## recipe; the ones you can't make yet are dimmed, with the missing inputs and ## station in red. All changes go through Actions. const UITheme = preload("res://scripts/ui/theme.gd") const SlotScript = preload("res://scripts/ui/slot.gd") const COLS := 8 var player: CharacterBody3D var container: Node = null ## open chest, or null var _player_slots: Array = [] var _chest_slots: Array = [] var _chest_panel: PanelContainer var _chest_grid: GridContainer var _chest_title: Label var _recipe_list: VBoxContainer var _station_label: Label var _worn_label: Label var _worn_btn: Button var _cursor = null ## stack held on the mouse var _cursor_view: Control var _cursor_icon: TextureRect var _cursor_count: Label func bind(p: CharacterBody3D) -> void: player = p set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) mouse_filter = Control.MOUSE_FILTER_STOP visible = false var dim := ColorRect.new() dim.color = Color(0, 0, 0, 0.35) dim.set_anchors_preset(Control.PRESET_FULL_RECT) dim.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(dim) var center := CenterContainer.new() center.set_anchors_preset(Control.PRESET_FULL_RECT) center.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(center) var row := HBoxContainer.new() row.add_theme_constant_override("separation", 16) center.add_child(row) # Chest panel (only while a chest is open). _chest_panel = UITheme.panel() var cv := VBoxContainer.new() _chest_title = UITheme.label("Chest", 20) cv.add_child(_chest_title) _chest_grid = GridContainer.new() _chest_grid.columns = 4 cv.add_child(_chest_grid) _chest_panel.add_child(cv) row.add_child(_chest_panel) # Player inventory. var pp := UITheme.panel() var pv := VBoxContainer.new() pv.add_theme_constant_override("separation", 8) pv.add_child(UITheme.label("Inventory", 20)) var grid := GridContainer.new() grid.columns = COLS for i in range(player.inventory.size()): var s: Panel = SlotScript.new(i, 60, str(i + 1) if i < 8 else "") s.clicked.connect(_on_player_slot) grid.add_child(s) _player_slots.append(s) if i == 7: pass pv.add_child(grid) pv.add_child(UITheme.label("Row 1 is your hotbar. Shift-click moves stacks to/from a chest.", 13, UITheme.TEXT_DIM)) var wr := HBoxContainer.new() wr.add_theme_constant_override("separation", 10) _worn_label = UITheme.label("", 15) wr.add_child(_worn_label) _worn_btn = Button.new() _worn_btn.text = "Take off" _worn_btn.focus_mode = Control.FOCUS_NONE _worn_btn.pressed.connect(func(): Actions.take_off(player) _refresh()) wr.add_child(_worn_btn) pv.add_child(wr) player.worn_changed.connect(_refresh) PlayerNet.cursor_changed.connect(_on_server_cursor) pp.add_child(pv) row.add_child(pp) # Crafting. var cp := UITheme.panel() cp.custom_minimum_size = Vector2(340, 0) var crv := VBoxContainer.new() crv.add_theme_constant_override("separation", 6) crv.add_child(UITheme.label("Crafting", 20)) _station_label = UITheme.label("", 13, UITheme.TEXT_DIM) crv.add_child(_station_label) # Categories: Tools, Weapons, Cooking... (ItemDB.CATEGORIES). Two rows # of tabs, so they all fit. _tabs = HFlowContainer.new() _tabs.add_theme_constant_override("h_separation", 4) _tabs.add_theme_constant_override("v_separation", 4) crv.add_child(_tabs) var scroll := ScrollContainer.new() scroll.custom_minimum_size = Vector2(320, 330) scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED _recipe_list = VBoxContainer.new() _recipe_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL _recipe_list.add_theme_constant_override("separation", 6) scroll.add_child(_recipe_list) crv.add_child(scroll) cp.add_child(crv) row.add_child(cp) # Stack on the cursor. _cursor_view = Control.new() _cursor_view.mouse_filter = Control.MOUSE_FILTER_IGNORE _cursor_view.top_level = true _cursor_icon = TextureRect.new() _cursor_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE _cursor_icon.size = Vector2(52, 52) _cursor_icon.position = Vector2(-26, -26) _cursor_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE _cursor_view.add_child(_cursor_icon) _cursor_count = UITheme.label("", 15) _cursor_count.position = Vector2(8, 6) _cursor_view.add_child(_cursor_count) add_child(_cursor_view) player.inventory.changed.connect(_refresh) ItemDB.icons_ready.connect(_refresh) func is_open() -> bool: return visible func open(chest: Node) -> void: container = chest if container != null: container.inventory.changed.connect(_refresh) visible = true player.ui_open = true Input.mouse_mode = Input.MOUSE_MODE_VISIBLE _pick_tab() _rebuild_chest() _refresh() func close() -> void: if WorldNet.is_client(): PlayerNet.ask("close") # the server puts the cursor back in our bag _cursor = null elif _cursor != null: var left: int = player.inventory.add(_cursor.id, _cursor.count) if left > 0 and container != null and is_instance_valid(container): left = container.inventory.add(_cursor.id, left) _cursor = null if container != null and is_instance_valid(container) and container.inventory.changed.is_connected(_refresh): container.inventory.changed.disconnect(_refresh) container = null visible = false player.ui_open = false Input.mouse_mode = Input.MOUSE_MODE_CAPTURED func _input(event: InputEvent) -> void: if not visible: return if event.is_action_pressed("inventory"): # ESC is handled by the HUD close() get_viewport().set_input_as_handled() elif event is InputEventMouseMotion: _cursor_view.global_position = event.position func _process(_d: float) -> void: if not visible or container == null: return # Containers can vanish while open (an emptied grave crumbles). if not is_instance_valid(container) or container.is_queued_for_deletion(): container = null _rebuild_chest() return if player.global_position.distance_to(container.global_position) > 5.0: close() func _rebuild_chest() -> void: for c in _chest_grid.get_children(): c.queue_free() _chest_slots.clear() _chest_panel.visible = container != null if container == null: return _chest_title.text = container.display_name() for i in range(container.inventory.size()): var s: Panel = SlotScript.new(i, 60) s.clicked.connect(_on_chest_slot) _chest_grid.add_child(s) _chest_slots.append(s) ## Online the server does every click (player_net.gd). Clicks in our own ## bag also happen here straight away, so dragging doesn't wait on the ## network; the server's copy replaces ours once it's caught up. func _on_player_slot(i: int, right: bool, shift: bool) -> void: if shift and container != null: Actions.transfer(player, player.inventory, i, container.inventory, container) else: _cursor = player.inventory.click(i, _cursor, right) if WorldNet.is_client(): PlayerNet.ask("inv_click", [0, i, right]) _refresh() func _on_chest_slot(i: int, right: bool, shift: bool) -> void: if container == null or not is_instance_valid(container) or not Actions._in_reach(player, container, 1.0): return if shift: Actions.transfer(player, container.inventory, i, player.inventory, container) elif WorldNet.is_client(): # A shared chest (someone else may be in it too): only the server's # answer counts. PlayerNet.ask("inv_click", [WorldNet.nid(container), i, right]) else: _cursor = container.inventory.click(i, _cursor, right) _refresh() ## Online: what the server says we're holding. func _on_server_cursor(stack) -> void: _cursor = stack if visible else null _refresh() func _refresh() -> void: if player == null: return for i in range(_player_slots.size()): _player_slots[i].set_stack(player.inventory.get_slot(i)) _player_slots[i].set_selected(i == player.selected) if container != null: for i in range(_chest_slots.size()): _chest_slots[i].set_stack(container.inventory.get_slot(i)) _cursor_view.visible = _cursor != null if _cursor != null: _cursor_icon.texture = ItemDB.icon(_cursor.id) _cursor_count.text = str(_cursor.count) if _cursor.count > 1 else "" _worn_label.text = "Wearing: %s" % (ItemDB.item_name(player.worn) if not player.worn.is_empty() else "nothing") _worn_btn.visible = not player.worn.is_empty() _rebuild_recipes() func _rebuild_recipes() -> void: if not visible: return for c in _recipe_list.get_children(): c.queue_free() var stations: Array[String] = [] for st in ["workbench", "campfire"]: if Actions.station_near(player, st): stations.append(st) _station_label.text = "Nearby: " + (", ".join(stations) if not stations.is_empty() else "nothing (hand crafting only)") _rebuild_tabs() for rid in ItemDB.recipes: var r: Dictionary = ItemDB.recipes[rid] if r.category == category: _recipe_list.add_child(_recipe_row(r)) var _tabs: HFlowContainer var category := "tools" ## the tab showing var _picked := false ## chose a tab since opening (don't jump about) ## Tabs for the categories that have recipes, each with how many of its ## recipes you could make right now. func _rebuild_tabs() -> void: for c in _tabs.get_children(): c.queue_free() var count := {} var ready := {} for rid in ItemDB.recipes: var r: Dictionary = ItemDB.recipes[rid] count[r.category] = int(count.get(r.category, 0)) + 1 if Actions.can_craft(player, rid): ready[r.category] = int(ready.get(r.category, 0)) + 1 if not count.has(category): category = count.keys()[0] if not count.is_empty() else "tools" for cat in ItemDB.CATEGORIES: if not count.has(cat): continue var b := Button.new() var n := int(ready.get(cat, 0)) b.text = ItemDB.CATEGORIES[cat] + (" (%d)" % n if n > 0 else "") b.toggle_mode = true b.button_pressed = cat == category b.focus_mode = Control.FOCUS_NONE b.add_theme_font_size_override("font_size", 13) if n > 0 and cat != category: b.add_theme_color_override("font_color", UITheme.OK) b.pressed.connect(func(): category = cat _picked = true _rebuild_recipes()) _tabs.add_child(b) ## On opening: Cooking if you're at a lit fire with something to cook, ## otherwise the tab you were on last. func _pick_tab() -> void: _picked = false if not Actions.station_near(player, "campfire"): return for rid in ItemDB.recipes: var r: Dictionary = ItemDB.recipes[rid] if r.category == "cooking" and Actions.can_craft(player, rid): category = "cooking" return func _recipe_row(r: Dictionary) -> Control: var ok := Actions.can_craft(player, r.id) var row := PanelContainer.new() row.add_theme_stylebox_override("panel", UITheme.box(UITheme.SLOT_BG, UITheme.SELECTED if ok else UITheme.SLOT_BORDER, 1, 5, 6)) var h := HBoxContainer.new() h.add_theme_constant_override("separation", 8) row.add_child(h) var icon := TextureRect.new() icon.texture = ItemDB.icon(r.output) icon.custom_minimum_size = Vector2(44, 44) icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED icon.modulate = Color.WHITE if ok else Color(1, 1, 1, 0.45) h.add_child(icon) var v := VBoxContainer.new() v.size_flags_horizontal = Control.SIZE_EXPAND_FILL var title := ItemDB.item_name(r.output) + (" x%d" % r.count if r.count > 1 else "") v.add_child(UITheme.label(title, 16, UITheme.TEXT if ok else UITheme.TEXT_DIM)) var req := RichTextLabel.new() req.bbcode_enabled = true req.fit_content = true req.scroll_active = false req.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART # (long lists wrap rather than widen the panel) req.add_theme_font_size_override("normal_font_size", 13) var parts: Array[String] = [] for id in r.inputs: var need := int(r.inputs[id]) var have: int = player.inventory.count(id) var col := UITheme.OK if have >= need else UITheme.BAD parts.append("[color=#%s]%s %d/%d[/color]" % [col.to_html(false), ItemDB.item_name(id), have, need]) if not r.station.is_empty(): var col := UITheme.OK if Actions.station_near(player, r.station) else UITheme.BAD parts.append("[color=#%s]@ %s[/color]" % [col.to_html(false), r.station]) req.text = " ".join(parts) req.custom_minimum_size = Vector2(170, 0) v.add_child(req) h.add_child(v) var b := Button.new() b.text = "Craft" b.disabled = not ok b.focus_mode = Control.FOCUS_NONE b.pressed.connect(_craft.bind(r.id)) h.add_child(b) return row ## Shift-click crafts up to five (handy for walls and floors). func _craft(recipe_id: String) -> void: var n := 5 if Input.is_key_pressed(KEY_SHIFT) else 1 for i in range(n): if not Actions.can_craft(player, recipe_id) or not Actions.craft(player, recipe_id): break _refresh()