Voltar para o Blog
Quest Log

Como Criar Sistema de Inventário para Jogos RPG: Guia Completo

Interface de sistema de inventário de RPG em desenvolvimento na Godot

Aprenda a desenvolver sistemas de inventário profissionais para RPGs. Tutorial completo com código, UI/UX, otimização e melhores práticas para Godot e Unity.

Como Criar Sistema de Inventário para Jogos RPG: Guia Completo

Quase todo dev que começa um RPG subestima o inventário. Parece simples: uma lista de itens, um botão pra equipar, pronto. Aí chega a hora de empilhar poção com poção, arrastar uma espada de dois slots, salvar o estado, atualizar a UI sem travar o frame, e o "simples" vira o sistema mais chato de manter do projeto inteiro.

Esse guia mostra como montar um inventário que aguenta crescer. Vou separar dados, regra de negócio e interface, mostrar código que roda de verdade em Godot e Unity, e apontar onde os iniciantes costumam se enforcar. A ideia não é te dar a arquitetura "perfeita", é te dar uma base que você entende e consegue estender quando o jogo pedir.

O Que Um Inventário Precisa Resolver

Um inventário não é só onde os itens ficam guardados. Ele comunica progressão (o jogador vê o equipamento melhorando), força decisão (espaço limitado obriga escolha) e carrega economia (o que vale a pena carregar, o que vende). Quando você projeta pensando nessas três funções, as decisões técnicas ficam mais óbvias.

Olha como jogos diferentes resolvem isso, porque a escolha define o seu código:

  • Diablo / Path of Exile: grid 2D onde cada item ocupa um retângulo de slots. O inventário vira um quebra-cabeça espacial. Decisão dura, código mais complexo.
  • The Witcher 3 / Skyrim: lista categorizada por abas (armas, armaduras, consumíveis). Sem puzzle espacial, mais fácil de filtrar e ordenar.
  • Terraria / Minecraft: hotbar de acesso rápido mais armazenamento massivo, com crafting amarrado ao inventário.

Não existe "o melhor". Existe o que combina com o seu jogo. Se você não tem certeza de que precisa do grid espacial, comece pela lista categorizada: é metade do trabalho e resolve 90% dos casos.

Separe em Camadas Desde o Início

O erro número um é misturar tudo no mesmo script: o item, a regra de "cabe ou não cabe" e o botão da tela vivendo juntos. Funciona no protótipo e te persegue pelo resto do projeto. Separe em camadas:

  1. Dados: o que é um item, o que é um slot. Sem lógica de jogo, sem UI.
  2. Lógica: adicionar, remover, empilhar, validar. Não sabe que UI existe.
  3. UI: desenha o estado e dispara ações. Não decide regra nenhuma.
  4. Persistência: salva e carrega.

A regra de ouro: a UI fala com a lógica, a lógica fala com os dados, e nada volta no sentido contrário escondido. Quando a lógica muda, ela avisa quem estiver ouvindo (geralmente a UI) por sinal/evento. É isso que mantém o sistema sustentável quando você adiciona crafting, comércio ou stash compartilhado depois.

A Camada de Dados

Comece definindo o item como um recurso, não como código vivo. Em Godot, Resource é perfeito pra isso: você cria cada item como um arquivo .tres no editor e referencia ele onde precisar.

# item_data.gd
class_name ItemData
extends Resource

enum Rarity { COMMON, UNCOMMON, RARE, EPIC, LEGENDARY }

@export var id: StringName
@export var display_name: String
@export var description: String
@export var icon: Texture2D
@export var rarity: Rarity = Rarity.COMMON
@export var max_stack: int = 1     # 1 = não empilha
@export var value: int = 0
@export var weight: float = 0.0

Repara que id é StringName, não String. Você vai comparar id o tempo todo (pra saber se dois itens empilham, pra salvar/carregar) e StringName compara muito mais rápido. Detalhe pequeno que importa quando o inventário tem centenas de comparações por frame.

O slot é o que carrega quantidade. Um item só, mais quantos você tem dele:

# inventory_slot.gd
class_name InventorySlot
extends RefCounted

var item: ItemData = null
var quantity: int = 0

func is_empty() -> bool:
    return item == null or quantity <= 0

func can_stack(other: ItemData) -> bool:
    # mesmo item e ainda tem espaço na pilha
    return item != null and item.id == other.id and quantity < item.max_stack

func space_left() -> int:
    if item == null:
        return 0
    return item.max_stack - quantity

Separar ItemData (a definição, compartilhada) de InventorySlot (a instância no inventário, com quantidade) é o detalhe que mais economiza dor depois. Cem poções no mundo apontam pro mesmo ItemData. Cada empilhamento no inventário é um slot com quantity. Você não duplica dados nem corre risco de editar a espada de um jogador e mudar a de todo mundo.

A Camada de Lógica

Aqui mora o add_item, e é onde o iniciante erra. Adicionar item não é "achar um slot vazio e jogar lá". É: primeiro tenta empilhar no que já existe, e só então usa slot vazio. Senão você acaba com três pilhas separadas de poção em vez de uma.

# inventory.gd
class_name Inventory
extends Resource

signal changed                     # UI escuta isso pra se redesenhar

@export var size: int = 20
var slots: Array[InventorySlot] = []

func _init() -> void:
    slots.resize(size)
    for i in size:
        slots[i] = InventorySlot.new()

# Retorna quantos NÃO couberam (0 = adicionou tudo)
func add_item(item: ItemData, amount: int = 1) -> int:
    var remaining := amount

    # 1) tenta completar pilhas existentes
    for slot in slots:
        if remaining <= 0:
            break
        if slot.can_stack(item):
            var add := mini(remaining, slot.space_left())
            slot.quantity += add
            remaining -= add

    # 2) usa slots vazios pro que sobrou
    for slot in slots:
        if remaining <= 0:
            break
        if slot.is_empty():
            slot.item = item
            slot.quantity = mini(remaining, item.max_stack)
            remaining -= slot.quantity

    if remaining < amount:
        changed.emit()
    return remaining

func remove_item(item: ItemData, amount: int = 1) -> int:
    var remaining := amount
    for slot in slots:
        if remaining <= 0:
            break
        if not slot.is_empty() and slot.item.id == item.id:
            var take := mini(remaining, slot.quantity)
            slot.quantity -= take
            remaining -= take
            if slot.quantity <= 0:
                slot.item = null
    if remaining < amount:
        changed.emit()
    return remaining

Dois pontos que valem ouro aqui. Primeiro: add_item devolve quanto não coube, em vez de só true/false. Isso te deixa fazer "pega o que cabe e larga o resto no chão" sem reescrever nada. Segundo: a lógica não conhece a UI. Ela só emite changed. Quem se atualiza é quem estiver ouvindo o sinal, e é por isso que você consegue ter duas telas (inventário e baú) olhando o mesmo dado sem gambiarra.

A versão equivalente em Unity / C# segue a mesma ideia, trocando o sinal por um evento:

// Inventory.cs
using System;
using System.Collections.Generic;
using UnityEngine;

public class Inventory : MonoBehaviour
{
    [SerializeField] private int size = 20;
    public event Action Changed;

    private readonly List<InventorySlot> slots = new();

    private void Awake()
    {
        for (int i = 0; i < size; i++)
            slots.Add(new InventorySlot());
    }

    // Retorna quanto NÃO coube (0 = adicionou tudo)
    public int AddItem(ItemData item, int amount = 1)
    {
        int remaining = amount;

        foreach (var slot in slots)          // completa pilhas
        {
            if (remaining <= 0) break;
            if (slot.CanStack(item))
            {
                int add = Mathf.Min(remaining, slot.SpaceLeft());
                slot.Quantity += add;
                remaining -= add;
            }
        }
        foreach (var slot in slots)          // usa vazios
        {
            if (remaining <= 0) break;
            if (slot.IsEmpty)
            {
                slot.Item = item;
                slot.Quantity = Mathf.Min(remaining, item.maxStack);
                remaining -= slot.Quantity;
            }
        }

        if (remaining < amount) Changed?.Invoke();
        return remaining;
    }
}

Inventário em Grid (Estilo Diablo)

Se o seu jogo precisa de itens que ocupam vários slots, o modelo muda. Agora o item tem largura e altura, e antes de colocar você precisa checar se o retângulo todo está livre. É mais código, então só vá por aqui se o puzzle espacial for parte do design, não por estética.

A checagem de "cabe aqui?" é o coração:

# grid_inventory.gd
class_name GridInventory
extends Resource

@export var grid_width: int = 10
@export var grid_height: int = 6

# matriz de referências pro item (null = vazio)
var cells: Array = []

func _init() -> void:
    cells.resize(grid_height)
    for y in grid_height:
        cells[y] = []
        cells[y].resize(grid_width)
        cells[y].fill(null)

func can_place(item: ItemData, origin: Vector2i, w: int, h: int) -> bool:
    # passou da borda?
    if origin.x + w > grid_width or origin.y + h > grid_height:
        return false
    if origin.x < 0 or origin.y < 0:
        return false
    # alguma célula da área já está ocupada?
    for y in range(origin.y, origin.y + h):
        for x in range(origin.x, origin.x + w):
            if cells[y][x] != null:
                return false
    return true

func place(item: ItemData, origin: Vector2i, w: int, h: int) -> bool:
    if not can_place(item, origin, w, h):
        return false
    for y in range(origin.y, origin.y + h):
        for x in range(origin.x, origin.x + w):
            cells[y][x] = item
    return true

A pegadinha clássica do grid é esquecer a checagem de borda antes de varrer as células: se você só testar ocupação, um item perto da direita "vaza" pra fora da matriz e o jogo quebra com índice inválido. Por isso a borda é checada primeiro, e em separado.

UI: o Mínimo Que Faz Diferença

A interface não decide regra. Ela desenha o estado atual e, quando o jogador faz algo, chama a lógica. O ciclo é sempre: a lógica muda, emite changed, a UI ouve e se redesenha. Nunca a UI alterando o array de slots na mão.

Em Godot, um nó de inventário que escuta o sinal e refaz a tela quando algo muda:

# inventory_ui.gd
extends Control

@export var inventory: Inventory
@export var slot_scene: PackedScene   # cena do slot visual
@onready var grid: GridContainer = $GridContainer

func _ready() -> void:
    inventory.changed.connect(_redraw)
    _build_slots()
    _redraw()

func _build_slots() -> void:
    for i in inventory.size:
        grid.add_child(slot_scene.instantiate())

func _redraw() -> void:
    for i in inventory.size:
        var slot_ui = grid.get_child(i)
        slot_ui.show_slot(inventory.slots[i])

Três detalhes de UX que separam o inventário "funcional" do "gostoso de usar":

  • Cor por raridade. Borda branca pra comum, verde pra incomum, azul pra rara, roxo pra épica, laranja pra lendária. É a convenção que o jogador de RPG já lê de cabeça, então respeite ela. No Godot isso é um StyleBox por raridade trocado no slot.
  • Tooltip ao passar o mouse. Nome, descrição, stats, valor. Sem tooltip o jogador fica clicando em tudo pra descobrir o que é.
  • Feedback no drag. Ao arrastar um item, destaque os slots onde ele pode cair. O Godot tem _get_drag_data, _can_drop_data e _drop_data justamente pra isso, use o sistema nativo em vez de reinventar arrasto na mão.

::blog-cta{title="Transforme Sua Paixão em Profissão" description="Sistemas complexos como inventários são o que separam desenvolvedores iniciantes dos profissionais. Nosso programa intensivo ensina arquiteturas avançadas que estúdios AAA realmente usam." buttonText="Candidate-se Agora" icon="fas fa-rocket" variant="highlight"}::

Ordenar e Filtrar

Auto-organizar é uma daquelas funções que o jogador ama e que é simples de fazer errado. O caminho seguro: junta tudo numa lista, ordena pela chave que o jogador escolheu, limpa o inventário e re-adiciona na ordem (reusando o add_item, que já cuida do empilhamento).

# adiciona em inventory.gd
enum SortMode { NAME, VALUE_DESC, RARITY_DESC }

func sort(mode: SortMode) -> void:
    # 1) coleta o que existe
    var stacks: Array = []
    for slot in slots:
        if not slot.is_empty():
            stacks.append({ "item": slot.item, "qty": slot.quantity })

    # 2) ordena
    match mode:
        SortMode.NAME:
            stacks.sort_custom(func(a, b): return a.item.display_name < b.item.display_name)
        SortMode.VALUE_DESC:
            stacks.sort_custom(func(a, b): return a.item.value > b.item.value)
        SortMode.RARITY_DESC:
            stacks.sort_custom(func(a, b): return a.item.rarity > b.item.rarity)

    # 3) limpa e re-adiciona
    for slot in slots:
        slot.item = null
        slot.quantity = 0
    for s in stacks:
        add_item(s.item, s.qty)

    changed.emit()

Filtro e busca seguem a mesma lógica: você não altera o inventário, só decide quais slots a UI mostra. Filtragem é problema de apresentação, não de dados. Mantenha assim e você nunca corrompe o estado real ao buscar "poção".

Performance: Onde Otimizar (e Onde Não)

A maior parte dos inventários nem chega perto de ter problema de performance. Um array de 20 a 100 slots é nada pra qualquer engine. Antes de "otimizar", meça com o profiler. Dito isso, dois gargalos aparecem de verdade quando o inventário cresce:

Redesenhar a UI toda hora. Se você reconstrói todos os slots a cada item que entra, num inventário grande isso engasga. A solução é o sinal changed que já usamos: redesenha em resposta a mudança, não todo frame. Se ainda assim pesar, redesenhe só o slot que mudou em vez da grade inteira.

Recriar nós de slot. Destruir e instanciar nós de UID toda vez que abre/fecha o inventário é caro. Crie os slots uma vez e reaproveite, escondendo os que não usa. É object pooling, e pra UI de inventário rende bem porque a quantidade de slots é fixa.

O que não vale a pena cedo: cache de "peso total" e "valor total". Recalcular a soma de 50 itens é trivial. Só cacheie isso se o profiler apontar que está doendo, o que provavelmente nunca vai acontecer.

::blog-cta{title="Domine Sistemas Complexos de Game Development" description="Inventários são apenas o começo. Sistemas de combate, IA, networking - cada um requer arquiteturas específicas que você só aprende com experiência prática guiada." buttonText="Veja Nossa Grade Curricular" icon="fas fa-graduation-cap" variant="default"}::

Salvar e Carregar

Inventário sem persistência é protótipo. E aqui tem uma armadilha: não salve a referência do Resource inteiro, salve o id do item mais a quantidade. Na hora de carregar, você reconstrói a partir de um banco de itens que mapeia id pro ItemData.

Por que isso importa: se você serializa o recurso direto e depois muda a definição do item (ajusta o dano da espada, troca o ícone), os saves antigos ficam com a versão velha congelada. Salvando só id e quantidade, o save sempre puxa a definição atual. É a diferença entre um save que envelhece bem e um que vira dívida técnica.

# esboço de save: lista de { "id": ..., "qty": ... }
func to_save() -> Array:
    var data: Array = []
    for slot in slots:
        if not slot.is_empty():
            data.append({ "id": String(slot.item.id), "qty": slot.quantity })
    return data

No load, você lê esse array, busca cada id no seu banco de itens e chama add_item. Simples, à prova de mudança, e nada de referência quebrada.

Qual Engine Usar Pra Isso

A arquitetura acima vale pra qualquer engine. A escolha entre Godot e Unity é mais sobre o seu jogo do que sobre o inventário:

  • Godot brilha em 2D e tem o sistema de Resource que encaixa perfeito pra dados de item: você cria item como arquivo no editor, sem código. Pra um RPG 2D indie, é o caminho mais rápido. Curva de entrada suave, gratuito, export pra Steam tranquilo.
  • Unity tem ScriptableObject, que cumpre o mesmo papel do Resource, e ganha em maturidade de mercado mobile, integrações e Asset Store. Se o destino é mobile ou você já tem time em C#, faz sentido.

Se é seu primeiro jogo e a dúvida persiste, vá de Godot. Você aprende os conceitos (dados, lógica, sinal, UI) que migram pra qualquer engine depois, sem o peso de uma ferramenta maior do que o projeto precisa.

Por Onde Começar

Não tente construir tudo de uma vez. A ordem que funciona:

  1. ItemData e InventorySlot. Os dados primeiro, sem UI nenhuma. Teste no console que add_item empilha certo.
  2. add_item / remove_item com empilhamento. Garanta que poção junta com poção antes de desenhar qualquer coisa.
  3. UI básica. Slots na tela ouvindo o sinal changed.
  4. Polish. Drag and drop, tooltip, cor por raridade.
  5. Ordenar, filtrar e salvar. Por último, porque dependem de tudo acima estar firme.

O inventário é uma das telas que o jogador mais usa no seu RPG. Se a base estiver bem separada em camadas, adicionar crafting, comércio e baú depois é incremento, não reescrita. Comece simples, teste cada pedaço, e cresça em cima de uma fundação que você entende.