M7.3 — The Inventory UI¶
What you'll learn
- How to build an inventory screen from Control nodes: a
GridContainerof slot buttons that renders the M7.1 model. - How to redraw the grid from the model's
changedsignal rather than tracking state in the UI. - Godot's drag-and-drop on Control nodes —
_get_drag_data,_can_drop_data,_drop_data— to drag items between bag slots and onto equipment slots. - Where the inventory screen lives (the
UICanvasLayerfrom M1.4) and how it toggles with theinventoryaction.
How it applies
- The inventory screen is where the player spends the metagame. Between fights, this is the screen they live on — comparing, equipping, deciding. Its clarity determines whether managing loot is a pleasure or a chore, and the genre's audience is unusually demanding about it.
- Rendering from the model keeps the UI honest. A grid that redraws from
Inventory.changedcan never disagree with the bag's contents, because it has no contents of its own — it is a view. UIs that cache their own copy of the data drift from it, which is the source of "the item's in my bag but not on screen" bugs. - Drag-and-drop is the genre's expected verb. Players expect to drag an item onto an equipment slot to wear it and drag it off to remove it. Godot has first-class drag-and-drop on Control nodes; learning the three-method protocol once covers inventory, equipment, hotbars, and stash.
- Control layout is its own skill. Containers, anchors, and sizing flags lay out a grid that scales with the window (M1.1's stretch). Getting the layout to behave is the difference between a UI that holds together at every resolution and one that breaks the first time someone resizes.
Concepts¶
The screen lives on the UI layer¶
The inventory screen is a Control hierarchy parented under the UI CanvasLayer (M1.4), so it draws in
screen space and ignores the camera. It is hidden by default and toggled by the inventory action (M1.3).
A typical structure:
InventoryScreen (Control, full-rect, hidden)
└── Panel
└── MarginContainer
└── GridContainer (columns = bag width)
├── SlotButton (×capacity)
└── ...
The GridContainer lays its children into a grid of N columns; give it capacity slot buttons and it
wraps them into rows automatically. Anchors set the panel's position; sizing flags let the grid fill the
panel.
Render from the model¶
Each slot is a button showing an item's icon (or empty). The grid does not store items — it reads
Inventory.items and paints. Redraw whenever the model changes:
Example
The screen redraws from Inventory.changed, mapping each slot index to the item at that index:
# res://scripts/inventory_screen.gd
extends Control
@onready var grid: GridContainer = $Panel/MarginContainer/GridContainer
func _ready() -> void:
Inventory.changed.connect(_redraw)
_redraw()
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("inventory"):
visible = not visible
func _redraw() -> void:
var slots := grid.get_children()
for i in slots.size():
var slot := slots[i] as TextureRect # or a Button with a TextureRect child
if i < Inventory.items.size():
slot.texture = Inventory.items[i].icon
else:
slot.texture = null
The grid is a pure view: _redraw walks the slot widgets and sets each one's icon from the
corresponding item, or clears it. Because it redraws on Inventory.changed, picking up, equipping, or
dropping an item updates the screen with no UI-side bookkeeping. Toggling visibility on the inventory
action shows/hides the whole screen.
Godot drag-and-drop: three methods¶
Control nodes implement drag-and-drop with three virtual methods on the source and target:
_get_drag_data(at_position)— called on the node where a drag starts. Return the data being dragged (here, the item and its source slot index) and optionally set a drag preview. Returning non-null begins the drag._can_drop_data(at_position, data)— called on a node the cursor is over during a drag. Returntrueif this node accepts that data (e.g., an equipment slot accepts only items of its slot type)._drop_data(at_position, data)— called on the target when the drag is released over it. Perform the move (swap items, equip).
Example
A bag slot as a drag source and drop target. Dragging carries the item and its index; dropping swaps the two slots:
# on a bag SlotButton (knows its index)
func _get_drag_data(_pos: Vector2) -> Variant:
var item := Inventory.items[index] if index < Inventory.items.size() else null
if item == null:
return null # nothing to drag from an empty slot
var preview := TextureRect.new()
preview.texture = item.icon
set_drag_preview(preview) # the icon follows the cursor
return { "item": item, "from_index": index }
func _can_drop_data(_pos: Vector2, data: Variant) -> bool:
return data is Dictionary and data.has("item")
func _drop_data(_pos: Vector2, data: Variant) -> void:
Inventory.swap(data["from_index"], index) # reorder within the bag
_get_drag_data returns null for an empty slot (nothing to drag) and otherwise packages the item and
its index, with set_drag_preview making the icon trail the cursor. _can_drop_data accepts any item
payload; an equipment slot would tighten this to data["item"].slot == my_slot. _drop_data
performs the move — here a reorder via an Inventory.swap(i, j) helper.
Dragging onto equipment¶
An equipment slot (a separate Control, rendering Equipment.equipped_in(slot)) is a drop target that
accepts only matching items and equips on drop:
Example
# on an equipment SlotButton (knows its slot type)
func _can_drop_data(_pos: Vector2, data: Variant) -> bool:
return data is Dictionary and data.has("item") and data["item"].slot == slot_type
func _drop_data(_pos: Vector2, data: Variant) -> void:
Equipment.equip(data["item"]) # M7.2 handles swap + recompute
The _can_drop_data check enforces the rule that a helm can't go in the weapon slot — the cursor shows
"can't drop" over a mismatched slot. On drop, it calls Equipment.equip (M7.2), which does the swap and
recompute; both Inventory.changed and Equipment.changed fire, so both panels redraw. No stat code in
the UI — the UI only moves data and lets the models react.
Layout that survives resizing¶
Use containers and sizing flags rather than absolute positions: a MarginContainer for padding, the
GridContainer with a fixed column count, slot buttons with a minimum size and SIZE_EXPAND_FILL where
appropriate. Anchor the panel (centered, or full-rect with margins). With M1.1's canvas_items stretch,
a container-based layout scales cleanly to any window; an absolutely-positioned one drifts. This is the
Control-layout discipline the genre's busy screens demand.
Walkthrough¶
- Build
res://scenes/ui/inventory_screen.tscn: aControl(full-rect, hidden) →Panel→MarginContainer→GridContainer(set Columns to your bag width, e.g., 6). Addcapacityslot widgets (aTextureButtonorButtonwith aTextureRect, each knowing itsindex). - Attach
inventory_screen.gd; connect toInventory.changed; togglevisibleon theinventoryaction. Add the screen underMain/UI(M1.4) inmain.tscn. - Implement the bag slot's
_get_drag_data/_can_drop_data/_drop_datafor reordering, and add anInventory.swap(i, j)helper to the model. - Build an equipment panel (a few equipment slot widgets, each with its
slot_type), renderingEquipment.equipped_in(slot)and redrawing onEquipment.changed. Implement its_can_drop_data(matching slot) and_drop_data(Equipment.equip). - Press
F5, collect items (they fill the grid viachanged), open the inventory with theinventoryaction, drag an item onto its equipment slot, and watch your stats change (M7.2 recompute) and both panels redraw. Drag it off to unequip.
Optional sanity check
With the inventory open, equip an item by dragging it to its slot, then close and reopen the
inventory (toggle the action twice). The grid and equipment panel should show the same state on reopen —
proof the UI is rendering from the model, not from transient UI state. Then try dragging a helm onto the
weapon slot: _can_drop_data should refuse it (the cursor shows no-drop), proof the slot-type rule is
enforced at the UI boundary while the equip logic stays in M7.2.
Self-check quiz¶
Q1 — Why does the grid redraw from Inventory.changed instead of updating itself when the player picks something up?
A. It is faster. B. The grid is a view with no data of its own; redrawing from the model's change signal guarantees the screen always matches the bag, with no UI-side state to drift out of sync. C. Because GridContainer can't be updated directly. D. Because the model can't be read directly.
Reveal answer
B. Rendering from changed keeps a single source of truth (the model) and makes the UI
incapable of disagreeing with it — pickups, equips, drops all flow through one redraw. A UI that
updated itself on pickup would have a second copy of the truth that drifts. A is not the reason. C
and D are false.
Q2 — In Godot's drag-and-drop, which method decides whether a target accepts the dragged item, and how does an equipment slot use it?
A. _get_drag_data; it checks the slot type.
B. _can_drop_data; an equipment slot returns true only when the dragged item's slot matches the
slot's type, so a helm can't be dropped on the weapon slot.
C. _drop_data; it rejects mismatches by doing nothing.
D. _ready; it pre-filters droppable items.
Reveal answer
B. _can_drop_data is the per-target acceptance check during a drag; the equipment slot
compares data["item"].slot to its own type, so the cursor shows can/can't-drop accordingly.
_get_drag_data (A) starts the drag on the source. _drop_data (C) performs the move after
acceptance — relying on it to reject is too late and messy. D is unrelated.
Q3 — Why build the screen from containers and sizing flags rather than absolute positions?
A. Absolute positions are deprecated. B. Container-based layout scales cleanly with the window under M1.1's stretch; absolute positions drift and break at other resolutions. C. Containers render faster. D. Drag-and-drop only works inside containers.
Reveal answer
B. Containers + sizing flags + anchors produce a layout that holds together as the window
resizes (with canvas_items stretch from M1.1); absolute coordinates assume one size and break
elsewhere. A is false. C is negligible. D is false (drag-and-drop is per-Control, not
container-bound).
Integration question¶
Q4 — open
The inventory screen reads the M7.1 model, equips via M7.2, lives on the M1.4 UI layer, and toggles
with the M1.3 inventory action — and contains no stat or storage logic of its own. Explain how
drag-to-equip flows through the three drag-and-drop methods and into the models, naming what redraws and
why the UI's lack of its own data is what makes 'open inventory, equip, reopen' always show correct
state.
Reveal expected answer
Drag-to-equip flows entirely through data the UI moves, with the models doing the work. The drag
starts on a bag slot: _get_drag_data packages {item, from_index} and sets a drag preview, so the
icon follows the cursor. As the cursor moves over an equipment slot, that slot's _can_drop_data
returns true only if data["item"].slot matches its type — enforcing the helm-can't-go-in-weapon
rule at the UI boundary. On release, the equipment slot's _drop_data calls Equipment.equip(item)
(M7.2), which removes any previously equipped item (modifiers off, recompute, returned to the bag),
puts the new item in the slot, removes it from the bag, appends its modifiers, and recomputes (M5.4)
— so the player's stats change immediately and stats_recomputed updates the HUD. Both
Inventory.changed and Equipment.changed fire; the bag grid and the equipment panel each redraw
from their model. The UI added no stat or storage logic — it only carried a payload and invoked
model operations. "Open, equip, reopen always shows correct state" works because the screen owns no
data: every time it is shown or anything changes, it paints from Inventory.items and
Equipment.slots, so there is no cached UI copy to fall out of sync — the same single-source-of-truth
discipline that governs Health and stats, now at the presentation layer. The models are the truth;
the UI is a window onto them.