Game Dev Artisan Logo
In this post we'll be performing a Feature Study of Diablo IV's Inventory system. We'll take a look at how the Itemization is composed and break down the elements with in the inventory and recreate a similar look and feel within Godot.
Game Design

Feature Study: Diablo IV's Inventory

Author Avatar
2023-11-27
46 min read

Table of Contents

Introduction 

As Game Developers we may be inspired to create based on something we see that excites us, or provides us with certain emotions. These inspirations may come from other games that we've played, but they may also come from other mediums that we enjoy. If we spend some time and study that medium, we enable ourselves to create something that is similar in nature or that shares in creativity.

In this Feature Study, we'll be taking a look at Diablo IV's Character Panel and Inventory including aspects of it's Itemization. Our goal is to better understand how the latest installation of Diablo in the franchise accomplishes it's implementation of Items, Inventories, and the way that information is presented to a player via the UI components. We'll recreate these elements in Godot 4 to demonstrate how we can get the creative likeness by studying how Diablo IV is structured.

This began as purely a UI study, but the more time I spent with the code and was building things out, the more I was inspired to replicate some of Diablo's Itemization. I really love what I ended up with, even though it's not fully functional and isn't something I would toss directly into a game.

I ended up in quite the deep dive in the way Diablo's systems work and really wanted to get the same feel with the items in this example. Not only did that deep dive teach me a ton about Diablo, I learned a few things wtihin Godot that will be helpful for me moving forward. Hopefully as I review what I've done, you learn a few things new yourself!

This is snapshot of some of my reference images I used and compiled within PureRef . pure-ref

Video 

As always, feel free to follow along with the Video or read at your own pace here!

Let's Examine 

Diablo IV takes an approach at simplifying the Character screen by combining the Character's stats, equipment, and current Inventory into a single UI panel.

This Character Panel can quickly be opened to provide information about our Characters Stats, which Gear is equipped on our Character and what new Loot we've picked up.

We'll begin by taking the Character Panel and dividing it into smaller pieces that better outline the interface's building blocks:

Character Details Section 

Our Character Details section contains a small panel for stats one one side, and on the other we have our equipment section. ui-character-details

The Stat Panel has an overview of our Character and allows us to get a quick glance at some important information: ui-character-stats

Our Character's Level, Name and Title: ui-character-stats-name

Button's for accessing our Profile and additional Materials and Detailed Stats: ui-character-stats-buttons

Our Character's Primary Stats: ui-character-stats-primary

Our Character's Attributes: ui-character-stats-attributes

On the right side, we have the Equipment by slot for our Character, along with a preview of what our Character looks like. ui-character-equipment

We have the Equipment slots for Armor and Jewelry: ui-character-equipment-slots

Lastly we have our Weapon slots: ui-character-equipment-weapons

Inventories Section 

The UI's Inventory section contains a tab for each category of Inventories, the Inventory Grid, and our various Currencies: ui-inventory

The Tabs allow us to switch between a specific Inventory Category (Equipment, Consumables, Quests, and Aspects) ui-inventory-tabs

The Inventory Grid for the current tab has a set number of slots based on the maximum amount of items our Inventory can store. Our Equipment inventory has 33 slots of storage that we can store on our character at a time. To give us more room, we must go to our Character's Stash to unload from any one of these inventories. ui-inventory-grid

Then breaking down our Currencies, we have a section for our Gold, Red Dust, and our Obols:

ui-inventory-currencies ui-inventory-currencies-gold
ui-inventory-currencies-red-dust ui-inventory-currencies-obols

UI Creation 

Now that we have studied the different components of Diablo's UI, we have a direction we can attempt to recreate it within Godot.

We start with our UI scene and layout some Control nodes to break up our UI into the proper sections. The way we are dividing we can easily use the MarginContainer for padding and the VBoxContainer to distribute our sections between the top Character Details section and our Inventory section. We'll wrap each of those in their own parent control nodes as well and distribute them as we need to using the layout > container sizing > stretch ratio. We'll use 3 and 2 respectively, which means our Character Details will take up 3/5ths of the container and the Inventory will use the remaining 2/5ths of the container. sections-node-tree

Note that we also have a ColorRect and TextureRect that we are using to define the background color and overlaying a texture for some detail. This is the technique I use a few times over to get a grainy feel on the background, without needing to create it external.

To do this I set the ColorRect to the color I want to use, and then for the TextureRect I use a NoiseTexture2D and let that set the scale for the type of texture I'm going for. I use FastNoiseLite with a type of Perlin and tinker with the width and height to get the style I want. ui-textured-bg-setup

Next we have our Character Details nodes; a MarginContainer that contains a HBoxContainer for the Stats and Character Equipment sections. We also have a Control container for our Level display which in itself contains the same ColorRect and TextureRect configuration described previously. We again distribute the Stats and Character Equipment sections using the Stretch Ratio 1/3 and 2/3 respectively. node-tree-character-details

For our Stats section, we have a fair bit going on. Our Stats Control is a type of VBoxContainer to distribute our Nameplate, Materials and Extra Stats, Primary Stats, and Attributes. node-tree-stats

Each Node use a layout of MarginContainers and VBoxContainer for alignments, but our Primary Stats and Attributes are both using some branched scenes that we've composed to make reusable.

Stat Icon Scene 

Our first custom scene is for our Stat's which include an icon, under our Primary Stats section. This contains an icon to highlight it's stat type, and contains a stat name and value. We will use our scene to export these values and create a reusable scene to simplify our Stats node tree.

The Node Tree is small and the script is short with only a few behaviors. node-tree-stat-icon-item-container

# stat_icon_item.gd

@tool
extends HBoxContainer

@onready var stat_label: Label = %StatLabel
@onready var value_label: Label = %ValueLabel
@onready var icon_texture_rect: TextureRect = %IconTextureRect

@export var stat_name: String = "Stat":
    set(value):
        stat_name = value
        _update_labels()
        
@export var stat_value: int = 10:
    set(value):
        stat_value = value
        _update_labels()

@export var stat_icon: Texture:
    set(value):
        stat_icon = value
        _update_labels()


func _ready():
    _update_labels()


func _update_labels():
    if stat_label:
        stat_label.text = stat_name
    if value_label:
        value_label.text = str(stat_value)
    if icon_texture_rect:
        icon_texture_rect.texture = stat_icon

Our StatIconItem has bindings to the labels for the Stat's Name and Value as well as the Icon TextureRect that we will modify. We'll export the stat_name, stat_value, and stat_icon variables and have setter methods that allow us to _update_labels and see them immediately in our editor thanks to the @tool definition at the top of our script defining this as a tool script that will run in editor. Our _update_labels function simply updates the correct nodes' values.

Attribute Item Scene 

Our next custom scene is for the Attribute items. This is a Name string and Value that we export and handle with a tool script: node-tree-attribute-item

# attribute_item.gd

@tool
extends MarginContainer
@onready var attribute_label: Label = %AttributeLabel
@onready var value_label: Label = %ValueLabel

@export var attribute_name: String = "Attribute":
    set(value):
        attribute_name = value
        _update_labels()
        
@export var stat_value: int = 10:
    set(value):
        stat_value = value
        _update_labels()


func _ready():
    _update_labels()


func _update_labels():
    if attribute_label:
        attribute_label.text = attribute_name
    if value_label:
        value_label.text = str(stat_value)

Character Equipment 

Now let's look at our Character Equipment section. We have a ColorRect, then we contain the rest within a MarginContainer. Inside here we have our Paper Doll for our Character, the background gradient shadow and the VboxContainer for our Equipment: Armor, Jewelry, and Weapons. These Slots are for our custom UIItemIcon which we will cover more in our UI Item Icon section within our Inventory. node-tree-character-equipment

Again note the layout is making use of MarginContainer for padding and VBoxContainer for list like alignment.

Put this all together and so far it looks like this: character-details-section-preview

Inventory Grid 

Our Inventory Section has a Node Tree containing the BG setup and a VBoxContainer for our Inventory Tabs, Inventory Grid, and our Currencies. The Inventory stuff is contained within our MarginContainer and another VboxContainer thus spacing and stacking our Tabs above our InventoryGridContainer Control node. This Grid has a ColorRect bg with our Slot placeholders and the InventoryGrid using the GridContainer node and a custom script for generating the Inventory and it's Items. node-tree-inventory

With our Node Tree setup with all our Control nodes, we should have an inventory that looks like this: inventory-grid-section-preview

Our InventoryGrid will populate our Inventory instance and create the UI elements for each Item.

Let's look at the script from a high level perspective, then take a step back and review what we are going to implement for our Items and the Item's data structure.

# inventory_grid.gd

extends GridContainer

var ui_item: PackedScene = preload("res://ui/inventory/ui_item_icon.tscn")

var prefixes: Array = [...]

var suffixes: Array = [...]

var inventory: Inventory

func _ready() -> void:
...


func _on_item_added(item: Item, _qty, _total) -> void:
...
    
    
func _on_item_removed(item: Item, _qty, _total) -> void:
...
    

func generate_item(category: Item.Category) -> Item:
...


func generate_name(item: Item) -> String:
...

Our Grid holds a reference to an Inventory class, a PackedScene of our UIItemIcon, and our functions deal with an Item class.

Data Driven Items 

This is the point in which this project was no longer only a UI study of Diablo IV and recreating that UI layout within Godot 4.

I wanted to populate items both in the Character's Equipment slots, as well as within my fancy Inventory Grid. At first I started with the idea of mocking up a template of a single item or maybe a few icons of armor and weapons and then call it a day.

I couldn't settle there, I had to have the full dopamine hit of random item Rarities, random rolled Stats, Sockets, Gems, and Sacred and Ancestral tiers!

Not only did I want the Inventory icons to be randomized, I wanted to be able to hover each item icon and get the tooltip showing all my random stats, item name, the fancy colored borders and backgrounds to let me know how sweet my loot is, and all the extra details we may want to pack into our item descriptions.

I wanted to see if I could not only mimic the UI, but also some of that fancy data structure that Diablo IV's itemization uses.

Itemization Overview 

Let's begin by examining Items in Diablo IV.

If we look at the different Categories of Items, we have Gear (Armor, Weapons, and Jewelry) and Socketable Items such as a Gem.

d4-item-tooltip

All Items at their core are going to have some common attributes:

  • Name
  • Rarity | Tier | Category
  • Image Icon
  • Description
  • Level Requirements
  • Tradable?
  • Class Restrictions
  • Account Bound?
  • Salvageable?
  • Value

More information is required for our Jewelry/Armor/Weapon/Socketable.

Here we can define Gear to extend from an Item containing additional details such as:

  • Item Power
  • Upgrades (Current/Max)
  • Affix (Count/Rolled)
  • Socket (Count/Rolled)
  • Durability

Even further we can extend Gear into Armor with it's own details such as the Type of Armor:

  • Chest
  • Helm
  • Pants
  • Footwear
  • Gloves
  • Shield

For the Weapons, we'll also extend Gear and add it's Types:

  • Axe
  • Axe2H
  • Bow
  • Crossbow
  • Dagger
  • Offhand
  • Mace
  • Mace2H
  • Staff
  • Sword
  • Sword2H
  • Scythe
  • Scythe2H
  • Wand
  • Polearm

Jewelry is either a Ring or Amulet Type, but we'll still be extending Gear.

Another category of Item is Socketables and from that we can extend it into a Gem.

Gem has a Type and Quality Types:

  • Diamond
  • Saphire
  • Ruby
  • Emerald
  • Amethyst
  • Topaz

Qualities:

  • Crude
  • Chipped
  • Normal
  • Flawless
  • Royal

We've got ourselves the elements for what we need to create some new Custom Resources!

Item Class 

We will start with our Base class for Item -> item.gd:

# item.gd

class_name Item extends Resource

@export var name: String = "Item Name"
@export var description: String
@export var image: Texture
@export var quantity: int = 1
@export var stackable: bool = false
@export var value: int = 1
@export var rarity: Rarities = Rarities.Common
@export var tier: Tiers = Tiers.Normal
@export var level_requirement: int = 1
@export var is_account_bound: bool = false
@export var salvageable: bool = true
@export var tradable: bool = true
@export_flags(
    "Barbarian",
    "Druid",
    "Necromancer",
    "Rogue",
    "Sorcerer",) var class_restrictions = 0
@export var inventory: Inventory

var class_strings = "Classes: ":
    get:
        var class_string = []
        
        for _id in Classes.size():
            var _class = Classes.values()[_id]
            if _class and (class_restrictions & _class) == _class:
                var key = Classes.keys()[_id]
                class_string.append(str(key))
            
        return ", ".join(class_string)

var compound_category = "":
    get:
        return _get_compound_category()

enum Classes {
    None = 0x0,
    Barbarian = 0x1,
    Druid = 0x2,
    Necromancer = 0x4,
    Rogue = 0x8,
    Sorcerer = 0x16,
}

enum Category {
    Item,
    Armor,
    Weapon,
    Jewelry,
    Gem
}

enum Tiers {
    Normal,
    Sacred,
    Ancestral
}

enum Rarities {
    Common,
    Magic,
    Rare,
    Legendary,
    Unique
}

static var RARITY_COLORS: Array[Color] = [
    Color(.3, .3, .3, 1),
    Color(.18, .23, .63, 1),
    Color(.58, .5, .0, 1),
    Color(.56, .21, .0, 1),
    Color(.54, .34, .12, 1),
]

func _get_compound_category() -> String:
    return "Item"


func _get_type() -> String:
    return "Item"

For our base Item, we setup @exports for our variables that we want to be able to modify on the resource. We also setup some helper variables that use a getter function to generate things like our class_strings and returning our compound_category from a function we can define on later class definitions. We also have some enumerators for our Character Classes, Categories of Items, Tiers, and Rarities. We also setup the array of RARITY_COLORS to help setup the branding of our backgrounds and borders of our item icons in our UI.

Gear Class 

Next we'll extend our Item and define our Gear -> gear.gd:

# gear.gd

class_name Gear extends Item

@export var power: int = 0
@export var affix_count: int = 0
@export var equipped: bool = false

static var AFFIX_COUNTS: Dictionary = {
    Rarities.Common: {
        "min": 0,
        "max": 0
    },
    Rarities.Magic: {
        "min": 1,
        "max": 2
    },
    Rarities.Rare: {
        "min": 2,
        "max": 4
    },
    Rarities.Legendary: {
        "min": 4,
        "max": 4
    },
    Rarities.Unique: {
        "min": 4,
        "max": 4
    }
}
var upgrades: int = 0
var max_upgrades: int = 0:
    get:
        return _get_max_upgrades()
var sockets: int = 0
var max_sockets: int = 1:
    get:
        return _get_max_sockets()
var socketed: Array[Socketable] = []

var durability: int = 100
var max_durability: int = 100

var affixes: Array[Affix] = [
]

var breakpoint_tier: int:
    get:
        if power < 150:
            return 1
        elif power < 340:
            return 2
        elif power < 460:
            return 3
        elif power < 625:
            return 4
        elif power < 725:
            return 5
        return 6

var min_ranges: Dictionary = {
    Tiers.Normal: 0,
    Tiers.Sacred: 600,
    Tiers.Ancestral: 685
}

var max_ranges: Dictionary = {
    Tiers.Normal: 620,
    Tiers.Sacred: 720,
    Tiers.Ancestral: 820
}

var rolled = false
    

func _get_max_sockets():
    return 1


func _get_max_upgrades():
    match(tier):
        Tiers.Normal:
            match(rarity):
                Rarities.Common:
                    return 0
                Rarities.Magic:
                    return 2
                Rarities.Rare:
                    return 3
                Rarities.Legendary:
                    return 4
                Rarities.Unique:
                    return 5
        _:
            return 5
            

func _get_compound_category() -> String:
    var _tier = Tiers.keys()[tier]
    var _rarity = Rarities.keys()[rarity]
            
    return "{tier}{rarity}{type}".format({
        "tier": "%s " % _tier if tier != Tiers.Normal else "",
        "rarity": "%s " % _rarity if rarity != Rarities.Common else "",
        "type": _get_type()
    })
    

func roll_affixes() -> void:
    affixes.clear()
    var affix_tier = AFFIX_COUNTS[rarity]
    for _i in randi_range(affix_tier.min, affix_tier.max):
        var has_affix = true
        var affix
        while(has_affix):
            affix = Affix.roll_affix()
            has_affix = affixes.any(func(a: Affix):
                return a.stat == affix.stat
            )
        affixes.append(affix)


func roll() -> void:
    sockets = randi_range(0, max_sockets)
    upgrades = randi_range(0, max_upgrades)
    roll_affixes()
    rolled = true
    
    
func socket(item: Socketable) -> void:
    if socketed.size() < sockets:
        socketed.append(item)
    return

Our Gear has quite a bit going on, but this is where our Itemization really takes off. We are defining the structure for Item power, Affixes, and other Gear specific variables. We setup some lower and upper limits to determine things like our AFFIX_COUNTS, max_upgrades, max_sockets, breakpoint_tier, Tier Ranges. We have some functions for setting up and rolling our item's stats: roll and roll_affixes; when we roll() we setup our number of sockets and upgrades for this item based on our ranges, then we slap on some Affixes by running our roll_affixes() function. This will leverage our Affix class to populate some strings in our description, no real behavior in this example, but fits in the UI nicely.

Affix Class 

# affix.gd

class_name Affix extends RefCounted

@export var stat: String
@export var type: Type
@export var min_amount: float
@export var max_amount: float
@export var amount: float
@export var template_string: String:
    get:
        match(type):
            Type.Percent:
                return "+{amount}% {stat}"
            Type.Stat:
                return "+{amount} {stat}"
        return "Affix Modifier"

var label: String:
    get:
        return template_string.format({
            "amount": ("%.2f" % amount) if (type == Type.Percent) else ("%d" % amount),
            "stat": stat.replace("_", " ")
        })

enum Type {
    Percent,
    Stat
}

enum Stat {
    Dexterity,
    Intelligence,
    Strength,
    Willpower,
    All_Stats
}

static var PERCENT_STATS: Dictionary = {
    "Total Armor": {
        "min": 2.0,
        "max": 4.8
    },
    "Damage": {
        "min": 4.4,
        "max": 10.0
    },
    "Attack Speed": {
        "min": 4.4,
        "max": 10.0
    },
    "Basic Skill Attack Speed": {
        "min": 4.4,
        "max": 10.0
    },
}

static func roll_affix() -> Affix:
    var affix = Affix.new()
    var affix_type = Type.values().pick_random()
    affix.type = affix_type
    var _stat
    var data
    match(affix_type):
        Type.Percent:
            _stat = PERCENT_STATS.keys().pick_random()
            data = PERCENT_STATS.get(_stat)
            affix.min_amount = data.min
            affix.max_amount = data.max
            affix.amount = randf_range(affix.min_amount, affix.max_amount)
        Type.Stat:
            _stat = Stat.keys().pick_random()
            affix.stat = _stat 
            data = [
                {
                    "min": 28,
                    "max": 42
                },
                {
                    "min": 38,
                    "max": 52
                }
            ].pick_random()
            affix.min_amount = data.min
            affix.max_amount = data.max
            affix.amount = randi_range(int(affix.min_amount), int(affix.max_amount))
    
    affix.min_amount = data.min
    affix.max_amount = data.max
    affix.amount = randf_range(affix.min_amount, affix.max_amount)
    affix.stat = _stat
    return affix

Our Affix code is a bit more involved so we can randomize based on flat percentages and stat based. The end result should be an Affix with a string that can be passed through a template. This structure can be extended much further to provide a mechanical benefit to our stat system if we wanted to flesh it out further for our needs!

Armor Class 

Luckily our Gear class handles the bulk of the heavy lifting for data and functions for what our equipment may need, but our Armor will have some unique distinctions:

# armor.gd

class_name Armor extends Gear

@export var type: Type = Type.Chest

enum Type {
    Chest,
    Helm,
    Pants,
    Footwear,
    Gloves,
    Shield
}

static var ARMOR_TEXTURE: Texture = preload("res://assets/armor/armory.png")

static var ARMOR_ICONS = {
    Type.Chest: [0, 8, 16, 24, 32, 40, 48, 56, 64, 72, 80, 88],
    Type.Helm: [1, 9, 17, 25, 33, 41, 49],
    Type.Pants: [2, 10, 18, 26, 34],
    Type.Footwear: [3, 11, 19, 27, 35],
    Type.Gloves: [4, 12, 20, 28, 36, 44, 52],
    Type.Shield: [7, 15, 23, 31, 39, 47],
}

var category: Category = Category.Armor

func _get_max_sockets() -> int:
    match(type):
        Type.Chest, Type.Pants:
            return 2
        _:
            return 1


func _get_type() -> String:
    return Type.keys()[type]

Notice that we can define our Armor's type based on our Type enumerator.

We also have a nice ARMOR_TEXTURE that we reference, along with the ARMOR_ICONS mapping frame indices to the specific Armor Type.

We also set the category as being the Armor Category. our _get_max_sockets function is defined as part of overriding our Gear implementation, which allows us to vary based on our Armor Type.

Lastly we override our _get_type function to be based on our Type enumerator.

Weapon Class 

Our Weapon class is almost identical in structure as our Armor class, instead we define our Weapon Types and use a new WEAPON_TEXTURE and it's mapping for WEAPON_ICONS.

The functions and category are all the same approach as our Armor implementation.

# weapon.gd

class_name Weapon extends Gear

@export var type: Type = Type.Axe

var category: Category = Category.Weapon

enum Type {
    Axe,
    Axe2H,
    Bow,
    Crossbow,
    Dagger,
    Offhand,
    Mace,
    Mace2H,
    Staff,
    Sword,
    Sword2H,
    Scythe,
    Scythe2H,
    Wand,
    Polearm
}

static var WEAPON_TEXTURE: Texture = preload("res://assets/armor/weapons.png")

static var WEAPON_ICONS: Dictionary = {
    Type.Axe: [44,46,47,48,49],
    Type.Axe2H: [44,46,47,48,49],
    Type.Bow: [90,91,92,93,94,95,96,97,98,99],
    Type.Crossbow: [80,81,82,83,84,85,86,87,88,89],
    Type.Dagger: [30,31,32,34,36,37,38,39],
    Type.Offhand: [70,71,72,73,74,75,76,77,78,79],
    Type.Mace: [43,45,50,51,52,53,54,55,56,59],
    Type.Mace2H: [43,45,52,53,54,55,56,59],
    Type.Staff: [0,1,2,3,4,5,6,7,8,9],
    Type.Sword: [60,61,62,63,64,65,66,67,68,69],
    Type.Sword2H: [40,41,60,61,62,63,64,65,66,67,68,69,],
    Type.Scythe: [33,35],
    Type.Scythe2H: [33,35],
    Type.Wand: [10,11,12,13,14,15,16,17,18,19],
    Type.Polearm: [20,21,22,23,24,25,26,27,28,29],
}

func _get_max_sockets() -> int:
    match(type):
        Type.Axe2H, Type.Bow, Type.Crossbow, Type.Mace2H, Type.Staff, Type.Sword2H, Type.Scythe2H, Type.Polearm:
            return 2
        _:
            return 1


func _get_type() -> String:
    match(type):
        Type.Axe2H:
            return "Two-Handed Axe"
        Type.Mace2H:
            return "Two-Handed Mace"
        Type.Sword2H:
            return "Two-Handed Sword"
        Type.Scythe2H:
            return "Two-Handed Scythe"
    return Type.keys()[type]

Jewelry Class 

Big surprise, the Jewelry class is like the rest of our Gear, with only a Ring and Amulet type, the implementation is even easier!

# jewelry.gd

class_name Jewelry extends Gear


@export var type: Type = Type.Ring

enum Type {
    Ring,
    Amulet
}


static var JEWELRY_TEXTURE: Texture = preload("res://assets/armor/armory.png")

static var JEWELRY_ICONS = {
    Type.Ring: [5, 13, 21, 29],
    Type.Amulet: [6, 14, 22, 30],
}

var category: Category = Category.Jewelry

func _get_max_sockets() -> int:
    return 2


func _get_type() -> String:
    return Type.keys()[type]

Socketable and Gem Classes 

Let's look at our other Item types; Socketable/Gem class.

First our Socketable is simply a new base class for future socketable Items: Gems and Hearts for Season 1, who know's what else we could add (Runes maybe?).

# socketable.gd

class_name Socketable extends Item
    
    
func _get_compound_category() -> String:
    return "Socketable"

Woah, so little code, what about our Gem?:

# gem.gd

class_name Gem extends Socketable

@export var type: Type = Type.Diamond
@export var quality: Quality = Quality.Crude:
    set(qual):
        quality = qual
        level_requirement = LEVEL_REQUIREMENTS[quality]

enum Type {
    Diamond,
    Saphire,
    Ruby,
    Emerald,
    Amethyst,
    Topaz
}

enum Quality {
    Crude,
    Chipped,
    Normal,
    Flawless,
    Royal
}

var LEVEL_REQUIREMENTS = [
    15,
    20,
    40,
    50,
    60
]

var texture = preload("res://assets/armor/gem.png")

var COLORS: Array[Color] = [
    Color(.68, .66, .62),
    Color(0, .23, .96),
    Color(.8, .12, .16),
    Color(.07, .58, .05),
    Color(.51, 0, .81),
    Color(.83, .57, .15)
]

var category: Category = Category.Gem

var color: Color:
    get:
        return COLORS[type]


func _init() -> void:
    stackable = true
    value = 4
    image = texture
    
    
func _get_compound_category() -> String:
    return "Gem"
    
    
func _get_type() -> String:
    return Type.keys()[type]

Looks like our Gem class is doing a lot of the heavy lifting for socketables right now, but that's how things are for this example. If I had implemented more Socketable items, I'd likely extract those shared behaviors into our common class, but for now, Gem is all we have.

A good consideration here is if the Socketable class is required then? I would say go with your gut, don't over complicate things if you don't need to, in my original run of this code, I didn't have a Socketable class, as only Gems were on my mind, so everything was based purely on Gem in the code, but I refactored that out to be based on Socketable instead when looking at a Gear's socket structures.

As always, use your best judgements!

Data Driven UI 

Now that we've created our data structures for Items, let's look back our Inventory Grid and see how we leverage that data to build out our Inventory and drive our UI with it all.

# inventory_grid.gd

extends GridContainer

var ui_item: PackedScene = preload("res://ui/inventory/ui_item_icon.tscn")

var prefixes: Array = [...]

var suffixes: Array = [...]

var inventory: Inventory

func _ready() -> void:
    inventory = Inventory.new()
    ...


func _on_item_added(item: Item, _qty, _total) -> void:
    ...
    
func _on_item_removed(item: Item, _qty, _total) -> void:
    ...	

func generate_item(category: Item.Category) -> Item:
    ...

func generate_name(item: Item) -> String:
    ...

First we've got our Inventory class -> inventory.gd

# inventory.gd

class_name Inventory extends Resource

signal item_added(item, qty, total)
signal item_removed(item, qty, total)

@export var max_capcity: int = 1
@export var items: Array[Item] = []


func _init(cap: int = 1):
    max_capcity = cap


func find(item: Item) -> Item:
    var _id: int
    if item is Gem:
        var _items = items.filter(
            func(_item: Item): 
                return _item is Gem and _item.type == item.type and _item.quality == item.quality
        )
        if _items.size():
            _id = items.find(_items.front())
        else:
            return
    else:
        _id = items.find(item)
    if _id != -1:
        return items[_id]
        
    return


func add(item: Item, qty: int = 1) -> void:
    var i = find(item)
    
    if !i:
        items.append(item)
        item.inventory = self
        i = item
        i.quantity = qty
        item_added.emit(i, qty, i.quantity)
    elif i.stackable:
        i.quantity += qty
        item_added.emit(i, qty, i.quantity)
        
    


func remove(item: Item, qty: int = 1) -> void:
    var i = find(item)
    if !i:
        return
        
    if i.quantity >= qty:
        i.quantity -= qty
        item_removed.emit(item, qty, i.quantity)
    else:
        items.erase(item)
        item_removed.emit(item, qty, 0)


func has(item: Item, minimum: int = 1) -> bool:
    return count(item) >= minimum
    

func count(item: Item) -> int:
    var i = find(item)
    if i:
        return i.quantity
        
    return 0
    
    
func sort() -> void:
    items.sort_custom(func(a: Item, b: Item):
        if a.category < b.category:
            return true
        elif a.category == b.category:
            if a.type < b.type:
                return true
            elif a.type == b.type:
                if a is Gear and b is Gear:
                    return a.power > b.power
                if a is Gem and b is Gem:
                    return a.quality > b.quality
        return false
    )

Here we have a data structure for storing our Item's and keeping track of when we add/remove 1 or more Item. We have signals that we emit when an item is added or removed form our Inventory. We also have a convenient sort function that will organize our Inventory based on our custom function.

Back in our Inventory Grid

# inventory_grid.gd

extends GridContainer

var ui_item: PackedScene = preload("res://ui/inventory/ui_item_icon.tscn")

...

func _ready() -> void:
    inventory = Inventory.new()
    for i in range(randi_range(30, 33)):
        var item_data = generate_item([
                Item.Category.Weapon, 
                Item.Category.Weapon, 
                Item.Category.Armor, 
                Item.Category.Armor, 
                Item.Category.Jewelry, 
                Item.Category.Jewelry, 
                Item.Category.Gem,
            ].pick_random())
        inventory.add(item_data, item_data.quantity)

    inventory.sort()
    
    for item in inventory.items:
        _on_item_added(item, item.quantity, item.quantity)

    inventory.item_added.connect(_on_item_added)
    inventory.item_removed.connect(_on_item_removed)


func _on_item_added(item: Item, _qty, _total) -> void:
    ...
    
func _on_item_removed(item: Item, _qty, _total) -> void:
    ...	

func generate_item(category: Item.Category) -> Item:
    ...

func generate_name(item: Item) -> String:
    ...

When our node is ready, we loop over a range of 30-33 items and call our generate_item function passing in a random item from our list.

Then we take that generated item and add it to our Inventory then call sort once we've added all our items. Once we've sorted, we'll loop over the items and call our _on_item_added function, passing the item and it's quantity.

We also want to connect our signals to the inventory so when we add or remove a new item, we can properly update our UI grid.

Let's look at our generate_* functions:

# inventory_grid.gd
...
func generate_item(category: Item.Category) -> Item:
    var item: Item
    var atlas: AtlasTexture = AtlasTexture.new()
    var type: int
    match(category):
        Item.Category.Gem:
            item = Gem.new()
            item.type = Gem.Type.values().pick_random()
            item.quality = Gem.Quality.values().pick_random()
            item.salvageable = false
            item.name = "{quality}{spacer}{type}".format({
                "quality": "" if item.quality == Gem.Quality.Normal else Gem.Quality.keys()[item.quality],
                "spacer": "" if item.quality == Gem.Quality.Normal else " ",
                "type": Gem.Type.keys()[item.type]
            })
            item.quantity = randi_range(1, 3)
            return item
        Item.Category.Armor:
            item = Armor.new()
            type = Armor.Type.values().pick_random()
            atlas.atlas = Armor.ARMOR_TEXTURE
            var i = Armor.ARMOR_ICONS[type].pick_random()
            atlas.region = Rect2((i % 8) * 32, (i / 8) * 32, 32, 32)
        Item.Category.Weapon:
            item = Weapon.new()
            type = Weapon.Type.values().pick_random()
            atlas.atlas = Weapon.WEAPON_TEXTURE
            var i = Weapon.WEAPON_ICONS[type].pick_random()
            atlas.region = Rect2((i % 10) * 24, (i / 10) * 24, 24, 24)
        Item.Category.Jewelry:
            item = Jewelry.new()
            type = Jewelry.Type.values().pick_random()			
            atlas.atlas = Jewelry.JEWELRY_TEXTURE
            var i = Jewelry.JEWELRY_ICONS[type].pick_random()
            atlas.region = Rect2((i % 8) * 32, (i / 8) * 32, 32, 32)
    ...
    
    return item


func generate_name(item: Item) -> String:
    var prefix = prefixes.pick_random()
    var suffix = suffixes.pick_random()
    return "{pre}{prefix_spacer}{type}{suffix_spacer}{suff}".format({
        "pre": prefix,
        "prefix_spacer": "" if prefix == "" else " ",
        "type": item._get_type(),
        "suff": suffix,
        "suffix_spacer":  "" if suffix == "" else " ",
    })

Our function will take the category of Item we want to generate and match on that to populate the Gem and return, or if it's a type of Gear, we'll create the proper Item class, set the type, create the AtlasTexture from the proper Texture and region, then the remaining common variables and return our generated item.

    match(category):
        ...
    
    item.image = atlas
    item.type = type
    item.name = generate_name(item)
    item.power = randi_range(0, 820)
    item.value = randi_range(10000, 200000)
    item.rarity = Item.Rarities.values().pick_random()
    item.tier = Item.Tiers.values().pick_random()			
    item.level_requirement = randi_range(0, 100)
    item.is_account_bound = randi_range(0,1) == 0
    item.salvageable = true
    item.tradable = (item.rarity != Item.Rarities.Unique)
    item.roll()
    for socket in randi_range(0, item.sockets):
        var gem = generate_item(Item.Category.Gem)
        item.socket(gem)
    
    return item

We also are calling our generate_name function to get the proper string name of our item and set it to our item's details.

Now let's look at those function's we've connected to our signals:

# inventory_grid.gd
...
func _on_item_added(item: Item, _qty, _total) -> void:
    var ui_item_icon = ui_item.instantiate() as UIItemIcon
    ui_item_icon.item = item
    add_child(ui_item_icon)
    
    
func _on_item_removed(item: Item, _qty, _total) -> void:
    for ui_icon in get_children() as Array[UIItemIcon]:
        if ui_icon.item == item:
            if _total == 0:
                remove_child(ui_icon)
            else:
                ui_icon._update_item_icon()

These functions help to create instances of our UIItemIcon and track those children and update when we remove the items from the Inventory.

UI Item Icon 

Our UIItemIcon is our connection between data and the UI, we'll assign the Item to the item variable of our UI element, and it'll do the heavy lifting for mapping the data to the proper components, and delegating how we view our Tooltip as well.

node-tree-ui-item-icon Our Node Tree consists of our Root Control node, Our Item/Highlight/ItemIcon Textures, a QuantityLabel, and a container for our Sockets. These will all be mapped in code for binding data within our script -> ui_item_icon.gd.

# ui_item_icon.gd

class_name UIItemIcon extends Control

@export var item: Item:
    set(_item):
        item = _item
        item.changed.connect(_update_item_icon)

@onready var item_texture: TextureRect = $ItemTexture
@onready var item_icon_texture: TextureRect = $ItemIconTexture
@onready var highlight_texture: TextureRect = $HighlightTexture
@onready var quantity_label: Label = %QuantityLabel
@onready var socket_controls: VBoxContainer = %Sockets

var overlay_shader: ShaderMaterial = preload("res://ui/shaders/color_overlay_shader.tres")
var SOCKET_SCENE = preload("res://ui/inventory/ui_socket.tscn")

func _ready() -> void:
    _update_item_icon()
    
func _update_item_icon() -> void:
    ...

func _get_drag_data(_at_position: Vector2) -> Variant:
    ...

func _can_drop_data(_at_position: Vector2, data: Variant) -> bool:
    ...
    
func _drop_data(_position, data) -> void:
    ...

We @export out our Item variable with a setter function so we can connect to the Item's changed signal, letting us know the item's data may have changed, such as quantity. When this happens we call our _update_item_icon function, which is the staple of this script.

After our @onready binds, we have our variables for the overlay_shader which is what we use to add some color shader to our bg texture. Our color_overlay_shader.tres ShaderMaterial has a shader -> ui_item_icon.gdshader

# ui_item_icon.gdshader

shader_type canvas_item;

uniform vec3 color = vec3(1.0, 1.0, 1.0);
void fragment() {
    vec4 base = texture(TEXTURE, UV);
    vec4 blend = vec4(color, base.a);
    vec4 limit = step(0.5, base);
    COLOR = mix(2.0 * base * blend, 1.0 - 2.0 * (1.0 - base) * (1.0 - blend), limit);
}

Our Shader is a Color mix of our base texture and our defined color shader parameter.

We also have our SOCKET_SCENE to instance for each socket of this item -> ui_socket.gd. This NodeTree is a Control root, with a TextureRect for the Socket Background as well as the Gem Texture; which we'll bind a reference in code.

# ui_socket.gd

class_name UISocket extends Control

@export var item: Gem:
    set(_item):
        item = _item
        _update_gem()
        
        
@export var gem_colors: Array[Color] = [
    Color(.68, .66, .62),
    Color(0, .23, .96),
    Color(.8, .12, .16),
    Color(.07, .58, .05),
    Color(.51, 0, .81),
    Color(.83, .57, .15)
]

@onready var gem: TextureRect = $Gem

var overlay_shader: ShaderMaterial = preload("res://ui/shaders/color_overlay_shader.tres" )

func _ready() -> void:
    _update_gem()


func _update_gem() -> void:
    if item:
        if gem and overlay_shader:
            gem.show()
            var shader_material = overlay_shader.duplicate()
            shader_material.set_shader_parameter("color", item.color)
            gem.material = shader_material
    else:
        gem.hide()

Our Socket will either show the gem with the shader applied based on our Item's color, or it'll hide the Gem Texture.

In our ui_item_icon.gd's _ready function, we're calling our _update_item_icon function.

# ui_item_icon.gd
...

func _update_item_icon() -> void:
    var color: Color
    if item:
        if item is Gear:
            if !item.rolled:
                item.roll()
        var mat = overlay_shader.duplicate() as ShaderMaterial
        match(item.category):
            Item.Category.Gem:
                item_icon_texture.texture = Gem.GEM_TEXTURE
                mat.set_shader_parameter("color", item.color)
                item_icon_texture.material = mat
                color = Item.RARITY_COLORS[Item.Rarities.Common]
                var bg_mat = overlay_shader.duplicate() as ShaderMaterial
                bg_mat.set_shader_parameter("color", color)
                item_texture.material = bg_mat
                highlight_texture.hide()
            Item.Category.Weapon, Item.Category.Armor, Item.Category.Jewelry:
                color = Item.RARITY_COLORS[item.rarity]
                mat.set_shader_parameter("color", color)
                item_texture.material = mat
                item_icon_texture.texture = item.image
                item_icon_texture.material = null
                highlight_texture.visible = !(item.tier == Item.Tiers.Normal)
                highlight_texture.self_modulate = Color.PEACH_PUFF if item.tier == Item.Tiers.Sacred else Color.WHITE
                if item is Gear and item.sockets:
                    for socket in socket_controls.get_children():
                        socket_controls.remove_child(socket)
                    for i in item.sockets:
                        var socket = SOCKET_SCENE.instantiate() as UISocket
                        if item.socketed.size() > i:
                            var gem: Gem = item.socketed[i] as Gem
                            if gem:
                                socket.item = gem
                        socket_controls.add_child(socket)
        
        if item.stackable:
            if item.quantity > 1:
                quantity_label.show()
            else:
                quantity_label.hide()
            quantity_label.text = str(item.quantity)
        else:
            quantity_label.hide()

    if !mouse_entered.is_connected(UI.on_hover_item.bind(self)):
        mouse_entered.connect(UI.on_hover_item.bind(self))
    if !mouse_exited.is_connected(UI.on_hover_leave.bind(self)):
        mouse_exited.connect(UI.on_hover_leave.bind(self))

We ensure we have an item defined, and then if it's Gear we want to ensure we've rolled it's stats. Then we get a duplicate of our ShaderMaterial which for the Gem icon we'll apply the shader as we previously did for our UISocket scene.

For the Icon's Background Texture we'll also set the material based on the item's rarity.

For Gear we also have the highlight Texture which we show for Sacred and Ancestral Tiers.

We loop over the Gear's sockets and create an instance of our UISocket and ensure we add it to the scene.

Stackable Items will show a quantity label when the value is over 1.

Last we ensure we have our Mouse events connected for our UI's functions!

UI Tooltip 

# ui.gd

class_name UI extends Control

var tooltip: Tooltip 
var TOOLTIP_SCENE: PackedScene = preload("res://ui/tooltip/tooltip.tscn")
static var _ui: UI

func _ready() -> void:
    _ui = self if !_ui else _ui


func _delete_tooltip() -> void:
    if _ui.tooltip and !_ui.tooltip.is_queued_for_deletion():
        _ui.remove_child(_ui.tooltip)
        if _ui.tooltip.mouse_entered.is_connected(_ui.on_hover_item):
            _ui.tooltip.mouse_entered.disconnect(_ui.on_hover_item)
        if _ui.tooltip.mouse_exited.is_connected(_ui.on_hover_leave):
            _ui.tooltip.mouse_exited.disconnect(_ui.on_hover_leave)
        _ui.tooltip.queue_free()
        _ui.tooltip = null


static func on_hover_item(item: UIItemIcon) -> void:
    if _ui:
        _ui._delete_tooltip()
        var tt: Tooltip = _ui.TOOLTIP_SCENE.instantiate() as Tooltip
        tt.item = item.item
        _ui.tooltip = tt
        tt.global_position.x = item.global_position.x - tt.size.x - 40
        _ui.add_child(tt)


static func on_hover_leave(_item: UIItemIcon) -> void:
    if _ui:
        _ui._delete_tooltip()

We setup a static reference to our ui, making this a singleton. There are many ways to tackle this piece, here I'm experimenting with Godot's static variables for our _ui variable.

Note our on_hover_* functions; they delete and recreate our UI's tooltip scene and ensure it's position and item is properly defined based on which UIItemIcon Item we hover from our Inventory Grid. These functions call over to our _delete_tooltip function for the cleanup and remove it from memory.

Our Tooltip is also doing some data binding similar to the UIItemIcon and has a more involved Node SceneTree and accompanied script. node-tree-tooltip-root node-tree-tooltip-info-panel

The Tooltip's Root is a Control node, and has a VBoxContainer to stack the EquippedContainer Control node for showing when our Item is equipped. This is done by a referenced EquippedPlate Control node containing a ColorRect background and MarginContainer for the Texture and Label. We'll toggle this visibility within our script.

Our InfoPanel Control node contains the bulk of bound data. We have a ColorRect BG and InfoContainer MarginContainer, nesting it's own ColorRect BG and MarginContainer. This sets up the layers of Border, padding, and Background Graident Textures: GradientRect and BorderRect which are each NinePatchRects and will be referenced in code to apply our shader to it based on our Item Rarity. Inside we have our MarginContainer for the IconTexture and the VBoxContainer for stacking the individual labels for our data binding to the Item properties; each of which is bound with a unique name to be referenced from within our script.

# tooltip.gd

@tool
class_name Tooltip extends Control

@export var color: Color
@export var item: Item

@onready var equipped_plate: Control = %EquippedPlate
@onready var info_container: MarginContainer = %InfoContainer
@onready var bg: ColorRect = %BG
@onready var icon_texture: TextureRect = %IconTexture
@onready var item_name: RichTextLabel = %ItemName
@onready var category: RichTextLabel = %Category
@onready var power: RichTextLabel = %Power
@onready var upgrades: RichTextLabel = %Upgrades
@onready var stat_separator: HSeparator = %StatSeparator
@onready var description: RichTextLabel = %Description
@onready var stat: RichTextLabel = %Stat
@onready var affix_separator: HSeparator = %AffixSeparator
@onready var affix: RichTextLabel = %Affix
@onready var level_requirement: RichTextLabel = %LevelRequirement
@onready var account_bound: RichTextLabel = %AccountBound
@onready var salvageable: RichTextLabel = %Salvageable
@onready var tradable: RichTextLabel = %Tradable
@onready var class_identifier: RichTextLabel = %ClassName
@onready var price: HBoxContainer = %Price
@onready var sell_value: RichTextLabel = %SellValue
@onready var coin_icon: TextureRect = %CoinIcon
@onready var durability: RichTextLabel = %Durability
@onready var gradient_rect: NinePatchRect = %GradientRect
@onready var border_rect: NinePatchRect = %BorderRect

@onready var node_map: Dictionary = { ... }

var SOCKET_CONTAINER: PackedScene = preload("res://ui/tooltip/socket_container.tscn")
var overlay_shader: ShaderMaterial = preload("res://ui/shaders/color_overlay_shader.tres")

var string_templates: Dictionary:
    get:
        ...

var item_tokens: Dictionary:
    get:
        ...

var hidden_nodes: Array:
    get:
        ...

func _ready() -> void:
    _update_tooltip_values()
        
func _update_tooltip_values() -> void:
    ...

func _on_info_container_resized() -> void:
    ...

The bulk of our work for setting up our Tooltip happens on our _update_tooltip_values function, which we call when our node is ready:

# tooltip.gd
...
func _update_tooltip_values() -> void:
    if !item:
        return
    icon_texture.texture = item.image
    var mat = overlay_shader.duplicate() as ShaderMaterial
    var col = Item.RARITY_COLORS[item.rarity]
    mat.set_shader_parameter("color", col)
    gradient_rect.material = mat
    border_rect.material = mat
    item_name.add_theme_color_override("default_color", col.lightened(.6))
    category.add_theme_color_override("default_color", col.lightened(.6))
    upgrades.add_theme_color_override("default_color", col.lightened(.6))
    for key in node_map.keys():
        var node = node_map.get(key) as RichTextLabel
        if node:
            var template: String = string_templates.get(key, "")
            node.text = template.format(item_tokens)
            if item:
                if hidden_nodes.has(key):
                    node.visible = false
                else:
                    match(key):
                        "upgrades": 
                            node.visible = false if !(item is Gear) else item.upgrades > 0
                        "account_bound":
                            node.visible = item.is_account_bound
                        "power":
                            if item is Gem:
                                node.text = ""
                        "salvageable":
                            node.visible = !item.salvageable
                        "tradable":
                            node.visible = !item.tradable
                        "class_identifier":
                            node.visible = item.class_restrictions != 0
    var last_node = stat
    if item is Gear:
        for _affix in item.affixes:
            var affix_node = affix.duplicate()
            affix_node.text = _affix.label
            last_node.add_sibling(affix_node)
            last_node = affix_node
    match(item.category):
        Item.Category.Gem:
            var gem_mat = overlay_shader.duplicate() as ShaderMaterial
            gem_mat.set_shader_parameter("color", item.color)
            icon_texture.material = gem_mat
        Item.Category.Weapon, Item.Category.Armor, Item.Category.Jewelry:
            for i in item.sockets:
                var socket = SOCKET_CONTAINER.instantiate() as SocketContainer
                if item.socketed.size() > i:
                    var gem: Gem = item.socketed[i] as Gem
                    if gem:
                        socket.gem = gem
                last_node.add_sibling(socket)
                last_node = socket
    equipped_plate.visible = item is Gear and item.equipped
    info_container.force_update_transform()
    bg.set_deferred("size", Vector2(bg.size.x, info_container.get_rect().size.y))

We are setting our icon's Texture, we setup our ShaderMaterial based on our Item Rarity, this is applied to our gradient_rect and border_rect Textures. We also set the color_override for the item_name, category, and upgrades Label.

We map over our node_map and match any potential string_templates and toggle any visibility based on boolean states:

# tooltip.gd
...
@onready var node_map: Dictionary = {
    "item_name": item_name,
    "category": category,
    "power": power,
    "upgrades": upgrades,
    "description": description,
    "stat": stat,
    "affix": affix,
    "level_requirement": level_requirement,
    "account_bound": account_bound,
    "salvageable": salvageable,
    "tradable": tradable,
    "class_identifier": class_identifier,
    "sell_value": sell_value,
    "durability": durability,
}
...
var string_templates: Dictionary:
    get:
        return {
            "item_name": "{item_name}{qty_string}",
            "category": "{compound_category}",
            "power": "{base_power}{bonus_power_string} Item Power",
            "upgrades": "[b]Upgrades[/b]: {current_upgrades}/{max_upgrades}",
            "description": "{description}",
            "stat": "Stat Modifier",
            "socket_label": "Empty Socket",
            "level_requirement": "Requires Level {level_requirement}",
            "account_bound": "Account Bound",
            "salvageable": "Cannot Salvage",
            "tradable": "Not Tradable",
            "class_identifier": "{class_identifier}",
            "sell_value": "[color=#d5af88]Sell Value[/color]: {sell_value}",
            "durability": "[color=#d5af88]Durability[/color]: {durability}/{max_durability}"
        }

var item_tokens: Dictionary:
    get:
        if !item:
            return {
                "item_name": "Test Item 2000",
                "compound_category": "Sacred Legendary Gloves",
                "base_power": "700",
                "bonus_power_string": "+25" if true else "",
                "current_upgrades": 4,
                "max_upgrades": 5,
                "level_requirement": 80,
                "class_identifier": "Rogue",
                "sell_value": 14000
            }
        else:
            var details: Dictionary = {
                "item_name": item.name,
                "qty_string": (" (%d)" % item.quantity) if (item.stackable and item.quantity > 1) else "",
                "compound_category": item.compound_category,
                "level_requirement": item.level_requirement,
                "class_identifier": str(item.class_strings),
                "sell_value": StringHelper.comma_sep(item.value),
            }
            if item is Gear:
                var extras: Dictionary = {
                    "base_power": item.power,
                    "bonus_power_string": "+{power}".format({"power": item.upgrades * 5}) if item.upgrades else "",
                    "current_upgrades": item.upgrades if item is Gear else 0,
                    "max_upgrades": item.max_upgrades if item is Gear else 0,
                    "durability": item.durability,
                    "max_durability": item.max_durability
                }
                details.merge(extras)
            if item is Gem:
                var extras: Dictionary = {
                    "description": "Can be inserted into equipment with sockets."
                }
                details.merge(extras)
            return details

var hidden_nodes: Array:
    get:
        var nodes = []
        match(item.category):
            Item.Category.Gem:
                nodes.append_array([
                    "class_identifier",
                    "stat",
                    "durability"
                ])
            _:
                nodes.append_array([
                    "description"
                ])
        return nodes
...

Note how we are using a getter function to define some of our Dictionaries and their corresponding templates.

We also loop over any Gear Affixs and add the corresponding labels as we need inside our Stat list.

Similar to our UIItemIcon, we look at our Item Category and for Gem we setup the shader for the icon, for Gear, we map our Sockets using our UISocket scene and add it to our scene tree.

Last we setup our Equipped Plate visibility and update our container's sizing.

# tooltip.gd
...
func _on_info_container_resized() -> void:
    if info_container and bg:
        bg.set_deferred("size", Vector2(bg.size.x, info_container.get_rect().size.y))

We'll also ensure our signal is bound so when our info container is resized for any reason, we'll update the sizing again.

Because our Tooltip is a tool script -> @tool, we can see a realtime preview in our Editor! tooltip-preview

And now that we have everything setup for Tooltips and UIItemIcons we can see it in action when we run our demo. Our Inventory Grid generates a full inventory, and when we hover over an Item within our grid, we can see our Tooltip showing that Item's details:

tooltip-hover-demo

Be sure to check out the great pixel art I've used in this example: IdylWild's Armory  and DARA90's RPG WEAPONS .

Equipping Gems 

What about all those drag related functions within our UIItemIcon? Well that allows us to drag and drop Items around and depending on our logic allow behavior to occur, like say slapping a Gem into an empty socket of an Item.

# ui_item_icon.gd
...
func _get_drag_data(_at_position: Vector2) -> Variant:
    var preview = Control.new()
    var icon = item_icon_texture.duplicate()
    icon.self_modulate.a = .5
    preview.add_child(icon)
    set_drag_preview(preview)
    return { item_id = "item", item = item}


func _can_drop_data(_at_position: Vector2, data: Variant) -> bool:
    if data.item is Socketable:
        if item is Gear:
            if item.socketed.size() < item.sockets:
                return true
    return false
    
    
func _drop_data(_position, data) -> void:
    if data.item is Socketable:
        if item is Gear:
            if item.socketed.size() < item.sockets:
                item.socket(data.item)
                if data.item.inventory:
                    data.item.inventory.remove(data.item, 1)
                _update_item_icon()
                UI.on_hover_leave(self)
                UI.on_hover_item(self)

Our _get_drag_data function defines what happens when we start dragging an Item. We'll set our dragging icon to our Item icon at half alpha. Then we return a data object that contains our item_id and item variable. This data object is passed between our _can_drop_data function and our _drop_data function.

These test what we can do with the data object, in our case our Item we chose to drag around, and where we drag to. If we test if the Item we are dragging is of type Socketable and the current UIItemIcon's item we are dragging over is a type of Gear with free sockets, then we can drop.

When we can and do drop our drag even onto a valid UIItemIcon, we tell our Gem to remove from the inventory, and the Gear to apply the socket item, and update it's UIItemIcon to reflect the change.

socketing-gems

Recap 

full-demo-preview

To wrap it up, I threw together a fun dungeon scene using Kenney's Dungeon Asset Pack  and rendered it out as a static background. Added a TextureRect with this render as a background under our UI scene, and added a gradient to transition between the UI Character Details panel and our "Game" rendered image. This gives it a nice look and give's it a nice atmosphere to match our UI!

With all that completed we can see that we have an exciting working prototype of Diablo IV's Itemization and UI likeness within our Godot 4 scene.

Our implementation for Itemization lets us randomize the items based on the categories and types of items we may want, fancy UI Icons that update based on the data, as well as showing a Tooltip upon hovering over the icon. We can drag and drop icons around for a socket mechanism, and this could be used to drag and drop to organize the inventory, or swap gear in equipment slots, or whatever else you wanted to implement for your game; you could even reintroduce the grid based mechanic from older Diablo games and have fun with the tetris style approach!

This project was much more invovled than our previous topics, but was really fun to work on. I hope you were able to take away some ideas on how to take an existing idea and break it down into understandable components and implement them for your games.

Join us over on our Discord  if you want to discuss the project in more detail!

If you want to support my work, consider sharing with others, becoming a member or leaving a one time donation on Ko-fi !

Thanks for joining me on this adventure!

Happy Coding.

Want to support our work?Support us via Ko-fi