Gestión del inventario de FiveM: consejos expertos para propietarios de servidores
Mejora el inventario de FiveM con ajustes de peso, tamaños de pila, imágenes, almacenes y consejos de los mejores servidores de rol.
Agency Scripts
Fundador y desarrollador principal en Agency Scripts
Por qué importa la arquitectura del inventario
El sistema de inventario es la base de cualquier servidor de roleplay de FiveM. Todas las interacciones de los jugadores con los objetos, desde recoger un arma hasta entregarle una llave a alguien, pasan por el inventario. Un inventario mal diseñado provoca exploits de duplicación de objetos, problemas de desincronización y frustración cuando los jugadores pierden su equipo. Los sistemas de inventario más robustos siguen un modelo con autoridad en el servidor, donde el cliente solo muestra lo que le indica el servidor y nunca se confía en que el cliente informe de su propio estado. Esta decisión arquitectónica, por sí sola, elimina la mayoría de los exploits de duplicación que afectan a los servidores con inventarios que confían en el cliente. Al planificar tu inventario, piensa primero en el flujo de datos: el servidor es la fuente de verdad, el cliente lo muestra y todas las modificaciones pasan por un evento del servidor validado.
Sistemas basados en peso o en espacios
Elegir entre sistemas de inventario basados en peso o en espacios determina fundamentalmente la experiencia del jugador. En un sistema basado en espacios, cada espacio contiene un tipo de objeto con un tamaño máximo de pila, y el número total de espacios define la capacidad de carga. En un sistema basado en peso, cada objeto tiene un valor de peso y el jugador tiene un peso máximo que puede cargar. Muchos frameworks modernos combinan ambos enfoques, usan espacios para organizar, pero limitan la capacidad total según el peso. Aquí tienes un ejemplo de definición de objeto que combina peso y espacios:
-- Shared item definitions (items.lua)
QBCore.Shared.Items = {
['water_bottle'] = {
name = 'water_bottle',
label = 'Water Bottle',
weight = 500, -- grams
type = 'item',
image = 'water_bottle.png',
unique = false,
useable = true,
shouldClose = true,
description = 'A refreshing bottle of water',
stackSize = 10, -- max per slot
},
['lockpick'] = {
name = 'lockpick',
label = 'Lockpick',
weight = 200,
type = 'item',
image = 'lockpick.png',
unique = false,
useable = true,
shouldClose = true,
description = 'Used to pick locks',
stackSize = 5,
},
}
El weight el campo se almacena en gramos para mayor precisión, y el stackSize determina cuántas unidades de ese objeto caben en un solo espacio. Cuando un jugador intenta recoger un objeto, valida tanto que haya un espacio disponible como que el peso total no supere el máximo. Esta doble validación evita que los jugadores carguen cantidades poco realistas de objetos pesados, incluso si tienen espacios libres.
Validación del servidor y protección contra exploits
Toda acción del inventario debe validarse en el servidor antes de aplicarse. Cuando un jugador arrastra un objeto del espacio 3 al espacio 7, el cliente envía una solicitud de movimiento y el servidor verifica que el espacio de origen realmente contenga ese objeto, que el espacio de destino pueda aceptarlo y que las cantidades sean coherentes. Nunca permitas que el cliente especifique cantidades de objetos ni que los cree de la nada. Aquí tienes un controlador seguro de movimientos en el servidor:
RegisterNetEvent('inventory:server:moveItem', function(fromSlot, toSlot, fromAmount)
local src = source
local Player = QBCore.Functions.GetPlayer(src)
if not Player then return end
local fromItem = Player.PlayerData.items[fromSlot]
if not fromItem then
-- Source slot is empty, possible exploit attempt
DropPlayer(src, 'Invalid inventory operation')
return
end
if fromAmount > fromItem.amount or fromAmount < 1 then
DropPlayer(src, 'Invalid inventory amount')
return
end
local toItem = Player.PlayerData.items[toSlot]
if toItem and toItem.name == fromItem.name and not fromItem.unique then
-- Stack items together
local maxStack = QBCore.Shared.Items[fromItem.name].stackSize or 50
local canStack = maxStack - toItem.amount
local moveAmount = math.min(fromAmount, canStack)
if moveAmount > 0 then
toItem.amount = toItem.amount + moveAmount
fromItem.amount = fromItem.amount - moveAmount
if fromItem.amount <= 0 then
Player.PlayerData.items[fromSlot] = nil
end
end
else
-- Swap items between slots
Player.PlayerData.items[toSlot] = fromItem
Player.PlayerData.items[fromSlot] = toItem
end
Player.Functions.SetPlayerData('items', Player.PlayerData.items)
end)
Fíjate en las llamadas a DropPlayer para operaciones claramente imposibles. Registrar estos eventos en una tabla de auditoría separada te ayuda a identificar intentos de exploit y patrones. Considera también limitar la frecuencia de los eventos del inventario, ya que los jugadores legítimos rara vez realizan más de unas pocas operaciones de inventario por segundo, mientras que los exploits automatizados suelen enviar cientos de solicitudes rápidamente.
Metadatos de objetos y objetos únicos
Los metadatos convierten objetos simples en objetos únicos y detallados. Un arma puede incluir su número de serie, durabilidad y modificaciones instaladas. Un teléfono puede guardar su número asignado y la referencia a su lista de contactos. Los alimentos pueden tener una fecha y hora de caducidad. Los metadatos se almacenan como una tabla de Lua serializada en JSON en la base de datos y se vinculan a cada instancia del objeto. La diferencia clave está entre los objetos apilables, que comparten los mismos metadatos, y los objetos únicos, que tienen su propia tabla de metadatos. Aquí tienes cómo crear un arma con todos sus metadatos:
-- Creating a weapon with metadata
function CreateWeaponItem(src, weaponName, serial)
local Player = QBCore.Functions.GetPlayer(src)
if not Player then return false end
local metadata = {
serial = serial or GenerateSerial(),
durability = 100.0,
ammo = 0,
attachments = {},
registered = false,
registeredTo = nil,
quality = math.random(85, 100),
created = os.time(),
}
return Player.Functions.AddItem(weaponName, 1, nil, metadata)
end
function GenerateSerial()
local chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
local serial = ''
for i = 1, 10 do
local idx = math.random(1, #chars)
serial = serial .. chars:sub(idx, idx)
end
return serial
end
Al mostrar objetos con metadatos en la NUI, pasa los metadatos junto con la información del objeto para que la interfaz pueda mostrar detalles como barras de durabilidad, números de serie y valoraciones de calidad. Esto crea una conexión más profunda entre los jugadores y sus objetos, y permite situaciones avanzadas de roleplay, como sistemas de registro de armas o investigaciones forenses que rastrean un número de serie hasta su propietario.
Rendimiento de la NUI con arrastrar y soltar
La interfaz del inventario es uno de los elementos NUI más sensibles al rendimiento, porque los jugadores interactúan con ella constantemente. Evita volver a renderizar toda la cuadrícula del inventario con cada actualización. En su lugar, usa un enfoque de DOM virtual o actualizaciones de elementos específicas que solo modifiquen los espacios que hayan cambiado. Cuando un jugador arrastra un objeto, gestiona el arrastre completamente en JavaScript mediante eventos del ratón, en lugar de enviar actualizaciones de posición al cliente de Lua durante el arrastre. Envía únicamente el resultado final al soltar el objeto, mediante una sola devolución de llamada NUI. Aquí tienes un patrón eficiente para gestionar el arrastre:
// Inventory NUI - performant drag and drop
let draggedItem = null;
let dragElement = null;
document.addEventListener('mousedown', (e) => {
const slot = e.target.closest('.inv-slot[data-has-item="true"]');
if (!slot) return;
draggedItem = {
slot: parseInt(slot.dataset.slot),
item: JSON.parse(slot.dataset.itemInfo),
};
dragElement = slot.cloneNode(true);
dragElement.classList.add('dragging-ghost');
dragElement.style.position = 'fixed';
dragElement.style.pointerEvents = 'none';
dragElement.style.zIndex = '9999';
document.body.appendChild(dragElement);
moveDragElement(e.clientX, e.clientY);
});
document.addEventListener('mousemove', (e) => {
if (!dragElement) return;
moveDragElement(e.clientX, e.clientY);
});
document.addEventListener('mouseup', (e) => {
if (!draggedItem) return;
const targetSlot = e.target.closest('.inv-slot');
if (targetSlot) {
const toSlot = parseInt(targetSlot.dataset.slot);
// Send only the final result to Lua
fetch(`https://${GetParentResourceName()}/moveItem`, {
method: 'POST',
body: JSON.stringify({
fromSlot: draggedItem.slot,
toSlot: toSlot,
amount: draggedItem.item.amount,
}),
});
}
if (dragElement) dragElement.remove();
draggedItem = null;
dragElement = null;
});
Para la representación visual, usa CSS Grid para organizar los espacios y evita las animaciones CSS complejas en los objetos del inventario, ya que los jugadores pueden tener docenas de espacios visibles al mismo tiempo. Los sprites de imagen para los iconos de objetos cargan más rápido que los archivos de imagen individuales y reducen las solicitudes HTTP cuando se abre la NUI por primera vez.
Sistemas de alijos y contenedores
Además del inventario personal, los jugadores necesitan acceder a almacenamiento externo, como maleteros de vehículos, alijos de casas y almacenamiento compartido de organizaciones. Cada tipo de contenedor debe tener sus propios límites de capacidad y reglas de control de acceso. Los maleteros de vehículos usan la matrícula como identificador único, los alijos de casas usan los ID de las propiedades y los alijos de trabajos usan el nombre del trabajo junto con una comprobación de grado. Guarda los inventarios de los contenedores en una tabla de base de datos específica, separada de los inventarios de los jugadores, para que las consultas sean eficientes:
CREATE TABLE IF NOT EXISTS stash_items (
id INT AUTO_INCREMENT PRIMARY KEY,
stash_id VARCHAR(100) NOT NULL,
slot INT NOT NULL,
item_name VARCHAR(50) NOT NULL,
amount INT DEFAULT 1,
metadata LONGTEXT DEFAULT '{}',
UNIQUE KEY unique_stash_slot (stash_id, slot),
INDEX idx_stash_id (stash_id)
);
-- Example stash_id values:
-- 'trunk_ABC123' (vehicle trunk by plate)
-- 'house_42' (house stash by property id)
-- 'police_evidence_1' (job stash with identifier)
Cuando un jugador abre un contenedor, carga su contenido desde la base de datos y bloquéalo para impedir que varios jugadores accedan a él al mismo tiempo. Usa una tabla de bloqueos del servidor que registre qué ID de alijo están abiertos y quién los tiene abiertos. Libera el bloqueo cuando el jugador cierre el contenedor o se desconecte. Esto evita el clásico exploit de duplicación en el que dos jugadores abren el mismo maletero y ambos retiran los mismos objetos.
Sincronización y persistencia del inventario
Mantener sincronizados los datos del inventario entre la memoria del servidor, la base de datos y la interfaz del cliente requiere una coordinación cuidadosa. Guarda periódicamente el inventario del jugador en la base de datos, no cada vez que cambia un objeto, para reducir la carga de escrituras en la base de datos. Un intervalo de guardado de 30 a 60 segundos funciona bien en la mayoría de los servidores. Además, guarda siempre al desconectarse un jugador y al apagarse el servidor, usando el evento playerDropped y un controlador de apagado. Implementa un sistema de indicador de cambios que solo escriba en la base de datos cuando el inventario haya cambiado desde el último guardado:
local inventoryDirty = {}
-- Mark inventory as needing save
function MarkDirty(citizenid)
inventoryDirty[citizenid] = true
end
-- Periodic save loop
CreateThread(function()
while true do
Wait(30000) -- 30 seconds
for citizenid, dirty in pairs(inventoryDirty) do
if dirty then
local Player = QBCore.Functions.GetPlayerByCitizenId(citizenid)
if Player then
SaveInventoryToDatabase(citizenid, Player.PlayerData.items)
end
inventoryDirty[citizenid] = nil
end
end
end
end)
AddEventHandler('playerDropped', function()
local src = source
local Player = QBCore.Functions.GetPlayer(src)
if Player then
local citizenid = Player.PlayerData.citizenid
if inventoryDirty[citizenid] then
SaveInventoryToDatabase(citizenid, Player.PlayerData.items)
inventoryDirty[citizenid] = nil
end
end
end)
En el cliente, agrupa las actualizaciones de la NUI para que varios cambios rápidos en el inventario, como recibir varios objetos al fabricar algo, provoquen una sola actualización de la interfaz en lugar de una por objeto. Así eliminas el parpadeo que los jugadores ven cuando los objetos aparecen de uno en uno en la cuadrícula del inventario.
Supervisión y optimización del rendimiento
Supervisa el rendimiento de tu sistema de inventario mediante el seguimiento de métricas clave: tiempo promedio de guardado en la base de datos, frecuencia de procesamiento de eventos y frecuencia de renderizado de la NUI. Usa el profiler de FiveM para identificar cuellos de botella en las devoluciones de llamada del inventario. Entre los problemas de rendimiento más comunes están recorrer todos los inventarios de los jugadores en cada frame para funciones basadas en la proximidad, como recoger objetos del suelo, serializar objetos grandes de metadatos sin necesidad y dejar activos los frames de la NUI cuando el inventario está cerrado. Para los objetos del suelo, usa un sistema de cuadrícula espacial que solo compruebe los objetos de la celda del jugador, en lugar de recorrer todos los objetos tirados en el servidor. Guarda en caché las definiciones de objetos en una tabla de búsqueda indexada por nombre de objeto para que obtener sus propiedades sea una búsqueda hash O(1), en lugar de recorrer un arreglo O(n). Por último, considera implementar la paginación del inventario para contenedores con capacidades muy grandes. Carga solo los espacios visibles y obtén filas adicionales a medida que el jugador se desplaza. Así mantienes ligeras las consultas a la base de datos y el renderizado de la NUI, incluso en almacenes con cientos de espacios.