fpx · inventory
Inventario por huecos con stacking y tope, paperdoll sobre EquipmentLayout declarativo e InventoryOps (equipar/usar/comprar/vender/trade todo-o-nada).
El ZIP incluye: addons/fpx/inventory, addons/fpx/items, addons/fpx/stats + sus tests + data/balance/default.tres.
Qué es
Inventario por huecos fijos con stacking hasta max_stack (el excedente abre hueco nuevo; add_item devuelve cuántas unidades ENTRARON — 0 = lleno y el llamador no pierde el ítem), ítems únicos, normalize() para curar saves, consume() sin mutación parcial. Equipment: paperdoll sobre un EquipmentLayout (huecos, qué admite cada uno, exclusiones declarativas), motivos de rechazo tipados y agregado gear_bonus() hacia stats. InventoryOps (FK-3): las operaciones compartidas cliente/servidor del ejemplar sobre (Inventory, Equipment) — equipar desde inventario con reversión, desequipar, usar consumible, destruir, comprar/vender, canje P2P todo-o-nada — con el gate de requisitos inyectado.
Instalación
- Descarga
fpx-inventory-v0.2.0.zip(botón de arriba) y extráelo en la raíz de tu proyecto Godot — deja la estructura tal cual:
tu-juego/
├── project.godot
├── addons/
│ └── fpx/
│ ├── inventory/
│ ├── items/
│ └── stats/
└── tests/ ← opcional: bórralo si no quieres los tests- Abre el proyecto (o corre
--importheadless). Sin plugins ni autoloads que activar: las clases quedan disponibles en cualquier script. - Opcional — verifica con la suite (necesita GdUnit4, ver la guía):
-a tests/inventory.
Ejemplo de uso
Usa las fixtures incluidas en el componente para probar en segundos; en tu juego, sustitúyelas por tus datos.
# Mochila por huecos con stacking (capacidad paramétrica) + paperdoll declarativo.
var inv := Inventory.new(30)
var eq := Equipment.new() # sin argumentos = los 9 huecos del ejemplar
var espada := ItemFixtures.equipment(&"espada", &"weapon", {&"weapon_atk": 40.0})
var pocion := ItemFixtures.consumable(&"pocion", 10, &"heal", 50)
inv.add_item(espada)
var entraron := inv.add_item(pocion, 25) # 25 uds → 3 huecos (tope 10)
# add_item devuelve cuántas unidades ENTRARON; 0 = lleno y NO pierdes el ítem.
# Equipar desde el inventario. El gate de requisitos se INYECTA (tu regla de
# nivel/clase); lo reemplazado vuelve a la mochila y si no cabe se REVIERTE todo.
var can_equip := func(it: ItemDefinition) -> StringName:
return InventoryOps.OK if it.required_level <= 10 else &"low_level"
var res := InventoryOps.equip_from_inventory(inv, eq, espada, &"", can_equip)
if res["reason"] == InventoryOps.OK:
var gear: GearBonus = eq.gear_bonus() # → StatSheet.set_source(&"equipment", gear)
# Usar un consumible: gasta UNA unidad y aplica el efecto registrado.
var used := InventoryOps.use_consumable(inv, pocion, ConsumableEffects.with_defaults(), hero)
# Comprar/vender: InventoryOps mueve SOLO ítems; el ORO lo mueve el llamador
# cuando reason == OK (patrón P-49: mismo código en cliente y servidor).
var buy := InventoryOps.buy_item(inv, pocion, hero_gold)
if buy["reason"] == InventoryOps.OK:
hero_gold -= buy["cost"]
# El paperdoll es DATOS: un juego con 3 huecos define su propio layout.
var layout := EquipmentLayout.new()
layout.slots = [&"weapon", &"armor", &"ring"]
layout.accepts = {&"weapon": [&"weapon"], &"armor": [&"armor"], &"ring": [&"ring"]}
var eq_mini := Equipment.new(layout)Demo en vivo
El componente REAL corriendo en tu navegador — Godot 4.7 exportado a Web sobre las fixtures del kit. La primera carga baja el motor (~10 MB comprimido) y se comparte entre todas las demos.
API pública
| Clase | Qué da |
|---|---|
Inventory | new(capacity = 100) · add_item(item, n) -> entraron · consume(id, n) -> bool · count_of · is_full · used_slots · normalize · capacity |
EquipmentLayout (Resource) | default_layout() (los 9 huecos del ejemplar) · slots · accepts · exclusions · candidates_for(item_slot) · slot_accepts · exclusion_reason |
Equipment | new(layout = default) · equip(item, target = &"") -> {reason, slot, replaced} · unequip(slot) · slot_for · accepts · gear_bonus() · equipped_ids() |
InventoryOps | equip_from_inventory(inv, eq, item, target, can_equip) · unequip_to_inventory · use_consumable(inv, item, effects, target) · destroy_item · sell_price · buy_item(inv, item, gold) · sell_item · trade_apply(a, gold_a, offer_a, b, gold_b, offer_b) |
Patrón P-49 del ejemplar: InventoryOps mueve SOLO inventario/equipo; el oro lo mueve el llamador cuando reason == OK.
Firmas completas (del código)
Equipmentextends RefCountedequipment.gd
Slots de equipo del GDD (E1-INV-02): arma + escudo o 2 armas, armadura, casco, botas, necklace, 2 anillos y belt. Los 6 slots de Astralitas son stub VISUAL en la UI (deshabilitados) y ningún sistema los lee. Núcleo puro (A5): los requisitos §8 los valida CharacterProgress.can_equip y el flujo con inventario/oro vive en GameState.
const OK: StringName = &""const SLOTS: Array[StringName] = …const SLOT_ACCEPTS: Dictionary[StringName, Array] = …Qué TIPO de pieza (ItemDefinition.slot) admite cada hueco del paperdoll: armas a las dos manos (2 armas es legal, GDD), escudo SOLO a la secundaria, anillos a cualquiera de sus dos huecos, el resto al suyo.
var equipped: Dictionary[StringName, ItemDefinition] = {}var layout: EquipmentLayout = nullDELTA kit-first (FK-3, plan §6.4 acople 2): el paperdoll es DATOS. Sin argumento, EquipmentLayout.default_layout() == las const SLOTS/SLOT_ACCEPTS de arriba (que se conservan como referencia y para slot_accepts estático): comportamiento idéntico a Stremus. Anotado en SYNC.md.
Equipment.new(paperdoll: EquipmentLayout = null)Constructor.
static func slot_accepts(slot: StringName, item: ItemDefinition) -> booltrue si la pieza puede vivir en ESE hueco del layout POR DEFECTO (v6: los drops del paperdoll validan el destino — el arma no entra al casco). Para un layout propio usa accepts(slot, item) de la instancia.
func accepts(slot: StringName, item: ItemDefinition) -> boolslot_accepts sobre el layout de ESTA instancia.
func slot_for(item: ItemDefinition) -> StringNameSlot destino para un ítem: el primer hueco VACÍO que lo admite, en el orden del layout; si todos están llenos, el primero (reemplaza). Con el layout por defecto: armas a la mano libre (principal primero), escudo SOLO a off_hand, anillos al primer hueco; el resto a su slot — idéntico al ejemplar.
func equip(item: ItemDefinition, target: StringName = &"") -> DictionaryEquipa y devuelve {"reason": OK|motivo, "slot": StringName, "replaced": ItemDefinition|null}. target fija el hueco EXACTO (drop del paperdoll, v6) y se valida con slot_accepts; vacío = el natural de slot_for. Regla GDD (E1-INV-02): un escudo con DOS armas equipadas se rechaza con motivo — o arma+escudo o 2 armas.
func unequip(slot: StringName) -> ItemDefinitionDesequipa el slot y devuelve el ítem (null si estaba vacío).
func gear_bonus() -> GearBonusAgregado de bonos de TODO el equipo (E1-INV-03): mismo vocabulario que las pasivas del árbol; entra al pipeline §3 como gear.
func equipped_ids() -> Array[StringName]IDs de las piezas equipadas (MOT-3): las lee el motor de conjuntos para contar cuántas piezas de cada SetDefinition están puestas.
EquipmentLayoutextends Resourceequipment_layout.gd
FK-3 · El paperdoll como DATOS (plan §6.4): qué huecos hay, qué tipo de pieza (ItemDefinition.slot) admite cada uno y las reglas de exclusión declarativas. En Stremus esto son las const SLOTS/SLOT_ACCEPTS y la regla «arma+escudo o 2 armas» codificada en Equipment.equip. Los defaults del esquema (default_layout()) SON los 9 huecos de producción de Stremus. Destino natural de una pieza (candidates_for): los huecos que la admiten, EN EL ORDEN de slots — el primero vacío gana; si todos están llenos, el primero (reemplaza). Con el layout de Stremus reproduce exactamente a Equipment.slot_for (arma → principal, luego secundaria; anillo → ring_1, luego ring_2). Exclusión: {"item_slot": tipo de la pieza que se rechaza, "when_filled_with": {hueco: tipo, …} (TODOS deben cumplirse), "reason": motivo tipado}.
const LAYOUT_PATH := "res://data/inventory/equipment_layout.tres"Ruta por convención del layout del juego (const de cabecera, guardia FK-0).
@export var slots: Array[StringName] = []Huecos del paperdoll, en orden de preferencia.
@export var accepts: Dictionary[StringName, Array] = {}hueco → tipos de pieza (ItemDefinition.slot) que admite.
@export var exclusions: Array[Dictionary] = []Reglas de exclusión declarativas (ver cabecera).
static func default_layout() -> EquipmentLayoutLos 9 huecos de Stremus: armas a las dos manos (2 armas es legal), escudo SOLO a la secundaria, anillos a cualquiera de sus dos huecos, el resto al suyo; escudo rechazado con DOS armas puestas (&"two_weapons").
func has_slot(slot: StringName) -> boolfunc slot_accepts(slot: StringName, item: ItemDefinition) -> boolfunc candidates_for(item_slot: StringName) -> Array[StringName]Huecos que admiten el tipo de pieza, en el orden de slots.
func exclusion_reason(item: ItemDefinition, equipped: Dictionary) -> StringNameMotivo de exclusión para equipar item con equipped puesto, o OK (&"").
Inventoryextends RefCountedinventory.gd
Inventario con stacking (E1-INV-01, GDD; MOT-2 P-56 §3.3): los consumibles y materiales apilan hasta ItemDefinition.max_stack unidades por slot; el excedente abre un slot nuevo. El equipo no apila (1 por hueco). Capacidad 100 huecos (2 páginas de 50 en la UI). Núcleo puro (A5): la UI y el pickup viven fuera; add_item devuelve cuántas unidades ENTRARON — si es 0, el pickup falla y el ítem NO desaparece (feedback del llamador). FLAG(MOT-2): reinstaura el tope max_stack que v7 (2026-07-10) había dejado sin uso (stacks sin tope, pedido del diseñador). Un stack sin tope se logra con datos (max_stack alto) — cero código. Ratificación async del diseñador.
const CAPACITY: int = 100Capacidad por defecto (la del ejemplar). DELTA kit-first (FK-3, plan §6.4 acople 1): la capacidad es parámetro de _init; sin argumento, idéntico a Stremus. Anotado en SYNC.md; retro-portar cuando toque.
var slots: Array[Dictionary] = []Slots fijos: cada entrada es {} (vacío) o {"item": ItemDefinition, "count": int}.
var capacity: int = CAPACITYNº de huecos de ESTE inventario (== slots.size()).
Inventory.new(slot_count: int = CAPACITY)Constructor.
func add_item(item: ItemDefinition, count: int = 1) -> intAñade hasta count unidades respetando max_stack. Devuelve las que entraron (0 = lleno). El excedente por encima del tope de un slot abre un hueco nuevo (MOT-2, P-56 §3.3).
func normalize() -> voidConsolida stacks duplicados del mismo ítem y los reparte respetando max_stack (MOT-2): cura saves con stacks partidos y, si un save antiguo dejó un stack por encima del tope actual, lo divide en huecos nuevos. El equipo (no stackeable) conserva su slot; los liberados quedan vacíos.
func count_of(item_id: StringName) -> intfunc consume(item_id: StringName, count: int = 1) -> boolConsume count unidades (false si no hay stock suficiente; sin mutación).
func is_full() -> boolfunc used_slots() -> intInventoryOpsextends RefCountedinventory_ops.gd
FK-3 · Operaciones de inventario+equipo (plan §6.4): la mitad de INVENTARIO de ProgressMutations de Stremus (scripts/core/progression/progress_mutations.gd) portada sobre (Inventory, Equipment) en vez de CharacterProgress. Mismos motivos tipados, mismas reversiones, cero drift: el gate de requisitos §8 (nivel/job/clase) es de progression y entra INYECTADO como can_equip. Patrón P-49 del ejemplar: estas funciones mueven SOLO inventario/equipo; el ORO lo mueve el llamador cuando reason == OK (así el server asienta su ledger y el cliente su GameState con el mismo código).
const OK := &""static func accept_all(_item: ItemDefinition) -> StringNameGate por defecto: sin requisitos (un juego sin niveles/jobs no inyecta nada).
static func equip_from_inventory(inventory: Inventory, equipment: Equipment, item: ItemDefinition, target: StringName = &"", can_equip: Callable = Callable()) -> DictionaryEquipa DESDE el inventario validando can_equip y las reglas del layout; lo reemplazado vuelve al inventario y si no cabe se REVIERTE la operación completa. Devuelve {"reason": OK|motivo, "slot": StringName}.
static func unequip_to_inventory(inventory: Inventory, equipment: Equipment, slot: StringName) -> StringNameDesequipa un hueco devolviendo la pieza al inventario (falla si está lleno: la pieza NO desaparece, sigue equipada).
static func destroy_item(inventory: Inventory, item: ItemDefinition) -> intDestruye TODO el stock de un ítem. Devuelve cuántas unidades se destruyeron.
static func use_consumable(inventory: Inventory, item: ItemDefinition, effects: ConsumableEffects = null, target: Object = null) -> DictionaryUsa un consumible fuera de combate: gasta UNA unidad y, si hay registro de efectos, lo aplica sobre target. Sin registro = solo gasta stock (v1 del ejemplar). Devuelve {"reason": OK|motivo, "result": Dictionary del efecto}.
static func sell_price(item: ItemDefinition) -> intPrecio de VENTA a NPC (P-49 v1 del ejemplar): mitad del catálogo, suelo 1; price 0 = invendible.
static func buy_item(inventory: Inventory, item: ItemDefinition, gold: int) -> DictionaryCompra a NPC: valida precio/oro/hueco y aplica SOLO la parte de inventario. Devuelve {"reason": OK|motivo, "cost": int}.
static func sell_item(inventory: Inventory, item: ItemDefinition) -> DictionaryVenta a NPC: consume UNA unidad. Devuelve {"reason": OK|motivo, "gain": int}.
static func trade_apply(a: Inventory, gold_a: int, offer_a: Dictionary, b: Inventory, gold_b: int, offer_b: Dictionary) -> DictionaryCanje P2P (escrow): valida TODO (oro/stock/hueco de AMBOS lados) y aplica el intercambio COMPLETO o nada. offer_x = {"gold": int, "items": [{"item": ItemDefinition, "count": int}]}. Devuelve {"reason", "side": &"a"|&"b"|&""}.
Qué configura el juego
La capacidad (Inventory.new(n)), su data/inventory/equipment_layout.tres (EquipmentLayout.LAYOUT_PATH) o un layout en memoria, el gate can_equip: Callable (nivel/job/clase: es de progression), y el registro ConsumableEffects.
Fixture mínima
testing/inventory_fixtures.gd — InventoryFixtures.starter_bag(capacity), canonical_paperdoll() (arma +40 → atk_melee == 150), golden_set_paperdoll(), three_slot_layout() (arma/armadura/anillo).
inventory_fixtures.gdcódigo completo
# res://addons/fpx/inventory/testing/inventory_fixtures.gd
class_name InventoryFixtures
extends RefCounted
## Fixture mínima del componente `inventory`: una mochila de inicio y un
## paperdoll equipado, ambos EN MEMORIA sobre los ítems de `ItemFixtures`, más
## el layout de 3 huecos del plan (arma/armadura/anillo) para probar que el
## paperdoll es datos. El paperdoll canónico reproduce el ejemplo de `stats`
## (arma +40) para verificar equipo → GearBonus → DerivedStats de punta a punta.
## Mochila de inicio: 25 pociones (3 huecos a tope 10) + 5 hierbas.
static func starter_bag(capacity: int = Inventory.CAPACITY) -> Inventory:
var inv := Inventory.new(capacity)
inv.add_item(ItemFixtures.consumable(&"pocion", 10, &"heal", 50), 25)
inv.add_item(ItemFixtures.material(&"hierba"), 5)
return inv
## Paperdoll (layout por defecto) con espada (+40 ATK de arma) y peto (+4 DEF).
static func canonical_paperdoll() -> Equipment:
var eq := Equipment.new()
eq.equip(ItemFixtures.equipment(&"espada", &"weapon", {&"weapon_atk": 40.0}))
eq.equip(ItemFixtures.equipment(&"peto", &"armor", {&"armor_def": 4.0}))
return eq
## Paperdoll con las 4 piezas del conjunto dorado puestas.
static func golden_set_paperdoll() -> Equipment:
var eq := Equipment.new()
for piece: ItemDefinition in ItemFixtures.golden_set_pieces():
eq.equip(piece)
return eq
## Layout mínimo del plan §6.4: 3 huecos (arma / armadura / anillo), sin exclusiones.
static func three_slot_layout() -> EquipmentLayout:
var layout := EquipmentLayout.new()
layout.slots = [&"weapon", &"armor", &"ring"]
layout.accepts = {
&"weapon": [&"weapon"],
&"armor": [&"armor"],
&"ring": [&"ring"],
}
return layoutCómo se prueba
tests/inventory/ — inventory_kit_test (stacking, único, slots, gear → stats), equipment_layout_test (el default == ejemplar; layout de 3 huecos; exclusiones), inventory_ops_test (stock, gate inyectado, reemplazo con inventario lleno, desequipar sin perder, consumible + efecto, compra/venta, trade todo-o-nada y reversión).
Ejemplar oro (Stremus)
scripts/core/progression/{inventory,equipment}.gd y la mitad de inventario de progress_mutations.gd (main@0be95b4). Los 9 huecos y la regla «arma+escudo o 2 armas» de Equipment.SLOTS/SLOT_ACCEPTS/equip = EquipmentLayout.default_layout(). Decisión 2026-07-10: se limita por huecos, no por peso.
Deuda declarada
- FLAG-INV-WEIGHT: sin peso; un juego con peso añade campo al ítem + regla en
InventoryOps. - FLAG-INV-DROPS-LOST (P-30): drops que no caben se pierden — política del juego, la aplica su
RewardFlow. - FLAG-INV-UNIQUE-EQUIPPED (ED-1): una copia equipada es la excepción aceptada del «único».
- Deltas kit-first anotados en SYNC.md (
Inventory._init(capacity),Equipmentsobre layout): retro-portar a Stremus. - Tests que se quedan en Stremus: batería completa de
inventory_test(CharacterProgress),trade_mutations_test,shop_mutations_test,hotbar_test.